透视JAVA——反编译、修补和逆向工程技术

java 专栏收录该内容
3 篇文章 0 订阅
1.1 技术综述,使用各种方法的时间和目的

表1-1给出了相应章节将要详细论述的技术的概述。

表1-1 技术综述

章 节


技 术


作 用

2


反编译类


● 恢复丢失的源代码

● 了解特性和窍门的实现

● 排除无文档说明的代码中的故障

● 修复产品或第三方代码中的紧急程序错误

● 评估自己的代码可能如何被破译

(续表)

章 节


技 术


作 用

3


混淆类


● 保护字节码不被反编译

● 保护字节码内的知识产权

● 防止应用程序被破译

4


破译类的非公用方法和变量


● 访问存在但没有暴露的功能

● 改变内部变量的值

5


替换和修补应用类


● 不必重构整个库文件而改变类的实现

● 改变第三方应用程序或框架的功能

6


使用有效的跟踪技术


● 创建易于维护和排除故障的应用程序

● 了解应用程序的内部运作

● 在现有应用程序中插入调试信息,了解实现的细节

7


管理Java安全措施


● 在关键系统资源的访问上添加或删除约束条件

8


窥探运行时

环境


● 确定系统属性的值

● 确定系统信息,例如:处理器的数量和内存容量局限

● 确定网络配置

9


用非正式调试程序破译编码


● 对不具有良好跟踪技术的程序进行破译

● 分析多线程应用程序的控制流

● 破译混淆的应用程序

10


运用性能分析工具(profiler)进行应用程序运行时分析


● 运用性能分析工具研究堆的使用和垃圾回收频率以提高性能

● 浏览对象的分配和引用,以发现并修复内存泄漏

● 研究线程分配和同步,找到死锁和数据竞争问题以提高性能

● 在运行时研究应用程序以更好地理解其内部结构

11


运用负载测试发现并调整可伸缩性问题


● 创建仿真系统负载的自动测试脚本

● 分析应用程序如何满足服务级别需要,例如:可伸缩性、可用性和容错性

12


逆向工程的应用


● 对用户界面要素,如:信息、警告、提示、图像、图标菜单和颜色进行破译



(续表)

章 节


技 术


作 用

13


窃 听技术


● 截取浏览器与Web服务器之间的HTTP通信

● 截取RMI客户端和服务器之间的通信

● 截取JDBC驱动程序的SQL语句和值

14


控制类的加载


● 实现定制类载入器,控制如何加载类,以及从何处加载

● 用定制类载入器快速执行字节码

● 在运行时程序化地创建类

15


替代和修补核心Java类


● 通过改变系统类的实现来改变核心行为

● 增强JDK的核心类功能以满足应用程序的需要

● 修复JDK实现中的错误

16


截取控制流


● 对系统错误,如:存储溢出和堆栈溢出作出的适度反应

● 捕捉到System.out和System.err的输出

● 截取对System.exit()的调用

● 对JVM停止运行的反应

● 通过JVMPI截取任意方法调用、对象分配或线程生命周期事件

17


理解和调整字节码


● 在字节码级别上改变类的实现

● 程序化地生成字节码

● 操纵字节码以引入新逻辑

18


运用本机代码修补法进行总控制


● 修补本机功能的执行

● 在最底层上扩充JVM的性能

19


保护商用程序免于被破译


● 用Java密码术保护敏感信息

● 用数字签名保护数据的完整性

● 对商用应用程序的特性执行安全许可政策
1.2 利用文件管理器提高程序开发效率

这里所讨论的技术都用于提高程序的开发效率。到目前为止,编写程序的质量和效率还是专家级程序员与初级程序员之间的区别。因为本书的意义就是要让读者成为专家,所以我感到介绍一些提高效率的工具是我的责任。破译技术和常规的开发需要对文件和目录进行操作,获得正确的工具会让这些变得容易一些。很明显,这取决于您决定是否使用工具。您一定要记住,大多数工具在安装、配置和掌握时,都需要前期投资——更不用说可能的许可费用。但正如大多数工具一样,投资很快就会得到回报。

我们将会介绍两种将Windows Explorer、记事本/文本编辑器和CMD.EXE组合起来的高级替代品。我们主要集中介绍Windows,毕竟大多数Java程序的开发都是在Windows上进行的,但高效率开发工具在其他平台上也是可用的。这听起来似乎有点愚蠢,我们居然以谈论记事本和CMD.EXE开始一本高级的Java书,不过我所见到的很大一部分开发人员还在使用记事本和CMD.EXE,所以我想给出另一个更好的替代品。

Windows Explorer是一个简单的shell,普通用户很容易理解,但它还不足以帮助程序员完成需要执行的任务。一个非常简单的示例就是创建和运行一个.bat文件。使用默认的Windows界面,就必须要浏览到目标目录,通过单击一些对话框来创建一个新文件,然后在记事本中打开文件并加以编辑。要运行这文件,可以双击它,但其输出或错误信息就会随着浏览器自动打开CMD窗口而丢失。所以一个更好的方法就是打开CMD.EXE,浏览目录,然后运行文件。最后,必须处理3个即不同步又不相关的窗口。另一个更好的可选方案就是使用集成的文件管理器软件,它包括一个带有文本编辑器的目录浏览界面、一个运行脚本、存档支持和使通用任务简化的诸多特征的命令行。
1.2.1 FAR和Total Commander

FAR(File and Archive Manager,文件和存档管理器)和Total Commander都是运行于Windows平台的高级文件管理器,可以追溯到DOS时代和Norton Commander。FAR和Total Commander作为共享件发布,可以无时间限制的使用,直到您自愿注册并为它提供一小笔费用。FAR和Total Commander均具有搜索文件、改变文件属性以及处理多个文件和目录的功能。它们具有内嵌网络和FTP支持,将远程的FTP站点呈现在面板上就如同是在本机目录的面板上一样。FAR具有一个功能强大的内置编辑器,可以配置成彩色高亮的。两者都有广泛的快捷键组合,并且FAR支持插入程序。这两种工具都支持在面板上浏览存档文件——例如JAR和Zip文件——的内容,就像浏览子文件夹一样。这样就不再需要像WinZip之类的软件了。更好的是,用户无需像在使用非集成软件时那样处理不同的当前目录和用户界面。表1-2给出了FAR和Total Commander的功能及其比较。

表1-2 FAR和Total Commander的比较

功 能


FAR


Total commader

文件和文件夹的创建、复制、查看、编辑和删除


优秀


优秀

内部和外部查看器/编辑器


优秀


一般(无内部编辑器)

存档(JAR、Zip等)内容的无缝浏览


优秀


优秀

功能和用户界面的广泛定制


优秀


一般

类似Windows的观感


较差


一般

可定制的用户菜单


优秀


优秀

内置Windows网络支持







内置FTP客户端







快捷键


优秀


一般

文件名过滤器


优秀


优秀

快捷查看


还行


优秀

命令、文件夹、查看和编辑历史记录


优秀


一般

定制的文件关联


优秀


一般

文件的突出显示


优秀


优秀

内存的需求


4MB–14MB


4MB–10MB

插件程序的API和各种插件程序的可用性


优秀(500多个插件程序)




每个副本的注册成本


25美元


28美元

总体评价


优秀


一般







尽管两种工具都是由其他软件增强的Windows Explorer的更好的替代品,但相对而言,FAR是更强大的工具。甚至在其默认的工具包中,FAR相比于Total Commander,提供了更多的特性和更高的效率。此外,加上500多个由其他开发人员编写的插件程序,其功能可以说是无限的。FAR的缺点是其不吸引人的用户界面,尽管您可以习惯这些界面。如图1-1所示的Total Commander看起来更像Windows Explorer;如果您不需要最终的定制和功能,这是一个更好的选择。

0672326388 #1 2.9.4

图1-1 Total Commander

不论您偏好哪个,都请尝试一下集成的文件管理器,尽管您可能感觉用起来有些困难。但从长远来看,这是值得的!
1.2.2 Java IDE

本书中的大部分技术并不涉及大量的编码,使用诸如FAR之类的工具,可以很轻松地完成所有要求的任务。但是IDE (Integrated Development Environment,集成开发环境)可以使编码更加容易,所以这一部分给出了IDE的一个简要概述以及使用的推荐。

现在,IDE的问题不在于是否使用IDE,而是在于使用哪种IDE。这取决于开发背景和个人的偏好。所以,我不想花太多时间谈论这些。IBM提供的Eclipse(http://www.eclipse.org)和Sun 提供的NetBeans (http://www. netbeans.org)是两种先进的免费IDE,而且都不错,尽管Eclipse的使用者更多一些。最好的商用IDE是IntelliJ IDEA、 Borland JBuilder、和Oracle JDeveloper。

由于我们要处理底层的编码和程序破译,最好还是选用一种灵活的且内存需求较小的IDE。我个人喜欢IDEA,因为它的灵活性、直观的界面以及丰富的快捷键和重构特性。这个IDE不是免费的,所以如果您不想为其付钱,那我就推荐使用Eclipse。

1.3 示例应用程序的功能和结构

本书的绝大部分都要使用同一示例应用程序。这个程序不是非常复杂,但是它的确包含了大多数独立的Java程序中都可以找到的组件的基本集合。本节要说明这个应用程序及其实现。Chat是Java实现的一个简单的TCP/IP chat程序。用户可以使用它通过网络交换即时信息。Chat保存对话记录并使用不同颜色来区分发送和接收的消息。Chat有一个菜单栏和About对话框。在distrib/bin目录中使用chat.bat脚本就可以启动Chat。图1-2显示正在运行的Chat程序。

Chat是用Java Swing实现的用户界面和用RMI实现的网络通信。在运行时,Chat的每个实例创建一个RMI注册项,其他的实例用它来发送消息给用户。用户须要输入想要发送消息的用户的主机名。在用户发送消息时,Chat查寻远程服务器对象并调用上面的一个方法。为了测试的目的,消息可以发送到“localhost”上,这样,发送和接收的相同信息就都添加到对话中了。Chat的UML类图如图1-3所示。

Chat目录结构遵循Java应用程序开发的实际标准。应用程序目录的“home”文件夹就命名为CovertJava。所包含的子文件夹列在表1-3中。

0672326388 #1 2.9.4

图1-2 Chat程序

图1-3 Chat类图

表1-3 Chat应用程序的目录结构

目 录 名 称


说 明

bin


包含脚本文件以及开发和测试脚本

build


包含Ant的build.xml以及其他与build相关的文件

classes


.class文件的编译器输出目录

distrib


包含分布式应用程序

distrib\bin


包含运行应用程序的脚本

distrib\conf


包含配置文件,如Java的政策文件

(续表)

目 录 名 称


说 明

distrib\lib


包含运行应用程序所用的库文件

distrib\patches


包含类的补丁

lib


包含构造应用程序所用的库文件

src


包含应用程序的源文件



在build目录内,Ant使用build.xml可以构造Chat程序。

1.4 快速测试

1. 应用哪种技术可以了解应用程序的内部实现?

2. 应用哪种技术可以改变应用程序的内部实现?

3. 应用哪种技术可以捕获客户端和服务器之间的通信?

4. 哪种Windows应用程序可以由FAR和Total Commander替代?

● 本书中给出的各种技术可以用于了解在系统级别上破译应用程序或应用JDK的实现内幕。

● 集成文件管理器提高了效率,值得为其投资。
2.1 确定何时进行反编译
当前章节:2.1 确定何时进行反编译
·1.3 示例应用程序的功能和结构
·1.4 快速测试
·1.5 小结
·2.2 了解最佳的反编译器
·2.3 反编译类
·2.4 反编译可行的要素

在理想世界里,反编译或许是没有必要的,除非某些人不喜欢编写好的说明文档,而您又想知道他们是如何实现某些特性的。但在现实世界中,经常会有一些直接引用源代码是最佳解决方案的情形,即便不是惟一的解决方案。下面就是进行反编译的一些原因:

● 恢复意外丢失的源代码

● 了解一种特性或窍门的实现

● 排除不具有良好文档说明的应用程序或库文件中的bug

● 修复不存在源代码的第三方代码中的紧急bug

● 学习保护自己的代码免于被破译

反编译是从Java字节码中生成源代码。可能是由于字节码的标准和充分说明的结构,这是一个编译的逆向过程。就像运行编译器从源代码生成字节码一样,我们可以运行反编译器从给定的字节码中得到源代码。当缺乏文档和源代码时,反编译是一种了解执行逻辑的有力方法,这就是为什么许多产品供应商明确地在许可协议中禁止反编译和逆向工程的缘故。如果不能确定自己行为的合法性,一定要核查一下许可协议或是从供应商那儿得到明确的许可。

一些人可能会认为我们不需要诉诸于如反编译等极端手段,可以向字节码的供应商寻求支持和bug修复。在专业环境中,如果您是应用程序的开发人员,您就要负责功能的完美无缺。用户和管理人员并不关心在您的代码还是在第三方代码中有bug。他们只关心问题是否已被解决,而且他们认为您应该为此负责。联系第三方代码的供应商是一个优先选择的方法。但是在紧急情况下,当需要在短短几小时内给出解决方案时,能够处理字节码将会让您比同行更具有优势,没准还会因此得到奖赏呢!



2.2 了解最佳的反编译器

要着手进行反编译的工作,需要有合适的工具。一个好的能够生成几乎可以与编译成字节码的源代码相媲美的代码。有些反编译器是免费的,而有些是需要付费的。尽管我支持商业软件背后的原则,但是这需要它能够给我提供比其免费的竞争产品更有用的东西,我才会使用。就反编译器而言,我还没有发现免费的反编译器会缺乏任何特性,所以我个人推荐使用免费工具,例如:JAD或JODE。表2-1列出了一些常用反编译器,并且包括一个强调每种反编译器质量的简短说明。其中给出的URL可能已经过期,所以还是用Google搜索一下,这是找到反编译器所在主页并下载最新版本的最好方法了。

表2-1 反编译器

工具/级别


许 可


说 明

JAD/优秀


非商业用途,免费


JAD是一种非常快捷、可靠和高级的反编译器。它完全支持内部类、匿名实现和其他高级语言的特性。具有干净的生成代码和组织良好的import。一些其他的反编译环境采用了命令行JAD作为反编译引擎

JODE/优秀


GNU公共许可


JODE是一种非常好的反编译器,用Java语言写成,完整源代码可以在SourceForge.net上得到。也许它没有JAD快也没有JAD用的广泛,但是它生成极好的结果,经常比JAD的还要干净。拥有反编译器本身的源代码对于教学目的其作用不可低估

Mocha/较好


免费


Mocha是第一个著名的反编译器,不仅造成了很多法律上的争论,还掀起了一波狂热的浪潮。Mocha最明显的特征是Java源代码几乎可以按照其最初的形式重新构造,这一点受到开发团体的好评,但也引起了法律部门的担心。公共代码自1996年起就没有再更新过,尽管Borland可能已经将其更新并集成到JBuilder中了



来自实践的故事

在Riggs银行,我们正准备开发一个非常巨大而且重要的J2EE应用软件,这个软件是配置在来自顶级J2EE供应商的一组应用服务器上的。有好几个小组正在等待产品环境的准备就绪,但是由于某些奇怪的原因,在一些主机上,应用服务器不能够启动。完全相同的安装在一些机器上可以运行,但在其他机器上就不能运行,出错信息显示的是无效配置URL。更为麻烦的是,出错信息中的URL在任何配置文件、shell脚本和环境变量中都找不到。

我们花费了好几天时间试图修复这一问题,但徒劳无功,而且情况差点变得更糟糕,因为有几个小组就要错过关键的截止日期了。在复制和重新安装应用服务器失败后,最后我们转而求助于寻找产生出错信息的应用服务器库中的类。对它和几个正在使用这个类的其他类进行反编译,显示URL是基于服务器安装目录程序化地生成的。安装目录是通过执行Unix命令pwd来决定的。这就造成了没有许可执行pwd的主机上的安装失败,但是误导的出错信息并没有表明这一点。修复这一许可花了几分钟,我们找到并反编译这个类的整个过程才花了不到一小时的时间。这样一来,一场虚幻的灾难就变成了IT团队的一次巨大胜利。

一条非常重要的准则就是反编译器在很大程度上支持更高级的语言构造,例如:内部类(inner class)和匿名实现。尽管从JDK 1.1以来字节码的格式已经非常稳定,但使用由其作者经常更新的反编译器也是非常重要的。JDK1.5中的新语言特性就需要对反编译器更新,所以一定要留意您正在使用的反编译器版本的发布日期。

也许您还在市面上看到过其他的反编译器,但是JAD和JODE确实很好用,并且已经得到广泛应用。很多产品都提供了GUI (graphical user interface,图形化用户界面),但要依赖于一个绑定的反编译器来完成实际的工作。例如:Decafe、DJ、和Cavaj都是绑定了JAD的GUI工具,所以就不单独列出了。本书以后的部分就使用命令行JAD来生成源代码。大多数时间内,我们使用命令行式的反编译器就足够了,当然,如果您更喜欢使用GUI,请相信它使用的是诸如JAD或JODE一样优秀的反编译器。

2.3 反编译类

如果您以前从未用过反编译器,那就来看看反编译器的强大之处吧!现在我们来处理一个稍稍高级一点版本的MessageInfo类,这是Chat用于发送消息文本和属性到远端主机一个类的。程序清单2.1给出了MessageInfoComp lex.java,它有一个匿名的内部类(MessageInfoPK)和一个main()方法,以描述反编译一些更为复杂的情况。

程序清单2.1 MessageInfoComplex的源代码

package covertjava.decompile;



/ **

* MessageInfo is used to send additional information with each * message acrossthe network. Currently it contains the name of * the host that the message

* originated from and the name of the user who sent it.

*/

public class MessageInfoComplex implements java.io.Serializable {



String hostName;

String userName;



public MessageInfoComplex(String hostName, String userName) {

this.hostName = hostName;

this.userName = userName;

}



/ **

* @return name of the host that sent the message

*/

public String getHostName() {

return hostName;

}



/ **

* @return name of the user that sent the message

*/

public String getUserName() {

return userName;

}



/ **

* Convenience method to obtain a string that best identifies the user.

* @return name that should be used to identify a user that sent * this message

*/

public String getDisplayName() {

return getUserName() + " (" + getHostName() + ")";

}





/ **

* Generate message id that can be used to identify this message * in a database

* The format is: <ID><UserName><HostName>. Names are limited to

* 8 characters

* Example: 443651_Kalinovs_JAMAICA would be generated for

* Kalinovsky/JAMAICA

*/

public String generateMessageId() {

StringBuffer id = new StringBuffer(22);



String systemTime = "" + System.currentTimeMillis();

id.append(systemTime.substring(0, 6));



if (getUserName() != null && getUserName().length() > 0) {

// Add user name if specified

id.append('_');

int maxChars = Math.min(getUserName().length(), 8);

id.append(getUserName().substring(0, maxChars));

}



if (getHostName() != null && getHostName().length() > 0) {

// Add host name if specified

id.append('_');

int maxChars = Math.min(getHostName().length(), 7) ;

id.append(getHostName().substring(0, maxChars));

}

return id.toString();

}







/ **

* Include an example of anonymous inner class

*/

public static void main(String[] args) {

new Thread(new Runnable() {

public void run() {

System.out.println("Running test");

MessageInfoComplex info = new MessageInfoComplex("JAMAICA","Kalinovsky");

System.out.println("Message id = " + info.generateMessageId());

info = new MessageInfoComplex(null, "JAMAICA");

System.out.println("Message id = " + info.generateMessageId());

}

}).start();

}







/ **

* Inner class that can be used as a primary key for MessageInfoComplex

*/

public static class MessageInfoPK implements java.io.Serializable {

public String id;

}

}

在用javac的默认选项编译完MessageInfoComplex.java后,就得到3个类文件:MessageInfoComplex.class、MessageInfoComplex$MessageInfo PK.class和MessageInfo- Complex$1.class。我们知道,早在JDK 1.1中,内部类和匿名类就已经添加到Java中了,但是设计目标是保持字节码格式与早期Java版本的兼容性。这就是为什么这些语言构造生成了某些独立的类,尽管这些类确实保持着与父类的关联。我们测试的最后一步就是在类文件上运行反编译器,然后将生成的源代码和原始的源代码进行比较。在此假定您已经下载并安装了JAD,并已将其添加到路径上,可以用如下的命令运行它:

jad MessageInfoComplex.class

在完成反编译后,JAD生成了文件MessageInfoComplex.jad,将其重新命名为MessageInfoComplex_FullDebug.jad,如程序清单2.2所示。

程序清单2.2 MessageInfoComplex的反编译代码

// Decompiled by Jad v1.5.7g. Copyright 2000 Pavel Kouznetsov.

// Jad home page:http://www.geocities.com/SiliconValley/Bridge/8617/jad.html

// Decompiler options: packimports(3)

// Source File Name: MessageInfoComplex.java

package covertjava.decompile;



import java.io.PrintStream;

import java.io.Serializable;



public class MessageInfoComplex

implements Serializable

{

public static class MessageInfoPK

implements Serializable

{



public String id;



public MessageInfoPK()

{

}

}





public MessageInfoComplex(String hostName, String userName)

{

this.hostName = hostName;

this.userName = userName;

}



public String getHostName()

{

return hostName;

}



public String getUserName()

{

return userName;

}



public String getDisplayName()

{

return getUserName() + " (" + getHostName() + ")";

}



public String generateMessageId()

{

StringBuffer id = new StringBuffer(22) ;

String systemTime = "" + System.currentTimeMillis();

id.append(systemTime.substring(0, 6));

if(getUserName() != null && getUserName().length() > 0)

{

id.append('_');

int maxChars = Math.min(getUserName().length(), 8);

id.append(getUserName().substring(0, maxChars));

}

if(getHostName() != null && getHostName().length() > 0)

{

id.append('_');

int maxChars = Math.min(getHostName().length(), 7);

id.append(getHostName().substring(0, maxChars));

}

return id.toString();

}



public static void main(String args[])

{

(new Thread(new Runnable() {



public void run()

{

System.out.println("Running test");

MessageInfoComplex info = new MessageInfoComplex("JAMAICA", "Kalinovsky");

System.out.println("Message id = " + info.generateMessageId());

info = new MessageInfoComplex(null, "JAMAICA");

System.out.println("Message id = " + info.generateMessageId());

}



})).start();

}

String hostName;

String userName;

}

下面花几分钟浏览一下所生成的代码。您就可以看到,上面的代码与原始代码的匹配几乎达到了100%!变量、方法和内部类声明的顺序不同,所以格式也不相同,但是逻辑绝对一致。所有的注释全都没有了,但像我们这样编写良好的Java代码是不需要注释的,这一点是显而易见的,不是吗?正因为使用了-g选项,完整的调试信息包含在javac中,所以使这个示例产生了如此良好的结果。如果源代码在编译时没有调试信息(-g:none选项),反编译的代码就会损失一些清晰度,例如方法的参数名称和局部变量的名称。下面的代码显示的是MessageInfoComplex的使用了局部变量的方法和构造函数,其中MessageInfoComplex没有包含调试信息:

public MessageInfoComplex(String s, String s1)

{

hostName = s;

userName = s1;

}



public String generateMessageId()

{

StringBuffer stringbuffer = new StringBuffer(22);

String s = "" + System.currentTimeMillis();

stringbuffer.append(s.substring(0, 6));

if(getUserName() != null && getUserName().length() > 0)

{

stringbuffer.append('_');

int i = Math.min(getUserName().length(), 8);

stringbuffer.append(getUserName().substring(0, i));

}

if(getHostName() != null && getHostName().length() > 0)

{

stringbuffer.append('_');

int j = Math.min(getHostName().length(), 7);

stringbuffer.append(getHostName().substring(0, j));

}

return stringbuffer.toString();

}

2.4 反编译可行的要素

Java源代码不像C/C++源代码一样被编译成二进制机器码,编译Java源代码会生成中间字节码,这是一种与平台无关的源代码的表示方式。字节码在加载后可以被解释或编译,这就产生了高级编程语言到底层机器代码之间的两步变换。是这一中间步骤使得反编译Java字节码近乎完美。字节码携带了在源文件中可以找到的所有重要信息。尽管注释和格式丢失了,但所有的方法、变量和编程逻辑都完好地保留下来。由于字节码不表示最低层机器语言,因此代码的格式非常类似于源代码。JVM规范定义了一组与Java语言操作符和关键字相匹配的指令,这样,如下一段Java代码:

public String getDisplayName() {

return getUserName() + " (" + getHostName() + ")";

}

就由如下的字节码表示:

0 new #4 <java/lang/StringBuffer>

3 dup

4 aload_0

5 invokevirtual #5 <covertjava/decompile/MessageInfoComplex.getUserName>

8 invokestatic #6 <java/lang/String.valueOf>

11 invokestatic #6 <java/lang/String.valueOf>

14 invokespecial #7 <java/lang/StringBuffer.<init>>

17 ldc #8 < (>

19 invokevirtual #9 <java/lang/StringBuffer.append>

22 aload_0

23 invokevirtual #10 <covertjava/decompile/MessageInfoComplex.getHostName>

26 invokevirtual #9 <java/lang/StringBuffer.append>

29 ldc #11 <)>

31 invokevirtual #9 <java/lang/StringBuffer.append>

34 invokestatic #6 <java/lang/String.valueOf>

37 invokestatic #6 <java/lang/String.valueOf>

40 areturn

第17章“理解和调整字节码”中会详细说明字节码格式,但是您只需要看看字节码就会看到相似之处。反编译器加载字节码并基于字节码指令重新构造源代码。类方法和变量的名称通常都是保留的,然而方法参数和局部变量的名称则丢失了。如果有调试信息,它可以给反编译器提供参数名称和行编号,这就使得重新构造的源文件的可读性更高了。

2.5 反编译代码的潜在问题

通常,反编译都生成一个可读的文件,它可以更改并能重新编译。但是,在某些偶然的情况下,反编译不能生成可以被再次编译的文件。如果字节码被混淆了,就会发生这样的情况,混淆程序给出的名称会导致编译时的模棱两可。在加载时,字节码是经过验证的,但在验证时,假定编译程序已经核查了一些错误。这样,字节码验证器就不像编译器那样严格,混淆程序就可以利用这一优势更好地保护知识产权。例如:下面就是JAD在来自方法MessageInfoComplex main()的匿名内部类的输出,其已被Zelix KlassMaster混淆程序混淆:

static class c

implements Runnable

{



public void run()

{

boolean flag = a.b;

System.out.println(a("*4%p\002\026&kj\016\0135"));

b b1 = new b(a("2\000\006_\";\0"), a("3 'w\005\02778u\022"));

System.out.println(a("5$8m\n\037$kw\017X|k").concat(S

tring.valueOf

?(String.valueOf(b1.d()))));

b1 = new b(null, a("2\000\006_\";\0"));

System.out.println(a("5$8m\n\037$kw\017X|k").concat(S

tring.valueOf

?(String.valueOf(b1.d()))));

if(flag)

b.c = !b.c;

}

private static String a(String s)

{

char ac[];

int i;

int j;

ac = s.toCharArray();

i = ac.length;

j = 0;

if(i > 1) goto _L2; else goto _L1

_L1:

ac;

j;

_L10:

JVM INSTR dup2 ;

JVM INSTR caload ;

j % 5;

JVM INSTR tableswitch 0 3: default 72

// 0 52

// 1 57

// 2 62

// 3 67;

goto _L3 _L4 _L5 _L6 _L7

_L4:

0x78;

goto _L8

_L5:

65;

goto _L8

_L6:

75;

goto _L8

_L7:

30;

goto _L8

_L3:

107;

_L8:

JVM INSTR ixor ;

(char);

JVM INSTR castore ;

j++;

if(i != 0) goto _L2; else goto _L9

_L9:

ac;

i;

goto _L10

_L2:

if(j >= i)

return new String(ac);

if(true) goto _L1; else goto _L11

_L11:

}

}

我们可以看到,这是完完全全的失败,甚至不怎么类似于Java源代码。更加麻烦的是,JAD生成的源代码不能被编译。其他两个反编译器报告了关于类文件的错误。毫无疑问,JVM毫无困难地识别并加载了有问题的字节码。在第3章“混淆类”中会详细介绍混淆。

保护知识产权的一个有力方法是将类文件编码,并用定制的类载入器在加载时解码。这样,反编译器就不能用在除了登录点和类加载程序以外的任何应用程序类上。尽管并不是不可破译的,但编码使得程序的破译更为困难了。黑客首先得反编译类加载程序,了解解码机制,其次要解码所有的类文件;然后才能进行反编译。第19章“保护商用程序免于被破译”给出了有关在Java应用软件中如何最好地保护知识产权的信息。

2.6 快速测试

1.反编译字节码的原因是什么?

2.哪些编译器选项会影响反编译的质量,是如何影响的?

3.为什么反编译的Java字节码几乎与源代码一致?

4.怎样才能保护字节码不被反编译?

● 反编译字节码所生成的源代码几乎与原始的源代码一致。

● 在缺少文档和源代码时,反编译是了解执行逻辑的一种有力方法。但是,反编译和逆向工程可能在许可协议中被严格禁止。

● 反编译需要下载和安装反编译器。

● 反编译Java类是有效的,因为字节码是一种位于源代码的和机器码之间的中间步骤。

● 好的混淆程序可以使反编译的代码非常难以阅读和理解。

3.1 保护代码背后的构思

逆向工程和破译技术自软件发展的早期就已经出现了。事实上,剽窃或复制他人的构思已经成为创建有竞争性产品的最容易的方法。当然,只要他人不介意,在其他人以前发现的基础上进行开发是完全可以接受的方法,而且会做的很好。但是大多数发明者和研究人员都希望从他们的工作中获得荣誉和可能的经济回报。简言之,就是他们也要偿还贷款以及去休假。

保护知识产权的一种良好途径就是让作者为其作品的独特性获得版权和专利权。这对于在研究和开发中需要大量投资的发明和重大发现当然是推荐的。为软件提供版权是一种相当容易和划算的过程,但是它只保护应用程序的“原始”代码,而不是代码背后的构思。其他人不能在未经作者允许的前提下使用已经获得版权的代码并将其用在自己的应用程序中,但如果用自己的代码实现同样的特性,就不能被认为是非法使用这一构思了。专利提供了一种更好的保护,因为它保护了构思和算法而不仅仅是具体的实现,但这种专利权申请非常昂贵,而且要花费好几年时间才能得到。

那么是否应用软件真有被破译的危险?如果应用软件具有好的构思,那这就是绝对的。在编写本书时候,大多广泛宣传的逆向工程的案例尚无涉及Java产品,但下面是一个Java供应商(DataDirect Technologies有限公司)的例外:

2002年7月1日,马里兰州罗克维尔市报道业界领先的数据连通供应商DataDirect Technologies有限公司日前起诉i-net Software GmbH公司侵犯版权以及违反合同。DataDirect Technologies有限公司正在努力寻求初步和永久法庭禁止令,以阻止i-net在市场中销售DataDirect Technologies有限公司认为是非法从其产品逆向工程所得的产品。

DataDirect Technologies有限公司认为竞争对手对其产品实行了逆向工程,而且截止到目前为止,他们的产品还几乎没有防止反编译的保护措施。

在现实世界中,如果竞争对手或黑客可以很容易的从源代码中掌握实现方法,赋予代码以版权和为方法提供专利并不能提供可靠保护。我们将在独立的一章中讨论法律保护的问题,但现在让我们关注一些Java应用软件保护知识产权的巧妙方法。

3.2 混淆-种知识产权的保护措施

混淆是将字节码变换成人不易读懂的格式,达到使逆向工程复杂的目的。典型的混淆包括去除所有的调试信息,例如:变量表和行编号以及机器生成名称的重命名包、类和方法。先进的混淆器则更进一步,通过重构现有的逻辑和插入不执行的伪代码来改变控制流程。混淆的前提是变换不会破坏字节码的有效性,也不会改变所展示的功能性。

混淆的可行与反编译的可行原因相同:Java字节码是标准化的,而且是很好归档的。混淆程序加载Java类文件,分析其格式然后根据所支持的特性进行变换。当所有的变换完成后,字节码就保存成一个新的类文件。新文件具有不同的内部结构,但其行为恰恰与原始文件一致。

混淆程序对于交付实现逻辑给用户的产品和技术是尤其必须的。典型示例就是HTML网页和JavaScript,这些产品是以源代码形式发布的。Java的处境也不好,尽管典型的Java是以二进制字节码形式发布的,利用前面章节所述的反编译程序就可以生成源代码——几乎可以与初始代码一样好。

3.3 由混淆程序执行的变换

由于对于混淆目前尚不存在标准,所以保护的级别随混淆程序的质量变换而变化。下面的部分给出了混淆程序中可以找到的一些共同特征。我们将用ChatServer的sendMessage方法来阐述每种变换如何影响反编译的代码。sendMessage的初始源代码如程序清单3.1所示:

程序清单3.1 sendMessage的初始源代码

public void sendMessage(String host, String message) throws

Exception {

if (host == null || host.trim().length() == 0)

throw new Exception ("Please specify host name");



System.out.println("Sending message to host " + host + ": " + message);

String url = "//" + host + ":" + this.registryPort + "/chatserver";

ChatServerRemote remoteServer = (ChatServerRemote)Naming.lookup(url);



MessageInfo messageInfo = new MessageInfo(this.hostName, this.userName);

remoteServer.receiveMessage(message, messageInfo);

System.out.println("Message sent to host " + host);

}
3.3.1 去除调试信息

Java代码可以包含编译程序插入的帮助调试运行代码的信息。由javac插入的信息包含部分或全部如下信息:行编号、变量名称和源文件名称。调试信息对运行类是没用的,但调试程序用它将字节码与源代码结合起来。反编译程序利用这一信息就可以更好地重构源代码。有了类文件中的完整调试信息,反编译的代码几乎可以与初始源代码完全一致。当去除了调试信息,所存储的名称就丢失了,反编译程序就不得不生成其自己的名称,在我们的示例中,在去除调试信息后,出现的sendMessage参数就是s1和s2,而不再是host和message。
3.3.2 名称的处理

开发人员给包、类和方法起了有含义的名称。我们的示例chat应用程序的服务器实现就被命名为ChatServer,发送消息到另一个用户的方法就称为sendMessage。好的名称对于开发和维护是非常关键的,但对于JVM则毫无意义。JRE(Java Runtime,Java运行时)不管sendMessage的名称是goShopping还是abcdefg都会调用并执行。通过替换有含义的人可读的名称为机器生成的名称,混淆程序就使得理解反编译代码的任务变得更难了。原来是ChatServer.sendMessage,现在变成了d.a,当存在很多类和方法具有相同名称时,反编译代码就很难理解了。好的混淆程序利用多态性的优点使得情形更为复杂。在原始代码中是三个不同名称和签名的方法执行不同的任务,但在混淆的代码中可以被重命名为同一公共名称。由于他们的签名不同,这样并不违反Java语言的规范,但是增加了反编译代码的混淆程度。程序清单3.2给出了混淆后的反编译sendMessage的示例,其中去除了调试信息并执行了名称的处理。

程序清单3.2 进行名称处理后反编译的sendMessage

public void a(String s, String s1)

throws Exception

{

if(s == null || s.trim().length() == 0)

{

throw new Exception("Please specify host name");

} else

{

System.out.println(String.valueOf(String.valueOf((

new StringBuffer("Sending message to host ")

).append(s).append(": ").append(s1))));

String s2 = String.valueOf(String.valueOf((

new StringBuffer("//")).append(s).append(":")

.append(b).append("/chatserver")));

b b1 = (b)Naming.lookup(s2);

MessageInfo messageinfo = new MessageInfo(e, f);

b1.receiveMessage(s1, messageinfo);

System.out.println("Message sent to host ".concat(

String.valueOf(String.valueOf(s))));

return;

}

}
3.3.3 编码Java字符串

Java字符串在字节码内是以普通文本格式储存的。大多数编写良好的应用程序在代码内都有生成用于调试的执行日志,记录跟踪信息。即使类和方法的名称改变了,由方法写到日志文件或控制台的字符串也会暴露方法的目的。在示例ChatServer.sendMessage中用如下代码输出一个跟踪信息:

System.out.println("Sending message to host " + host + ": " + message);

即使ChatServer.sendMessage被重新命名为d.a,在反编译的消息体中还会看到这样的跟踪信息,很明显就知道方法做了什么。但如果字节码中的字符串经过编码。类的反编译版本就像如下的代码:

System.out.println(String.valueOf(String.valueOf((new

StringBuffer(a("A\025wV6|\0279_:a\003xU:2\004v\0227)\003m\022"))

).append(s).append(a("(P")).append(s1))));

如果仔细地研究编码的字符串,它首先传到a()方法中,后者将其编码并返回可读字符串给System.out.println()方法。字符串编码是商用混淆程序应该提供的一个强大特性。
3.3.4 改变控制流

早期提供的变换使得对混淆代码的逆向工程变得困难,但这并不改变Java代码的基本结构,也没有为算法和程序控制流提供任何保护,而这些控制流通常都是创新的最重要部分。ChatServer.sendMessage的反编译版本显示早期的混淆仍旧十分容易读懂。可以看到,首先代码核查有效的输入并给出关于出错的异常事件,然后搜索远程服务程序对象并且调用其上的一个方法。

最好的混淆程序可以通过插入伪条件和goto声明来变换字节码的执行流程。这在某种程度上减缓了执行的速度,但相对于知识产权保护的增强而言,代价是很小的。程序清单3.3显示了应用上述所有变换后sendMessage的样子。

程序清单3.3 经过所有变换后反编译的sendMessage

public void a(String s, String s1)

throws Exception

{

boolean flag = MessageInfo.c;

s;

if(flag) goto _L2; else goto _L1

_L1:

JVM INSTR ifnull 29;

goto _L3 _L4

_L3:

s.trim();

_L2:

if(flag) goto _L6; else goto _L5

_L5:

length();

JVM INSTR ifne 42;

goto _L4 _L7

_L4:

throw new Exception(a("\002)qUe7egDs1,rM6:*g@6<$yQ"));

_L7:

System.out.println(String.valueOf(String.valueOf((

new StringBuffer(a("\001 zP\177<\"4Ys!6uSsr1{\024~=6'\024"})

).append(s).append(a("he")).append(s1))));

String.valueOf(String.valueOf(

(new StringBuffer(a(")j"))).append(s).append(":")

.append(b).append(a(")&|Ub! fBs "))));

_L6:

String s2;

s2;

covertjava.chat.b b1 = (covertjava.chat.b)Naming.lookup(s2);

MessageInfo messageinfo = new MessageInfo(e, f);

b1.receiveMessage(s1, messageinfo);

System.out.println(a("\037 gGw5 4Gs<14@yr-{Gbr"}.concat(String.valueOf

?(String.valueOf(s))));

if(flag)

b.c = !b.c;

return;

}

现在完全混乱啦!sendMessage只不过是一个相当小的方法,几乎没有什么条件逻辑。如果控制流程的混淆应用于更复杂的带有for循环、if声明和局部变量的方法,混淆就会更有效。
3.3.5 插入讹用的代码

插入讹用的代码是一种在某种程度上不确定的技术,在某些混淆程序中用于防止混淆的类被反编译,这种技术是基于Java运行时对Java字节码规范的不精确解释。JRE没有严格加强字节码格式验证的所有规则,这就允许混淆程序将不正确的字节码引入到类文件中。所引入的代码不会影响原始代码的执行,但在企图反编译类文件时就会造成失败——或最多生成了充满关键字JVM INSTR的难懂的源代码。程序清单3.3显示了反编译程序如何处理讹用的代码。使用这一方法的风险就是讹用的代码不能运行在更接近规范的JVM版本上。即使当前对多数JVM还不是问题,但今后可能会成为麻烦。
3.3.6 删除未使用的代码(压缩)

作为一个额外的优点,大多数混淆程序都会删掉未使用的代码,这样会减小应用程序的规模。例如:假设被命名为A的类中有个叫做m()的方法从未被任何类调用,m()的代码就从A的字节码中被删除。这一特征对于从Internet上下载的或在不安全环境中安装的代码特别有用。
3.3.7 优化字节码

混淆程序所吹嘘的另一个优点是潜在的代码优化。供应商宣称,在可行的位置将非最终方法声明为最终的,执行较少的代码改进能够帮助提高执行速度。很难评测所获得的实际性能,而且大多数供应商不公布评测标准。在此需要注意的是,随着每个新版本的发布,JIT编译程序正变得越来越强大。所以,像方法最终化和无用代码消除这样的特性是最可能由其执行的。

3.4 了解最佳的混淆程序

目前有很多种混淆程序,其中大多数都包含相同的核心特性。表3-1只包括一部分最流行的产品,免费和付费的都有。

表3-1 流行的混淆程序

产 品 名 称


KLASSMASTER


PROGUARD


RETRO GUARD


DASH-O


JSHRINK

版本号


4.1


1.7


1.1.13


2.x


2.0

价格


$199–$399


免费


免费


$895–$2995


$95

是否删除调试信息
















是否对名称进行处理
















是否对Java字符串编码
















是否改变控制流
















(续表)

产 品 名 称


KLASSMASTER


PROGUARD


RETRO GUARD


DASH-O


JSHRINK

是否插入讹用的代码
















是否删除未使用的代码(压缩)
















是否优化字节码
















脚本语言和混淆控制的灵活性


优秀


优秀


一般


未做评价


一般

堆栈跟踪的重构


















由于商业应用软件具有知识产权,所以我首推Zelix KlassMaster,理由就是其特有的控制流混淆。这一技术使得混淆的代码真正难以破解,所以这一产品确实值得您为其付钱。在编写此书时,它是已知的惟一具有这一特性的混淆程序。ProGuard可以免费从www.sourceforge.net上得到,是对预算很在乎的用户的不需要商业级的保护的应用的最佳选择。

3.5 潜在问题和通用解决方案

混淆是一个相当安全的过程,可以完整的保留应用程序的功能。但某些情况下,混淆程序所执行的变换可能会不经意地破坏以前可以运行的代码。接下来看看一些常见的问题和推荐的解决方案。
3.5.1 动态类加载

只要在整个系统中,包、类、方法和变量的名称一致的改变,就可以很好地处理它们的重命名。混淆程序保证字节码内的所有静态索引都会更新,映像到新的名称。但是,如果代码是使用Class.forName()或ClassLoader.load Class()执行动态类加载,传递一个原始类名称,就会产生一个ClassNotFound异常事件。现代的混淆程序已经可以很好的处理这样的类,这些混淆程序试图用改变字符串来映像新的名称。如果字符串是在运行时创建或从属性文件中读取,尽管混淆程序不能对其进行处理,好的混淆程序会生成一个带有指出潜在运行时问题的警告的日志文件。

最简单的解决方案就是配置混淆程序,使之保留动态加载类的名称,类的内容,如:方法、变量和代码仍旧被转换。
3.5.2 反射

反射需要方法和字段名称的编译时方面的知识,所以也会受混淆的影响。要确保使用一个好的混淆程序并查看警告的日志文件。正像动态类加载一样,如果运行时错误是由混淆造成的,就必须从混淆中排除Class.getMethod或Class.getField中引用的方法和字段名称。

来自实践的故事

CreamTec的最具创新性的产品WebCream,可以从网站上免费下载。免费版限制为5个用户使用,要想更多的用户使用,就必须购买商用许可证。我是在乌克兰长大的,我知道很多人宁愿破解许可,将免费版转换成正常情况下价值数千美元的无限制版。在CreamTec的时候,我们使用一个简单的免费混淆程序,它只能执行名称的处理。我们认为它已经足够好了,直到一位视限制功能的商业软件为对人格的侮辱的朋友在不到15分钟内就破解了我们的许可代码。这个信息十分清晰了,我们决定采购Zelix KlassMaster尽我们的可能保护产品。在我们使用了强力的控制流程混淆再加上一些特殊窍门。我们的朋友就不能像从前很容易获取许可代码了——并且因为他不想花时间破解,他放弃了。
3.5.3 串行化

串行化的Java对象包括实例数据和有关类的信息。如果类的版本或其结构改变,就会产生一个反串行化异常事件。混淆的类可以被串行化和反串行化,但是,试图用混淆的类反串行化未混淆类的实例就会失败。这不是一个很常见的问题,大抵上,从混淆中排除可串行化的类或避免串行化的类的混用就可以解决。
3.5.4 违反命名惯例

方法的重命名可能会违反EJB等设计模式,在EJB模式中,bean的开发人员需要提供具有确定名称和签名的方法。EJB回调方法是超类或接口没有定义的方法,如:ejbCreate和ejbRemove。为这些方法提供特定签名仅仅是EJB规范所描述的一种惯例而已,由container强制实现。改变回调方法的名称违反了命名惯例,并使得bean不可用。所以时刻要注意将此类方法从混淆中排除。
3.5.5 维护的难题

最后,但不是不重要的是,混淆令维护应用程序和排除故障变得更困难了。Java异常事件处理是隔离有缺陷代码的一种有效方式,而查看堆栈跟踪一般可以告诉我们何处出错以及什么样的错误。为源代码文件名和行编号保留调试信息令运行时可以报告出错代码的准确位置。如果不够细心,混淆程序可能会禁止这一特性并使调试变得更困难,因为开发人员只能看到被混淆的类名称而不是真正的类名称和行编号。

我们在混淆的代码中至少应该保留行编号。好的混淆程序会为变换生成一个日志,其中包含原始类名称和方法与混淆后对应名称之间的对应关系。

下面是从Zelix KlassMaster为类ChatServer生成的日志文件中摘录的一段:

Class: public covertjava.chat.ChatServer => covertjava.chat.d

Source: "ChatServer.java"

FieldsOf: covertjava.chat.ChatServer

hostName => e

protected static instance => a

messageListener => d

protected registry => c

protected registryPort => b

userName => f

MethodsOf: covertjava.chat.ChatServer

public static getInstance() => a

public getRegistry(int) => a

public init() => b

public receiveMessage(java.lang.String, covertjava.chat.MessageInfo)

?NameNotChanged

public sendMessage(java.lang.String, java.lang.String) => a

public setMessageListener(covertjava.chat.MessageListener)

=> a

这样,如果异常事件堆栈跟踪显示的是covertjava.chat.d.b方法,我们就可以利用日志找到它原来在一个原名为covertjava.chat.ChatServer的类中叫做“init”。如果这个异常事件出现在covertjava.chat.d.a中,我们肯定就不知道原方法名称了,因为存在多种映射关系(正好证明了重载的强大)。这就是行编号非常重要的原因。通过使用日志文件和最初源文件中的行编号,我们很快就可以确定应用程序代码中出问题的区域。

一些混淆程序提供了一种重构堆栈跟踪的工具。这样很方便就可以获得被混淆的堆栈跟踪的真实堆栈跟踪信息。这一工具与我们前面所采用的方法相同,但它是自动完成任务的——那我们为什么不节省点时间呢?而且它还允许搅乱行编号从而提供特别的保护。

3.6 运用Zelix KlassMaster混淆一个Chat应用程序

尽管每种混淆程序都有其自己的配置变换格式,但都支持一组共同的特性。Chat应用程序不包含最新的算法和正在申请专利的创造,但很合心意,所以我们就用Zelix KlassMaster将其保护起来,不让黑客和窃贼窃 听。

首先要获得Zelix KlassMaster的一个副本,将其安装在本地机器上。记住:我们建议的Chat应用程序的根目录为CovertJava。接下来从Zelix KlassMaster的安装目录下将ZKM.jar复制到的项目的lib目录下,这样就能针对其编写脚本文件。最简单的方式就是用KlassMaster的GUI创建混淆脚本文件。在lib目录中用java -jar ZKM.jar命令运行GUI。然后在出现的初始帮助对话框中选择Set Classpath选项。现在选择将要使用的运行时的JDK库文件,在接下来出现的Open Classes对话框中,选择CovertJava/lib/chat.jar。之后,KlassMaster就会加载Chat应用程序的所有类,我们可以看到字节码的内部结构。屏幕如图3-1所示。

在使用GUI时,很容易看到KlassMaster是多么灵活。我们可以手动改变类、方法和字段的名称;修改类和方法的可视性;将方法设为最终,改变文本字符串以及其他很酷的事,KlassMaster尝试在整个加载的代码中传播这些变更,所以如果其他的类引用了某一方法,而我们改变了这一方法的名称,则所引用的类也会更新并反映这一改变。在完成所有改变后,就可以保存类或首先整理并混淆之。加载到GUI环境中的类在混淆后还可以进一步修改,尽管我想不出有何原因有些人需要这样做。欲了解KlassMaster的特性的详细资料和用法请参考用户手册。

0672326388 #1 2.9.4

图3-1 Chat的类加载到KlassMaster的GUI上

每种好的Java应用程序都提供构造它的脚本文件,所以我们也要将混淆整合到构造脚本文件中。我们开始使用KlassMaster的GUI创建混淆脚本文件。然后手动更新使其更为灵活。手动编写脚本文件或复制和修改示例脚本文件是完全可能的。运行GUI并从Tool菜单中选择ZKM Script Helper,然后进行如下操作:

1. 阅读说明页上的指令并单击Next。

2. 在Classpath Statement页上选择rt.jar并单击Next。

3. 在Open Statement页上找到CovertJava/distrib/chat.jar并单击>来选择打开。我们只需要一个文件,因为所有的应用程序类都打包在里面了。单击Next。

4. 在TrimExclude Statement页上,默认的排除项被预先设置以排除混淆时可能会产生错误的情况。例如:重命名EJB实现类的方法会造成它不可用,所以EJB是默认被排除的。

5. 在Trim Statement页上,选择复选框Delete Source File Attributes和Delete Deprecated Attributes去除调试信息;然后单击Next。





6. 在Exclude Statement页上的Don’t Change Main Class Name组合框中选择covertjava.chat.ChatApplication保留其名称。这就保持JAR清单条目有效,用户可以继续用人可读的名称调用chat。

7. 在Obfuscate Statement页上,选择Obfuscate Control Flow组合框中的Aggressive。然后在Encrypt String Literals组合框中选择Aggressive,并在Line Number Tables组合框中选择Scramble。这就确保给代码足够的保护,而且以后我们还可以变换堆栈跟踪。在确认Produce a Change Log File被选中后单击Next。

8. 在SaveAll Statement页上,找到CovertJava/distrib并创建一个名为obfuscated的子目录。选择新建的目录作为输出,单击Next。

9. 下一页将显示脚本文字并允许我们将其保存在目录中。将其在CovertJava/build目录中保存为obfuscate_script.txt,退出GUI。最终的脚本应类似于程序清单3.4。

程序清单3.4 由GUI生成的混淆脚本

/***********************************************************/

/* Generated by Zelix KlassMaster 4.1.1 ZKM Script Helper 2003.08.13 17:03:43 */

/***********************************************************/

classpath "c:\java\jdk1.4\jre\lib\rt.jar"

"c:\java\jdk1.4\jre\lib\sunrsasign.jar"

"c:\java\jdk1.4\jre\lib\jsse.jar"

"c:\java\jdk1.4\jre\lib\jce.jar"

"c:\java\jdk1.4\jre\lib\charsets.jar";



open "C:\Projects\CovertJava\distrib\chat.jar";



trim deleteSourceFileAttributes=true

deleteDeprecatedAttributes=true

deleteUnknownAttributes=false;



exclude covertjava.chat.^ChatApplication^ public static main(java.lang.String[]);

obfuscate changeLogFileIn=""

changeLogFileOut="ChangeLog.txt"

obfuscateFlow=aggressive

encryptStringLiterals=aggressive

lineNumbers=scramble;

saveAll archiveCompression=all "C:\Projects\CovertJava\distrib\obfuscated";

用相对路径代替绝对路径是个好主意,所以脚本文件打开的不是C:\Projects\CovertJava\distrib\chat.jar而是distrib\chat.jar。最后,通过声明一个定制任务和添加调用它的目标就可以将混淆整合到构造过程中了。KlassMaster是用Java编写的,可以被任意构造脚本所调用。很实用的是,它为Ant集成提供了一个封套,所以我们必须要做的就是在Chat的build.xml添加如下内容:

<!-- Define a task that will execute Zelix KlassMaster to

obfuscate classes -->

<taskdef name="obfuscate" classname="ZKMTask" classpath="${basedir}/lib/ZKM.jar"/>

...

<!-- Define a target that produces obfuscated version of Chat -->

<target name="obfuscate" depends="release">

<obfuscatescriptFileName="${basedir}/build/obfuscate_scr

ipt.txt"

logFileName="${basedir}/build/obfuscate_log.txt"

trimLogFileName="${basedir}/build/obfuscate_trim_log.txt"

defaultExcludeFileName="${basedir}/build/obfuscate_de

faultExclude.txt"

defaultTrimExcludeFileName="${basedir}/build/obfusca

te_defaultTrimExclude.txt"

defaultDirectoryName="${basedir}"

/>

</target>

现在我们在混淆的目标上运行Ant。如果构造得成功,在CovertJava/distrib/obfu scated中就会创建一个新文件(chat.jar)。这个文件包含Chat混淆后的版本,还可以使用java -jar chat.jar命令调用它。花点时间看看JAR的内部,尝试反编译一些类。

在结束使用KlassMaster的主题前,再给一些从混淆中排除类和类成员的脚本文件格式的示例。表3-2给出的格式可以用于接收名称作为参数的混淆脚本的声明。ZKM脚本语言支持通配符,如:* (任意字母顺序)和? (任意一个字母),和布尔运算,如:||(或)和!(非)。欲了解详细情况和完整的语法,请参考的KlassMaster的文件。



表3-2 KlassMaster常用的名称格式

语 法


与之匹配的是

package1.package2.


package1和package2的包名称。其他包名称和package2的子包是不匹配的

*.


应用程序的所有包名称

Class1


类Class1的名称

package1.Class1


包package1中类Class1的名称而不是package1的名称

Package1.^Class1


包package1和类Class1的名称

package1.^Class1^ method1()


包package1、类Class1和无参数的方法method1的名称

package1.100

100^Class1^ method1(*)

3.7 破解混淆的代码

到目前为止我们已经花了大量时间讨论如何通过混淆来保护知识产权,现在用几句话来说说保护的力度。好的混淆程序确实可以使应用程序很难被破解吗?绝对是!它能保证应用程序不被破解?根本不!

除非使用控制流程的混淆,否则阅读和处理混淆的代码不是那么困难。关键点是找到一个好的反编译开始点,第2章“反编译类”中给出了几种应用程序的逆向工程技术,但混淆可以战胜大多数这种技术。例如:最有效的定位开始点方式是通过类文件进行搜索。有了字符串编码,搜索就不会得到结果,因为字符串不是以纯文本形式储存的。包名称和类名称也不能用于掌握应用程序的结构和选择好的开始点。对于适当规模的应用程序,反编译应用程序切入点和处理控制流程在技术上仍然是可能的,但这不切实际。

对于混淆流程的代码,了解应用程序实现的最明智方法是使用一个好的旧调试程序。大多数IDE都具有调试能力,但我们的示例需要一个重量级的调试程序,它不需要源代码就可以工作。为找到一个好的反编译开始点,应用程序应该运行在调试模式中。Java有一种标准的调试API,称为Debugger API(咄!),既能在远程调试也可以本地调试。远程调试可以让调试程序本身附属在运行在调试模式下的应用程序上,这也是一种首选的破译应用程序的途径。好的调试程序显示关于运行线程、每个线程调用的堆栈、加载的类和内存中对象的全面信息,还能让我们设置断点和跟踪方法的执行。处理混淆的应用程序的关键是使用正规接口(UI或编程API)寻找感兴趣的特性,然后依靠调试程序了解实现特性的一个或多个类。在确定类后,就可以像在第2章描述的那样进行反编译和研究这些类了,在第9章“用非正规调试程序破译编码”将详细介绍如何运用调试程序。

3.8 快速测试

1.Java应用程序中保护知识产权的方法是什么?

2.混淆程序提供最强大的保护是哪些变换?

3.哪些变换可能会导致本章中列出的哪个潜在问题?

4.研究混淆的代码最有效途径是什么

● 在Java字节码中,混淆是保护知识产权的最佳途径。

● 混淆执行如下一些或全部变换:去除调试信息、名称的处理、编码字符串、改变控制流程、插入讹用的代码、删除未使用的代码和优化字节码。

● 混淆带来了维护的困难,这可以通过配置混淆程序将其减到最小。

● 混淆的代码仍旧可读,除非使用了控制流程的混淆和字符串编码。

4.1 封装的问题

封装是面向对象编程的支柱之一。封装的目的是将接口与应用程序的实现相分离和模块化应用组件。一般推荐将数据成员定为私有或保护,并提供公共访问和修改器的功能(也称为获取器和设置器功能)。此外,通常还推荐将内部实现的方法定义为私有或者公共属性,以保护该类不被错误使用。遵循封装的原则可以创建一个更好的应用程序,但偶尔地,它被证明可能是使用上的一个障碍,这一点类的开发人员并不能预见到。

我们将在试验中使用java.awt.BorderLayout。或许在某种程度上,将会鼓励JavaSoft的工程师添加增加公共方法。我们可以从JDK安装目录下的src.jar中获得BorderLayout程序的源代码

4.2 访问包和保护类成员

首先演示一下如何简单地访问包可视的变量和方法。我们的示例使用了一个包可视的变量,但该技术对保护的可见性同样适用。当变量或方法没有用可视性关键字(例如:public、protected或private)声明时,就是package visible的。BorderLayout用center变量存储了按如下声明添加的BorderLayout.CE- NTER添加的组件:

package java.awt;

public class BorderLayout implements LayoutManager2, java.io.Serializable {

...

Component center;

....

}

回顾一下,包可视的成员可被声明它们的类以及所有在同一包中的类访问。在我们的示例中,任何在java.awt包中的类都可以直接访问center变量。因此一个较为简单的解决方案就是在java.awt数据库中创建一个帮助类——AwtHelper,并用它访问BorderLayout实例中的包可视成员。AwtHelper类具有一个公共的函数,它可以取得BorderLayout中的一个实例,并根据给定的配置约束条件返回组件:

package java.awt;

public class AwtHelper {

public static Component getChild(BorderLayout layout, String key)

{

Component result = null;

if (key == BorderLayout.NORTH)

result = layout.north;

else if (key == BorderLayout.SOUTH)

result = layout.south;

else if (key == BorderLayout.WEST)

result = layout.west;

else if (key == BorderLayout.EAST)

result = layout.east;

else if (key == BorderLayout.CENTER)

result = layout.center;

return result;

}

}

来自实践的故事

Webcream是将Java Awt和Swing应用程序转换为交互式HTML网站的产品。它通过仿效在服务器端运行的图形用户界面(graphical user interface,GUI)程序中的图形环境,捕捉并转化当前的窗口到HTML网页中来实现这一功能。为生成HTML,Webcream遍历了所有的容器并试图用HTML表格模拟Java布局。其中Webcream需要支持的一个布局就是BorderLayout。对于有BorderLayout的容器,HTML透视模块需要知道哪一子组件已添加到了South部分,哪一子组件已添加到North部分,诸如此类。BorderLayout在south、north等成员变量中存储这一信息。现在的问题是这些变量被定义为包可视,而getchild()方法则被定义为私有。为解决不能公开访问BorderLayout子组件的问题,Webcream工程师不得不依靠本章所要讲述的破解技术。

下面来编写一个名为covertjava.visibility.PackageAccessTest的测试类,该类使用AwtHelper从Chat的MainFrame中获得了分割面板的实例。下面摘录的源代码是我们最感兴趣的部分:

Container container = createTestContainer();

if (container.getLayout() instanceof BorderLayout) {

BorderLayout layout = (BorderLayout)container.getLayout();

Component center = AwtHelper.getChild(layout, BorderLayout.CENTER);

System.out.println("Center component = " + center);

}

我们获得了容器的布局信息,如果是BorderLayout,就用AwtHelper获得中心组件。Chat的MainFrame在中心单元中有分割面板;因此,如果该代码编写正确,就会在系统控制板上看到一个JSplitPane实例。运行PackageAccessTest,就会得到如下异常事件:

java.lang.SecurityException: Prohibited package name: java.awt

之所以引发异常事件是因为java.awt被认为是一个系统命名空间,不应为常规类所用。如果我们尝试破译一个第三方类的包可视的成员,此类情况就不会发生,但是我们特意选取一个系统类去描述一个实际的示例。如果包是密封的,则用于非系统命名空间(如com.mycompany.mypackage)的技术的潜在问题就会出现。把helper类加入到一个密封的数据包需要使用的技术与在第5章“替换和修补应用类”中解释的用于添加修补类的技术相同。

添加系统类比较困难,因为系统类的加载和处理都不同于应用类。在第16章“截取控制流”中,将要综合讨论系统类的问题。尽管如此,目前足以保证将一个类加载到系统包中,但该类必须被放置到启动类路径中。在java命令行,可以将一个目录或者JAR文件用-Xbootclasspath参数添加到启动类路径中。由于我们已经有了一个Chat程序的patches子目录,所以也将其用作系统类的子目录。修改build.xml,将含有AwtHelper的java.lang目录转移到distrib/patches目录下,并在distrib/bin目录下创建一个新的脚本文件(package_access_test.bat),如下所示:

@echo off

set CLASSPATH=..\lib\chat.jar

java -Xbootclasspath/p:..\patches covertjava.visibility.PackageAccessTest

运行package_access_test.bat,产生如下输出:

C:\Projects\CovertJava\distrib\bin>package_access_test.bat

Testing package-visible access

Center component = javax.swing.JSplitPane[,0,0,0x0,...]

必须将类放置在系统启动类路径上,这使得部署更为费劲,因为它需要修改启动脚本。例如,部署到诸如Tomcat 或WebLogic等(Web)容器中的Web应用程序就不能再简单地通过控制台或者应用程序部署目录进行部署。启动应用服务器的脚本文件必须修改为包含-Xbootclasspath参数。这一技术的另一个缺点是它不适用于私有成员。最后,把类加入到包可能会违反使用许可协议。对于BorderLayout就是如此,因为在Sun公司java使用许可协议中,有一部分明确禁止将类加入到任何以java开始的包中。下一节将介绍另一个可以部分解决这些问题的替代方法。
4.3 访问私有类成员

私有类成员只能由声明它们的类才可以访问。这是Java语言保证其封装的基本规则之一。但果真如此么?在所有时刻(编译时和运行时)它都保证成立吗?也许您会说:“哦,这家伙既然这么写了,一定有某种例外。”如果您是这样认为的,那么您就对了。在编译时,Java编译程序保证了私有成员的私有特性。从而,一个类的私有方法和私有成员变量不能被其他类静态引用。然而,Java具有一种强大的反射机制,使得在运行时可以查询以及访问变量和方法。由于反射是动态的,因此编译时的检查就不再适用了。取而代之的是,Java运行时依靠一种安全管理器(如果它存在)来检验调用代码对某一特定的访问而言是否有足够的权限。安全管理器提供了足够的保护,因为所有反射的API函数在执行自身逻辑之前都必须委派给它。破坏这种保护机制的原因是安全管理器常常没有被设置。默认情况下,安全管理器是没有被设置的,除非代码明确地安装一个默认的或定制的安全管理器,否则运行时的访问控制检查并不起作用。即使设置了安全管理器,典型情况是通过一个政策文件来配置它,这一政策文件可以扩展为允许访问反射的API。

如果仔细观察BorderLayout类,就会发现它已经具有一个基于位置关键字返回子组件的名为getChild的方法,该方法具有如下签名:

private Component getChild(String key, boolean ltr)

这听起来很不错,因为不必编写自己的实现代码了。但问题是这一方法被声明为私有的并且没有可以调用它的公共方法。为了充分利用现有的JDK代码,就必须使用反射API来调用BorderLayout.getChild()。我们将继续使用与前几节相同的测试结构。不过这一次验证类不再使用AwtHelper,测试类委派给自己的助手函数(getChild()):

public class PrivateAccessTest {

public static void main(String[] args) throws Exception {

Container container = createTestContainer();

if (container.getLayout() instanceof BorderLayout) {

BorderLayout layout = (BorderLayout)container.getLayout();

Component center = getChild(layout, BorderLayout.CENTER);

System.out.println("Center component = " + center);

}

...

}

public static Component getChild(BorderLayout layout, String key) throws Exception {

Class[] paramTypes = new Class[]{String.class, boolean.class};

Method method = layout.getClass().getDeclaredMethod("getChild", paramTypes);

// Private methods are not accessible by default

method.setAccessible(true);

Object[] params = new Object[] {key, new Boolean(true)};

Object result = method.invoke(layout, params);

return (Component)result;

}

...

}

getChild()方法的实现是这一技术的核心。它通过反射机制获得方法对象,然后调用setAccessible(true),通过指定参数值为true来禁用访问控制检查,从而使得该方法可以被其他类调用。getChild()的其他部分是简单的反射API使用。运行convertjava.visibility.PrivateAccessTest就会生成与在前面部分所看到的相同的输出结果:

C:\Projects\CovertJava\distrib\bin>private_access_test.bat

Testing private access

Center component = javax.swing.JSplitPane[,0,0,0x0,...]

该结果十分令人吃惊。如果使用System.setSecurityManager或者通过命令行方式设置了安全管理器(大多数应用服务程序和中间产品通常都是这样),那么我们还需要多做一些工作。如果运行测试时传递-Djava.securi ty.manager到java命令行,就会得到如下异常事件:

java.security.AccessControlException: access denied

(java.lang.RuntimePermission accessDeclaredMembers)

为了让我们的代码能和已安装的安全管理器协同工作,必须准予通过反射访问声明的成员以及禁止访问检查的许可。这通过添加准予code base 这两种许可Java政策文件的方式来实现:

grant {

permission java.lang.RuntimePermission "accessDeclaredMembers";

permission java.lang.reflect.ReflectPermission "suppressAccessChecks";

};

最后,在目录distrib\bin下创建一个新测试脚本文件(private_access_ test.bat),添加一个命令行参数(java.security.policy)来安装政策文件:

set CLASSPATH=..\lib\chat.jar

set JAVA_ARGS=%JAVA_ARGS% -Djava.security.manager

set JAVA_ARGS=%JAVA_ARGS% -Djava.security.policy=../conf/java.policy

java %JAVA_ARGS% covertjava.visibility.PrivateAccessTest

如果已经安装了政策文件,则需要插入准予的条款。Java安全文件允许使用policy.rul.n属性来加入额外的策略文件。请参阅第7章“管理Java安全特性”,该章详细讨论了Java的安全特性和政策文件。依赖于反射API的技术也可以用于访问包和保护的成员。有了这种技术就不必在第三方提供的包中添加助手类了。但映射API的缺点在于由于它必须处理运行时的信息并且还得通过一些安全检查,所以速度相当慢。当速度是关键因素时,依靠包和受保护成员的助手类更恰当一些。另外一种可选方案是将类实例对象串行化成字节数组流,然后解析此数组流来获取成员变量的值,显然这是一个单调而又冗长的过程,不适用于暂短的变量。

4.4 快速测试

1.哪种技术可以用于获得保护变量的值?

2.哪种技术可以用于获得私有变量的值?

3.每种技术的优缺点都是什么?

● 没有被声明为public的方法和变量仍然可以访问。

● 具有package或protected可视属性的成员可以通过将助手类插入其包中或使用反射API访问。

● 具有private可视属性的成员可以使用反射API访问。

● 如果安装了安全措施管理器,Java政策文件需要更改为允许反射API的非限制访问。

5.1 当进行各种尝试都失败后应该做什么

几乎每个开发人员在某些地方都要用到其他人开发的库文件或是组件。而且在某些地方,几乎每个开发人员对一些库文件都会感到困惑并希望找到其开发人员,咨询为什么一个方法被设计为私有的。当然,我们中间的大多数人还不会到那个程度,但的确能够改变使得我们的生活变痛苦的事情还是很好的。并不是因为这些库是由水平很差的人编写的;只是因为即便是最聪明的设计师也不能预见到其他开发人员使用他们的代码的所有方式。

当然,温和地解决问题总是更好。如果能让供应商改进其代码,或者我们自己可以改进自然可以这么做。但严峻的现实情况是:您正在阅读的这本书证明了在现实生活中传统的方法并不总是起作用。这就是事情变得有趣之所在。话说回来,什么时候应该求助于替换和修补类呢?下面就是几种应采用黑客技术方法的情形:

● 正在使用第三方的库时,它具有所需要的功能,但没有提供公共的API——例如,直到JDK 1.4,Java Swing才开始提供一个方法来获得一组JComponent监听器。这一组件将监听器存储在不能公开访问的包可视变量中,这样就无法用程序方式找出组件是否有事件监听器。

● 正在使用第三方的类或接口时,但暴露的功能对我们的应用程序而言还不够灵活——在API中的一个简单变更就可以节省您几天的工作量或是为您提供问题的惟一解决方案。在这种情况下,您会对库文件的99%感觉都很好,但剩下的1%却让您不能有效地使用它。

● 在正使用的产品或API中存在bug,而且您等不及供应商来修复时——例如:JRun3.0在HP UX上的JVM版本检测就有一个bug。在分析Java Runtime报告的版本字符串时,会错误地推断它正在JDK旧版本下运行,从而拒绝运行它。

● 在需要非常密切与产品整合,但其体系结构却没有开放到您满意的程度时——很多框架将接口与实现分割开来。内部的接口用于访问功能,而具体的类则用于提供实现。绝大部分Java核心库都通过系统属性指定实现类。AWT工具包和SAX语法分析程序就是这样的示例,其中的实现类分别可以用java.awt.toolkit和org.xml.sax.driver系统属性来指定。如果需要为库提供不同的实现类,而该库文件又没有提供定制的方法就需要使用破解技术了。

● 正在使用的是第三方的代码,但是期望的功能不能工作时——我们不能确认是否因为没有正确使用代码还是因为代码内有bug。文献并没有提及这个问题,而且我们也没有变通方法。在第三方代码中临时插入调试和跟踪信息可以帮我们了解系统里发生了什么。

● 有一个紧急产品问题需要修复时——我们不能冒险调换新代码来提供产品的环境。问题的解决方法需要在代码中进行一些小改动,这只会影响很少几个类。

如果涉及第三方代码就可能侵犯了许可协议,所以必须保证阅读它并负责贵公司法律部门处理。版权法执行的非常严格,改变第三方代码一般都是违法的。要获得供应商的许可再执行解决方案而不要自已承担破解的责任。使用本章给出的方法的好消息是不需要对所使用的库或产品作直接改动,不必篡改代码,而是给不满意的代码提供替代功能。按这种方式,就好像是从供应商的类中得到自己的类而不用考虑方法,尽管这有些巧妙地将法律问题置于一边。让我们来看看怎样着手进行吧。

5.2 找到必须修补的类

首先要确定需要修补哪些代码。通常是很明显的,我们立即就知道具体的类或接口。如果您认为您特别聪明不需要告诉如何定位代码,那就直接跳到讲述如何修补的部分。否则,就请坐下来放松一下,学习几种实现这一目的的方法吧。
5.2.1 常用的方法

定位需要修补的类的常用方法包括:找到开始点和搜索执行序列,直到找到想要改动的代码。如果在起始点附近没有遇到想要改动的代码,就必须找到一个新的起始点重复上述过程。好的执行起始点对迅速得到结果是很关键的。很显然通常都从类着手,例如:对于API或逻辑修补,切入点应该是想要改动的接口或类。如果想要将一个类的私有方法变成公共的,起始点就应该是那些值得怀疑的类。如果需要修复导致Java异常事件中的bug,起始点就应该是堆栈跟踪顶部的类。

不管情况如何,在建立起始类之后,就应该得到源代码(如果有必要就反编译字节码),而且,如果有必要的话还应该反复研究实际需要修补的类。这一过程跟反复研究时序图很类似,从刚刚说到的方法开始检查其调用的每个类。在有好几百个类的大系统中,可能会确定好几个起始点,并从中选出到需要改动的代码最近路径的起始点。

来自实践的故事

AT&T无线公司正在升级其用于蜂窝电话激活的定单登录系统。这一系统是用Java编写的,必须从JDK 1.2移植到JDK 1.3。升级迫在眉睫,因为要修复Swing和其他Java包中的bug,而且用户迫切等待更好的选择。JDK 1.3的性能提高和优化内存消耗在作出升级决策的诸多因素中所占的比重最大。开发是在Windows系统上进行的,但产品的环境是HP的UX。在修复所有bug并在JDK 1.3下单元测试了后,Java的新版本就安装到HP的UX服务器体系结构上并开始正式的集成测试。

遗憾的是,在检测JDK版本时由于提供J2EE服务的应用服务程序中有一处bug,应用程序服务器不能启动。在从系统属性分析版本字符串中有一处错误,但由于在Windows和Unix上是不一样的,所以直到集成测试阶段,这个bug才浮出水面。应用服务程序拒绝在HP上运行,认为自己是运行在Java的一个早期版本上,这时候再要回到JDK 1.2上已经太晚了,而且也没有解决问题的补丁。为了挽救这一项目,工程师们诉诸于反编译进行版本核对的应用服务程序中的类并自行修复bug。在部署了补丁以后,服务程序启动并在工作环境下运行良好。可惜的是没有一个工程师被派到牙买加旅游——管理者先前承诺过的——也许这就是生活吧。
5.2.2 搜寻文本串

一个大的复杂系统包含很多包和成百上千的类。如果没有一个清晰的起始点,在试图研究应用程序逻辑时就很容易迷失方向。考虑一下应用服务器,如WebLogic的启动代码。在启动过程中,WebLogic要执行几百个任务,会用到很多线程来完成这些任务,我建议您不要从类weblogic.Server开始用反复研究的方式去破解它。

这种情况下最可靠的方法是基于文本来搜索已知与目标类靠近的字符串。写得好的产品和库适当配置后可以输入大量的调试信息到日志文件里。除了对维护和故障排除有明显的好处以外,这让确定出现问题的代码的位置也变得更容易。当配置应用程序使之输出详细的日志文件时,如果在某处出现问题,就可以使用最后一次成功(或第一次错误)的日志信息确定切入点。正如我们所知,字节码将字符串存储为纯文本,这也就意味着我们可以搜索所有的.class文件,找到在日志文件中看到的子串。假设在使用安全框架时,抛出了关于某个名称的Invalid username的异常事件。拒绝的原因未知,解决方案也未知。如果堆栈跟踪方式不可用,获得代码最简单的方式就是通过在框架的所有.class文件中搜索Invalid username。很可能的是在整个代码中只有一两个这样的类文件,通过反编译类文件,就会了解问题的根源所在。同样地,也可以在所有的类文件中搜索一个方法或类名称、GUI标签、HTML页的一个子串或认为是嵌入在Java代码中的任何其他字符串。
5.2.3 已混淆的代码的处理

更糟糕的一种情况是不得不处理已经混淆的代码。好的混淆程序重新命名了包、类、方法和变量。市场上最好的产品甚至将Java字符串都编码了,所以搜索跟踪信息不会有结果。这就将我们的任务陷入一点一点弄懂应用程序的尴尬境地了。在此,我们不得不使用一种更具创造性的方法了,否则就像大海捞针一样。了解混淆的原理对我们进行搜寻会有帮助。尽管混淆程序可以自由地改变应用程序的类和方法的名称,但它不能对系统的类也这样做。例如:如果一个库文件检测一个文件是否存在,如果该文件不存在就会抛出异常事件,对异常事件字符串进行二进制搜索,如果混淆程序足够智能已经将其编码,就不会得到结果了。但是,在File或FileInputStream上进行搜索,我们就会得到相关的代码。与之类似,如果应用程序不能正确读取系统日期或时间,我们就可以搜索类Calendar的方法java.util.Date或getTime。最大的问题是:混淆的类不总是在反编译后能被重新编译。要了解更多的信息请参阅第2章“反编译类”

5.3 一个需要修补的示例

接下来要修改前面给出的Chat应用程序,在对话窗口中显示用户名和主机名而不是只显示主机名。回忆一下最初的应用程序在收到每条消息时都会输出主机名并在后跟一个冒号,如图5-1所示。

图5-1 最初的Chat的主窗口

这使得应用程序的实现变得容易,但用户当然更希望看到正在发送消息的人是哪一个而不是哪个计算机在发送消息。Chat的改进是免费的开放的,但是它不存在源代码。

就像其他Java应用程序一样,字节码是打包在一个或多个JAR文件中发布的,所以首要任务就是创建一个工作目录并将所有JAR解压缩到其中。这样就很容易搜寻和直接访问.class文件了,后者就是我们搜索的目标。在创建工作目录后执行jar xf chat.jar,就会看到如下文件:

images

AboutDialog.class

ChatApplication.class

ChatServer.class

ChatServerRemote.class

MainFrame.class

MainFrame$1.class

MessageInfo.class

MessageListener.class

下面来试试前面给出的各种定位起始点的方法,看看哪种方法对这个应用程序最有效。
5.3.1 使用类名称

比较幸运的是字节码没有被混淆,所以我们可以看到类名称并且从中找出我们想要的。5秒钟的测试就会给出结论:第一眼看到的MainFrame就是最佳之选。浏览反编译的代码就会看到对话的所有记录都是通过看起来类似下面形式的appendMessage方法完成的:

void appendMessage(String message, MessageInfo messageInfo) {

if (messageInfo == null) {

this.conversation.append("<font color=\"red\”>”);

this.conversation.append("You");

}

else {

this.conversation.append("<font color=\"blue\">");

this.conversation.append(messageInfo.getDisplayName());

}

this.conversation.append(": ");

this.conversation.append("</font>");

this.conversation.append(message);

this.conversation.append("<br>");

this.txtConversation.setText(this.conversation.toString() +

"</BODY></HTML>");

}

方法的实现使用了类MessageInfo的getDisplayName()方法来获得发送方的名称。这就引导我们反编译类MessageInfo来得到getDisplayName的实现,如下所示:

public String getDisplayName() {

return getHostName();

}

通过上述代码我们发现Chat用户接口依赖于MessageInfo,并且当前的实现只使用了主机名。我们的任务就是修补MessageInfo.getDisplayName(),以便既使用主机名也使用用户名。
5.3.2 搜寻文本串

假定Chat是一个大应用程序,在多个不同包里有500多个类。希望基于类的名称猜对类就像希望自己的代码在第一次编译后就可以正确运行一样。我们需要使用一种更可靠的办法找到起始点。Chat应用程序给出了相当规范的日志信息,所以要尽量使用它。在启动后,我们给另一个用户发送一条消息,收到回复后在Java控制台上就会得到如下输出:

Initializing the chat server...

Trying to get the registry on port 1149

Registry was not running, trying to create one...

ChatApplication server initialized

Sending message to host JAMAICA: test

Received message from host JAMAICA

不难猜出,在发送或是接收到一个消息时,对话历史记录中就会追加一条新消息。十分明显,诸如作为发送方或是信息目的地的主机的信息是不会成为静态字符串的一部分的。所以,我们用Received message from host作为在工作目录中对所有.class文件的搜索准则。在搜索过程中找到一个ChatServer.class文件,可以迅速将其反编译得到ChatServer.jad。在反编译的源代码中搜索字符串会发现如下所示的方法receiveMessage():

public void receiveMessage(String message, MessageInfo

messageInfo)

throws RemoteException

{

System.out.println("Received message from host " + messageInfo.getHostName());

if(messageListener != null)

messageListener.messageReceived(message, messageInfo);

}

为ChatServer.jad搜索messageListener,就知道这是一个接口,并且知道一个名为setMessageListener()的方法设置了监听器的实例。现在我们有两个选择:一是找到实现MessageListener的类,看看(如果存在多个)哪个与ChatServer相关。另一途径是根据Java方法名称在字节码中以文本形式存储的事实。因为代码没有被混淆,我们可以在所有的类文件中搜索setMessageListener()。在此我们使用后一种方法开始搜索。在这个示例中会返回两个类:ChatServer和MainFrame。可以推断:在ChatServer上只有MainFrame担当监听器的角色,将其反编译。剩下的工作就跟前面所做的完全一样了,用类名称找到MainFrame。在这个示例应用程序中,从类名称猜测起始点被证明是更为快捷方式,但是其中包含了某种幸运的因素。对于大多数实际的应用程序而言,使用日志信息是一种更为可靠的方法。
5.3.3 运用调用堆栈搜寻程序逻辑

Java中的很多问题都通过异常事件暴露出来。异常事件可以由Java运行时类或应用程序本身给出,当然,异常事件提供的出错信息和异常事件类型通常足够解决问题了。但是编写本书的原因就是因为生活中并不是所有的事情都这么简单。我们可能得到一个NullPointerException或没有出错信息的异常事件,并且如果我们是在处理第三方代码,就没有如何处理的线索了。只要许可证没有禁止我们反编译源代码,就可以着手使用一种更便捷的方法进行搜索。

了解产生异常事件原因的最简单也是最可靠的途径就依赖于调用堆栈。我们已经注意到操作系统使用堆栈来跟踪方法的调用。如果方法A调用方法B,A的信息就放置在堆栈上。如果B进一步调用方法C,B的信息也要放置在堆栈上。随着每个方法的返回,堆栈就用于决定哪个方法应该恢复执行。总之,在Java中,我们可以访问所有堆栈,或是通过调试程序(调用异常事件上的printStackTrace())或是使用Thread.dumpStack()方法。通常对于服务器端的应用程序而言,使用调试程序的负担被认为是难以承受的,所以对于我们的示例,我建议使用后两种方法。Exception的 printStackTrace()已经广泛使用,任何人都不会感到惊奇。Thread的 dumpStack不是很常用,但它对于如果想看看谁在运行时调用方法还是非常有帮助的。简单地在正在研究的方法体上加上:

Thread.dumpStack();

并运行应用程序。每次调用方法时,都可以精确地知道异常事件如何产生以及应该检查哪些其他的方法。

调用堆栈将在第16章“截取控制流”中详细说明。

5.4 修补类以提供新逻辑

现在我们已经知道需要修补的对象了,实际的工作相对容易多了。或是直接从已发布的程序这中获得源文件,或是反编译.class文件(要确认许可证没有禁止)来获得源文件。为了便于维护,应该将所有修补的类保存在一个单独的目录中,如在patches中。重建目录结构来匹配类的包结构并用自己喜欢的IDE或阵旧的编辑器和命令行编译器对类进行修改。紧记:您可能在一定时间升级库,所以应该为插入的每段代码都加上注释。这样,在得到原始类的新版本时就可以很容易地重复上述步骤了。同样的原因,应该将自己所做的变更隔离开,并且将它们都保存在同一个文件中。如果添加了大量的代码,就要考虑创建一个帮助类并委托给这个类,这样对修补的类就可以只做很少的改动了。

在我们的示例中,必须使用UserName (HostName)模式提供方法getDisplayName()的一个新实现。然后在应用程序的安装目录创建一个名为patches的新目录,子目录名为covertjava.chat,并将MessageInfo.jad复制到该目录下,将其重命名为MessageInfo.java。因为MessageInfo已经包含了方法getUserName(),所以我们的任务就是将两个字符串连接起来。重新编写方法getDisplayName(),其代码如下:

public String getDisplayName() {

return getUserName() + " (" + getHostName() + ")";

// *** original logic patched by Alex Kalinovsky to meet new

requirements

// return getHostName();

}

然后编译该方法,完成后就可以更新应用程序,让新逻辑发挥作用。此外还要将所作的变更保存为文档,以便以后维护代码。

5.5 重构应用程序来加载和使用修补的类

这一任务使窍门实际上起作用。需要确保JVM使用了我们提供的补丁版本的类代替了旧版本。这让我们回到Java编程的基础,设置正确的CLASSPATH。是的,这一操作非常简单,我们只需要确保档案文件(Zip或JAR)或含有类补丁版本的目录出现在包含类原始版本的档案文件或目录之前的类搜索路径中。在启动应用程序并配置其CLASSPATH的脚本文件中,让这个档案文件或目录成为第一入口。将所有的补丁保存在一起并与修补的产品和库分开,这样有益于维护。在得到新版本库后,就可以覆盖旧的库JAR文件而不必担心弄散自己的补丁。当然必须要重新测试,因为补丁不仅仅依靠公共接口,也与私有实现有关,可能还需要更新补丁,以便与新的库相匹配。最后,但也是最重要的,如果需要向某人解释到底对系统做了什么变动,就可以将patches目录和文档提供给他。

常言道:“不是所有的事情都是看起来的样子(All is not what it seems.)”。我们经常自认为系统正在加载自己的新类,但事实上加载的还是旧版本的类。保证使用的是新版本的最好办法就是给新类增加一个调试跟踪,或者甚至可以使用一个粗略的System.out.println()。当运行应用程序时,记得查看跟踪信息。然后就可以删掉它。在我们的示例代码中,添加了一个静态初始化程序,以打印出标志补丁在有效运行的信息,其代码如下所示:

static {

//Log the fact that patch is in effect

System.out.println("Loaded patched MessageInfo");

}

为了实实在在的看到改动,需要更新Chat应用程序的启动脚本将补丁的目录包括进来。将bin/chat.bat复制到bin/chat_patched.bat中,并在编辑器中打开它。然后改变CLASSPATH初始化,使它包含patches目录,并在应用程序文件chat.jar之前:

set CLASSPATH=..\patches;..\lib\chat.jar

接下来保存该文件并运行它。我们发送一些测试信息给localhost,如果所做的操作无误,新屏幕就应该如图5-2所示。

如果正在修补的类是一个系统类,工作就更困难了。系统类要从启动类路径上加载——例如:java.lang.String。即使我们将修补后的类放置在CLASSPATH中作为第一个类,它也不能代替原始的类。欲了解如何修补系统类,请参阅第15章“代替和修补核心Java类”。

0672326388 #1 2.9.4

图5-2 修补后的Chat主窗口

5.6 修补封装的包

Java支持封装包(sealed package)的概念。如果包是封装的,那么这个包中的所有类都必须从同一个JAR文件中加载。对于应用程序开发人员和工具提供商而言,这被认为是防止我们了解技术的最大特性。要将包封装,必须在JAR清单文件中设定属性名为Sealed的属性值为true。

在Chat应用程序的chat.jar被封装时,运行bin\chat_patched.bat会产生如下异常事件:

Exception in thread "main" java.lang.ExceptionInInitializerError

at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)

at sun.reflect.NativeConstructorAccessorImpl.newInstance

?(NativeConstructorAccessorImpl.java:39)

at sun.reflect.DelegatingConstructorAccessorImpl.newInstance

?(DelegatingConstructorAccessorImpl.java:27)

at java.lang.reflect.Constructor.newInstance(Constructor.java:274)

...

Caused by: java.lang.SecurityException: sealing violation: package covertjava.chat is sealed

at java.net.URLClassLoader.defineClass(URLClassLoader.java:225)

at java.net.URLClassLoader.access$100(URLClassLoader.java:54)

at java.net.URLClassLoader$1.run(URLClassLoader.java:193)

at java.security.AccessController.doPrivileged(Native Method)

封装包通常很容易被破解,这对提供商来说是不幸的,而对于黑客和像我们一样好奇的人来说却是幸运的。我们所需要做的就是在JAR清单文件内将属性Sealed的属性值改成false,或是将JAR的内容解压到工作目录内并修改启动脚本,以便使用这个目录而不是原来的JAR文件。这是意图良好但却从未严格执行的一个示例

5.7 快速测试

1.在寻找需要修复的类时应该采用哪些方法?

2.为什么在定位类时使用基于文本的搜索是可行的,这种方法何时会失效?

3.在应用程序中(不是在catch块内)怎样为任意点获得调用堆栈?

4.安装补丁的必要条件是什么?

要修补应用逻辑需要:

● 定位包含逻辑的类。

● 获得类的源代码。

● 修改源代码以实现新逻辑。

● 编译和部署新类文件。

● 更新应用程序启动环境,以便加载新类。
  • 0
    点赞
  • 0
    评论
  • 0
    收藏
  • 打赏
    打赏
  • 扫一扫,分享海报

©️2022 CSDN 皮肤主题:大白 设计师:CSDN官方博客 返回首页

打赏作者

iteye_6738

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值