Android-反编译教程-全-
Android 反编译教程(全)
零、前言
《反编译 Java》最初出版于 2004 年,由于多种原因,对于对反编译感兴趣的人来说,它更像是一本深奥的书,而不是面向普通编程读者的任何东西。
早在 1998 年我开始写这本书的时候,网站上有很多小应用,一想到有人可以下载你的辛勤工作并将其逆向工程成 Java 源代码,对许多人来说是一个可怕的想法。但是小程序和拨号上网走的是同一条路,我怀疑这本书的许多读者从来没有在网页上见过小程序。
在这本书出版后,我意识到有人反编译你的 Java 类文件的唯一方法是首先侵入你的 web 服务器并从那里下载它们。如果他们做到了这一点,你就比人们反编译你的代码要担心得多。
除了一些明显的例外——比如 Corel 的 Java for Office 作为桌面应用运行,以及其他 Swing 应用——十多年来,Java 代码主要存在于服务器上。客户端浏览器上几乎没有任何东西,对类文件的零访问意味着零反编译问题。但奇怪的是,随着 Android 平台的出现,这一切都发生了变化:你的 Android 应用存在于你的移动设备上,可以被编程知识非常有限的人轻松下载并进行逆向工程。
一个 Android 应用以 APK 文件的形式下载到你的设备上,该文件包括所有的图像和资源以及存储在单个classes.dex
文件中的代码。这是一种与 Java 类文件非常不同的格式,设计用于在 Android Dalvik 虚拟机(DVM)上运行。但是可以很容易地将其转换回 Java 类文件,并反编译回原始源代码。
反编译是将机器可读代码转换为人类可读格式的过程。当一个可执行文件或者一个 Java 类文件或者一个 DLL 被反编译时,你并不能完全得到原始的格式;相反,你得到的是一种伪源代码,它通常是不完整的,而且几乎总是没有注释。但是,通常,理解原始代码就足够了。
反编译 Android 解决了编程社区中未被满足的需求。出于某种原因,反编译 Android APKs 的能力在很大程度上被忽略了,尽管对于任何有适当思维的人来说,将 APK 反编译回 Java 代码都相对容易。这本书通过观察那些试图恢复源代码的人和那些试图使用混淆等方法保护源代码的人目前使用的工具和技巧来重新平衡。
这本书是为那些想通过反编译来学习 Android 编程的人,那些只想学习如何将 Android 应用反编译成源代码的人,那些想保护他们的 Android 代码的人,以及最后,那些想通过构建一个.dex
反编译器来更好地理解.dex
字节码和 DVM 的人。
本书通过以下方式让你对反编译器和混淆器的理解更上一层楼
- Explore Java bytecode and opcode in detail.
- Study the structure of DEX file and opcode, and explain its relationship with Java class files.
- The difference between, with examples to show you how to decompile Android APK files.
- Give simple strategies to show you how to protect your code.
- Show you to build your own decompiler and obfuscator.
反编译 Android 不是一般的 Android 编程书。事实上,这与作者教你如何将想法和概念转化为代码的标准教科书完全相反。您对将部分编译的 Android 操作码转换回源代码感兴趣,这样您就可以了解最初的程序员在想什么。除了与操作码和 DVM 相关的地方,我不会深入讨论语言结构。所有的重点都放在低级虚拟机设计上,而不是语言语法上。
本书的第一部分解释了 APK 格式,并向您展示了 Java 代码是如何存储在 DEX 文件中并随后由 DVM 执行的。你还会看到反编译和混淆的理论和实践。我展示了一些反编译器的技巧,并解释了如何解开最尴尬的 APK。您了解了人们试图保护其源代码的不同方式;在适当的时候,我会揭露这些技术的任何缺陷或潜在问题,以便您在使用任何源代码保护工具之前得到适当的通知。
本书的第二部分主要关注如何编写你自己的 Android 反编译器和混淆器。您构建了一个可扩展的 Android 字节码反编译器。尽管 Java 虚拟机(JVM)的设计是固定的,但语言却不是。许多早期的反编译器不能处理 JDK 1.1 中出现的 Java 结构,比如内部类。因此,如果新的构造出现在classes.dex
中,您将准备好处理它们。
一、奠定基础
首先,在这一章中,我向你介绍了反编译器的问题,以及为什么虚拟机和 Android 平台面临如此大的风险。你了解了反编译器的历史;你可能会惊讶,它们几乎和计算机一样存在了很久。因为这是一个非常情绪化的话题,我花了一些时间来讨论反编译背后的法律和道德问题。最后,如果你想保护你的代码,你会看到一些选择。
编译器和反编译器
计算机语言的发展是因为大多数正常人不能用机器代码或其最接近的等价物,汇编程序工作。幸运的是,在计算技术发展的早期,人们就意识到人类不适合用机器代码编程。诸如 Fortran、COBOL、C、VB 以及最近的 Java 和 C#等计算机语言的发展,使我们能够以一种人类友好的格式表达我们的想法,然后可以转换成计算机芯片可以理解的格式。
最基本的是,编译器的工作是将这种文本表示或源代码翻译成一系列 0 和 1 或机器代码,计算机可以将其解释为您希望它执行的操作或步骤。它使用一系列模式匹配规则来做到这一点。词法分析器标记源代码——任何错误或不在编译器词典中的单词都会被拒绝。然后,这些标记被传递给语言解析器,它将一个或多个标记与一系列规则进行匹配,并将这些标记翻译成中间代码 (VB。NET、C#、Pascal 或 Java)或有时直接转换成机器代码(Objective-C、C++或 Fortran)。任何不符合编译器规则的源代码都会被拒绝,编译会失败。
现在你知道编译器是做什么的了,但我只是触及了皮毛。编译器技术一直是一个专门的、有时是复杂的计算领域。现代的进步意味着事情会变得更加复杂,尤其是在虚拟机领域。在某种程度上,这种驱动力来自 Java 和. net。JIT 编译器试图通过优化 Java 字节码的执行来缩短 Java 和 C++执行时间的差距。这似乎是一个不可能的任务,因为 Java 字节码毕竟是解释的,而 C++是编译的。但是 JIT 编译器技术取得了显著的进步,也使得 Java 编译器和虚拟机变得更加复杂。
大多数编译器会做大量的预处理和后处理。预处理程序通过去除所有不必要的信息,如程序员的注释,并添加任何标准的或包含的头文件或包,为词法分析准备源代码。典型的后处理器阶段是代码优化,编译器解析或扫描代码,重新排序,并删除任何冗余以提高代码的效率和速度。
反编译器(这并不奇怪)将机器码或中间代码翻译回源代码。换句话说,整个编译过程是相反的。机器码以某种方式被标记化,并被解析或翻译回源代码。不过,这种转换很少会产生原始源代码,因为信息会在预处理和后处理阶段丢失。
考虑与人类语言的类比:将 Android 包文件(APK)反编译回 Java 源代码就像将德语(classes.dex
)翻译成法语(Java 类文件),然后翻译成英语(Java 源代码)。一路上,一些信息在翻译过程中丢失了。Java 源代码是为人类而不是为计算机设计的,通常有些步骤是多余的,或者以稍微不同的顺序执行会更快。由于这些丢失的元素,很少(如果有的话)反编译产生原始源代码。
目前有一些反编译器,但是它们没有被广泛宣传。反编译器或反汇编器可用于 Clipper(瓦尔基里),FoxPro (ReFox 和 Defox),Pascal,C (dcc,decomp,Hex-Rays),Objective-C (Hex-Rays),Ada,当然还有 Java。即使是世界各地杜恩斯伯里迷们喜爱的牛顿,也不安全。毫不奇怪,对于解释型语言(如 VB、Pascal 和 Java)来说,反编译器更为常见,因为要传递大量的信息。
虚拟机反编译器
有过几次值得注意的反编译机器码的尝试。克里斯蒂娜·希福恩特斯的 dcc 和最近的 Hex-Ray 的 IDA 反编译器只是两个例子。然而,在机器码级别,数据和指令是混合在一起的,要恢复原始代码要困难得多(但并非不可能)。
在虚拟机中,代码只是简单地通过了预处理器,而反编译器的工作是逆转编译的预处理阶段。这使得解释的代码更容易反编译。当然,没有评论,更糟糕的是,没有规范,但也没有 R&D 成本。
为什么 Java 配 Android?
在我谈论“为什么是 Android?”我首先需要问,“为什么是 Java?”这并不是说所有的 Android 应用都是用 Java 编写的——我也包括 HTML5 应用。但是 Java 和 Android 是紧密结合在一起的,所以我真的不能离开另一个来讨论一个。
最初的 Java 虚拟机(JVM)被设计为在电视有线机顶盒上运行。因此,它是一个非常小堆栈的机器,使用有限的指令集将指令推入堆栈或从堆栈中取出。这使得使用相对较少的练习就能很容易理解这些说明。因为编译现在是一个两阶段的过程,JVM 还要求编译器传递大量信息,比如变量和方法名,否则这些信息是不可用的。当你试图理解反编译的源代码时,这些名字几乎和注释一样有用。
JVM 的当前设计独立于 Java 开发工具包(JDK)。换句话说,语言和库可能会改变,但是 JVM 和操作码是固定的。这意味着,如果 Java 现在易于反编译,它也很可能总是易于反编译。正如您将看到的,在许多情况下,反编译 Java 类就像运行简单的 DOS 或 UNIX 命令一样简单。
将来,JVM 很可能会被修改以停止反编译,但是这会破坏任何向后兼容性,并且所有当前的 Java 代码都必须重新编译。虽然这种情况以前在使用不同版本 VB 的微软世界中也发生过,但是除了 Oracle 之外,许多公司都开发了虚拟机。
让这种情况变得更加有趣的是,那些希望让自己的操作系统或浏览器支持 Java 的公司通常会创建自己的 JVM。Oracle 只负责 JVM 规范。这种情况已经发展到这样的程度,对 JVM 规范的任何基本改变都必须是向后兼容的。修改 JVM 以防止反编译将需要大量的手术,并且很可能会破坏这种向后兼容性,从而确保 Java 类在可预见的将来会反编译。
JDK 没有这样的兼容性限制,并且每个版本都添加了更多的功能。虽然第一批反编译器,如 Mocha,在 JDK 1.1 中引入内部类时失败了,但目前最受欢迎的 JD-GUI 完全能够处理内部类或 Java 语言的后续添加,如泛型。
在下一章中,你会学到更多关于为什么 Java 面临着反编译的风险,但是现在,这里是 Java 易受攻击的七个原因:
- 为了便于移植,Java 代码被部分编译,然后由 JVM 解释。
- Java 编译后的类包含大量 JVM 的符号信息。
- 由于向后兼容的问题,JVM 的设计不太可能改变。
- JVM 中几乎没有指令或操作码。
- JVM 是一个简单的堆栈机器。
- 标准应用对反编译没有真正的保护。
- Java 应用被自动编译成更小的模块化类。
让我们从一个简单的类文件例子开始,如清单 1-1 所示。
清单 1-1。 简单的 Java 源代码示例
public class Casting { public static void main(String args[]){ for(char c=0; c < 128; c++) { System.out.println("ascii " + (int)c + " character "+ c); } } }
清单 1-2 显示了使用 javap 的清单 1-1 中的类文件的输出,javap 是 JDK 附带的 Java 的类文件反汇编器。你可以很容易地反编译 Java,因为——正如你在本书后面看到的 JVM 是一个简单的堆栈机器,没有寄存器和有限数量的高级指令或操作码。
清单 1-2。 Javap 输出
`Compiled from Casting.java
public synchronized class Casting extends java.lang.Object
/* ACC_SUPER bit set /
{
public static void main(java.lang.String[]);
/ Stack=4, Locals=2, Args_size=1 /
public Casting();
/ Stack=1, Locals=1, Args_size=1 */
}
Method void main(java.lang.String[])
0 iconst_0
1 istore_1
2 goto 41
5 getstatic #12
8 new #6
11 dup
12 ldc #2 <String "ascii ">
14 invokespecial #9 <Method
java.lang.StringBuffer(java.lang.String)>
17 iload_1
18 invokevirtual #10 <Method java.lang.StringBuffer append(char)>
21 ldc #1 <String " character ">
23 invokevirtual #11 <Method java.lang.StringBuffer
append(java.lang.String)>
26 iload_1
27 invokevirtual #10 <Method java.lang.StringBuffer append(char)>
30 invokevirtual #14 <Method java.lang.String toString()>
33 invokevirtual #13 <Method void println(java.lang.String)>
36 iload_1
37 iconst_1
38 iadd
39 i2c
40 istore_1
41 iload_1
42 sipush 128
45 if_icmplt 5
48 return
Method Casting()
0 aload_0
1 invokespecial #8 <Method java.lang.Object()>
4 return<`
显然,一个类文件包含了大量的源代码信息。我在本书中的目的是向您展示如何获取这些信息,并将其逆向工程为源代码。我还将向您展示可以采取哪些步骤来保护信息。
为什么选择安卓?
到目前为止,除了 applets 和 Java Swing 应用之外,Java 代码通常位于服务器端,很少或没有代码在客户端运行。随着谷歌 Android 操作系统的推出,这种情况发生了变化。Android 应用,不管是用 Java 还是 HTML5/CSS 写的,都是 apk 形式的客户端应用。这些 apk 然后在 Dalvik 虚拟机(DVM)上执行。
DVM 在许多方面不同于 JVM。首先,它是基于寄存器的机器,不像基于堆栈的 JVM。DVM 使用一个具有不同结构和操作码的 Dalvik 可执行(DEX)文件,而不是将多个类文件捆绑到一个 jar 文件中。从表面上看,反编译 APK 似乎要困难得多。然而,有人已经为您做了所有的艰苦工作:一个名为 dex2jar 的工具允许您将 dex 文件转换回 jar 文件,然后可以将其反编译回 Java 源代码。
因为 apk 存在于手机上,所以可以很容易地下载到 PC 或 Mac 上,然后进行反编译。您可以使用许多不同的工具和技术来访问 APK,还有许多反编译器,我将在本书的后面介绍。但是获取源代码最简单的方法是使用市场上的任何文件管理工具,比如 ASTRO File Manager,将 APK 复制到手机的 SD 卡上。一旦 SD 卡插入 PC 或 Mac,就可以使用 dex2jar 进行反编译,然后使用您喜欢的反编译器,如 JD-GUI。
Google 已经使得在你的构建中添加 ProGuard 变得非常容易,但是在默认情况下不会发生混淆。目前(直到这个问题得到更高的关注),代码不太可能使用模糊处理来保护,所以代码很有可能被完全反编译回源代码。ProGuard 作为混淆工具也不是 100%有效,正如你在第四章和第七章中看到的。
许多 Android 应用通过 web 服务与后端系统对话。他们在数据库中寻找物品,或者完成一次购买,或者将数据添加到工资单系统,或者将文档上传到文件服务器。允许应用连接到这些后端系统的用户名和密码通常是硬编码在 Android 应用中的。因此,如果你没有保护你的代码,并且你把你的后台系统的钥匙留在你的应用中,你就冒着有人破坏你的数据库并获得他们不应该访问的系统的风险。
不太可能,但完全有可能,有人可以访问源代码,并可以重新编译应用,让它与不同的后端系统对话,并将其用作获取用户名和密码的一种方式。这些信息可以在稍后阶段使用真正的 Android 应用访问私人数据。
这本书解释了如何隐藏你的信息,不让这些窥探的眼睛看到,并提高门槛,因此找到后端服务器的钥匙或找到存储在你手机上的信用卡信息需要比基本知识多得多的知识。
在将你的 Android 应用发布到市场之前,保护好它也是非常重要的。几个网站和论坛共享 APK,因此即使你通过发布更新版本来保护你的应用,原始的未受保护的 APK 可能仍然存在于手机和论坛上。您的 web 服务 API 也必须同时更新,这迫使用户更新他们的应用,并导致糟糕的用户体验和潜在的客户流失。
在第四章中,你会学到更多关于为什么 Android 会面临反编译的风险,但是现在这里列出了 Android 应用易受攻击的原因:
- 有多种简单的方法可以访问 Android APKs。
- 将 APK 翻译成 Java jar 文件以便后续反编译是很简单的。
- 到目前为止,几乎没有人使用混淆或任何形式的保护。
- 一旦 APK 被释放,就很难取消访问。
- 使用 apktool 等工具,一键反编译是可能的。
- apk 在黑客论坛上分享。
清单 1-3 显示了来自清单 1-1 的Casting.java
文件在转换成 DEX 格式后的 dexdump 输出。如您所见,这是类似的信息,但采用了新的格式。第三章更详细地介绍了不同之处。
清单 1-3。dex dump 输出
`Class #0 -
Class descriptor : 'LCasting;'
Access flags : 0x0001 (PUBLIC)
Superclass : 'Ljava/lang/Object;'
Interfaces -
Static fields -
Instance fields -
Direct methods -
0 : (in LCasting;)
name : '
type : '()V'
access : 0x10001 (PUBLIC CONSTRUCTOR)
code -
registers : 1
ins : 1
outs : 1
insns size : 4 16-bit code units
catches : (none)
positions :
0x0000 line=1
locals :
0x0000 - 0x0004 reg=0 this LCasting;
1 : (in LCasting;)
name : 'main'
type : '([Ljava/lang/String;)V'
access : 0x0009 (PUBLIC STATIC)
code -
registers : 5
ins : 1
outs : 2
insns size : 44 16-bit code units
catches : (none)
positions :
0x0000 line=3
0x0005 line=4
0x0027 line=3
0x002b line=6
locals :
Virtual methods -
source_file_idx : 3 (Casting.java)`
反编译器的历史
关于反编译器的历史写得很少,这很令人惊讶,因为几乎每个编译器都有一个反编译器。让我们花一点时间来谈谈它们的历史,这样你就能明白为什么这么快就为 JVM 和 DVM 创建了反编译器。
自从简陋的 PC 出现之前——不要说了,自从 COBOL 出现之前,反编译器就已经以这样或那样的形式存在了。你可以一直追溯到 ALGOL,找到最早的反编译器的例子。早在 1960 年,Joel Donnelly 和 Herman Englander 就在美国海军电子实验室(NEL)实验室编写了 D-Neliac。它的主要功能是将非 Neliac 编译的程序转换成与 Neliac 兼容的二进制文件。(Neliac 是一种 ALGOL 类型的语言,代表海军电子实验室国际 ALGOL 编译器。)
多年来,已经有了其他针对 COBOL、Ada、Fortran 和许多其他深奥的主流语言的反编译器,它们运行在 IBM 大型机、PDP-11 和 UNIVACs 等之上。这些早期开发的主要原因可能是翻译软件或转换二进制文件以在不同的硬件上运行。
最近,规避 2000 年问题的逆向工程成为反编译的可接受的一面——转换遗留代码以规避 2000 年问题通常需要反汇编或完全反编译。但是逆向工程是一个巨大的增长领域,并没有在世纪之交后消失。道琼斯指数触及 10,000 点大关和欧元的引入所引发的问题导致了金融计划的失败。
逆向工程技术也被用来分析旧代码,这些代码通常有成千上万的增量变化,以消除冗余并将这些遗留系统转换成更高效的动物。
在一个更基本的层面上,PC 机代码的十六进制转储给程序员提供了额外的洞察力,让他们了解某些东西是如何实现的,并被用来打破对软件的人为限制。例如,包含游戏和其他工具的定时炸弹或限制副本的杂志光盘经常被打补丁,以将演示副本更改为软件的完整版本;这通常是用原始的反汇编程序完成的,如 DOS 的调试程序。
任何精通汇编语言的人都可以学会快速发现代码中的模式,并绕过适当的源代码片段。盗版软件是软件业的一个大问题,而分解代码只是专业和业余盗版者使用的一种技术。因此,许多神秘的复制保护技术都失败了。但是这些都是原始的工具和技术,从头开始编写代码可能比用汇编程序重新创建源代码更快。
多年来,传统软件公司也参与了软件逆向工程。竞赛使用逆向工程和反编译工具在世界各地研究和复制新技术。一般来说,这些都是内部的反编译器,不供公众使用。
很可能第一个真正的 Java 反编译器是由 IBM 编写的,而不是由《摩卡》的作者 Hanpeter van Vliet 编写的。丹尼尔·福特的白皮书《Jive:一个 Java 反编译器》(1996 年 5 月)出现在 IBM Research 的搜索引擎中;这打败了摩卡,摩卡直到第二年 7 月才公布。
像 dcc 这样的学术反编译器可以在公共领域获得。Hex-Ray 的 IDA 等商业反编译器也开始出现。幸运的是,对于微软这样的公司来说,使用 dcc 或 Hex-Rays 反编译 Office 会产生如此多的代码,以至于它对用户来说就像 debug 或十六进制转储一样友好。大多数现代商业软件的源代码是如此之大,以至于没有设计文档和大量的源代码注释就变得难以理解。让我们面对现实吧:许多人的 C++代码在他们写出来六个月后仍然很难读懂。对于其他人来说,在没有帮助的情况下破译来自编译 C++代码的 C 代码有多容易?
更仔细地回顾解释型语言:Visual Basic
让我们以 VB 为例来看看早期版本的解释语言。VB 的早期版本由它的运行时模块vbrun.dll
以一种有点类似于 Java 和 JVM 的方式解释。像 Java 类文件一样,VB 程序的源代码被捆绑在二进制文件中。奇怪的是,VB3 比 Java 保留了更多的信息——甚至包括程序员的注释。
最初版本的 VB 生成了一个中间伪代码,叫做 p-code ,是 Pascal 语言,起源于 P-System ( [www.threedee.com/jcm/psystem/](http://www.threedee.com/jcm/psystem/)
)。在你说任何事情之前,是的,Pascal 和它的所有衍生物都同样容易被反编译——包括微软早期版本的 C 编译器,所以没有人会觉得被遗漏了。p 代码与字节码没有什么不同,本质上是在运行时由vbrun.dll
解释的 VB 操作码。如果你曾经想知道为什么你需要在 VB 可执行文件中包含vbrun300.dll
,现在你知道了。你必须包含vbrun.dll
,这样它才能解释 p 代码并执行你的程序。
来自德国的 H. P. Diettrich 博士是同名著作《DoDi——也许是最著名的 VB 反编译器——的作者。曾经,VB 有一种反编译器和混淆器(或保护工具,因为它们在 VB 中被称为)的文化。但是随着 VB 转向编译代码而不是解释代码,反编译器的数量急剧减少。DoDi 在其网站上免费提供了 VBGuard,其他来源也提供了诸如 Decompiler Defeater、Protect、Overwrite、Shield 和 VBShield 等程序。但是它们也随着 VB5 和 VB6 一起消失了。
那当然是以前了。NET,这又兜了一圈:VB 再一次被演绎。毫不奇怪,许多反编译器和混淆器再次出现在。NET 世界,如 ILSpy 和 Reflector 反编译器,以及风度和 Dotfuscator 混淆器。
汉彼得·范·弗利特和摩卡
奇怪的是,作为一个技术主题,这本书也有非常人性化的元素。1996 年,在荷兰从癌症手术中康复期间,Hanpeter van Vliet 编写了第一个公共领域反编译器 Mocha。他还编写了一个名为 Crema 的混淆器,试图保护 applet 的源代码。如果说摩卡是乌兹机枪,那么克莉玛就是防弹衣。在如今经典的互联网营销策略中,摩卡是免费的,而克莉玛则收取少量费用。
当摩卡的测试版首次出现在汉彼得的网站上时,引起了巨大的争议,尤其是在 CNET 的一篇文章中。由于争议,汉彼得采取了非常光荣的步骤,从他的网站上删除了摩卡。然后他允许他网站的访问者投票决定是否应该再次提供摩卡咖啡。投票结果是十比一支持摩卡,很快它就重新出现在了汉彼得的网站上。
然而,摩卡从未走出测试版。在为一篇关于这一主题的网络技术文章做研究时,我从他的妻子英格丽德那里得知,汉彼得的喉癌最终夺去了他的生命,他于 1996 年新年前夕去世,享年 34 岁。
克莉玛和摩卡的源代码在汉彼得去世前不久卖给了博兰,所有收益归英格丽所有。JBuilder 的一些早期版本附带了一个混淆器,可能是 Crema。它试图通过用控制字符替换 ASCII 变量名来保护 Java 代码不被反编译。
我将在本书的后面更多地讨论其他 Java 反编译器和混淆器的宿主。
反编译时要考虑的法律问题
在您开始构建自己的反编译器之前,让我们借此机会考虑一下为了您自己的享受或利益而反编译他人代码的法律含义。仅仅因为 Java 将反编译技术从一些非常严肃的领域转移到了更主流的计算领域,并不能减少你或你的公司被起诉的可能性。这可能会让它更有趣,但你真的应该小心。
作为一小组基本规则,请尝试以下方法:
- 不要反编译一个 APK,重新编译它,然后把它当成你自己的。
- 不要想把重新编译的 APK 卖给任何第三方。
- 尽量不要反编译带有明确禁止反编译或逆向工程代码的许可协议的 APK 或应用。
- 不要反编译一个 APK 来移除任何保护机制,然后为了你自己的使用而重新编译它。
保护法
在过去的几年里,当涉及到反编译软件时,大企业已经使法律坚定地向它倾斜。公司可以使用许多法律机制来阻止你反编译他们的软件;如果你因为一家公司发现你反编译了它的程序而不得不出现在法庭上,你几乎没有或者根本没有法律辩护。专利法、版权法、包覆许可证中的反逆向工程条款,以及诸如数字千年版权法(DMCA)等许多法律都可能被用来对付您。不同的国家或州可能适用不同的法律:例如,“无反向工程条款”软件许可证在欧盟(EU)是无效条款。但基本概念是相同的:为了将代码克隆到另一个竞争产品中而反编译一个程序,你可能违反了法律。秘诀在于,你不应该站着、跪着或非常用力地压制原作者的合法权利(版权)。这并不是说反编译是不可行的。在某些有限的条件下,法律倾向于通过所谓的合理使用的概念进行反编译或逆向工程。几乎从时间的开端,当然也是从工业时代开始,许多人类最伟大的发明都来自那些站在巨人肩膀上创造出一些特别东西的人。例如,蒸汽火车和电灯泡的发明是相对温和的技术进步。其他人提供了底层的概念,而最终的对象是由像乔治·斯蒂芬森或托马斯·爱迪生这样的人来创造的。(你可以在[www.usgennet.org/usa/topic/steam/Early/Time.html](http://www.usgennet.org/usa/topic/steam/Early/Time.html)
看到斯蒂芬森欠许多其他发明家的债务的一个很好的例子,比如詹姆斯·瓦特。这是专利出现的原因之一:允许人们建立在其他发明的基础上,同时仍然给最初的发明者一些补偿,比如说 20 年。
双亲
在软件领域,商业秘密通常受到版权法的保护,并且越来越多地受到专利的保护。专利可以保护程序的某些部分,但是一个完整的程序被一个专利或一系列专利保护的可能性很小。软件公司希望保护他们的投资,所以他们通常求助于版权法或软件许可证来防止人们从本质上窃取他们的研究和开发成果。
版权
但是版权法并不是坚如磐石,因为否则就没有为一个想法申请专利的动机,专利局也会很快倒闭。版权保护并不延伸到计算机程序的接口,如果开发者能够证明他们已经反编译了程序,以查看他们如何能够与程序中任何未发布的应用编程接口(API)进行互操作,那么他们可以使用合理使用辩护。
计算机程序法律保护指令
如果你住在欧盟,那么你很可能会受到计算机程序法律保护指令的约束。这个指令声明你可以在一定的限制条件下反编译器:例如,当你试图理解功能需求来创建一个与你自己的程序兼容的接口。换句话说,如果您需要访问第三方程序的内部调用,并且作者拒绝以任何价格泄露 API,您可以进行反编译。但是你只能用这些信息来创建你自己程序的接口,而不是创建一个有竞争力的产品。你也不能对任何受保护的区域进行逆向工程。
多年来,微软的应用据称从对 Windows 3.1 和 Windows 95 的底层未发布 API 调用中获得了不公平的优势,这些调用比已发布的 API 快几个数量级。电子前沿基金会(EFF)提出了一个有用的路线图类比来帮助解释这种情况。假设您正从底特律前往纽约,但您的地图没有显示任何州际路线;当然,你最终会通过走小路到达那里,但是如果你有一张完整的州际地图,路程会短很多。如果这些情况属实,欧盟指令将成为拆卸 Windows 2000 或微软 Office 的理由,但在尝试之前,你最好请一位好律师。
逆向工程
在美国,判例也允许法律反编译。迄今为止最著名的案例是 Sega 诉 Accolade ( [
digital-law-online.info/cases/24PQ2D1561.htm](http://digital-law-online.info/cases/24PQ2D1561.htm)
)。1992 年,Accolade 公司赢得了对世嘉公司的诉讼;裁决称,雅高未经授权拆卸世嘉目标代码并不侵犯版权。Accolade 将世嘉的二进制文件逆向工程为一个中间代码,允许 Accolade 提取一个软件密钥,使 Accolade 的游戏能够与世嘉 Genesis 视频控制台进行交互。显然,世嘉不打算让 Accolade 访问其 API,或者在这种情况下,解锁世嘉游戏平台的代码。法院支持 Accolade,判定反向工程构成了合理使用。但在你认为这给了你反编译代码的全权委托之前,你可能想知道雅达利诉任天堂([
digital-law-online.info/cases/24PQ2D1015.htm](http://digital-law-online.info/cases/24PQ2D1015.htm)
)在非常相似的情况下对雅达利不利。
法律大局
总之——你可以看出这是法律部分——美国的法院案例和欧盟指令都强调,在某些情况下,逆向工程可以用于理解互操作性和创建程序接口。它不能用来制作复制品,并作为竞争产品出售。大多数 Java 反编译不属于互操作性范畴。更有可能的是,反编译器想要盗版代码,或者,充其量,理解软件背后的基本思想和技术。
尚不清楚通过逆向工程来发现 APK 是如何创作的是否构成合理使用。美国 1976 年的版权法排除了“任何想法、程序、过程、系统、操作方法、概念、原则或发现,无论其描述的形式如何”,这听起来像是辩护的开始,也是越来越多的软件专利被颁发的原因之一。反编译盗版或非法出售软件无法辩护。
但从开发商的角度来看,形势看起来很黯淡。唯一的保护——用户许可证——几乎和禁止盗版 MP3 的法律一样有用。它不会从物理上阻止任何人进行非法复制,也不会对家庭用户起到真正的威慑作用。没有任何法律手段可以保护你的代码免受黑客的攻击,有时,试图创建当今安全系统的人似乎感觉自己站在了白痴的肩膀上。你只需要看看对电子书保护计划的调查([
slashdot.org/article.pl?sid=01/07/17/130226](http://slashdot.org/article.pl?sid=01/07/17/130226)
)和 DeCSS 惨败([
cyber.law.harvard.edu/openlaw/DVD/resources.html](http://cyber.law.harvard.edu/openlaw/DVD/resources.html)
)就能明白许多所谓的安全系统实际上有多脆弱。
道德问题
反编译是学习 Android 开发和 DVM 如何工作的一个很好的方法。如果你遇到一种你以前没见过的技术,你可以快速反编译它,看看它是如何完成的。反编译通过看到其他人的编程技术,帮助人们爬上 Android 学习曲线。反编译 apk 的能力可以决定基本的 Android 理解和深入的知识。的确,有大量的开源示例可供参考,但是如果您可以选择自己的示例并修改它们以满足您的需求,那会更有帮助。
但是如果没有讨论窃取他人代码背后的道德问题,任何一本关于反编译的书都是不完整的。由于这种情况,Android 应用带有完整的源代码:如果你愿意,可以说是强制开源。
作者、出版商、作者的代理人以及作者代理人的母亲想要声明,我们并不是提倡本书的读者为了教育目的之外的任何目的反编译器。本书的目的是向您展示如何反编译源代码,但我们不鼓励任何人反编译其他程序员的代码,然后尝试使用、出售或重新打包,就好像这是您自己的代码一样。请不要对任何有许可协议的代码进行逆向工程,该协议声明您不应该反编译代码。这不公平,你只会给自己找麻烦。(此外,你永远无法确定反编译器生成的代码是 100%准确的。如果你打算使用反编译作为你自己产品的基础,你可能会大吃一惊。话虽如此,数以千计的 apk 可用,当反编译时,将帮助你理解好的和坏的 Android 编程技术。
在某种程度上,我是在为“不要射杀信使”辩护。我不是第一个发现 Java 中这个缺陷的人,当然也不会是最后一个写这个主题的人。我写这本书的原因,就像互联网的早期一样,基本上是利他的。换句话说,我发现了一个很酷的小技巧,我想告诉大家。
保护自己
盗版软件是许多软件公司头疼的问题,也是其他公司的大生意。至少,软件盗版者可以使用反编译器来消除许可限制;但是想象一下,如果这项技术可以反编译 Office 2010,重新编译它,并作为一种新的竞争产品出售,会有什么后果。在某种程度上,当 Corel 发布其 Office for Java 的测试版时,这种情况很容易发生。
你能做些什么来保护你的代码吗?是:
- 许可协议:许可协议并不能为想要反编译你代码的程序员提供任何真正的保护。
- 代码中的保护方案:在代码中散布保护方案(比如检查手机是否有根)是没有用的,因为这些方案可以在反编译代码之外进行注释。
- 代码指纹:这被定义为用于标记或指纹化源代码以证明所有权的伪造代码。它可以与许可协议一起使用,但只有在法庭上才真正有用。更好的反编译工具可以分析代码并删除任何虚假代码。
- 混淆:混淆将一个类文件中的方法名和变量名替换成怪异而奇妙的名字。这可能是一个很好的威慑,但源代码通常仍然是可见的,这取决于您选择的混淆器。
- 知识产权(IPR)保护方案:这些方案,如 Android Market 数字版权管理(DRM),通常会在几小时或几天内被破坏,通常不会提供太多保护。
- 服务器端代码:对 APKs 最安全的保护是隐藏 web 服务器上所有有趣的代码,只使用 APK 作为瘦前端 GUI。这样做的缺点是,您可能仍然需要在某个地方隐藏一个 API 密钥来访问 web 服务器。
- 原生代码:Android 原生开发套件(NDK)允许你在 C++文件中隐藏密码信息,这些文件可以反汇编但不能反编译,并且仍然运行在 DVM 之上。如果操作正确,这项技术可以增加一层重要的保护。它还可以与数字签名检查一起使用,以确保没有人在另一个 APK 中窃取您精心隐藏的信息。
- 加密:加密还可以与 NDK 结合使用,以提供额外的防拆卸保护,或者作为向任何后端 web 服务器传递公钥和私钥信息的一种方式。
这些选项中的前四个仅起威慑作用(一些混淆器比另一些更好),其余四个是有效的,但有其他含义。我将在本书的后面更详细地讨论它们。
总结
反编译是新 Android 程序员最好的学习工具之一。要了解如何编写 Android 应用,还有什么比从手机中取出一个例子并反编译成源代码更好的方法呢?当移动软件公司破产时,反编译也是一个必要的工具,修复代码的唯一方法就是自己反编译。但是,如果你试图保护无数小时的设计和开发投资,反编译也是一种威胁。
这本书的目的是创造关于反编译和源代码保护的对话——区分事实和虚构,并展示反编译 Android 应用有多容易,以及可以采取什么措施来保护代码。有些人可能会说反编译不是问题,开发人员总是可以被训练去阅读竞争对手的汇编程序。但是一旦允许轻松访问 Android 应用文件,任何人都可以下载 dex2jar 或 JD-GUI,反编译就会变得容易得多。不信?然后继续读下去,自己做决定。
一、机器中的幽灵
如果你想知道混淆器或反编译器到底有多好,那么看看 DEX f
文件和相应的 Java 类文件中发生了什么会有所帮助。否则,你就要依赖第三方供应商的话,或者充其量是一个知识渊博的评论家的话。对于大多数人来说,当您试图保护任务关键型代码时,这还不够好。至少,您应该能够明智地谈论反编译领域,并提出显而易见的问题来理解正在发生的事情。
“别理窗帘后面的那个人。”
绿野仙踪
目前,来自谷歌的各种声音都在说,反编译 Android 代码没什么可担心的。大家不都是在组装层面做了好几年了吗?Java 刚起步的时候也有类似的杂音。
在本章中,您将打开一个 Java 类文件;在下一章中,您将剖析 DEX 文件格式。这将为后面关于混淆理论的章节打下基础,并在反编译器的设计过程中帮助你。为了达到这个阶段,您需要理解字节码、操作码和类文件,以及它们与 Dalvik 虚拟机(DVM)和 Java 虚拟机(JVM)的关系。
市场上有几本关于 JVM 的非常好的书。最好的是比尔·凡纳斯在 Java 2 虚拟机内部的(麦格劳-希尔,2000)。这本书的一些章节可以在网上[www.artima.com/insidejvm/ed2/](http://www.artima.com/insidejvm/ed2/)
找到。如果你找不到这本书,那就去看看 Venners 同样优秀的关于 JavaWorld.com 的文章。这一系列的文章是他后来扩展成书的原始素材。Sun 的 Java 虚拟机规范,第二版(Addison-Wesley,1999 年),由 Tim Lindholm 和 Frank Yellin 编写,对于潜在的反编译器作者来说,既全面又非常翔实。但是作为一个规范,它不是你所说的好的读物。这本书也可以在[
java.sun.com/docs/books/vmspec](http://java.sun.com/docs/books/vmspec)
在线获得。
然而,这里的重点与其他 JVM 书籍有很大不同。我从相反的方向看待事物。我的任务是让您从字节码到源代码,而其他人都想知道源代码是如何被翻译成字节码并最终执行的。您感兴趣的是如何将 DEX 文件转换成类文件,以及如何将类文件转换成源代码,而不是如何解释类文件。
本章着眼于如何将一个类文件分解成字节码,以及如何将这些字节码转换成源代码。当然,你需要知道每个字节码是如何工作的;但是你对它们在 JVM 中会发生什么不太感兴趣,因此这一章的重点也有所不同。
JVM:一个可利用的设计
Java 类文件是为通过网络或互联网快速传输而设计的。因此,它们很紧凑,也相对容易理解。为了便于移植,Java 编译器 javac 只将类文件的一部分编译成字节码。然后由 JVM 解释和执行,通常是在不同的机器或操作系统上。
JVM 的类文件接口由 Java 虚拟机规范严格定义。但是 JVM 最终如何将字节码转换成机器码,这取决于开发人员。这真的与你无关,因为你的兴趣再次停留在 JVM 上。如果您认为类文件类似于其他语言(如 C 或 C++)中的目标文件,等待被 JVM 链接和执行,只是带有更多的符号信息,这可能会有所帮助。
一个类文件携带如此多的信息有很多原因。许多人认为互联网有点像现代的蛮荒西部,在那里,骗子正密谋用病毒感染你的硬盘,或者等着窃取任何可能经过他们的信用卡信息。结果,JVM 被设计成自下而上地保护 web 浏览器免受流氓小程序的攻击。通过一系列的检查,JVM 和类加载器确保没有恶意代码可以上传到网页上。
但是所有的检查都必须以极快的速度执行,以减少下载时间,所以最初的 JVM 设计者选择一个简单的堆栈机器来完成这些重要的安全检查并不奇怪。事实上,JVM 的设计相当安全,尽管一些早期的浏览器实现犯了几个或三个严重的错误。如今,Java 小程序不太可能在任何浏览器中运行,但是 JVM 的设计还是一样的。
对开发人员来说不幸的是,保持代码安全的同时也使代码更容易被反编译。JVM 受限的执行环境和不复杂的体系结构,以及它的许多指令的高级本质,都对程序员不利,而对反编译器有利。
在这一点上,可能也值得一提脆弱的超类问题。在 C++中添加一个新方法意味着所有引用该类的类都需要重新编译。Java 通过将所有必要的符号信息放入类文件来解决这个问题。然后,JVM 负责链接和最终的名称解析,动态加载所有需要的类——包括任何外部引用的字段和方法。这种延迟的链接或动态加载,可能是 Java 更容易被反编译的原因。
顺便说一下,我在这些讨论中忽略了本机方法。本地方法当然是包含在应用中的本地 C 或 C++代码。使用它们会破坏 Java 应用的可移植性,但这是防止 Java 程序被反编译的一种可靠方法。
事不宜迟,让我们简单看一下 JVM 的设计。
简易堆垛机
JVM 本质上是一个简单的堆栈机器,有一个程序寄存器来管理程序流。Java 类加载器获取类并将其呈现给 JVM。
您可以将 JVM 分成四个独立的、不同的部分:
- 许多
- 程序计数器(PC)寄存器
- 方法区域
- JVM 堆栈
每个 Java 应用或 applet 都有自己的堆和方法区,每个线程都有自己的寄存器或程序计数器和 JVM 堆栈。然后,每个 JVM 堆栈被进一步细分为堆栈框架,每个方法都有自己的堆栈框架。一段话包含了很多信息。图 2-1 用一个简单的图表说明。
图 2-1。Java 虚拟机
图 2-1 中的阴影部分是所有线程共享的,白色部分是特定于线程的。
堆
让我们先处理堆,让它不碍事,因为它对 Java 反编译过程的影响很小或没有影响。
与 C 或 C++开发人员不同,Java 程序员不能分配和释放内存;它由 JVM 负责。new 操作符在堆上分配对象和内存,当程序不再引用某个对象时,JVM 垃圾收集器会自动释放这些对象和内存。
这有几个很好的理由;安全性要求 Java 中不使用指针,这样黑客就不能突破应用进入操作系统。没有指针意味着其他东西——在本例中是 JVM——必须负责分配和释放内存。内存泄漏也应该成为过去,至少理论上是这样的。一些用 C 和 C++编写的应用因像筛子一样泄漏内存而臭名昭著,因为程序员没有注意在适当的时候释放不需要的内存——并不是说任何阅读本文的人都会犯这样的罪。垃圾收集也应该使程序员更有效率,花在调试内存问题上的时间更少。
如果您确实想了解更多关于堆中发生的事情,请尝试 Oracle 的堆分析工具(hat)。它使用 Java 2 SDK 版和更高版本生成的 JVM 堆的hprof
文件转储或快照。它被设计成“调试不必要的对象保留”(内存泄漏给你和我)。你看,垃圾收集算法,比如引用计数和标记清除技术,也不是 100%准确。类文件可能有没有正确终止的线程,ActionListener
未能注销,或者对一个对象的静态引用在该对象应该被垃圾收集很久之后仍然存在。
这对反编译过程几乎没有影响。我提到它只是因为它是一个有趣的东西——或者是一个帮助调试 Java 代码的重要工具,这取决于您的思维方式或您老板的立场。
这留下了三个需要关注的区域:程序寄存器、堆栈和方法区域。
程序计数器寄存器
为了简单起见,JVM 使用很少的寄存器:控制程序流的程序计数器,以及堆栈中的另外三个寄存器。尽管如此,每个线程都有自己的程序计数器寄存器,用于保存堆栈上正在执行的当前指令的地址。Sun 选择使用有限数量的寄存器来满足支持很少寄存器的体系结构。
方法区
如果您跳到“类文件内部”一节,您会看到类文件被分解成许多组成部分,以及方法的确切位置。每个方法都有自己的代码属性,其中包含特定方法的字节码。
尽管类文件包含关于程序计数器应该为每条指令指向哪里的信息,但类加载器在代码开始执行之前会注意代码在内存区域中的位置。
当程序执行时,程序计数器通过指向下一条指令来跟踪程序的当前位置。方法区域中的字节码遍历其类似汇编程序的指令,在处理变量时使用堆栈作为临时存储区域,而程序遍历该方法的完整字节码。程序在方法区域内的执行不一定是线性的;跳跃和 gotos 很常见。
JVM 栈
栈只不过是临时变量的存储区。所有的程序执行和变量操作都是通过将变量推入堆栈框架或从堆栈框架中取出来实现的。每个线程都有自己的 JVM 堆栈框架。
JVM 堆栈由三个不同的部分组成,分别是局部变量(var)、执行环境(frame)和操作数堆栈(optop)。vars、frame 和 optop 寄存器指向堆栈的每个不同区域。该方法在自己的环境中执行,操作数堆栈用作字节码指令的工作空间。optop 寄存器指向操作数堆栈的顶部。
正如我所说的,JVM 是一个非常简单的机器,它在操作数堆栈上弹出和推送临时变量,并在变量中保留任何局部变量,同时继续在堆栈框架中执行方法。堆栈夹在堆和寄存器之间。
因为堆栈非常简单,所以不能存储复杂的对象。这些被外包给垃圾场。
在一个类文件内
为了获得一个类文件的整体视图,让我们再看一下来自第一章的Casting.java
文件,如清单 2-1 所示。使用 javac 编译它,然后对二进制类文件进行十六进制转储,如图 2-2 中的所示。
清单 2-1。 Casting.java
,现在拥有田地!
`public class Casting {
static final String ascStr = "ascii ";
static final String chrStr = " character ";
public static void main(String args[]){
for(char c=0; c < 128; c++) {
System.out.println(ascStr + (int)c + chrStr + c);
}
}
}`
图 2-2。Casting.class
正如您所看到的,Casting.class 很小很紧凑,但是它包含了 JVM 执行Casting.java
代码所需的所有信息。
为了进一步打开类文件,在本章中,您将通过将类文件分成不同的部分来模拟反汇编程序的操作。当我们分解 Casting.class 时,我们还将构建一个名为 ClassToXML 的原始反汇编器,它将类文件输出为易读的 XML 格式。ClassToXML 使用来自[www.freeinternals.org](http://www.freeinternals.org)
的 Java 类文件库(jCFL)来完成繁重的工作,可以从该书的 Apress.com 页面下载。
您可以将类文件分成以下组成部分。
- 幻数
- 次要和主要版本号
- 恒定池计数
- 常数存储库
- 访问标志
this
阶级- 超类
- 接口计数
- 接口
- 字段计数
- 菲尔茨
- 方法计数
- 方法
- 属性计数
- 属性
JVM 规范使用类似于 struct- 的格式来显示类文件的不同组件;参见清单 2-2 。
清单 2-2。 类文件结构
Classfile { int magic, short minor_version, short major_version, short constant_pool_count, cp_info constant_pool[constant_pool_count-1], short access_flags, short this_class, short super_class,
short interfaces_count, short interfaces [interfaces_count], short fields_count, field_info fields [fields_count], short methods_count, method_info methods [methods_count], short attributes_count attributes_info attributes[attributes_count] }
这似乎一直是显示类文件的一种非常麻烦的方式,所以您可以使用一种 XML 格式,这种格式允许您更快地遍历类文件的内部结构。当您试图解开它的含义时,它也使类文件信息更容易理解。图 2-3 显示了完整的类文件结构,所有 XML 节点都已折叠。
图 2-3。Casting.class
的 XML 表示
接下来,您将看到每个不同的节点及其形式和功能。在第六章中,您将学习为所有 Java 类文件创建 ClassToXML 本章中的代码仅适用于Casting.class
。要运行本章的代码,首先从[www.freeinternals.org](http://www.freeinternals.org)
下载 jCFL jar 文件,并把它放在您的类路径中。然后执行以下命令:
javac ClassToXML.java java ClassToXML < Casting.class > Casting.xml
神奇的数字
很容易找到魔术号和版本号,因为它们出现在类文件的开头——你应该能在图 2-2 中找到它们。十六进制的幻数是类文件的前 4 个字节(0xCAFEBABE),它告诉 JVM 它正在接收一个类文件。奇怪的是,这些也是下一代平台上多架构二进制(MAB)文件的前四个字节。在 Java 的早期实现中,Sun 和 NeXT 之间一定发生过一些人员的交叉授粉。
选择 0xCAFEBABE 有很多原因。首先,很难从字母 A 到 F 中找出有意义的八个字母的单词。据詹姆斯·高斯林说,“死亡咖啡馆”是他们办公室附近一家咖啡馆的名字,感恩而死乐队过去常在那里演出。所以 0xCAFEDEAD 和此后不久的 0xCAFEBABE 成为了 Java 文件格式的一部分。我的第一反应是,很遗憾 0xGETALIFE 不是一个合法的十六进制字符串,但我也想不出更好的十六进制名称。还有更糟糕的幻数,比如 0xFEEDFACE、0xDEADBEEF,可能还有最糟糕的 0xDEADBABE,它们分别被摩托罗拉、IBM 和 Sun 使用。
Microsoft 的 CLR 文件有一个类似的头,BSJB,它是以。Net 平台:布莱恩·哈利,苏珊·拉德克-斯普尔,杰森·詹德和比尔·艾文思。好吧,也许 0xCAFEBABE 也没那么糟糕。
次要和主要版本
次要和主要版本号是接下来的四个字节 0x0000 和 0x0033,参见清单 2-2 ,或者次要版本 0 和主要版本 51,这意味着代码是由 JDK 1.7.0 编译的。JVM 使用这些主要编号和次要编号来确保它能够识别并完全理解类文件的格式。JVM 将拒绝执行任何具有更高的主版本号和次版本号的类文件。
次要版本用于需要更新 JVM 的小变更,主要版本用于需要完全不同和不兼容的 JVM 的大规模基本变更。
恒定池计数
所有的类和接口常量都存储在常量池中。令人惊讶的是,常量池计数占用了接下来的 2 个字节,告诉您常量池中有多少个可变长度元素。
0x0035 或整数 53 是示例中的数字。JVM 规范告诉你constant_pool[0]
是 JVM 保留的。事实上,它甚至没有出现在类文件中,所以常量池元素存储在constant_pool[1]
到constant_pool[52]
中。
恒池
下一项是常量池本身,它的类型是cp_info
;参见清单 2-3 。
清单 2-3。 cp_info
结构
cp_info { byte tag, byte info[] }
常量池由可变长度元素的数组组成。在后面的类文件中,它充满了对常量池中其他条目的符号引用。常量池计数告诉你常量池中有多少个变量。
类文件所需的每个常量和变量名都可以在常量池中找到。这些通常是字符串、整数、浮点数、方法名等等,所有这些都是固定的。然后,在类文件中的任何地方,每个常量都被其常量池索引引用。
常量池中的每个元素(记住示例中有 53 个)都以一个标签开始,告诉您下一个常量是什么类型。表 2-1 列出了类文件中使用的有效标签及其相应的值。
常量池中的许多标签是对常量池其他成员的符号引用。例如,每个String
指向一个Utf8
标签,字符串最终存储在那里。Utf8
的数据结构如清单 2-4 所示。
清单 2-4。 Utf8
结构
Utf8 { byte tag, int length, byte bytes[length] }
我在常量池的 XML 输出中尽可能地折叠了这些数据结构(见清单 2-5 ),这样你就可以很容易地阅读了。
清单 2-5 。Casting.class
恒池为
<ConstantPool> <ConstantPoolEntry> <id>1</id> <Type>Methodref</Type> <ConstantPoolAddress>13,27</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>2</id> <Type>Fieldref</Type> <ConstantPoolAddress>28,29</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>3</id> <Type>Class</Type> <ConstantPoolAddress>30</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>4</id> <Type>Methodref</Type> <ConstantPoolAddress>3,27</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>5</id> <Type>String</Type> <ConstantPoolAddress>31</ConstantPoolAddress>
</ConstantPoolEntry> <ConstantPoolEntry> <id>6</id> <Type>Methodref</Type> <ConstantPoolAddress>3,32</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>7</id> <Type>Methodref</Type> <ConstantPoolAddress>3,33</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>8</id> <Type>String</Type> <ConstantPoolAddress>34</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>9</id> <Type>Methodref</Type> <ConstantPoolAddress>3,35</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>10</id> <Type>Methodref</Type> <ConstantPoolAddress>3,36</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>11</id> <Type>Methodref</Type> <ConstantPoolAddress>37,38</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>12</id> <Type>Class</Type> <ConstantPoolAddress>39</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>13</id> <Type>Class</Type> <ConstantPoolAddress>40</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>14</id> <Type>Utf8</Type> <ConstantPoolValue>ascStr</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>15</id> <Type>Utf8</Type>
<ConstantPoolValue>Ljava/lang/String</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>16</id> <Type>Utf8</Type> <ConstantPoolValue>ConstantValue</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>17</id> <Type>Utf8</Type> <ConstantPoolValue>chrStr</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>18</id> <Type>Utf8</Type> <ConstantPoolValue><init></ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>19</id> <Type>Utf8</Type> <ConstantPoolValue>V</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>20</id> <Type>Utf8</Type> <ConstantPoolValue>Code</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>21</id> <Type>Utf8</Type> <ConstantPoolValue>LineNumberTable</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>22</id> <Type>Utf8</Type> <ConstantPoolValue>main</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>23</id> <Type>Utf8</Type> <ConstantPoolValue>Ljava/lang/String</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>24</id> <Type>Utf8</Type> <ConstantPoolValue>StackMapTable</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>25</id>
<Type>Utf8</Type> <ConstantPoolValue>SourceFile</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>26</id> <Type>Utf8</Type> <ConstantPoolValue>Casting</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>27</id> <Type>NameAndType</Type> <ConstantPoolAddress>18,19</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>28</id> <Type>Class</Type> <ConstantPoolAddress>41</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>29</id> <Type>NameAndType</Type> <ConstantPoolAddress>42,43</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>30</id> <Type>Utf8</Type> <ConstantPoolValue>java/lang/StringBuilder</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>31</id> <Type>Utf8</Type> <ConstantPoolValue>ascii</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>32</id> <Type>NameAndType</Type> <ConstantPoolAddress>44,45</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>33</id> <Type>NameAndType</Type> <ConstantPoolAddress>44,46</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>34</id> <Type>Utf8</Type> <ConstantPoolValue>character</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry>
<id>35</id> <Type>NameAndType</Type> <ConstantPoolAddress>44,47</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>36</id> <Type>NameAndType</Type> <ConstantPoolAddress>48,49</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>37</id> <Type>Class</Type> <ConstantPoolAddress>50</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>38</id> <Type>NameAndType</Type> <ConstantPoolAddress>51,52</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>39</id> <Type>Utf8</Type> <ConstantPoolValue>Casting</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>40</id> <Type>Utf8</Type> <ConstantPoolValue>java/lang/Object</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>41</id> <Type>Utf8</Type> <ConstantPoolValue>java/lang/System</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>42</id> <Type>Utf8</Type> <ConstantPoolValue>out</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>43</id> <Type>Utf8</Type> <ConstantPoolValue>Ljava/io/PrintStream</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>44</id> <Type>Utf8</Type> <ConstantPoolValue>append</ConstantPoolValue> </ConstantPoolEntry>
<ConstantPoolEntry> <id>45</id> <Type>Utf8</Type> <ConstantPoolValue>Ljava/lang/StringBuilder</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>46</id> <Type>Utf8</Type> <ConstantPoolValue>Ljava/lang/StringBuilder</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>47</id> <Type>Utf8</Type> <ConstantPoolValue>Ljava/lang/StringBuilder</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>48</id> <Type>Utf8</Type> <ConstantPoolValue>toString</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>49</id> <Type>Utf8</Type> <ConstantPoolValue>Ljava/lang/String</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>50</id> <Type>Utf8</Type> <ConstantPoolValue>java/io/PrintStream</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>51</id> <Type>Utf8</Type> <ConstantPoolValue>println</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>52</id> <Type>Utf8</Type> <ConstantPoolValue>Ljava/lang/String</ConstantPoolValue> </ConstantPoolEntry> </ConstantPool>
当您花时间检查类文件的输出时,这是一个简单而优雅的设计。取第一种方法引用,constant_pool[1]
:
<ConstantPoolEntry> <id>1</id> <Type>Methodref</Type> <ConstantPoolAddress>13,27</ConstantPoolAddress>
</ConstantPoolEntry>
这告诉您在constant_pool[13]
中查找类,以及在constant_pool[27]
中查找类名和类型
<ConstantPoolEntry> <id>13</id> <Type>Class</Type> <ConstantPoolAddress>40</ConstantPoolAddress> </ConstantPoolEntry>
指向constant_pool[40]
:
<ConstantPoolEntry> <id>40</id> <Type>Utf8</Type> <ConstantPoolValue>java/lang/Object</ConstantPoolValue> </ConstantPoolEntry>
但是您也有constant_pool[27]
要解析,它给出了方法的名称和类型:
<ConstantPoolEntry> <id>27</id> <Type>NameAndType</Type> <ConstantPoolAddress>18,19</ConstantPoolAddress> </ConstantPoolEntry>
常量池的元素 18 和 19 包含方法名及其描述符。根据 JVM 规范,方法描述符采用以下形式:
(ParameterDescriptor *) ReturnDescriptor
返回描述符可以是 void 的V
或者是FieldType
中的一个(见表 2-2 ):
<ConstantPoolEntry> <id>18</id> <Type>Utf8</Type> <ConstantPoolValue><init></ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>19</id> <Type>Utf8</Type> <ConstantPoolValue>V</ConstantPoolValue> </ConstantPoolEntry>
在这种情况下,方法的名称是<init>
,它是每个类文件中的一个内部 JVM 方法;其方法描述符为()V
,或void
,用于字段描述符映射(见表 2-2 )。
因此,您现在可以重新创建该方法,如下所示:
void init()
你也可以试着解开一些其他的类。如果从目标类或方法向后工作,可能会有所帮助。有些字符串很难理解,但是稍加练习,方法签名就变得清晰了。
最早的混淆器只是将这些字符串重新命名为完全无法理解的东西。这阻止了原始的反编译器,但没有损害类文件,因为 JVM 使用了指向常量池中的字符串的指针,而不是字符串本身,只要您没有重命名内部方法(如<init>
)或破坏对外部库中任何 Java 类的引用。
从下面的条目中,您已经知道您的 import 语句需要什么类:constant_pool[36, 37, 39, 46]
。注意在Casting.java
例子中没有接口或者静态最终类(参见清单 2-1 )。这些将作为常量池中的字段引用出现,但是到目前为止,这个简单的类解析器已经足够完整,可以处理您想处理的任何类文件。
访问标志
访问标志包含位掩码,它告诉你是在处理一个类还是一个接口,以及它是公共的、最终的等等。所有接口都是抽象的。
共有八种访问标志类型(见表 2-3 ,但将来可能会引入更多类型。ACC_SYNTHETIC
、ACC_ANNOTATION
和ACC_ENUM
是 JDK 1.5 中相对较新的新增内容。
在this
类或接口之前,访问标志被or
结合在一起以给出修饰符的描述。0x21
告诉你Casting.class
中的this
类是一个公共(和超)类,你可以通过回溯到清单 2-1 中的代码来验证它的正确性:
<AccessFlags>0x21</AccessFlags>
本类和超类
接下来的两个值指向this
类和超类的常量池索引。
<ThisClass>12</ThisClass> <SuperClass>13</SuperClass>
如果您遵循清单 2-5 中的 XML 输出,constant_pool[12]
指向constant_pool[39]
;这里的Utf8
结构包含字符串Casting
,告诉你this
是Casting
类。超类在constant_pool[13]
,指向constant_pool[40]
;这里的Utf8
结构包含java/lang/Object
,因为每个类都有object
作为它的超类。
接口和接口数
清单 2-1 中的Casting.java
例子没有任何接口,所以你必须看一个不同的例子来更好地理解接口是如何在类文件中实现的(参见清单 2-6 )。
清单 2-6。 界面示例
`interface IProgrammer {
public void code();
public void earnmore();
}
interface IWriter {
public void pullhairout();
public void earnless();
}
public class Person implements IProgrammer, IWriter {
public Person() {
Geek g = new Geek(this);
Author t = new Author(this);
}
public void code() { /* ..... / }
public void earnmore() { / ..... / }
public void pullhairout() { / ..... / }
public void earnless() { / ..... */ }
}
class Geek {
IProgrammer iprog = null;
public Geek(IProgrammer iprog) {
this.iprog = iprog;
iprog.code();
iprog.earnmore();
}
}
class Author {
IWriter iwriter = null;
public Author(IWriter iwriter) {
this.iwriter = iwriter;
iwriter.pullhairout();
iwriter.earnless();
}
}`
清单 2-6 有两个接口,IProgrammer
和IWriter
。对类文件运行 ClassToXML 会在 interfaces 部分提供以下信息:
<InterfaceCount>2</InterfaceCount> <Interfaces> <Interface>8</Interface> <Interface>9</Interface> </Interfaces>
这解析为常量池中的IProgrammer
和IWriter
字符串,如下所示:
<ConstantPoolEntry> <id>8</id>
`
字段和字段计数
field_info
的结构如清单 2-7 所示。
清单 2-7 。field_info
数据结构
field_info { u2 access_flags; u2 name_index; u2 descriptor_index; u2 attributes_count; attribute_info attributes[attributes_count]; }
Casting.class
有两个静态和最终字段,ascStr
和chrStr
(见清单 2-1 )。我还使它们成为静态的和最终的,以强制一个ConstantValue
字段属性。
现在,如果您取出 XML 中的相关部分,您会看到有两个字段(清单 2-8 )。让我们关注第一个。
清单 2-8。 Casting.java
字段信息
<FieldCount>2</FieldCount> <Fields> <Field> <AccessFlags>ACC_STATIC, ACC_FINAL</AccessFlags> <Name>ascStr</Name>
<Descriptor>java.lang.String</Descriptor> <Attributes> <Attribute> <AttributeType>String</AttributeType> <AttributeName>ascii</AttributeName> </Attribute> </Attributes> </Field> <Field> <AccessFlags>ACC_STATIC, ACC_FINAL</AccessFlags> <Name>chrStr</Name> <Descriptor>java.lang.String</Descriptor> <Attributes> <Attribute> <AttributeType>String</AttributeType> <AttributeName>character</AttributeName> </Attribute> </Attributes> </Field> </Fields>
字段访问标志(参见表 2-4 )告诉您该字段是public
、private
、protected
、static
、final
、volatile
还是transient
。
对于任何编写过 Java 的人来说,前五个和最后一个关键字应该是显而易见的。volatile
关键字告诉一个线程该变量可能被另一个线程更新,transient
关键字用于对象序列化。本例中的访问标志0x0018
表示静态最终字段。
回到表 2-2 ,在你解开不同的字段描述符之前,让你的头脑清醒一下:
<Field> <AccessFlags>ACC_STATIC, ACC_FINAL</AccessFlags> <Name>ascStr</Name>
<Descriptor>java.lang.String</Descriptor> <Attributes> <Attribute> <AttributeType>String</AttributeType> <AttributeName>ascii</AttributeName> </Attribute> </Attributes> </Field>
描述符指回constant_pool[14]
字段ascStr
,它有字段描述符constant_pool[15]
或Ljava/lang/String
;这是一个String
类的实例。
字段属性
毫无疑问,属性计数就是属性的数量,后面紧跟着属性本身。整个类文件的属性的格式如清单 2-9 所示。
清单 2-9。 attribute-info
结构
attribute_info { u2 attribute_name_index; u4 attribute_length; u1 info[attribute_length]; }
在字段数据结构、方法数据结构和属性数据结构(类文件数据结构的最后一个元素)中可以找到几种不同的属性类型。但是这里真正感兴趣的只有两个字段属性,ConstantValue
和Synthetic
。ConstantValue
用于常量变量,例如在当前示例中声明为 static 和 final 的变量。在 JDK 1.1 中引入了Synthetic
变量来支持内部类。
Signature
和Deprecated
属性也是可能的,用户也可以定义他们自己的属性类型,但是它们与当前的讨论无关。
清单 2-11。 字段属性数据
<Attributes> <Attribute> <AttributeType>String</AttributeType> <AttributeName>ascii</AttributeName> </Attribute> </Attributes>
第一个字段的属性(见清单 2-11 ),是一个可以在constant_pool[5]
(见清单 2-12 )中找到的常量,一个字符串,它依次指向字符串“ascii”。
清单 2-12。恒池中的字段
<ConstantPoolEntry> <id>5</id> <Type>String</Type> <ConstantPoolAddress>31</ConstantPoolAddress> </ConstantPoolEntry> <ConstantPoolEntry> <id>31</id> <Type>Utf8</Type> <ConstantPoolValue>ascii</ConstantPoolValue> </ConstantPoolEntry>
现在,您已经将第一个字段反编译成了它的原始格式:
static final String ascStr = "ascii ";
方法和方法计数
现在是类文件最重要的部分:方法。所有的源代码都被转换成字节码并存储或包含在method_info
区域中。(实际上,它在方法的代码属性中,但是您已经非常接近了。)如果有人可以得到字节码,那么他们可以尝试将它转换回源代码。Methods
元素前面是方法计数和数据结构(见清单 2-13 ),与前一节中的field_info
结构没有什么不同。三种类型的属性通常出现在method_info
级别:Code
、Exceptions
,对于内部类再次出现Synthetic
。
清单 2-13。 method_info
结构
method_info { u2 access_flags; u2 name_index; u2 descriptor_index; u2 attributes_count; attribute_info attributes[attributes_count]; }
清单 2-1 的Casting.class
中的方法如清单 2-14 所示。
清单 2-14。 Casting.class
方法信息
<MethodCount>2</MethodCount> <Methods> <Method> <Attributes>
<Attribute> <AttributeName>Code:</AttributeName> <Max_Stack>1</Max_Stack> <Max_Locals>1</Max_Locals> <Method_Args>1</Method_Args> <Method_Code> 0: aload_0 1: invokespecial #1 4: return </Method_Code> <Method_LineNumberTable>line 1: 0</Method_LineNumberTable> </Attribute> </Attributes> <AccessFlags>public</AccessFlags> <Name>Casting</Name> <Descriptor>();</Descriptor> </Method> <Method> <Attributes> <Attribute> <AttributeName>Code:</AttributeName> <Max_Stack>3</Max_Stack> <Max_Locals>2</Max_Locals> <Method_Args>1</Method_Args> <Method_Code> 0: iconst_0 1: istore_1 2: iload_1 3: sipush 128 6: if_icmpge 51 9: getstatic #2 12: new #3 15: dup 16: invokespecial #4 19: ldc #5 21: invokevirtual #6 24: iload_1 25: invokevirtual #7 28: ldc #8 30: invokevirtual #6 33: iload_1 34: invokevirtual #9 37: invokevirtual #10 40: invokevirtual #11 43: iload_1 44: iconst_1 45: iadd 46: i2c 47: istore_1
48: goto 2 51: return </Method_Code> <Method_LineNumberTable> line 8: 0 line 9: 9 line 8: 43 line 11: 51 </Method_LineNumberTable> <Method_StackMapTableEntries>2</Method_StackMapTableEntries> <Method_StackMapTable> frame_type = 252 /* append */ offset_delta = 2 locals = [ int ] frame_type = 250 /* chop */ offset_delta = 48</Method_StackMapTable> </Attribute> </Attributes> <AccessFlags>public, static</AccessFlags> <Name>main</Name> <Descriptor>(java.lang.String[]);</Descriptor> </Method> </Methods>
根据原始源代码中使用的修饰符,为每个方法设置不同的访问标志;参见表 2-5 。存在许多限制,因为一些访问标志是互斥的——换句话说,一个方法不能同时声明为ACC_PUBLIC
和ACC_PRIVATE
,甚至不能声明为ACC_PROTECTED
。然而,您通常不会反汇编非法的字节码,所以您不太可能遇到任何这样的可能性。
示例中的第一个方法是 public 第二种是公共静态方法。
现在您可以找到最终方法的名称和方法描述符:
<Name>main</Name> <Descriptor>(java.lang.String[]);</Descriptor>
您从constant_pool[22]
和constant_pool[23]
中提取方法的名称和描述,如清单 2-15 所示。
清单 2-15。 Casting.class
方法名和描述符常量池信息
<ConstantPoolEntry> <id>22</id> <Type>Utf8</Type> <ConstantPoolValue>main</ConstantPoolValue> </ConstantPoolEntry> <ConstantPoolEntry> <id>23</id> <Type>Utf8</Type> <ConstantPoolValue>Ljava/lang/String</ConstantPoolValue> </ConstantPoolEntry>
现在,您可以在没有任何基础代码的情况下重新组装该方法
public static void main(java.lang.String args[]) { /* */ }
或者干脆
import java.lang.String; ... public static void main(String args[]) { /* */ }
其余的方法以类似的方式退出常量池。
方法属性
属性出现在类文件结构的field
、method
和attributes
元素中。每个属性都以引用常量池和属性长度的attribute_name_index
开头。但是类文件的肉在方法属性中(见清单 2-16 )。
清单 2-16 。初始化方法属性
<Attributes> <Attribute> <AttributeName>Code:</AttributeName> <Max_Stack>1</Max_Stack> <Max_Locals>1</Max_Locals> <Method_Args>1</Method_Args> <Method_Code> 0: aload_0 1: invokespecial #1 4: return </Method_Code> <Method_LineNumberTable>line 1: 0</Method_LineNumberTable> </Attribute> </Attributes>
本例中的属性类型是代码属性。代码属性如清单 2-17 所示。
清单 2-17。 代码属性
Code_attribute { u2 attribute_name_index; u4 attribute_length; u2 max_stack; u2 max_locals; u4 code_length; u1 code[code_length]; { u2 exception_table_length; {
u2 start_pc; u2 end_pc; u2 handler_pc; u2 catch_type; } exception_table[exception_table_length]; u2 attributes_count; attribute_info attributes[attributes_count]; }
attribute_length
是代码属性的长度减去前 6 个字节。attribute_type
和attribute_name
占用前 6 个字节,不包含在attribute_length
中。max_stack
和max_locals
给出操作数堆栈和堆栈帧局部变量部分的最大变量数。这将告诉你栈有多深,以及有多少变量将被推入栈中和栈外。
代码长度给出了以下代码数组的大小。代码数组只是一系列字节,其中每个字节码是一个保留的字节值或操作码,后跟零个或多个操作数,或者换句话说:
opcode operand
查看在Casting.class
上运行 ClassToXML 的输出(清单 2-14 ,您会看到有两个方法,main
和init
,这是一个空的构造函数,当开发人员选择不添加他们自己的构造函数时,Java 编译器总是会添加它。每个方法都有自己的代码数组。
方法
在我解释什么字节码映射到哪个操作码之前,让我们看一下最简单的方法,这是第一个代码段:
2ab70001b1
当您将它转换成操作码和操作数时,它就变成了
2a aload 0 b70001 invokespecial #1 b1 return
2a
变成了aload 0
。这将本地变量 0 加载到堆栈中,这是invokespecial
所要求的。b70001
变成了invokespecial #1
,其中invokespecial
用于在有限的情况下调用一个方法,比如实例初始化方法(对你我来说是<init>
),这就是你在这里看到的。#1
是对constant_pool[1]
的引用,是一个CONSTANT_Methodref
结构。清单 2-18 收集了constant_pool[1]
的所有相关常量池条目。
清单 2-18。 <init>
法恒池解析
`
您可以手动将符号引用解析为
<Method java.lang.Object.<init>()V>
这是 javac 编译器添加到所有还没有构造函数的类中的空构造函数。最后一个b1
操作码是一个简单的return
语句。所以第一个方法可以直接转换回下面的代码,一个空的构造函数:
public class Casting() { return; }
主要方法
第二个代码属性不太重要。为了更进一步,您需要知道每个十六进制值映射到哪个操作码。
注意:尽管这个例子列表比大多数其他操作码列表都短(你忽略了任何大于 201 的操作码),它仍然运行了几页;你可以在附录 A 和[www.apress.com](http://www.apress.com)
中查阅。注意,超过 201 的操作码是为将来使用而保留的,因为它们对类文件中的原始字节码没有影响,可以安全地忽略。
您还需要知道 Java 语言的每个元素是如何被编译成字节码的,这样您就可以颠倒这个过程。然后,您可以看到如何将剩余的代码属性转化为操作码及其操作数。
main
方法有以下 52 字节的byte_code
属性,它在清单 2-19 中被分解成操作码和操作数
033c1b110080a2002db20002bb000359b700041205b600061bb600071208b60006 1bb60009b6000ab6000b1b0460923ca7ffd2b1
清单 2-19。 主要方法
<Method> <Attributes> <Attribute> <AttributeName>Code:</AttributeName> <Max_Stack>3</Max_Stack> <Max_Locals>2</Max_Locals> <Method_Args>1</Method_Args> <Method_Code> 0: iconst_0 1: istore_1 2: iload_1 3: sipush 128 6: if_icmpge 51 9: getstatic #2 12: new #3 15: dup
16: invokespecial #4 19: ldc #5 21: invokevirtual #6 24: iload_1 25: invokevirtual #7 28: ldc #8 30: invokevirtual #6 33: iload_1 34: invokevirtual #9 37: invokevirtual #10 40: invokevirtual #11 43: iload_1 44: iconst_1 45: iadd 46: i2c 47: istore_1 48: goto 2 51: return </Method_Code> <Method_LineNumberTable> line 8: 0 line 9: 9 line 8: 43 line 11: 51 </Method_LineNumberTable> <Method_StackMapTableEntries>2</Method_StackMapTableEntries> <Method_StackMapTable> frame_type = 252 /* append */ offset_delta = 2 locals = [ int ] frame_type = 250 /* chop */ offset_delta = 48</Method_StackMapTable> </Attribute> </Attributes> <AccessFlags>static</AccessFlags> <Name>main</Name> <Descriptor>(java.lang.String[]);</Descriptor> </Method> </Methods>
你可以用与前一种方法类似的方式对操作码和操作数进行逆向工程,如你在表 2-6 中所见。
iconst_0
和istore_1
将数字 0 推送到堆栈上,sipush
将数字 128 推送到堆栈上,if_icmpge
比较两个数字和goto
程序计数器或PC = 51
(即如果数字相等则返回)。下面是来自Casting.class
代码的代码片段:
for(char c=0; c < 128; c++) { }
以同样的方式,您可以完成分析以返回完整的 main 方法。本书的目的是向您展示如何通过编程来实现这一点。
可以从 Apress 站点的下载区获得 ClassToXML,它像一个真正的反汇编器一样输出字节码。现在您已经看到了创建一个反汇编器是多么容易,您可能会明白为什么这么多反汇编器都有用户界面。
属性和属性计数
最后两个元素包含类文件属性的数量和剩余的属性,通常是SourceFile
和InnerClasses
。
SourceFile
是最初用来生成代码的 Java 文件的名称。InnerClasses
属性有点复杂,被几个不能处理内部类的反编译器忽略了。
您并不局限于SourceFile
和InnerClasses
属性。可以在这里或者在任何字段或方法属性部分中定义新属性。开发人员可能希望将信息存储在自定义属性中,可能使用它进行一些低级检查,或者存储加密的代码属性以防止反编译。假设您的新代码属性遵循所有其他属性的格式,您可以添加任何想要的属性,这将被 JVM 忽略。每个属性需要 2 个字节,提供一个指向常量池的数字,给出属性的名称,attribute_name_index
;4 个字节给出属性中剩余字节的长度,attribute_length
。
总结
您最终到达了类文件的末尾,并在此过程中手动反汇编了 ClassToXML(参考[www.apress.com](http://www.apress.com)
上的相应文件)。我希望你开始明白这一切是如何结合在一起的。尽管类文件的设计简洁紧凑,但是由于初始和最终编译阶段的分离,您需要大量的信息来帮助您恢复源代码。多年来,程序员一直受到编译成可执行文件通常提供的编码的保护,但是在中间阶段拆分编译并携带如此多的信息是自找麻烦。
第三章着眼于解开 DEX 文件格式,帮助你理解如何将它逆向工程回 Java 源代码。
三、在 DEX 文件中
我们需要另一个虚拟机用于 Android 手机,而 Java 虚拟机(JVM)不够好,这看起来可能有点奇怪。但为了优化和性能,所有 Android 手机上都使用了 Dalvik 虚拟机(DVM)。它是以一个原始开发者在冰岛家乡的一个地方命名的,在设计上与 JVM 有很大的不同。DVM 使用寄存器,而不是压入-弹出堆栈机器。相应的 DVM 字节码或 DEX 文件也是与 Java 类文件完全不同的设计。
但是并没有失去一切。关于 DEX 文件规范有足够多的信息来重复你在第二章中查看的关于类文件的相同练习,并得出相同的令人愉快的结论,让你获得对 DEX 文件字节码的访问,并将其转换回 Java 源代码,即使你在本章中是手动进行的。DEX 文件可以分解成不同的部分:header
和常量池的 DEX 版本,常量池的data
部分包含字符串、字段、方法和类信息的指针。
机器中的幽灵,第二部
当你从 Android Market 或亚马逊 Marketplace 下载一个应用到你的 Android 手机上时,你正在下载一个 Android 包(APK)文件。每一个 APK 文件都是 zip 格式的。将.apk
文件扩展名改为.zip
,解压文件后会得到 APK 中包含的资源、图像、、AndroidManifest.xml
文件和classes.dex
文件,结构类似于图 3-1 中的所示。
图 3-1。 解压后的 APK 文件
一个 Java jar 文件有许多类文件,而每个 APK 文件只有一个classes.dex
文件,如图 3-2 中的所示。根据谷歌的说法,出于性能和安全原因,APK 格式不同于类文件格式。但是不管原因是什么,从逆向工程的角度来看,这意味着您的目标现在是classes.dex
文件。您已经完全脱离了 Java 类文件格式,现在需要理解classes.dex
文件的内容,这样您就可以将它反编译回 Java 源代码。
图 3-2。 Class 文件 vs DEX 文件
第四章介绍了许多 Android 和第三方工具,可以帮助你分离 apk 和classes.dex
文件。在本章中,您将手动创建自己的classes.dex
反汇编器。
转换铸件
首先,你需要将你的Casting.class
文件从第三章转换成classes.dex
文件,这样你就有东西可以用了。这个classes.dex
文件将在 Android 手机的命令行上运行,但它不是一个经典的 APK 文件。然而,classes.dex
格式是一样的,所以这是开始 DEX 文件研究的好地方。
您可以使用 Android 平台工具附带的 dx 程序进行这种转换。确保Casting.class
文件在casting
文件夹中,并执行以下命令:
javac c:\apress\chap3\casting\Casting.java
dx --dex --output=c:\temp\classes.dex C:\apress\chap3\casting
图 3-3 以十六进制格式显示了清单 3-1 中Casting.java
代码的结果classes.dex
文件。
清单 3-1。??Casting.java
`public class Casting {
static final String ascStr = "ascii ";
static final String chrStr = " character ";
public static void main(String args[]){
for(char c=0; c < 128; c++) {
System.out.println("ascii " + (int)c + " character "+
c);
}
}
}`
图 3-3。classes.dex
为了进一步打开 DEX 文件,在本章中,您将通过将 DEX 文件分解成几个部分来模拟反汇编程序的操作。您可以通过构建自己的名为 DexToXML 的原语反汇编器来实现这一点,该反汇编器获取 DEX 文件并将代码输出为易读的 XML 格式。
将 DEX 文件分解成组成部分
您可以将类文件分成以下组成部分:
header
string_ids
type_ids
proto_ids
field_ids
method_ids
class_defs
data
link_data
header
部分包含了文件信息的摘要、文件大小以及指向其他信息的指针或偏移量。String_ids
列出了文件中的所有字符串,Java 类型可以在type_ids
部分找到。稍后您将看到原型的proto_ids
、field_ids
、method_ids
和class_defs
部分如何让您将类名、方法调用和字段反向工程回 Java。data
部分是安卓版的常量池。link_data
部分是针对静态链接文件的,与本讨论无关,所以本章不提供相关部分。
DEX 文件格式规范([
source.android.com/tech/dalvik/dex-format.html](http://source.android.com/tech/dalvik/dex-format.html)
)使用类似结构的格式来显示 DEX 文件的组成部分;参见清单 3-2 。
清单 3-2。 DEX 文件结构
Dexfile { header header_item, string_ids string_id_item[], type_ids type_id_item[], proto_ids proto_id_item[], field_ids field_id_item[], method_ids method_id_item[], class_defs class_def_item[], data ubyte[],
link_data ubyte[] }
和上一章一样,您使用 XML 格式,因为它允许您更快地在 DEX 文件的内部结构中来回遍历。它还使 DEX 文件信息更容易理解,因为你解开了它的含义。DEX 文件结构——所有 XML 节点都已折叠——如清单 3-3 所示。
清单 3-3。 DexToXML
`
以下部分解释了每个节点中的内容。
标题部分
header
部分包含文件剩余部分的顶层信息。DEX 文件的结构与其原始格式的 Java 类文件有很大不同,类似于微软。Net PE 文件比你在上一章看到的更多。header
报头包含幻数、校验和、签名和类文件的大小。剩下的信息告诉您字符串、类型、原型、方法和类有多大,并提供一个地址指针或偏移量,您可以在 classes.dex 文件中找到实际的字符串、类型、原型、方法和类。在data
部分还有一个指向地图信息的指针,它重复了header
部分的许多信息。
清单 3-4 使用了一种类似结构的格式来展示header
是如何布局的。
清单 3-4。 Header
截面结构
DexfileHeader{ ubyte[8] magic, int checksum, ubyte[20] signature, uint file_size, uint header_size,
uint endian_tag, uint link_size, uint link_off, uint map_off, uint string_ids_size, uint string_ids_off, uint type_ids_size, uint type_ids_off, uint proto_ids_size, uint proto_ids_off, uint field_ids_size, uint field_ids_off, uint method_ids_size, uint method_ids_off, uint class_defs_size, uint class_defs_off, uint data_size, uint data_off }
header
字段详见表 3-1 。
DEX 文件中的header
部分在图 3-4 中突出显示,你可以使用表 3-1 来跟踪每个字段出现的位置。但是读取十六进制需要某种自虐,这就是为什么 DexToXML 以更容易阅读的 XML 格式输出相同的数据。清单 3-5 中的显示了 DexToXML 标题字段。
图 3-4。classes.dex
中的表头字段
清单 3-5。??header
段的 DexToXML 输出
`
其中几个字段需要进一步解释:magic
、checksum
、header_size
和Endian_tag
。header
部分的其余字段是尺寸和到其他部分的偏移量。
魔法
DEX 文件的幻数是前 8 个字节,并且总是十六进制的64 65 78 0A 30 33 35 00
或字符串dex\n035\0
。规范提到换行符和\0
是为了防止某些类型的腐败。035
预计会像类文件中的主版本和次版本一样随时间而变化。
校验和
校验和是文件的 Adler32 校验和,不包括幻数。在图 3-4 中的classes.dex
文件中,第二块中第一行的十六进制为62 8B 44 18
。但是数据是以小端存储的,所以真正的校验和是相反的,是值0x18448B62
。
Header_size
Header
所有classes.dex
文件的大小相同:0x70
。
Endian _ 标签
所有classes.dex
文件中的endian_tag
是0x12345678
,它告诉你数据是以小端存储的(反向)。未来的 DEX 文件不一定总是如此。但是现在,你可以假设它是小尾序的。
字符串 _ 标识部分
从header
部分可以知道,这个classes.dex
文件中有<string_ids_size>26</string_ids_size>
个字符串,可以在下面的地址找到:<string_ids_offset>0x00000070</string_ids_offset>
。顺便说一下,这是在header
部分的末尾。但是你已经从标题大小知道了:<header_size>0x00000070</header_size>
。
在classes.dex
文件中的这 26 个条目中的每一个都是一个 8 字节的地址偏移量或string_data_off
,它指向data
部分中的实际字符串。在图 3-5 中,可以看到第一个string_ids
条目是72 02 00 00
。记住存储是 little-endian 的,这告诉你第一个字符串可以在地址0x00000272
找到,在文件的data
部分的更下面。最后一个字符串条目是73 03 00 00
,它告诉您最后一个字符串位于0x00000373
的偏移量或地址处。
图 3-5。 string_ids
段classes.dex
清单 3-6 显示了 XML 格式的strings_ids
部分,你继续构建文件的 XML 表示。
清单 3-6。DexToXMLstring_ids
节
`
与类文件不同,字符串不会混杂在常量池中。string_ids
部分完全由指向存储在data
部分中的字符串的指针组成。这些字符串可以在从0x00000272
开始的data
部分找到,也可以在列表 3-7 中找到。
清单 3-7。中的弦data
节中的
string[0]: character string[1]: <init> string[2]: C string[3]: Casting.java string[4]: I string[5]: L string[6]: LC string[7]: LCasting; string[8]: LI string[9]: LL string[10]: Ljava/io/PrintStream; string[11]: Ljava/lang/Object; string[12]: Ljava/lang/String; string[13]: Ljava/lang/StringBuilder; string[14]: Ljava/lang/System; string[15]: V string[16]: VL string[17]: [Ljava/lang/String; string[18]: append string[19]: ascStr string[20]: ascii string[21]: chrStr string[22]: main string[23]: out string[24]: println string[25]: toString
type _ ids 部分
header
部分告诉您有 10 个type_ids
从偏移0x000000D8
开始(清单 3-8 )。第一个type_id
,如图 3-6 所示,是02 00 00 00
。那指向string_id[2]
,那指向data
段的C
(见strings_ids
)。其余的type_id
以类似的方式脱落。
图 3-6。 type_ids
段classes.dex
清单 3-8。DexToXMLtype_ids
节
`
正如您在上一节中看到的,字符串是在data
节中的string_ids
节中给定的偏移量或地址处找到的。我已经抽出了type_id
的字符串,这样你可以更容易地遵循逆向工程过程;参见清单 3-9 。
清单 3-9。中的类型data
部分
type[0]: C type[1]: I type[2]: LCasting; type[3]: Ljava/io/PrintStream;
type[4]: Ljava/lang/Object; type[5]: Ljava/lang/String; type[6]: Ljava/lang/StringBuilder; type[7]: Ljava/lang/System; type[8]: V type[9]: Ljava/lang/String;
proto _ ids 部分
Proto_id
s 包含了Casting.java
中的原型方法。DVM 使用Proto_id
和相关的type_id
组装method_id
。[图 3-7 再次显示了它们在classes.dex
文件中的位置。
每个proto_id
有三个部分,如清单 3-10 中的结构所示。这些是指向method
参数的简短描述或 ShortyDescriptor(参见表 2-2 )的string_id
的指针,一个指向返回类型的type_id
的指针,以及一个进入data
部分的地址偏移量以找到参数列表。
图 3-7。 proto_ids
段classes.dex
清单 3-10。 proto_id
结构
ProtoID{ uint shorty_idx, uint return_type_idx, uint parameters_off }
在这个例子中,根据header
文件有七个proto_id
。这些在清单 3-11 的 DexToXML 中显示;原型本身显示在清单 3-12 中。
清单 3-11。DexToXMLproto_ids
节
`
清单 3-12 显示了来自data
部分的原型。
清单 3-12。中的原型data
章节
proto[0]: Ljava/lang/String; proto( ) proto[1]: Ljava/lang/StringBuilder; proto( C ) proto[2]: Ljava/lang/StringBuilder; proto( I ) proto[3]: Ljava/lang/StringBuilder; proto( Ljava/lang/String; ) proto[4]: V proto( ) proto[5]: V proto( Ljava/lang/String; ) Proto[6]: V proto( Ljava/lang/String; )
字段标识部分
接下来是field_id
s。每个field_id
有三个部分:类名、字段类型和字段名称。清单 3-13 以结构格式展示了这一点。
清单 3-13。 field_id
结构
FieldID{ ushort class_idx, ushort type_idx, uint name_idx }
图 3-8 显示了classes.dex
文件中field_ids
段的位置。
图 3-8。 field_ids
段classes.dex
在这个例子中,根据header
文件有三个fields_id
。这些显示在清单 3-14 的 DexToXML 中,字段本身显示在清单 3-15 中。
清单 3-14。DexToXMLfield_ids
节
`
在这一部分中,您可以根据前面的string_ids
和type_ids
部分中的信息组合字段。对于field [0]
,可以看到类的名称是type_id[2]
或Casting
,字段的类型是type_id[5]
或string
,字段的名称是string_id[19]
或ascStr
:
type_id[2] = LCasting; type_id[5] = Ljava/lang/String; string_id[19] = ascStr
清单 3-15 显示了这一点以及类似解析的剩余字段。
清单 3-15。 字段信息
field_ids[0]: Casting.ascStr:Ljava/lang/String; field_ids[1]: Casting.chrStr:Ljava/lang/String; field_ids[2]: java.lang.System.out:Ljava/io/PrintStream;
method _ ids 部分
每个method_id
都有三个部分:类名、来自proto_ids
部分的方法原型和方法名。清单 3-16 以结构格式展示了这一点。
清单 3-16。 method_id
结构
MethodIDStruct{ ushort class_idx, ushort proto_idx,
uint name_idx }
图 3-9 显示了classes.dex
文件中method_ids
段的位置。
图 3-9。 method_ids
段classes.dex
在这个例子中,根据header
文件有九个method_id
。这些显示在清单 3-17 的 DexToXML 中,方法本身显示在清单 3-18 中。
清单 3-17。DexToXMLmethod_ids
节
`
您可以根据前面章节中的信息手工组装这些方法,而不必转到data
章节。对于method [0]
,类的名字是type_id[2]
或者 L Casting
,方法的原型是proto_id[4]
或者V proto ()
,方法的名字是string_id[1[ <init>
:
type_id[2] = LCasting; proto_id[4] = V proto( ) string_id[1] = <init>
清单 3-18 显示了这一点以及类似的解决方法。
清单 3-18。 方法
method[0]: Casting.<init> (<init>()V) method[1]: Casting.main (main([Ljava/lang/String;)V) method[2]: java.io.PrintStream.println (println(Ljava/lang/String;)V) method[3]: java.lang.Object.<init> (<init>()V) method[4]: java.lang.StringBuilder.<init> (<init>()V) method[5]: java.lang.StringBuilder.append (append(C)Ljava/lang/StringBuilder;) method[6]: java.lang.StringBuilder.append (append(I)Ljava/lang/StringBuilder;) method[7]: java.lang.StringBuilder.append (append(Ljava/lang/String;)Ljava/lang/StringBuilder;) method[8]: java.lang.StringBuilder.toString (toString()Ljava/lang/String;)
class _ defs 部分
每个class_def
有八个部分:类的id
、类的access_flags
、超类的type_id
、接口列表的address
、源文件名的 string_id
、任何注释(与逆向工程源代码无关)的另一个address
、类数据的address
(在这里可以找到更多的类信息)以及最后的address
,在这里可以找到任何静态字段的初始值。清单 3-19 以结构格式显示了这一点。
清单 3-19。 class_defs
结构
ClassDefsStruct { uint class_idx, uint access_flags, uint superclass_idx, uint interfaces_off, uint source_file_idx, uint annotations_off, uint class_data_off, uint static_values_off, }
图 3-10 显示了classes.dex
文件中class_defs
段的位置。
图 3-10。 class_defs
段classes.dex
在这个例子中,只有一个类,如清单 3-20 中的 DexToXML 所示。
清单 3-20。 DexToXML class_defs
段
`
在classes.dex
中,类别Casting
的access_flags
值为0x00000001
。表 3-2 列出了访问标志的转换;在这种情况下,访问标志是public
。
通过手工组装 Java 代码,你可以看到这个类被定义为来自type_id[2]
的公共类Casting
,超类是来自type_id[4]
的java/Lang/Object
,源文件是来自string_id[3]
的Casting.java
:
access_flags = public
type_id[2] = LCasting; type_id[4] = Ljava/lang/Object; string_id[3] = Casting.java
数据部分
你现在在data
段,是classes.dex
的真肉。早先的信息导致了这一点。当您解析文件的剩余部分时,您有一个选择:您可以顺序地解析它,或者开始跟随数据偏移量中的地址来查找您想要反编译的字节码。
最明显的做法是遵循数据偏移,所以让我们试试这种方法。首先是来自class_defs
的class_data_item
,假设您正在寻找字节码。class_data_item
部分包含关于字段和方法的信息;接下来是code_item
部分,它包含字节码。
类别 _ 数据 _ 项目
从反汇编class_defs
中,你知道这个文件中唯一的类Casting.java
的地址是0x392
。信息是在一个class_data_item
结构中,类似于清单 3-21 。
清单 3-21。 class_data_item
结构
ClassDataItemStruct { uleb128 static_fields_size, uleb128 instance_fields_size, uleb128 direct_method_size, uleb128 virtual_method_size, encoded_field[static_fields_size] static_fields, encoded_field[instance_fields_size] instance_fields, encoded_method[direct_fields_size] direct_methods, encoded_method[virtual_fields_size] virtual_methods }
Uleb128 是一种用于存储大整数的无符号小端基 128 编码格式。要将整数转换为 uleb128,您需要将其转换为二进制,将其填充为 7 位的倍数,将其分成 7 个位组,在除最后一个位组之外的所有位上添加高 1 位以形成字节(即 8 位),转换为十六进制,然后将结果翻转为 little-endian。如果你看一个例子,这就更有意义了。下面这个例子来自维基百科([
en.wikipedia.org/wiki/LEB128](http://en.wikipedia.org/wiki/LEB128)
):
10011000011101100101 In raw binary
010011000011101100101 Padded to a multiple of 7 bits 0100110 0001110 1100101 Split into 7-bit groups 00100110 10001110 11100101 Add high 1 bits on all but last group to form bytes 0x26 0x8E 0xE5 In hexadecimal 0xE5 0x8E 0x26 Output stream
多亏了例子中的小整数,转换就容易多了:uleb128 中的0x2
是2
。
encoded_field
和encoded_method
还有另外两种结构,采用清单 3-22 和清单 3-23 所示的格式。Field_idx_diff
的不同之处在于,尽管第一个条目是直接的field_id[]
引用,但是任何后续的field_id[]
条目都被列为与之前列出的field_id
的差异。Method_idx_diff
遵循同样的模式。
清单 3-22。 encoded_field
结构
EncodedFieldStruct{ uleb128 field_idx_diff, (explain that it's diff and directly for the first) uleb128 access_flags }
清单 3-23。 encoded_method
结构
EncodedMethodStruct{ uleb128 method_idx_diff, (explain that it's diff and directly for the first) uleb128 access_flags, uleb128 code_off }
图 3-11 显示了你在classes.dex
文件中的位置:在data
部分的正中间。
图 3-11。 class_data_item
段classes.dex
清单 3-24 显示了class_defs
部分的 DexToXML 输出。
清单 3-24。DexToXMLclass_defs
`
手动组装信息,您可以看到如下所示的静态字段和方法信息:
static_field[0] field[0]: Casting.ascStr:Ljava/lang/String; access_flags = static & final static_field[1] field[1]: Casting.chrStr:Ljava/lang/String; access_flags = static & final direct_method[0] method[0]: Casting.<init> (<init>()V) access_flags = public & constructor code_offset = 0x00001d4 direct_method[1] method[1]: Casting.main (main(Ljava/lang/String;)V) access_flags = public & static code_offset = 0x00001ec
现在,您已经拥有了类中方法和字段的所有信息;如果这不明显,您将从外向内重新创建Casting.java
代码。您还应该特别注意code_offset
,因为它是字节码所在的位置,将用于重新创建源代码。那是你接下来要去的地方。
代码石
class_data_item
告诉你code_item
从0x1d4
开始第一个<init>
方法,0x1ec
开始主方法。信息在一个code_item
结构中,类似于[清单 3-25 。
清单 3-25。 code_item
结构
CodeItemStruct {
ushort registers_size, ushort ins_size, ushort outs_size, ushort tries_size, uint debug_info_off, uint insns_size, ushort[insns_size] insns, ushort padding, try_item[tries_size] tries, encoded_catch_handler_list handlers }
这花了一些时间,但是要特别注意CodeItemStruct
中的insns
元素:那是classes.dex
存储字节码指令的地方。
图 3-12 显示了classes.dex
文件中code_item
段(init
和main
)的位置。这个突出显示的区域是两个code_item
的,它们是一个接一个存储的。
图 3-12。 code_item
段classes.dex
清单 3-26 显示了每个code_item
部分的 DexToXML 输出。
清单 3-26。DexToXMLcode_item
`
注: 附录中的表 A-2“DVM 字节码到操作码的映射”列出了 DVM 操作码,您可以使用这些操作码将十六进制代码转换成等效的操作码或字节码,并完成反汇编。进行转换时,请参考这一点。
你知道清单 3-26<init>
中的第一个方法基于<insns_size />
有四条指令。从十六进制文件中可以看到,这四个十六进制代码是7010 0300 0000 0e00
。您可以手动将其转换为以下内容:
7010 invoke-direct 10 string[16]: VL 0003 method[3]: java.lang.Object.<init> (<init>()V) 0000 no argument 0e00 return-void
这是 javac 编译器添加到所有还没有构造函数的类中的空构造函数。因此,您的第一个方法可以直接转换回以下代码,这是一个空的构造函数:
public class Casting() { }
总结
这就完成了对classes.dex
文件的分解。完整的 DexToXML 解析器代码在 Apress 网站上([www.apress.com](http://www.apress.com)
)。它包括其他部分,如map_data
和debug_info
,它们也在classes.dex
文件中,但与反编译过程无关。下一章将讨论 Android 反汇编、反编译和混淆世界中所有可用的工具和技术。在第五章和第六章中,你将回到 DexToXML 和 DexToSource,你的 Android 反编译器。
四、贸易工具
本章着眼于黑客用来从 Android 包文件(APK)逆向工程底层源代码的一些工具和一些简单的技术。它还简要介绍了主要的开源和商业混淆器,因为混淆是保护源代码最流行的工具。此外,这一章涵盖了这些混淆器背后的理论,这样你就可以更好地了解你所购买的东西。
让我们从某人如何破解你的安卓 APK 文件开始这一章。这样,当您试图保护您的代码时,您可以开始避免一些最明显的陷阱。
下载 APK
多年来,Java 代码可以被反编译或逆向工程成与原始代码非常接近的东西已经不是什么秘密了。但自从浏览器小程序多年前失宠以来,这从来就不是一个棘手的问题。原因简单明了:准入。互联网上的大多数 Java 代码都存在于服务器上,而不是浏览器中。有些桌面应用是用 Java Swing 写的,比如 Corel 的 WordPerfect。但这些都是值得注意的例外,大多数 Java 代码位于防火墙后的 web 服务器上。所以,在你反编译它们之前,你必须侵入一个服务器来访问类文件。这是不太可能的情况;坦率地说,如果有人通过入侵你的服务器获得了对你的类文件的访问权,那么你有比他们反编译你的代码更糟糕的事情要担心。
但安卓手机不再是这样了。在第三章中,你看到了 Java 代码是如何被编译成一个classes.dex
文件的。然后,classes.dex
文件与所有其他资源(如图像、字符串、文件等)捆绑在一起,成为您的客户下载到他们手机上的 APK。你的安卓 app 是客户端;有了正确的知识,任何人都可以访问 APK,并最终访问您的代码。
有三种方法可以访问 APK:将它备份到 SD 卡上;通过互联网论坛;通过使用 Android SDK 自带的 Android 平台工具。这些选项将在以下章节中讨论。
备份 APK
或许获得 APK 的最简单方法是使用备份工具将 APK 下载到微型 SD 卡上,以便以后在 PC 上检查。步骤如下:
- 从 Android Market 下载并安装免费版的 ASTRO 文件管理器。
- 将微型 SD 卡插入手机
- 打开 ASTRO,按下手机上的菜单键。
- 选择工具
应用管理器/备份。
- 选中目标 APK 旁边的复选框,点击备份,参见图 4-1 。
- 关闭 ASTRO 文件管理器。
- 取出微型 SD 卡,并将其插入电脑。或者,如果你没有 SD 卡,把 APK 用电子邮件发给你自己。
图 4-1。 使用 ASTRO 文件管理器备份 APK
论坛
如果你正在寻找一个更受欢迎的 apk,它可能很容易在网上找到。比如[
forum.xda-developers.com](http://forum.xda-developers.com)
的 XDA 开发者论坛,就是开发者分享新旧 apk 的地方。
平台工具
如果你开发 Android 应用,那么你更有可能想要使用 Android 平台工具来访问 APK。这是下载 APK 的简单方法,但只有当你获得了手机的 root 或管理员权限时,这就是所谓的root手机。以我不那么科学的观点来看,由于 Android 平台的开源性,Android 手机比 iPhones 或 Windows mobile 手机更容易被 root 或越狱。开源吸引了更多的开发者,他们通常想知道(或者如果他们有时间的话,可以选择知道)他们的手机是如何通过拆开软件或硬件来工作的。其他人可能只是想捆绑他们的手机,获得免费 Wi-Fi,这是运营商不鼓励的。
在 Android 上扎根很容易,谷歌早些时候就通过其解锁的 Nexus 手机系列鼓励了这一点。下一节将展示给手机找根是多么容易;我无法涵盖所有设备和 Android 版本,但它们都遵循相似的模式。找到一部手机最困难的事情往往是为你的设备找到正确的 USB 驱动程序。
请记住,给你的手机找根是有风险的。除其他外,这样做可能会使保修无效;因此,如果出现任何问题,你可能会留下一个死设备。
窃听电话
有很多不同的选择,当谈到扎根你的手机。对你的手机来说,最好的方法取决于手机类型以及手机上运行的 Android 版本。最直接的方法是从 XDA 开发者论坛下载 Z4Root、SuperOneClick 或 Universal Androot Android 应用,并将其安装在手机上。有段时间,安卓市场有 Z4Root 但是,毫不奇怪,它和其他 apk 将根你的手机再也找不到了。
Z4Root 在运行 Android 2.2 (Froyo)的早期机器人上工作得很好,并使用 RageAgainstTheCage 病毒获得 Root 访问权限。这是谷歌在 Android 2.3(姜饼)中修复的。但是 GingerBreak 随后被开发出来,允许黑客进入运行 Gingerbread 的手机。随着 Superboot 现在可以获得 Android 4.0(冰淇淋三明治)手机的 root 访问权限,这种情况一直持续到今天。
我们来看看 Z4Root 在 Android 2.2.1 或者 Froyo 上是如何工作的。虽然是 Android 的早期版本,但在姜饼上使用 GingerBreak 或在冰淇淋三明治上使用 Honeycomb 或 Superboot 时,过程是相同的。
在 Android 2.2.1 手机上安装 Z4Root 的步骤如下:
- 备份你的手机。
- 从
[
forum.xda-developers.com](http://forum.xda-developers.com)
下载 APK。如果你的电脑上有一个病毒扫描程序,它会弹出一条消息,说你下载了一个带有 RageAgainstTheCage 病毒的文件,这是 Z4Root 用来入侵手机的漏洞。 - 将 APK 从您的计算机复制到 SD 卡上。
- 将 SD 卡放入手机,使用 ASTRO 文件管理器安装 Z4Root。
- 遵循 Z4Root 中的步骤,如图图 4-2 和 4-3 所示。在第一个屏幕上选择 Root 然后,选择临时根来根您的电话,直到您的电话重新启动,或选择永久根来保持根。Z4Root 对你的手机进行 Root 可能需要几分钟的时间,但是如果成功的话,设备会重新启动,手机也会被 root。
图 4-2。 Z4Root 安装
图 4-3。 在 Z4Root 中选择一个临时或永久的根
如果您在手机已经根化之后运行 Z4Root,它会为您提供取消根化手机的选项。这在你需要更换手机又不想让保修失效的情况下很有用(见图 4-4 )。
图 4-4 使用 Z4Root 禁用 root
Z4Root 使用的 RageAgainstTheCage 病毒会产生大量的 adb (Android Debug Bridge)进程,直到手机的进程数达到极限。Z4Root 杀死最后一个进程;然后,由于 Android 2.2.1 中的一个 bug,最后一个 adb 进程仍然以 root 身份运行,并且还允许 adb 在重启时以 root 身份运行,因此手机受到了威胁。
安装和使用平台工具
要查看手机是否已经成功 rooted,需要从[
developer.android.com](http://developer.android.com)
开始安装 Android SDK。你可以在 SDK 的platform-tools
和tools
目录中找到很多好东西,包括达尔维克调试监控服务(DDMS ),它可以让你像一样调试手机,还可以截图;电话模拟器;以及 dedexer 工具,它可以帮助您查看classes.dex
文件的内部。
前面简单提到的 adb 工具将你的电脑和手机连接起来。使用 adb,可以连接手机或平板电脑,从其命令行执行 Unix 命令;参见清单 4-1 ,使用 Windows 7 机器显示。如果运行su
命令后得到#
提示,如图所示,那么你的手机就成功 rooted 了。
清单 4-1。 扎根手机
`C:\Users\godfrey>adb devices
List of devices attached
0A3A9B900A01F014 device
C:\Users\godfrey>adb shell
$ su
su
`
连接到计算机的根电话使您可以轻松访问电话上的所有 apk。您可以通过使用 adb shell 在您的设备上为付费或受保护的应用执行命令ls /data/app
或ls /data/app-private
来找到您的目标 apps 参见清单 4-2 。
清单 4-2。 寻找 APK 下载
`c:\android\android-sdk\platform-tools>adb shell
$ su
ls /data/app
com.apps.aaa.roadside-1.apk
com.pyxismobile.Ameriprise.ui.activity-1.zip
com.s1.citizensbank-1.zip
com.hungerrush.hungryhowies-1.apk
com.huntington.m-1.apk
com.netflix.mediaclient-1.apk
com.priceline.android.negotiator-1.apk
com.google.android.googlequicksearchbox-1.apk
ls /data/app-private
com.s1.citizensbank-1.apk
com.pyxismobile.Ameriprise.ui.activity-1.apk`
如果您在/data/app
目录中看到一个.zip
文件,那么 APK 就在/data/app-private
目录中。
退出 adb shell,从您的计算机命令行使用adb pull
命令将 APK 复制到您的本地文件夹;参见清单 4-3 。
清单 4-3。 使用adb pull
命令
c:\android\android-sdk\platform-tools>adb pull data/app/com.riis.mobile.apk
反编译 APK
在第三章的中,你看到了classes.dex
的格式与 Java 类文件格式截然不同。但是目前没有classes.dex
反编译器——只有 Java 类文件反编译器。你必须等到第五章和第六章来构建你自己的反编译器。同时,您可以使用 dex2jar 将classes.dex
转换回类文件。
dex2jar 是一个将 Android 的.dex
格式转换成 Java 的.class
格式的工具。它从一种二进制格式转换到另一种二进制格式,而不是转换到源代码。并且可以从[
code.google.com/p/dex2jar](http://code.google.com/p/dex2jar)
买到。一旦转换成类文件格式,仍然需要使用像 JD-GUI 这样的 Java 反编译器来查看 Java 源代码。
从命令行,在 APK 上运行以下命令:
c:\temp>dex2jar com.riis.mobile.apk c:\temp>jd-gui com.riis.mobile.apk.dex2jar
或者,您可以使用 Ryszard winiewski 的 apktool,它从[
code.google.com/p/android-apktool](http://code.google.com/p/android-apktool)
开始提供。在 Windows 上,安装 apktool 后,你可以用鼠标右键反编译一个 APK。apktool 解压 APK,运行 baksmali(一个反汇编器),使用 AXMLPrinter2 解码AndroidManifest.xml
文件,使用 dex2jar 将classes.dex
转换为 jar 文件,然后在 JD-GUI 中弹出 Java 代码。
APK 档案里有什么?
apk 是压缩格式的。您可以通过将扩展名改为.zip
并使用您最喜欢的解压缩工具来解压缩文件。(许多工具,如 7-Zip,会识别出它是一个 Zip 文件,并在不需要改变扩展名的情况下将其压缩。)这样一个 zip 文件的内容如图图 4-5 所示。
图 4-5。 解压缩后的 APK 文件的内容
META-INF
目录包含一个manifest.mf
或清单文件,其中包含所有文件的清单摘要。cert.rsa
拥有用于签署文件的证书,cert.sf
拥有 APK 的资源列表以及每个文件的 SHA-1 摘要。res
目录包含所有的 APK 资源,比如 XML 布局定义文件和相关的图像,而assets
包含图像和 HTML、CSS 和 JavaScript 文件。AndroidManifest.xml
包含 APK 的名称、版本号和访问权限。这通常是二进制格式,需要使用 AXMLPrinter2 转换成可读格式。最后,您得到了包含编译后的 Java 类文件的classes.dex
文件;和resources.asrc
,它包含任何不在 resources 目录中的预编译资源。AXMLPrinter2 在[
code.google.com/p/android4me](http://code.google.com/p/android4me)
可用。清单 4-4 展示了如何使用它来解码AndroidManifest.xml
文件。
清单 4-4。 AXMLPrinter2.jar
命令
java -jar AXMLPrinter2.jar AndroidManifest.xml > AndroidManifest_decoded.xml
清单 4-5 显示了一个使用 AXMLPrinter2 解码后的AndroidManifest.
xml 文件。
清单 4-5。 解码AndroidManifest.xml
`
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="1"
android:versionName="1.0"
package="com.riis.agile.agileandbeyond.android"
<application
android:label="@7F070000"
android:icon="@7F020015"
android:name=".OpenSourceBridgeApplication"
android:debuggable="true"
<activity
android:theme="@android:01030006"
android:label="@7F070000"
android:name=".LaunchActivity"
<intent-filter
<action
android:name="android.intent.action.MAIN"
APK 中可以有其他目录。如果该文件是一个 HTML5/CSS 应用,那么它将有一个包含 HTML 页面和 JavaScript 代码的资源库。如果 APK 使用其他 Java 库或本地库中的任何 C++代码,那么在一个lib
文件夹中会有.jar
和.so
文件。
随机 APK 问题
在我下载的 50 个随机样本中,只有 1 个有任何形式的保护。随着反编译 Android 代码的问题变得更好理解,这种情况可能会改变。本节概述了我在示例中遇到的一些问题。所有公司名称、web 服务 URL 和 API 密钥或登录信息都已被修改,以保护无辜者。
Web 服务密钥和登录
虽然许多 Android 应用是独立的,但许多是经典的客户端应用,通过网络服务密钥与后端系统通信。清单 4-6 显示了反编译的源代码,带有生产 web 服务的 API 密钥以及帮助和支持信息。这可能还不足以侵入 web 服务,但它邀请黑客进一步探索 API。
清单 4-6。 暴露网络服务 API 键
`public class PortalInfoBuilder
{
public static List a(Context paramContext)
{
ArrayList localArrayList = new ArrayList();
Boolean localBoolean = Boolean.valueOf(0);
PortalInfo localPortalInfo = new PortalInfo("Production",
"https://runapiportal.riis.com/portal.svc",
"d3IWwZ9TjkoNFtNYtwsLYM+gk/Q=", localBoolean);
localPortalInfo.b("https://support.riis.com/riis_payroll//%d/help.
htm");
localPortalInfo.c("http://www.riis.com/ /guided_tours.xml");
boolean bool = localArrayList.add(localPortalInfo);
return localArrayList;
}
}`
清单 4-7 显示了一个受用户名和密码保护的 API。但是通过反编译 APK,黑客可以访问 API 传输的任何信息。在这种情况下,API 不会检查浏览器是否来自移动设备,也不会检查信息是否来自网站。如果你的 API 提供的信息是有价值的,那么最好隐藏用户名和密码;在本章后面的“保护您的源代码”一节中,您会看到如何做到这一点。
清单 4-7。 暴露 API 用户名和密码
private String Digest(ArrayList<String> paramArrayList) { // setup String str5 = "CB8F9322-0C1C-4B28A4:" + str2 + ":" + "cxYacuzafrabru5a1beb"; String str7 = "POST:" + "https://www.riis.com/api/"; }
清单 4-8 显示了相同的用户名和密码,它们无缘无故地在一个配置文件中重复出现。在发布应用之前,请确保所有用户名和密码无论出现在哪里都得到妥善保护。
清单 4-8。 暴露 Web 服务用户名和密码
public static final String USER_NAME = ”CB8F9322-0C1C-4B28A4"; public static final String PASSWORD = " cxYacuzafrabru5a1beb";
数据库模式
另一个值得关注的领域是数据库,敏感信息通常存储在这里。反编译 APK 允许黑客看到存储在电话上的 SQLite 或其他数据库的数据库模式信息。许多 apk 将个人的信用卡信息存储在本地数据库中。获取这些数据可能需要有人偷你的手机或者制造一个安卓病毒,这种可能性不大;但还是那句话,这又是一条不该曝光的信息。
清单 4-9 显示了一个电话应用的一些数据库模式信息,而清单 4-10 显示了一个本地存储信用卡信息的 HTML5 应用的信息。
清单 4-9。 创建模式和数据库位置信息
public class DB
{ public static final String ACTIVATION_CODE = "activationcode"; public static final String ALLOWEDIT = "allowedit"; private static final String ANYWHERE_CREATE = "create table %s (_id integer primary key autoincrement, description text, phoneNumber text not null, isActive text not null);"; private static final String ANYWHERE_TABLE = "anywhere"; private static final String AV_CREATE = "create table SettingValues (_id integer primary key autoincrement, keyname text not null, attribute text not null, value text not null);"; private static final String AV_TABLE = "SettingValues"; private static final String CALLCENTER_CREATE = "create table callcenters (_id integer primary key autoincrement, ServiceUserId text not null, Name text, PhoneNumber text, Extension text, Available text not null, LogoffAllowed text not null);"; private static final String DATABASE_NAME = "settings.db";
private static final int DATABASE_VERSION = 58; }
清单 4-10。 存储信用卡信息
`// ****************************** Credit Cards Table
api.createCustCC = function (email,name,obj){
var rtn=-1;
try{
api.open();
conn.execute('INSERT INTO CustomerCC (Email,CCInfo,Name)
VALUES(?,?,?)',email,JSON.stringify(obj),name);
rtn=conn.lastInsertRowId;
}`
HTML5/CSS
相当数量的 Android APKs 最初是用 HTML5/CSS 编写的。使用 PhoneGap 等工具,HTML5/CSS 文件被转换成 apk,然后上传到 Android market。这些应用中的 Java 代码是一个框架,它只是从 Android 框架中调用 HTML5 应用。解压缩 APK,你可以在assets
文件夹中找到原始的 JavaScript。
有时 JavaScript 包含比 Java 源代码更危险的信息,因为在创建 APK 之前,注释通常不会被删除。使用 JavaScript 压缩器有助于解决这个问题(参见本章后面的“混淆器”一节)。
假冒应用
反编译 apk 通常不会导致 100%的代码被逆向工程。dex2jar 经常无法将classes.dex
完全转换成 Java jar 文件。但是通过一些努力,可以调整生成的 Java 源代码,使其重新编译成一个被窃取或劫持的 APK,然后以不同的名字重新提交到 Android market。还可以创建虚假应用,从银行应用或任何需要登录的应用中获取用户名和密码。
迄今为止,最著名的假冒应用是一个收集网飞账户信息的假冒网飞应用。这个特殊的例子没有使用任何反编译的代码,但是一个看起来像真正的应用的被劫持的应用将提供一个复杂的水平,将欺骗大多数人放弃登录信息。假冒应用也是将恶意软件上传到手机或设备的良好载体,几乎没有被检测到的机会,因为安卓市场没有预先批准。不过,请注意,Android Bouncer 这个奇妙的名字现在正在捕捉这些假冒的应用。
反汇编程序
如果你花时间在 Android 字节码上,你会逐渐注意到不同的模式和语言结构。通过练习和大量的耐心,字节码变成了另一种语言。
到目前为止,您已经看到了两个反汇编程序:dx,它是 Android SDK 的一部分;以及 DexToXML,它将classes.dex
反汇编成一个 XML 结构。你在第三章中使用了 Android 的 dx 工具将Casting.class
编译成classes.dex
格式,但它也可以将classes.dex
文件反汇编成文本。
让我们简单看一下 dx 输出和其他一些替代方案,看看它们是否比 DexToXML 更好。首先,不要忘记十六进制编辑器,它通常可以提供黑客需要的所有信息。
十六进制编辑器
多年来,黑客一直使用十六进制编辑器和其他更复杂的工具,如 Numega 的 SoftICE 和最近的 Hex-Rays 的 IDA,来绕过各种软件的定时炸弹版本的许可计划。破解 20 世纪 80 年代末和 90 年代几乎每本电脑杂志上的游戏演示版是我的许多程序员同事的必经之路。
通常,程序员试图通过检查日期是否在安装日期之后 30 天来保护他们的游戏和工具。30 天后,评估版停止运行。如果你买不起真货,你可以在电脑上设置永久的时间,在评估期前几天。或者,如果你够聪明,你会意识到开发人员必须将安装日期存储在某个地方:如果你够幸运,它就在某个简单的地方,比如在.ini
文件或注册表中,你可以将它永久地设置为某个遥远的未来日期,比如 1999 年。
当你差不多能读懂汇编程序时,通过仪式就真正完成了;设置断点以缩小安全功能的范围;找到检查评估日期的那段代码并禁用它,或者创建一个程序可以接受的序列号或密钥,这样评估副本就成为了该软件的一个功能完整的版本。
有无数更复杂的机制来保护更昂贵的程序;许多昂贵的 CAD 程序上使用的加密狗立即跃入脑海。通常,大多数保护机制除了防止普通人禁用或破解它们之外,没有什么作用。在 Java 世界中,攻击这种机制的工具是十六进制编辑器。
大多数程序员非但没有从过去吸取教训,反而注定要重蹈覆辙。绝大多数许可证保护的例子都依赖于像条件跳转这样简单的东西。在清单 4-11 中,来自 Google 的修改后的样本代码展示了如何在 2012 年底终止一个演示。
清单 4-11。 定时炸弹试用 App 代码
if (new Date().after(new GregorianCalendar(2012,12,31).getTime())) { AlertDialog.Builder ad = new AlertDialog.Builder(SomeActivity.this); ad.setTitle("App Trial Expired"); ad.setMessage("Please download Full App from Android Market."); ad.setPositiveButton("Get from Market", new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int whichButton) { Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse("http://market.android.com/search?q=pname:com.riis.app_f ull")); startActivity(i); finish(); } }).show(); }
使用第三章中的信息可以找到 Android 字节码。然后快速浏览一下,使用十六进制编辑器将after
更改为before
,将试用版应用变成完整版。一些十六进制编辑器,如 IDA,使这变得非常简单;参见图 4-6 。
图 4-6。 IDA 十六进制编辑器
dx 和 dexdump
Dx 是 Android SDK 的一部分,可以与 dexdump 一起在platform-tools
目录中找到。带有 verbose 选项的 dx 命令可以完全分解任何一个classes.dex
文件,如果你想看到classes.dex
的内部,这是目前最好的反汇编程序。以下命令输出classes.dex
的反汇编版本:编译casting
目录下的Casting.class
文件,输出casting.dump
:
dx --dex --verbose-dump --dump-to=c:\temp\casting.dump c:\temp\casting
清单 4-12 显示了文件头部分的输出。
清单 4-12。classes.dex
标题段的 Dx 输出
000000: 6465 780a 3033 |magic: "dex\n035\0" 000006: 3500 | 000008: 628b 4418 |checksum
00000c: daa9 21ca 9c4f |signature 000012: b4c5 21d7 77bc | 000018: 2a18 4a38 0da2 | 00001e: aafe | 000020: 5004 0000 |file_size: 00000450 000024: 7000 0000 |header_size: 00000070 000028: 7856 3412 |endian_tag: 12345678 00002c: 0000 0000 |link_size: 0
000030: 0000 0000 |link_off: 0 000034: a403 0000 |map_off: 000003a4 000038: 1a00 0000 |string_ids_size: 0000001a 00003c: 7000 0000 |string_ids_off: 00000070 000040: 0a00 0000 |type_ids_size: 0000000a 000044: d800 0000 |type_ids_off: 000000d8 000048: 0700 0000 |proto_ids_size: 00000007 00004c: 0001 0000 |proto_ids_off: 00000100 000050: 0300 0000 |field_ids_size: 00000003 000054: 5401 0000 |field_ids_off: 00000154 000058: 0900 0000 |method_ids_size: 00000009 00005c: 6c01 0000 |method_ids_off: 0000016c 000060: 0100 0000 |class_defs_size: 00000001 000064: b401 0000 |class_defs_off: 000001b4 000068: 7c02 0000 |data_size: 0000027c 00006c: d401 0000 |data_off: 000001d4
Dexdump 是 Java 类文件反汇编器 javap 的 Android SDK 等价物。产生清单 4-13 中的输出的 dexdump 命令如下:
dexdump -d -h classes.dex
清单 4-13。 带反汇编文件头的普通 Dexdump 输出
`Processing 'classes.dex'...
Opened 'classes.dex', DEX version '035'
Class #0 header:
class_idx : 2
access_flags : 1 (0x0001)
superclass_idx : 4
interfaces_off : 0 (0x000000)
source_file_idx : 3
annotations_off : 0 (0x000000)
class_data_off : 914 (0x000392)
static_fields_size : 2
instance_fields_size: 0
direct_methods_size : 2
virtual_methods_size: 0
Class #0 -
Class descriptor : 'LCasting;'
Access flags : 0x0001 (PUBLIC)
Superclass : 'Ljava/lang/Object;'
Interfaces -
Static fields -
0 : (in LCasting;)
name : 'ascStr'
type : 'Ljava/lang/String;'
access : 0x0018 (STATIC FINAL)
#1 : (in LCasting;)
name : 'chrStr'
type : 'Ljava/lang/String;'
access : 0x0018 (STATIC FINAL)
Instance fields -
Direct methods -
0 : (in LCasting;)
name : '
type : '()V'
access : 0x10001 (PUBLIC CONSTRUCTOR)
code -
registers : 1
ins : 1
outs : 1
insns size : 4 16-bit code units
0001d4: |[0001d4]
Casting.
0001e4: 7010 0300 0000 |0000: invoke-
direct {v0},
Ljava/lang/Object;.
method@0003
0001ea: 0e00 |0003: return-void
catches : (none)
positions :
0x0000 line=1
locals :
0x0000 - 0x0004 reg=0 this LCasting;
1 : (in LCasting;)
name : 'main'
type : '([Ljava/lang/String;)V'
access : 0x0009 (PUBLIC STATIC)
code -
registers : 5
ins : 1
outs : 2
insns size : 44 16-bit code units
0001ec: |[0001ec]
Casting.main:([Ljava/lang/String;)V
0001fc: 1200 |0000: const/4 v0,
int 0 // #0
0001fe: 1301 8000 |0001: const/16 v1,
int 128 // #80
000202: 3510 2800 |0003: if-ge v0,
v1, 002b // +0028
000206: 6201 0200 |0005: sget-object
v1, Ljava/lang/System;.out:Ljava/io/PrintStream; // field@0002
00020a: 2202 0600 |0007: new-instance
v2, Ljava/lang/StringBuilder; // type@0006
00020e: 7010 0400 0200 |0009: invoke-
direct {v2}, Ljava/lang/StringBuilder;.
000214: 1a03 1400 |000c: const-string
v3, "ascii " // string@0014
000218: 6e20 0700 3200 |000e: invoke-
virtual {v2, v3},
Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/St
ringBuilder; // method@0007
00021e: 0c02 |0011: move-result-
object v2
000220: 6e20 0600 0200 |0012: invoke-
virtual {v2, v0},
Ljava/lang/StringBuilder;.append:(I)Ljava/lang/StringBuilder; //
method@0006
000226: 0c02 |0015: move-result-
object v2
000228: 1a03 0000 |0016: const-string
v3, " character " // string@0000
00022c: 6e20 0700 3200 |0018: invoke-
virtual {v2, v3},
Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/St
ringBuilder; // method@0007
000232: 0c02 |001b: move-result-
object v2
000234: 6e20 0500 0200 |001c: invoke-
virtual {v2, v0},
Ljava/lang/StringBuilder;.append:(C)Ljava/lang/StringBuilder; //
method@0005
00023a: 0c02 |001f: move-result-
object v2
00023c: 6e10 0800 0200 |0020: invoke-
virtual {v2},
Ljava/lang/StringBuilder;.toString:()Ljava/lang/String; //
method@0008
000242: 0c02 |0023: move-result-
object v2
000244: 6e20 0200 2100 |0024: invoke-
virtual {v1, v2},
Ljava/io/PrintStream;.println:(Ljava/lang/String;)V // method@0002
00024a: d800 0001 |0027: add-int/lit8
v0, v0, #int 1 // #01
00024e: 8e00 |0029: int-to-char
v0, v0
000250: 28d7 |002a: goto 0001 //
-0029
000252: 0e00 |002b: return-void
catches : (none)
positions :
0x0000 line=8
0x0005 line=9
0x0027 line=8
0x002b line=11
locals :
Virtual methods -
source_file_idx : 3 (Casting.java)`
驱动剂
Dedexer 是来自匈牙利工程师 Gabor Paller 的开源反汇编工具。在[
dedexer.sourceforge.net](http://dedexer.sourceforge.net)
有售。Dedexer 是 dx 的优秀替代产品。清单 4-14 显示了执行以下命令后的 Dedexer dex.log
输出文件:
java -jar ddx1.18.jar -o -d c:\temp casting\classes.dex
清单 4-14。 Dedexer 头段输出
00000000 : 64 65 78 0A 30 33 35 00 magic: dex\n035\0 00000008 : 62 8B 44 18 checksum 0000000C : DA A9 21 CA 9C 4F B4 C5 21 D7 77 BC 2A 18 4A 38 0D A2 AA FE signature 00000020 : 50 04 00 00 file size: 0x00000450 00000024 : 70 00 00 00 header size: 0x00000070 00000028 : 78 56 34 12 00 00 00 00 link size: 0x00000000 00000030 : 00 00 00 00
link offset: 0x00000000 00000034 : A4 03 00 00 map offset: 0x000003A4 00000038 : 1A 00 00 00 string ids size: 0x0000001A 0000003C : 70 00 00 00 string ids offset: 0x00000070 00000040 : 0A 00 00 00 type ids size: 0x0000000A 00000044 : D8 00 00 00
type ids offset: 0x000000D8 00000048 : 07 00 00 00 proto ids size: 0x00000007 0000004C : 00 01 00 00 proto ids offset: 0x00000100 00000050 : 03 00 00 00 field ids size: 0x00000003 00000054 : 54 01 00 00 field ids offset: 0x00000154 00000058 : 09 00 00 00 method ids size: 0x00000009 0000005C : 6C 01 00 00 method ids offset: 0x0000016C 00000060 : 01 00 00 00 class defs size: 0x00000001 00000064 : B4 01 00 00 class defs offset: 0x000001B4 00000068 : 7C 02 00 00 data size: 0x0000027C 0000006C : D4 01 00 00 data offset: 0x000001D4 00000070 : 72 02 00 00
巴克斯马利
Backsmali 在冰岛语中是拆卸器的意思,延续了与 Dalvik 虚拟机相关的名称的冰岛语主题,你可能记得这是以一个冰岛村庄命名的,最初的程序员之一(丹·博恩施泰因)的祖先就来自那里。Baksmali 是由一个叫 JesusFreke 的人写的,可以在[
code.google.com/p/smali](http://code.google.com/p/smali)
和 smali 一起获得,后者在冰岛语中是汇编器。清单 4-15 显示了执行以下命令时classes.dex
的 baksmali 输出:
java -jar baksmali-1.3.2.jar -o c:\temp casting\classes.dex
清单 4-15。??Casting.smali
`.class public LCasting;
.super Ljava/lang/Object;
.source "Casting.java"
static fields
.field static final ascStr:Ljava/lang/String; = "ascii "
.field static final chrStr:Ljava/lang/String; = " character "
# direct methods
.method public constructor
.registers 1
.prologue
.line 1
invoke-direct {p0}, Ljava/lang/Object;->
return-void
.end method
.method public static main([Ljava/lang/String;)V
.registers 5
.parameter
.prologue
.line 8
const/4 v0, 0x0
:goto_1
const/16 v1, 0x80
if-ge v0, v1, :cond_2b
.line 9
sget-object v1, Ljava/lang/System;->out:Ljava/io/PrintStream;
new-instance v2, Ljava/lang/StringBuilder;
invoke-direct {v2}, Ljava/lang/StringBuilder;->
const-string v3, "ascii "
invoke-virtual {v2, v3}, Ljava/lang/StringBuilder;-
append(Ljava/lang/String;)Ljava/lang/StringBuilder;
move-result-object v2
invoke-virtual {v2, v0}, Ljava/lang/StringBuilder;-
append(I)Ljava/lang/StringBuilder;
move-result-object v2
const-string v3, " character "
invoke-virtual {v2, v3}, Ljava/lang/StringBuilder;-
append(Ljava/lang/String;)Ljava/lang/StringBuilder;
move-result-object v2
invoke-virtual {v2, v0}, Ljava/lang/StringBuilder;-
append(C)Ljava/lang/StringBuilder;
move-result-object v2
invoke-virtual {v2}, Ljava/lang/StringBuilder;-
toString()Ljava/lang/String;
move-result-object v2
invoke-virtual {v1, v2}, Ljava/io/PrintStream;-
println(Ljava/lang/String;)V
.line 8
add-int/lit8 v0, v0, 0x1
int-to-char v0, v0
goto :goto_1
.line 11
:cond_2b
return-void
.end method`
反编译器
自 20 世纪 90 年代初以来,至少已经发布了十几个反编译器:Mocha、WingDis、Java 优化和反编译环境(JODE)、SourceAgain、DejaVu、Jad、Homebrew、JReveal、DeCafe、JReverse、jAscii 和 JD-GUI。还有许多程序——例如 Jasmine 和 NMI——为命令行受损者提供了 Jad 或 Mocha 的前端。有些,比如最著名的摩卡,已经过时了;而且除了 JD-GUI 和 Jad 之外的大部分反编译器都已经不可用了。下面的章节回顾了其中的一些。
摩卡
许多最早的反编译器早就消失了;Jive 甚至从未见过天日。摩卡的一生,就像它的作者汉彼得·范·弗利特一样,是短暂的。1996 年 6 月的最初测试版有一个姊妹程序 Crema,售价 39 美元;它保护类文件不被 Mocha 使用混淆反编译。
作为最早的反编译器之一,Mocha 是一个简单的命令行工具,没有前端 GUI。它使用 JDK 1.02,并作为类的 zip 文件分发,这些类被 Crema 混淆。Mocha 能够识别和忽略被 Crema 混淆的类文件。毫不奇怪,Mocha 不支持 jar 文件,因为在最初编写 Mocha 时它们还不存在。和所有早期的反编译器一样,Mocha 不能反编译内部类,这只出现在 JDK 1.1 中。
要使用 Mocha 反编译文件,请确保mocha.zip
文件在您的类路径中,并使用以下命令反编译:
java mocha.Decompiler [-v] [-o] Casting.class
反编译器只是作为测试版发布;它的作者在把它变成你所说的生产质量之前就过早地死去了。Mocha 的流分析是不完整的,它在许多 Java 构造上都失败了。过去有几个人试图修补 Mocha,但这些努力基本上都白费了。现在使用 JD-GUI 或 Jad 更有意义。
就在他 34 岁死于癌症之前,汉彼得把摩卡和克莉玛的代码卖给了博兰;一些 Crema 混淆代码被加入到 JBuilder 的早期版本中。就在 1996 年新年前夜 Hanpeter 去世几周后,Mark LaDue 的 HoseMocha 出现了,它允许任何人保护他们的文件不被 Mocha 反编译,而不必支付 Crema。
杰德
Jad 快速、免费且非常有效,是第一批正确处理内部类的反编译器之一。这是帕维尔·库兹涅佐夫的作品,他毕业于莫斯科国立航空学校应用数学系,杰德获释时他住在塞浦路斯。它可以从[www.varaneckas.com/jad](http://www.varaneckas.com/jad)
获得,并且可能是本章中使用的最简单的命令行工具。
Jad 的最新可用版本是 2001 年的 v1.58。根据 FAQ,主要的已知错误是它不能很好地处理内联函数;这应该不是问题,因为大多数编译器都让 JIT 引擎来执行内联。
在大多数情况下,您需要做的只是键入以下内容:
jad target.class
对于一个人的表演来说,Jad 是非常完整的。它最有趣的特性是,它可以用类文件的字节码的相关部分来注释源代码,这样您就可以看到反编译代码的每一部分来自哪里。这是理解字节码的一个很好的工具;清单 4-16 显示了一个例子。
清单 4-16。 Casting.class
被贾德反编译
`// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.kpdus.com/jad.html
// Decompiler options: packimports(3)
// Source File Name: Casting.java
import java.io.PrintStream;
public class Casting
{
public Casting()
{
}
public static void main(String args[])
{
for(char c = '\0'; c < 128; c++)
System.out.println((new StringBuilder()).append("ascii
").append(c).append(" character ").append(c).toString());
}
static final String ascStr = "ascii ";
static final String chrStr = " character ";
}`
JD-GUI
在 2012 年,JD-GUI 是事实上的 Java 反编译器。这是巴黎的 Emmanual Dupuy 写的,可从[
java.decompiler.free.fr](http://java.decompiler.free.fr)
开始获得。
拖放您的 Java 类文件,它们会立即被反编译。JD-GUI 还有一个 eclipse 插件 JD-Eclipse,以及一个可以与其他应用集成的核心库。
JD-GUI 是为 JDK 1.5 编写的,并且具有到目前为止的所有现代构造。它还可以无缝地处理 jar 文件。图 4-7 显示了运行中的 JD-GUI。
图 4-7。 Casting.class
被 JD-GUI 反编译
dex2jar
Dex2jar 是一个把 Android 的.dex
格式转换成 Java 的.class
格式的工具——只是把一种二进制格式转换成另一种二进制格式,而不是转换成 Java 源码。您仍然需要对生成的 jar 文件运行 Java 反编译器来查看源代码。Dex2jar 可从[
code.google.com/p/dex2jar/](http://code.google.com/p/dex2jar/)
获得,作者是潘晓波,浙江科技大学毕业生,目前在中国一家计算机安全公司工作。
Dex2jar 并不完美:它无法转换classes.dex
文件中大量的方法。但是如果没有 dex2jar 和 undx(见下一节),就不会有任何 Android 反编译。
要将 APK 文件转换为 jar 文件以便进一步反编译,请运行以下命令:
c:\temp>dex2jar com.riis.mobile.apk c:\temp>jd-gui com.riis.mobile.apk.dex2jar
undx
Undx 是另一个鲜为人知的 DEX 文件到类文件的转换器。它最初是由 Marc Schoenefeld 在 2009 年写的,并在 [www.illegalaccess.org](http://www.illegalaccess.org)
可用。它现在似乎是一个死项目,并且早于 Android SDK 文件夹中的 dexdump 从tools
移动到platform-tools
目录。
apktool
Apktool 是反编译器的一个可怕的补充。安装完成后,鼠标右键将解压 APK,运行 baksmali,然后运行 AXMLPrinter2 和 dex2jar,并启动 JD-GUI——它完全自动化了反编译 APK 的过程。这使得反编译过程从艺术变成了鼠标点击,并允许任何能安装 ASTRO 文件管理器的人都能看到 APK 的源代码。Apktool 从[
code.google.com/p/android-apktool/](http://code.google.com/p/android-apktool/)
开始可用。
保护您的信息来源
既然您已经理解了这个问题,并且已经看到了 dex2jar 和 JD-GUI 的有效性,那么您可能想知道是否有任何方法可以保护代码。如果你想问为什么你应该写 Android 应用,这是适合你的部分。
下面这段话有助于解释我所说的保护你的消息来源是什么意思:
我们希望通过使逆向工程在技术上变得如此困难,以至于变得不可能或者至少在经济上不可行,来保护代码。
—克里斯蒂安·科尔伯格、克拉克·汤普森和道格拉斯·洛 1
你可能涉足两个阵营之一:程序员可能对理解其他人如何实现有趣的效果感兴趣,但是从商业的角度来看,没有人希望其他人将他们的代码作为自己的代码卖给第三方。更糟糕的是,在某些情况下,反编译 Android 代码可以让某人通过访问后端 web 服务 API 来攻击系统的其他部分。
1“混淆转换的分类”,计算机科学技术报告 148 (1997),[
researchspace.auckland.ac.nz/handle/2292/3491](https://researchspace.auckland.ac.nz/handle/2292/3491)
。
你在前面的章节中已经看到,出于多种原因,Android classes.dex
文件包含了异常大量的符号信息。正如您所看到的,没有受到某种保护的 DEX 文件返回的代码几乎与原始代码相同——当然,除了完全没有程序员注释之外。这一节介绍了限制 dex 文件中的信息量并使反编译器的工作尽可能困难的步骤。
理想的解决方案是一个黑盒应用,它将一个 DEX 文件作为输入,输出一个等效的受保护版本。不幸的是,到目前为止,还没有什么可以提供完全的保护。
很难定义评估每个当前可用保护策略的标准。但是您可以使用以下三个标准来衡量每种工具或技术的有效性:
- 反编译器(效能)有多混乱?
- 它能击退所有反编译的企图吗(弹性)?
- 应用开销(成本)是多少?
如果代码的性能严重下降,那么代价可能太高了。或者,如果您使用 web 服务将代码转换为服务器端代码,那么这将比独立应用产生更大的持续成本。
让我们看看市场上可用的开源和商业混淆器及其他工具,以及它们在保护您的代码方面有多有效。第一章介绍了保护你的代码的合法手段。以下是保护您的 Android 源代码的技术方法列表:
- 编写两个版本的 Android 应用
- 困惑
- Web 服务和服务器端执行
- 给你的代码加指纹
- 本地方法
编写两个版本的 Android 应用
软件业的标准营销实践,尤其是在网络上,是允许用户下载软件的全功能评估副本,该软件在一定时间或使用次数后停止工作。这个先试后买系统背后的理论是,在规定的时间后,比如说 30 天,用户已经习惯了你的程序,他们很乐意为完整版本付费。
但是大多数软件开发者都意识到这些完整版的评估程序是一把双刃剑。它们展示了程序的全部功能,但通常很难保护,不管你说的是什么语言。在本章的前面,您已经看到了十六进制编辑器是如何方便地通过许可方案,无论是用 C++、Visual Basic 还是 Java 编写的。
采用了许多不同类型的保护方案,但是在 Java 世界中,您只有一个非常简单的保护工具:
if boolean = true execute else exit
这些类型的方案自从第一次出现在 VB 共享软件中就被破解了。通过将十六进制编辑器中的一位翻转为
if boolean = false execute else exit
如果能编写一个演示小程序或应用,让潜在客户体验一下产品,而又不赠送商品,那该有多好。考虑通过删除除基本功能外的所有功能,而只保留菜单选项来削弱演示。如果这太多,那么考虑使用第三方供应商,如 WebEx 或 Citrix,这样潜在客户就可以看到您的应用,但永远没有机会运行它来对抗反编译器。
当然,这并不能阻止任何人在购买完整功能版本后反编译该版本,删除任何许可方案,然后将应用转让给其他第三方。但是他们将不得不为此付出代价,而且通常这足以成为黑客在别处寻找的障碍。
混淆
十几个 Java 混淆器已经出现了。这种技术的大多数早期版本现在很难找到。如果你足够努力的话,你仍然可以在网上找到它们的踪迹,但是除了一两个明显的例外,Java 混淆器大部分已经变得默默无闻了。
这留下了一个有趣的问题,如何辨别剩下的几个混淆器是否有用。也许最初的混淆器中一些非常有用的东西已经丢失了,这些东西本来可以保护你的代码,但在市场变得更糟时却不能坚持足够长的时间。你需要理解混淆是什么意思,因为否则你没有办法知道一个混淆器是否比另一个更好(除非市场需求是你的决定因素)。
当混淆被宣布为非法时,只有不法之徒才会使用 sifjdifdm wofiefiemf eifm。
—Paul Tyma,抢先软件公司
这一部分着眼于混淆理论。我将借用科尔伯格、汤普森和洛的观点来帮助阐明我的立场。在他们的论文中,作者将混淆分为三个不同的领域:
- 布局混淆
- 控制混淆
- 数据混淆
表 4-1 列出了一个合理完整的混淆集合,分为这三种类型,在某些情况下还会进一步分类。表中省略了文章中一些对 Java 特别无效的转换类型。
大多数 Java 混淆器只执行布局混淆,有一些有限的数据和控制混淆。这部分是由于 Java 验证过程丢弃了任何非法的字节码语法。如果你主要写小程序,Java 验证器是非常重要的,因为远程代码总是被验证的。如今,当小程序越来越少时,Java 混淆器没有采用更高级的混淆技术的主要原因是混淆后的代码必须在各种 Java 虚拟机(JVM)上工作。
尽管 JVM 规范定义得很好,但是每个 JVM 对规范都有自己的略微不同的解释,这导致了在 JVM 如何处理不再由 Java 源代码表示的字节码时有许多特殊之处。JVM 开发人员不太注意测试这种类型的字节码,您的客户对字节码的语法是否正确不感兴趣——他们只想知道为什么它不能在他们的平台上运行。
请记住,在高级形式的模糊处理中有一定程度的走钢丝——我称之为高级模式模糊处理——所以你需要非常小心这些程序会对你的字节码做什么。模糊处理越激烈,代码就越难被反编译,但是就越有可能使 DVM 崩溃。
最好的混淆器在不破坏 DVM 的情况下执行多次转换。毫不奇怪,混淆公司在谨慎方面犯了错误,这不可避免地意味着对你的源代码的保护更少。
布局混淆
大多数混淆器的工作原理是模糊变量名或打乱类文件中的标识符,试图使反编译的源代码无用。正如你在第三章中看到的,这并不能阻止字节码的执行,因为 DEX 文件使用指向数据段中的方法名和变量的指针,而不是实际的名称。
混淆代码通过使用自动生成的垃圾变量重命名常量池中的变量,同时保持代码语法正确,从而破坏了反编译器的源代码输出。然后,这在 DEX 文件的数据部分结束。实际上,这个过程消除了程序员在给变量命名时给出的所有线索(大多数优秀的程序员选择有意义的变量名)。这也意味着,由于重名,反编译的代码在重新编译之前需要一些返工。
无论有没有变量名的提示,大多数有能力的程序员都可以通过混乱的代码。通过适当的关心和注意,也许借助于剖析器来理解程序流,也许借助于反汇编器来重命名变量,大多数混淆的代码都可以被改回更容易处理的东西,不管混淆有多严重。
早期的混淆器如 JODE 用a
、b
、c
、d
代替了方法名...z()
。Crema 的标识符更加难以理解,使用类似 Java 的关键字来迷惑读者(见清单 4-17 )。其他几个混淆器更进了一步,使用了 Unicode 风格的名称,这有一个很好的副作用,使许多现有的反编译器崩溃。
列表 4-17。 克丽玛保护码
private void _mth015E(void 867 % static 931){ void short + = 867 % static 931.openConnection(); short +.setUseCaches(true); private01200126013D = new DataInputStream(short +.getInputStream()); if(private01200126013D.readInt() != 0x5daa749) throw new Exception("Bad Pixie header"); void do const throws = private01200126013D.readShort(); if(do const throws != 300) throw new Exception("Bad Pixie version " + do const throws); _fld015E = _mth012B(); for = _mth012B(); _mth012B(); _mth012B(); _mth012B();
short01200129 = _mth012B(); _mth012B(); _mth012B(); _mth012B(); _mth012B(); void |= = _mth012B(); _fld013D013D0120import = new byte[|=]; void void = |= / 20 + 1; private = false; void = = getGraphics(); for(void catch 11 final = 0; catch 11 final < |=;){ void while if = |= - catch 11 final; if(while if > void) while if = void; private01200126013D.readFully(_fld013D013D0120import, catch 11 final, while if); catch 11 final += while if; if(= != null){ const = (float)catch 11 final / (float)|=; =.setColor(getForeground()); =.fillRect(0, size().height - 4, (int)(const * size().width), 4); } } }
大多数混淆器在减少类文件的大小方面比保护源代码要好得多。但是抢先软件拥有一项专利,它打破了原始源代码和混淆代码之间的联系,并在一定程度上保护了您的代码。所有的方法都被重命名为a
、b
、c
、d
等等。但是与其他程序不同,PreEmptive 使用运算符重载来重命名尽可能多的方法。重载的方法有相同的名字,但是参数的数量不同,所以不止一个方法可以被重命名a()
:
getPayroll() becomes a() makeDeposit(float amount) becomes a(float a) sendPayment(String dest) becomes a(String a)
清单 4-18 中的显示了抢占式的一个例子。
清单 4-18。 运算符重载
`// Before Obfuscation
private void calcPayroll(RecordSet rs) {
while (rs.hasMore()) {
Employee employee = rs.getNext(true);
employee.updateSalary();
DistributeCheck(employee);
}
}
// After Obfuscation
private void a(a rs) {
while (rs.a()) {
a = rs.a(true);
a.a();
a(a);
}
}`
给不同的方法取多个名字可能会非常混乱。的确,重载的方法很难理解,但也不是不可能理解。它们也可以被重新命名,以便于阅读。话虽如此,运算符重载已被证明是最好的布局混淆技术之一,因为它打破了原始代码和混淆后的 Java 代码之间的联系。
控制混淆
控制混淆背后的概念是通过分解源代码的控制流来迷惑任何查看反编译源代码的人。属于一起的功能块被分开,不属于一起的功能块被混合在一起,使得源代码更加难以理解。
科尔伯格等人的论文将控制混淆进一步分为三类:计算、聚合和排序。让我们更详细地看看这些混淆或转换中最重要的一些。
计算混淆
让我们看看计算混淆,它试图隐藏控制流,并加入额外的代码来迷惑黑客。
(1)插入死代码或无关代码
您可以插入死代码或伪代码来迷惑攻击者;它可以是额外的方法,或者仅仅是几行不相关的代码。如果您不希望您的原始代码的性能受到影响,那么以一种永远不会被执行的方式添加代码。但是要小心,因为许多反编译器甚至混淆器会删除那些永远不会被调用的代码。
不要把自己局限于插入 Java 代码——没有理由不能插入不相关的字节码。Mark Ladue 编写了一个名为 HoseMocha 的小程序,通过在每个方法的末尾添加一个pop
字节码指令来修改类文件。就大多数 JVM 而言,这条指令是不相关的,被忽略了。但是摩卡处理不了,崩溃了。毫无疑问,如果摩卡的作者活了下来,这个问题很容易解决,但他没有。
(2)扩展循环条件
您可以通过使循环条件变得更加复杂来混淆代码。通过用第二个或第三个不做任何事情的条件来扩展循环条件,可以做到这一点。它不应该影响循环执行的次数或降低性能。尝试在扩展条件中使用 bitshift 或?
操作符来增加一些趣味。
(3)将可约转化为不可约
混淆的圣杯是创建无法转换回原始格式的混淆代码。为此,您需要断开字节码和原始 Java 源代码之间的链接。混淆器将字节码控制流从原始的可约流转换成不可约的流。因为 Java 字节码在某些方面比 Java 更有表现力,所以可以使用 Java 字节码goto
语句来帮助。
让我们重温一句古老的计算格言,它指出使用goto
语句是任何自以为是的计算机程序员所犯下的最大罪过。埃德格·w·迪克斯特拉的论文“去发表被认为有害的声明”(dl.acm.org/citation.cfm?doid=362929.362947
)是这种特殊宗教狂热的开端。反goto
声明阵营在其全盛时期产生了足够多的反-goto
情绪,足以将它与最好的 iPhone 与 Android 之战相提并论。
常识告诉我们,在某些有限的情况下使用goto
语句是完全可以接受的。例如,您可以使用goto
来替换 Java 使用break
和continue
语句的方式。问题在于使用goto
来跳出一个循环,或者让两个goto
语句在同一个范围内操作。您可能见过也可能没见过,但是字节码广泛使用了goto
语句作为控制代码流的手段。但是两个goto
?? 的作用域从不交叉。清单 4-18 中的 Fortran 语句说明了一个goto
语句脱离控制循环。
清单 4-18。 使用goto
语句中断控制循环
do 40 i = 2,n if(dx(i).le.dmax) goto 50 dmax = dabs(dx(i)) 40 continue 50 a = 1
反对使用这种类型的编码风格的一个主要论点是,它几乎不可能对程序的控制流进行建模,并且给程序引入了任意性——从定义上来说,这几乎是一种灾难。控制流已经变成不可约。
作为一种标准的编程技术,尝试这样做是一个非常糟糕的想法,因为它不仅可能引入不可预见的副作用——不再可能将流减少到单个流图中——而且还会使代码变得难以管理。
但是有一种观点认为这是保护字节码的完美工具,如果你能假设编写保护工具来产生非法的goto
的人知道他们在做什么,并且不会引入任何讨厌的副作用。这无疑使得字节码更难逆向工程,因为代码流确实变得不可约了;但是重要的是,添加的任何新构造都要尽可能与原始构造相似。
在我结束这个话题之前,我要提出几点警告。尽管传统的模糊类文件几乎肯定在功能上与它的原始对应物是相同的,但重新排列的版本就不一样了。你必须非常信任这个保护工具,否则它会因为奇怪的间歇性行为而受到指责。如果可能,总是在目标设备上测试转换后的代码。
(4)添加冗余操作数
另一种方法是在一些基本计算中加入额外的无关紧要的项,并在使用结果之前对结果进行四舍五入。例如,清单 4-19 中的代码打印“k = 2”。
清单 4-19。冗余操作数前的前的
`import java.io.*;
public class redundantOperands {
public static void main(String argv[]) {
int i=1;
int j=2;
int k;
k = i * j;
System.out.println("k = " + k);
}
}`
给代码添加一些冗余的操作数,如清单 4-20 所示,结果会完全一样,因为你在打印之前把k
转换成了整数。
清单 4-20。 冗余操作数后
`import java.io.*;
public class redundantOperands {
public static void main(String argv[]) {
int i = 1, j = 2;
double x = 0.0007, y = 0.0006, k;
k = (i * j) + (x * y);
System.out.println(" k = " + (int)k);
}
}`
(5)去掉编程习惯用语(或者写马虎的代码)
大多数优秀的程序员在其职业生涯中积累了大量的知识。为了提高生产率,他们一遍又一遍地使用相同的组件、方法、模块和类,每次的方式都略有不同。就像潜移默化一样,一种新的语言逐渐进化,直到每个人都决定用或多或少相同的方式做一些事情。Martin Fowler 等人的书Refactoring:Improving the Design of Existing Code(Addison-Wesley,1999 年)是一部优秀的收集现有代码并对其进行重构的技术的书。
但是这种类型的语言标准化创造了一系列的习惯用法,给了黑客太多有用的提示,即使他们只能反编译你的部分代码。因此,扔掉你所有的编程知识,停止使用你知道已经被许多其他程序员借用的设计模式或类,并且破坏你现有的代码。
编写草率的代码,或者篡改代码,是很容易的。这是一种异端的方法,它让我恼火,并最终影响代码的性能和长期维护,但如果您使用某种自动化的篡改工具,它可能会工作得很好。
(6)并行化代码
将代码转换为线程会显著增加其复杂性。代码不一定是线程兼容的,正如你在清单 4-21 的中的HelloThread
例子中看到的。控制流程已经从顺序模式转变为准并行模式,每个线程负责打印不同的单词。
清单 4-21。 添加线程
`import java.util.*;
public class HelloThread extends Thread
{
private String theMessage;
public HelloThread(String message) {
theMessage = message;
start();
}
public void run() {
System.out.println(theMessage);
}
public static void main(String []args)
{
new HelloThread("Hello, ");
new HelloThread("World");
}
}`
这种方法的缺点是,要确保线程计时正确以及任何进程间通信都正常工作,以使程序按预期执行,这涉及到编程开销。有利的一面是,在现实世界的例子中,可能需要很长时间才能意识到代码可以折叠成一个顺序模型。
聚合混淆
在聚合混淆中,你把应该在一起的代码分开。您还可以合并通常或逻辑上不属于一起的方法。
(1)内联和概述方法
内联方法——用方法的实际主体替换每个方法调用——通常用于优化代码,因为这样做消除了调用的开销。在 Java 代码中,这有使代码膨胀的副作用,常常使代码变得更难理解。您还可以通过创建一个虚拟方法来扩充代码,该方法采用一些内联方法并将它们概括成一个虚拟方法,该方法看起来像是被调用了,但实际上并不做任何事情。
Mandate 的 OneClass obfuscator 将这种转换发挥到了极致,它将应用中的每个类内联到一个 Java 类中。但是像所有早期的混淆工具一样,OneClass 已经不复存在了。
(2)交错方法
尽管交替使用两种方法是一项相对简单的任务,但是将它们分开要困难得多。清单 4-22 显示了两个独立的方法;在清单 4-23 中,我将代码交错在一起,这样方法看起来是连接的。这个例子假设您想要显示余额并通过电子邮件发送发票,但是没有理由不允许您通过电子邮件发送发票。
清单 4-22。showBalance``emailInvoice
void showBalance(double customerAmount, int daysOld) { if(daysOld > 60) { printDetails(customerAmount * 1.2); } else { printDetails(customerAmount); } } void emailInvoice(int customerNumber) { printBanner(); printItems(customerNumber); printFooter(); }
清单 4-23。??showBalanceEmailInvoice
void showBalanceEmailInvoice(double customerAmount, int daysOld, int customerNumber) {
printBanner(); if(daysOld > 60) { printItems(customerNumber); printDetails(customerAmount * 1.2); } else {
printItems(customerNumber); printDetails(customerAmount); } printFooter(); }
(3)克隆方法
您可以克隆一个方法,以便在几乎相同的情况下调用相同的代码但不同的方法。您可以根据一天中的时间调用一个方法而不是另一个方法,以给出存在外部因素的表象,而实际上并不存在。在这两种方法中使用不同的风格,或者将克隆与交错转换结合使用,这样这两种方法看起来非常不同,但实际上执行相同的功能。
(4)循环变换
编译器优化通常会执行许多循环优化。您可以手动执行相同的优化,或者在工具中对它们进行编码以混淆代码。循环展开减少循环被调用的次数,循环裂变将单个循环转化为多个循环。例如,如果你知道maxNum
能被 5 整除,你可以展开for
循环,如清单 4-23 所示。清单 4-24 显示了一个循环分裂的例子。
清单 4-23。 循环展开
// Before for (int i = 0; i<maxNum; i++){ sum += val[i]; } // After for (int i = 0; i<maxNum; i+=5){ sum += val[i] + val[i+1] + val[i+2] + val[i+3] + val[i+4]; }
清单 4-24。 循环裂变
// Before for (x=0; x < maxNum; x++){
i[x] += j[x] + k[x]; } // After for (x=0; x < maxNum; x++) i[x] += j[x]; for (x=0; x < maxNum; x++) i[x] += k[x];
排序混淆
使用这种技术,您将变量和表达式重新排序成奇怪的组合和格式,以在反编译器的头脑中制造混乱。
(1)重新排序表达式
重新排序语句和表达式对混淆代码的影响很小。但是有一个例子,当字节码和 Java 源代码之间的链接再次断开时,在字节码级别重新排序表达式会产生更大的影响。
抢先软件使用一个被称为瞬时变量缓存(TVC)的概念来重新排序字节码表达式。TVC 是一种简单的技术,已经在 DashO 中实现。假设你想交换两个变量,x
和y
。最简单的方法是使用一个临时变量,如清单 4-24 所示。否则,可能会导致两个变量包含相同的值。
清单 4-24。 变量互换
temp = x; x = y; y = temp;
这产生了清单 4-25 中的字节码来完成变量交换。
清单 4-25。 字节码中的变量交换
iload_1 istore_3 iload_2 istore_1 iload_3 istore_2
但是 JVM 的堆栈行为意味着不需要临时变量。临时变量缓存在堆栈上,堆栈现在兼作内存位置。你可以删除临时变量的加载和存储操作,如清单 4-26 所示。
清单 4-26。 使用 DashO 的 TVC 在字节码中交换变量
iload_1 iload_2 istore_1 istore_2
(2)重新排序循环
你可以转换一个循环,让它返回(见清单 4-27 )。这可能不会在优化方面做太多,但它是更简单的混淆技术之一。
清单 4-27。 循环反转
// Before x = 0; while (x < maxNum){ i[x] += j[x]; x++; } // After x = maxNum; while (x > 0){ x--; i[x] += j[x]; }
数据混淆
科尔伯格等人的论文将数据混淆进一步分为三种不同的分类:存储和编码、聚合、和排序。到目前为止,您看到的许多转换都利用了这样一个事实,即程序员如何编写代码有标准的约定。把这些惯例颠倒过来,你就有了一个好的混淆过程或工具的基础。您使用的转换越多,任何人或任何工具理解原始源代码的可能性就越小。这一节讨论将数据重新塑造成不太自然的形式的数据混淆。
(1)存储和编码
存储和编码通过将数据编码到位掩码或拆分变量来寻找存储数据的不寻常方式。数据最终应该总是和最初一样。在别人甚至你自己的代码写了 6 到 12 个月之后,通常很难理解,但是如果它以这些新颖的方式存储,那么这种类型的编码会使理解变得更加困难。
(2)改变编码
Collberg 等人的论文展示了一个简单的编码例子:一个整数变量int i = 1
被转换成i' = x*i + y
。如果您选择x = 8
和y =3
,您将得到如清单 4-28 所示的转换。
清单 4-28。 可变混淆
`// Before // After
int i = 1; int i = 11;
while (i < 1000) { while (i<8003) {
val = A[i]; val = A[(i-3)/8];
i++; i+=8;
} }`
(3)分裂变量
变量也可以分成两部分或更多部分,以创建更高级别的混淆。科尔伯格建议使用查找表。例如,如果你试图定义布尔值a= true
,那么你将变量分成a1=0
和a2=1
,并在表 4-2 中查找,将其转换回布尔值。
(4)将静态数据转换成程序数据
一个有趣但不太实用的转换是通过将数据从静态数据转换为过程数据来隐藏数据。例如,字符串中的版权信息可以在您的代码中以编程方式生成,可能使用前面讨论的组合交错转换。输出版权声明的方法可以使用查找表方法,或者将应用中几个不同变量的字符串组合起来。
(5)聚合
在数据聚合中,通过合并变量、将变量放入不相关变量的数组中以及在不需要的地方添加线程来隐藏数据结构。
(6)合并标量变量
变量可以合并在一起,也可以转换成不同的基数再合并。变量值可以存储在一系列位中,并使用各种位掩码运算符提取出来。
(7)类转换
我最喜欢的转换之一是使用线程来迷惑试图窃取代码的黑客。这是一个开销,因为线程更难理解,也更难正确处理。如果有人愚蠢到试图反编译代码而不是自己写代码,那么他们很可能会被大量的线程吓跑。
有时线程不实用,因为开销太大;下一个最好的混淆是使用一系列的类转换。类的复杂度随着类的深度而增加。我所讨论的许多转换违背了程序员对世界上什么是好的和正确的自然感觉,但是如果您将继承和接口使用到了极致,那么您将会很高兴地听到这创建了黑客需要时间来理解的深层层次结构。
如果你不想的话,你也不必污损它(参见“删除编程习惯用法”一节);你也可以重构。将两个相似的类重构为一个父类,但留下一个或多个重构类的错误版本。您还可以将两个不同的类重构为一个父类。
(8)数组变换
像变量一样,数组可以被拆分、合并或交织成一个数组;折叠成多个维度;或者展平成一维或二维阵列。一种简单的方法是将数组分成两个独立的数组,一个包含数组的偶数索引,另一个包含数组的奇数索引。程序员使用二维数组是有目的的;改变数组的维数会对理解代码造成很大的障碍。
排序
对数据声明进行排序会删除任何反编译代码中的大量实用信息。通常,数据是在方法的开始或第一次被引用之前声明的。将数据声明分散到整个代码中,同时仍然将数据元素保持在适当的范围内
混淆结论
最好的混淆器会使用本节中介绍的一些技术。但是你不需要购买混淆器——你可以自己添加许多这样的转换。目的是通过删除尽可能多的信息来尽可能多地迷惑潜在的反编译器。您可以通过编程方式或在编写代码时实现这一点。一些转换要求开发者模拟在编译器的优化阶段发生了什么;其他的只是糟糕的编码实践来迷惑黑客。
在离开本节之前,有几个注意事项。首先,请记住,如果您在常量池中多次使用相同的标识符来混淆代码,您可能需要先与抢先软件讨论,因为它拥有这项技术的专利。第二,你可以尝试任何形式的高模式模糊处理,因为通常你不会坚持你的代码只能在特定的手机或设备上运行。
最后,编写非常糟糕的代码会使你的代码非常难以阅读。小心不要把婴儿和洗澡水一起倒掉。模糊代码很难维护,并且根据转换情况,可能会破坏代码的性能。注意你应用的变换。从长远来看,自动化外观设计以便可以自动重构会对你有所帮助。如果你需要的话,ProGuard 和 DashO 都可以让你恢复混淆。
网络服务
有时候最简单的想法是最有效的。保护代码的一个更简单的想法是分割你的 Android 源代码,让远程服务器上的大部分功能远离任何窥探。下载的 APK 是一个简单的 GUI 前端,没有任何有趣的代码。服务器代码不必用 Java 编写,web 服务可以用轻量级 RESTful API 编写。但是正如你在本章前面看到的,小心隐藏任何用户名和密码,这样黑客就不会攻击你的 web 服务。不过,拆分代码也有一些缺点,因为如果设备离线,它将无法工作。
给你的代码加指纹
虽然它实际上并不能保护你的代码,但是在你的软件中放一个数字指纹可以让你以后证明你写了你的代码。理想情况下,这个指纹——通常是一个版权声明——就像一个软件水印,即使你的原始代码在进入其他人的 Java 应用或小程序之前经历了许多的改变或操作,你也可以在任何时候恢复它。正如我多次说过的,没有 100%万无一失的方法来保护你的代码,但是如果你可以通过证明你写了原始代码来挽回一些损失,那就没关系了。
如果你感到困惑,请注意对你的代码进行数字指纹识别和对你的小程序或应用进行签名是完全不同的。当涉及到保护你的代码时,签名的小程序没有任何作用。签署 applet 有助于下载或安装软件的人通过查看与软件相关联的数字证书来决定是否信任 applet。这是一种保护机制,让使用你的软件的人证明这个应用是由 XYZ Widget Corp .编写的。用户可以在继续下载小程序或启动应用之前,决定他们是否信任 XYZ Widget Corp。另一方面,数字指纹通常使用解码工具来恢复。它有助于保护开发者的版权,而不是最终用户的硬盘。
指纹识别的几种尝试试图使用例如定义的编码风格来保护整个应用。更原始的指纹类型将指纹编码到一个伪方法或变量名中。这个方法名或变量可能由各种参数组成,比如日期、开发人员姓名、应用名称等等。但是这种方法会产生一个第 22 条军规。假设你在你的代码中放了一个虚拟变量,有人碰巧把反编译的方法,连同虚拟变量一起,剪切并粘贴到他们的程序中。如果不反编译他们的代码并且在这个过程中可能违反了法律,你怎么知道这是你的代码呢?
大多数反编译器,甚至一些混淆器都去除了这些信息,因为在代码被解释或执行时,这些信息并没有发挥积极的作用。最终,您需要能够通过调用伪方法或使用一个永远不会为真的假条件子句,使反编译器或混淆器相信任何受保护的方法都是原始程序的一部分,这样该方法就永远不会被调用:
if(false) then{ invoke dummy method }
一个聪明的人可以看到一个伪方法,即使反编译器看不到前面的子句永远不会为真。他们会得出结论,伪方法很可能是某种指纹。因此,您需要在方法级别附加指纹信息,以获得更健壮的指纹。
最后,您不希望指纹损害应用的功能或性能。正如您所看到的,Java 验证器通常在决定您可以对代码应用什么保护机制方面起着重要的作用,所以您需要确保您的指纹不会阻止您的字节码通过验证器。
让我们用这个讨论来定义一个好的数字指纹系统的标准:
- 它不使用死代码伪方法或伪变量。
- 即使只是程序的一部分被盗,指纹也需要工作。
- 应用的性能不应该受到影响。最终用户不应该注意到指纹代码和非指纹代码之间的区别。
- 指纹代码应该在功能上等同于原始代码。
- 指纹必须足够健壮或隐蔽,以经受住反编译攻击以及任何混淆工具。否则,可以简单地删除它。
- 字节码应该语法正确,才能通过 Java 验证程序。
- 类文件需要能够经受住其他人用他们自己的指纹对代码进行指纹识别。
- 你需要一个相应的解码工具来恢复和查看指纹,最好是使用密钥。指纹不应该被肉眼或其他黑客看到。
你不需要担心指纹是否高度可见。一方面,如果它既可见又健壮,那么它很可能会吓跑偶尔的黑客。但是,经验更丰富的攻击者会知道攻击的确切位置。如果偶然的黑客不知道应用受到保护,那么就没有预先的威慑去查看其他地方。抢先的 DashO 有一个指纹选项。
本地方法
一种有助于隐藏关键信息(如登录用户名和密码)的方法是将密码移动到本地库中。原生代码反编译成汇编代码,要难读得多,只能反汇编,不能反编译。
Android 原生开发工具包(NDK)是 Android SDK 的配套工具,允许开发者用原生代码创建应用的一部分。要创建一个使用 Java 本地接口(JNI)的本地库,在项目的根目录下创建一个名为jni
的文件夹。JNI 文件可以称为decompilingandroid-jni.c
。它必须有一个后缀-jni.c
,这样 NDK 才能拿起它。清单 4-29 是decompilingandroid-jni.c
中返回字符串的一个简单方法的例子。
清单 4-29。 土法decompilingandroid-jni.c
jstring Java_com_riis_decompilingandroid_getPassword(JNIEnv* env, jobject thiz) { return (*env)->NewStringUTF(env, "password"); }
对该方法的引用在com.riis.decompilingandroid
中处理:
`static
{
// Load JNI library
System.loadLibrary("decompilingandroid-jni");
}
/* Native methods that is implemented by the
- 'decompilingandroid-jni' native library, which is packaged
- with this application.
public native String getPassword();`
返回类型是jstring
,方法名以Java_
开头,后面是类路径、类名和方法。这个全名对于 JNI 将这个方法映射到com.riis.example
类很重要。
创建一个名为Android.mk
的 make 文件,描述 NDK 构建的本地源代码:
LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := decompilingandroid-jni LOCAL_SRC_FILES := decompilingandroid-jni.c include $(BUILD_SHARED_LIBRARY)
通过运行项目目录中的ndk-build
脚本来构建您的本地代码。ndk-build
脚本作为 NDK SDK 的一部分安装,必须在 Linux、OS X 或 Windows(带 Cygwin)平台下运行:
cd <project> <ndk>/ndk-build
如果构建成功,您将得到以下文件:
<project>/libs/armeabi/libdecompilingandroid-jni.so
简单地将静态字符串移入本地库并不一定能消除不安全字符串的问题。在前面的例子中,通过在文本编辑器中查看libdecompilingandroid-jni.so
可以很容易地找到字符串“password”。为了进一步保护密码,将它分成多个块,然后将结果连接在一起。清单 4-30 是破解cxYacuzafrabru5a1beb
web 服务 API 密码的一个例子。
清单 4-30。 对反汇编者隐藏密码
char str[80]; char *str1 = "bru"; char *str2 = "1beb"; char *str3 = "5a"; char *str4 = "fra"; char *str5 = "cxY"; char *str6 = "uza"; char *str7 = "ac";
`strcpy(str, str5);
strcat(str, str7);
strcat(str, str6);
strcat(str, str4);
strcat(str, str1);
strcat(str, str3);
strcat(str, str2);
return (*env)->NewStringUTF(env, str);`
本机代码只能在编译时针对的特定处理器上运行。每个 Android 设备都运行在 ARM 处理器上,除了少数几个不是普遍可用的,但未来可能会改变。谷歌电视不支持 NDK。
非混淆策略结论
Java 类文件和现在的 DEX 文件包含如此多的信息,这使得保护底层源代码变得异常困难。然而,大多数软件开发商继续忽视后果,让他们的知识产权处于危险之中。一个好的混淆过程应该需要多项式时间来产生和指数时间来逆转。我希望本节中相当详尽的模糊转换列表能够帮助您接近那个目标。至少,Collberg 等人的论文包含了足够的信息,可供任何想在这一领域入门的开发者参考。
表 4-3 总结了本章讨论的方法。值得注意的是,许多最初的混淆工具没有在互联网泡沫破灭后幸存下来,这些公司要么已经倒闭,要么转向其他专业化领域。
最后一句话:非常小心依赖混淆作为唯一的保护方法。请记住,反汇编程序也可以用来拆开您的classes.dex
文件,并允许某人直接编辑字节码。不要忘记,网络上的交互式演示或软件的有限功能演示版本会非常有效。
混淆器
基于您对混淆技术的新认识,让我们快速浏览一下现有的 Android 混淆器。
克丽玛
虽然对 Android APKs 不起作用,但因为历史原因,还是值得提一下 Crema。像当时的许多 Java 混淆器一样,它已经不存在了。正如本章前面提到的,Crema 是最初的混淆器,是经常提到的由已故的 Hanpeter Van Vliet 编写的 Mocha 的补充程序。摩卡是免费赠送的,但克莉玛大约要 30 美元。为了抵御摩卡咖啡,你必须买克莉玛。
Crema 执行一些基本的混淆,并且有一个有趣的副作用:它标记类文件,这样 Mocha 拒绝反编译任何以前通过 Crema 运行的小程序或应用。但是市场上很快出现了其他对 Crema 不友好的反编译器。
阿帕尔德
ProGuard 是 Android SDK 附带的一个开源混淆器。为了在您的构建过程中启用它,在构建发布版本之前,将以下内容添加到您的project.properties
文件中。每个人都应该这样做,以便在他们的 Android 项目中获得基本的混淆:
proguard.config=proguard.cfg
ProGuard 主要提供布局混淆保护,正如你从图 4-8 中看到的。它不隐藏用户名和密码,而是重命名方法和字符串,使它们不再向黑客提供任何上下文信息。
图 4-8。 程序保护码
如果你使用 ProGuard,要注意你使用了多少公共类,因为默认情况下它们不会被混淆。使用 ProGuard 时,实践良好的面向对象设计是有回报的。一个很好的经验法则是在混淆后总是反编译你的 APK,以确保它确实在做你认为它在做的事情。
达绍
DashO 是一个商业混淆器,来自于早期 Java 时代就已经存在的抢先软件。它执行布局、控制和数据混淆。使用向导让 DashO 混淆一个 Android 应用。图 4-9 显示了 DashO GUI 在左侧菜单中,您可以看到控制流、重命名和字符串加密选项。
图 4-9。 妫办公会
图 4-10 使用了与图 4-8 中相同的应用,但是这一次用 DashO 代替了 ProGuard。请注意字符串加密。
图 4-10。DashO-保护代码
JavaScript 混淆器
就像 Java 混淆器一样,JavaScript 混淆器也有很多种。如果你使用 HTML5/CSS 方法来编写你的 Android 应用,那么使用 JavaScript 混淆器或压缩器至少可以从你的代码中删除注释。对于那些试图黑你的应用的人来说,评论简直太有用了。黑客也不需要反编译 HTML5/CSS 应用——他们需要做的只是解压缩它。这使得创建一个假版本的应用变得非常容易。
这里有两个 JavaScript 混淆器值得研究:
- YUI 压缩机,从
[
github.com/yui/yuicompressor](https://github.com/yui/yuicompressor)
开始供应 - JSMin,可从
[www.crockford.com/javascript/jsmin.html](http://www.crockford.com/javascript/jsmin.html)
获得
我没有包括任何基于 web 的产品,因为您希望能够从命令行运行混淆器,以将其包括在您的构建过程中。这样,您可以确保您的 JavaScript 总是模糊的。
YUI 压缩器的调用如下,其中最小化版本的 JavaScript 文件被命名为decompilingandroid-min.js
而不是decompilingandroid.js
:
java -jar yuicompressor.jar -o '.js$:-min.js' *.js
清单 4-31 显示 YUI 压缩机之前的代码,清单 4-32 显示 YUI 压缩机之后的代码。
清单 4-31。 在 YUI 压缩机之前
`window.$ = \(telerik.\);
$(document).ready(function() {
movePageElements();
var text = $('textarea').val();
if (text != "")
\(('textarea').attr("style", "display: block;");
else
\)('textarea').attr("style", "display: none;");
//cleanup
text = null;
});
function movePageElements() {
var num = null;
var pagenum = $(".pagecontrolscontainer");
if (pagenum.length > 0) {
var num = pagenum.attr("pagenumber");
if ((num > 5) && (num < 28)) {
var x = \(('div#commentbutton');
\)("div.buttonContainer").prepend(x);
}
else {
$('div#commentbutton').attr("style", "display: none;");
}
}
//Add in dropshadowing
if ((num > 5) && (num < 28)) {
var top = $('.dropshadow-top');
var middle = $('#dropshadow');
var bottom = \(('.dropshadow-bottom');
\)('#page').prepend(top);
\(('#topcontainer').after(middle);` `middle.append(\)('#topcontainer'));
middle.after(bottom);
}
//cleanup
num = null;
pagenum = null;
top = null;
middle = null;
bottom=null;
}
function expandCollapseDiv(id) {
\(telerik.\)(id).slideToggle("slow");
}
function expandCollapseHelp() {
$('.helpitems').slideToggle("slow");
//Add in dropshadowing
if (\(('#helpcontainer').length) {
\)('#help-dropshadow-bot').insertAfter('#helpcontainer');
$('#help-dropshadow-bot').removeAttr("style");
}
}
function expandCollapseComments() {
var style = \(('textarea').attr("style");
if (style == "display: none;")
\)('textarea').fadeIn().focus();
else
$('textarea').fadeOut();
//cleanup
style = null;
}`
清单 4-32。YUI 压缩机后
window.$=$telerik.$;$(document).ready(function(){movePageElements( );var a=$("textarea").val();if(a!=""){$("textarea").attr("style","displa y: block;")}else{$("textarea").attr("style","display: none;")}a=null});function movePageElements(){var e=null;var b=$(".pagecontrolscontainer");if(b.length>0){var e=b.attr("pagenumber");if((e>5)&&(e<28)){var a=$("div#commentbutton");$("div.buttonContainer").prepend(a)}else{ $("div#commentbutton").attr("style","display: none;")}}if((e>5)&&(e<28)){var f=$(".dropshadow-top");var
d=$("#dropshadow");var c=$(".dropshadow- bottom");$("#page").prepend(f);$("#topcontainer").after(d);d.appen
d($("#topcontainer"));d.after(c)}e=null;b=null;f=null;d=null;c=nul l}function expandCollapseDiv(a){$telerik.$(a).slideToggle("slow")}function expandCollapseHelp(){$(".helpitems").slideToggle("slow");if($("#he lpcontainer").length){$("#help-dropshadow- bot").insertAfter("#helpcontainer");$("#help-dropshadow- bot").removeAttr("style")}}function expandCollapseComments(){var a=$("textarea").attr("style");if(a=="display: none;"){$("textarea").fadeIn().focus()}else{$("textarea").fadeOut( )}a=null};
YUI 压缩程序混淆并最小化,而 JSMin 只是最小化了 JavaScript。需要注意的是,也有 JavaScript 美化器可以逆转这个过程;参见[
jsbeautifier.org](http://jsbeautifier.org)
。
总结
在这一章中,你已经学会了如何找到一个电话的根,下载和反编译一个 APK,并使用一些工具混淆 APK。有很多东西需要消化。在接下来的两章中,您将构建自己的 Android 混淆器和反编译器。在第五章第一节中,你负责设计,在第六章第三节中,你完成了反编译器的实现。在本书的最后一章,你将回到你在本章中首次使用的许多工具,看看它们对一系列现实世界的 Android 应用有多有效。它们中的每一个都将作为一个案例研究,在这个案例中,您拥有原始源代码来测试您的源代码对反编译器和反汇编器的保护。
五、反编译器设计
接下来的两章关注如何创建反编译器,它实际上是一个交叉编译器,将字节码翻译成源代码。我介绍了相关设计决策背后的理论,但目的是提供足够的背景信息来帮助您入门,而不是给你一个完整的编译器理论章节。
不要期望你的反编译器 DexToSource 比目前市场上的任何东西都更全面或更好;说实话,它可能比 Jad 或 JD-GUI 更接近于 Mocha。和大多数事情一样,前 80-90%是最容易的,后 10-20%需要更长的时间来完成。但是 DexToSource 向您展示了如何编写一个简单的 Android 反编译器的基本步骤,它可以对您将遇到的大多数代码进行逆向工程。它也是第一个纯粹的classes.dex
反编译器——其他的一切都需要你在反编译之前将classes.dex
翻译成 Java 类文件。
我将在本章介绍 DexToSource 反编译器的总体设计,并在下一章深入探讨它的实现。我将向您展示如何反编译一些开源 apk,并展望 Android 反编译器、混淆器和字节码重排器的未来,以此来结束这本书。
后面两章的基调尽量实用;我尽量不要用太多的理论来烦你。这并不是说用无穷无尽的编译理论来充实这本书没有吸引力;只是关于这个主题的其他好书太多了。由阿尔弗雷德·艾侯、拉维·塞西和杰弗里·厄尔曼(普伦蒂斯霍尔出版社,2006 年)合著的《编译器:原理、技术和工具》( Compilers: Principles,Techniques,and Tools ),因其封面设计也被称为《龙书》,这只是迅速跃入脑海的一个较好的例子。安德鲁·阿佩尔的Java 现代编译器实现(剑桥大学出版社,2002 年)是另一本被极力推荐的书。我更喜欢查尔斯·菲舍尔和理查德·勒布朗(艾迪森·韦斯利,1991)的风格用 C 语言制作编译器。话虽如此,当有你需要了解的理论考虑时,我会在必要时讨论它们。
设计背后的理论
如前所述,编写反编译器与编写编译器或交叉编译器非常相似,因为两者都将数据从一种格式转换为另一种格式。反编译器和编译程序的本质区别在于它们的方向相反。在标准编译器中,源代码被转换为令牌,然后被解析和分析,最终生成二进制可执行文件。
碰巧的是,反编译是一个与编译非常相似的过程,但是在这种情况下,编译器的后端将中间符号改回源代码,而不是汇编代码。因为一个 Android classes.dex
文件的二进制格式,可以快速将二进制转换成字节码;然后,您可以将字节码视为另一种语言,反编译器成为交叉编译器或源代码翻译器,将字节码转换为 Java。
大量的其他源代码翻译器在不同语言之间进行翻译:例如,从 COBOL 到 C,甚至从 Java 到 Ada 或 C,这给了你很多地方去寻找灵感。
如果您对操作码和字节码之间的区别感到困惑,那么操作码就是像sget-object
这样的单个指令,后面可能会也可能不会跟着数据值或操作数。操作码和操作数统称为字节码。[最大 255]
定义问题
最简单地说,您试图解决的问题是如何将classes.dex
转换成一系列对应 Java 源代码的文件。清单 5-1 显示了来自前一章清单 4-15 的Casting.java
源代码的classes.dex
文件的反汇编版本的字节码。这些是你之前(字节码)和之后(Casting.java
)的图片。
清单 5-1。 铸造字节码
const/4 v0,0
const/16 v1,128 if-ge v0,v1,28 sget-object v1, field[2] new-instance v2, type[6] invoke-direct method[4], {v2} const-string v3, string[20] invoke-virtual method[7], {v2, v3} move-result-object v2 invoke-virtual method[6], {v2, v0} move-result-object v2 const-string v3, string[0] invoke-virtual method[7], {v2, v3} move-result-object v2 invoke-virtual method[5], {v2, v0} move-result-object v2 invoke-virtual method[8], {v2} move-result-object v2 invoke-virtual method[2], {v1,v2} add-int/lit8 v0, v0, 1 int-to-char v0, v0 goto d7 return-void
填写文件、字段和方法名称的整体结构看起来足够简单;您可以从 DexToXML 获得该信息。但是问题的实质是将操作码和操作数转换成 Java。您需要一个能够匹配这些操作码并将数据转换回 Java 源代码的解析器。您还需要能够镜像控制流和任何显式传输(注意goto
语句)以及处理任何相应的标签。
操作码可以分为以下类型:
- 加载和保存指令
- 算术指令
- 类型转换指令
- 对象创建和操作
- 操作数堆栈管理指令
- 控制转移指令
- 方法调用和返回指令
- 处理异常
- 实施
finally
- 同步
每个操作码都有一个定义好的行为,您可以在解析器中使用它来重新创建原始的 Java。Google 在[www.netmite.com/android/mydroid/dalvik/docs/dalvik-bytecode.html](http://www.netmite.com/android/mydroid/dalvik/docs/dalvik-bytecode.html)
的“Dalvik VM 的字节码”很好地描述了 Dalvik 操作码,只能称之为 Technicolor 细节。您将在反编译器的语法中使用这些信息来创建一个解析器,它将把清单 5-1 中的操作码和操作数转换回原始源代码。
本章的目标是向你展示如何实现这一点。你的解析器的最基本的结构将类似于图 5-1 。
图 5-1。 DexToSource 解析器
字节码的输入字符流需要分成一个令牌流(称为 lexer ),以便解析器进行分析。解析器使用这个令牌流,并根据解析器中定义的一系列规则输出 Java 源代码。
本章解释了如何创建词法分析器和语法分析器,并讨论了这种方法是否最有意义,以及如何对其进行调整以创建更健壮的东西。首先,下一节将讨论可以帮助你创建图 5-1 的词法分析器和语法分析器的编译器工具,而不是手工构建。
(反)编译工具
在编写你的反编译器之前,你需要做一些选择。您可以手工编写整个反编译器,就像对几个 Java 反编译器所做的那样;或者你可以看看有助于使工作更容易编码的工具。这些工具被称为编译器-编译器,它们被定义为帮助创建解析器、解释器或编译器的任何工具。这是我在这里概述的方法,主要关注以下工具:
- 法律
- Yacc
- JLex 吗
- 杯子
- 安特卫普
这些工具中最常见的是 Lex 和 Yacc(又一个编译器-编译器)。这种编译器-编译器工具可以用来扫描和解析字节码,并且已经被许多开发人员用于更复杂的任务。Lex 和 Yacc 操作文本输入文件。Lex 输入文件定义如何使用模式匹配对输入字符流进行标记化。Yacc 输入文件由一系列令牌的产生规则组成。这些定义了语法,相应的动作生成一个用户定义的输出。
Lex 和 Yacc 中定义的标记化规则和模式匹配规则用于生成典型的 C 文件,然后编译这些文件,并用于将输入文件转换成所需的目标输出。出于您的目的,编译器-编译器工具将生成 Java 文件,而不是 C 文件,这些文件将成为您的反编译器引擎的源代码。
使用编译器-编译器工具有两个主要原因。首先,这些工具极大地减少了代码行数,这使得读者更容易理解概念。其次,使用这样的工具可以将开发时间缩短一半。
不利的一面是,生成的代码一旦编译,可能比手工制作编译器前端要慢得多。但是让代码易于理解是这本书的先决条件——没有人想阅读大量的代码来理解正在发生的事情。所以,这本书使用了 Lex 和 Yacc 的一个版本或衍生版本。
无数的选择都基于 Lex 和 Yacc。如果你选择 Java 作为目标输出语言,那么你的选择是 JLex 或者 JFlex 和 CUP(构建有用的解析器)或者 BYACC/J 作为经典的 Lex 和 YACC 变体。然后是另一个语言识别工具(ANTLR,[www.antlr.org](http://www.antlr.org))
),编译器-编译器工具,以前被称为 PCCTS,和 JavaCC ( [
javacc.java.net](http://javacc.java.net)),
,它们将词法分析器和语法分析器步骤合并到一个文件中。
Lex 和 Yacc
Lex 和 Yacc 一起工作。Lex 将传入流解析成标记,Yacc 解析这些标记并生成输出。Lex 和 Yacc 是 Unix 命令行工具,大多数 Unix 和 Linux 版本都有,尽管奇怪的是 Mac OSs 上没有。
Lex 使用正则表达式将传入的流分解成标记,Yacc 尝试使用 shift/reduce 机制将这些标记与许多产生式规则进行匹配。大多数产生式规则都与一个动作相关联,在本例中,正是这些上下文相关的动作输出 Java 源代码。
令牌也被称为终端。生产规则由单个非终结标识。每个非终端由一系列终端和其他非终端组成。大多数人使用的一个类比是将终端(令牌)视为树叶,将非终端视为树上的树枝。
Yacc 是一个自底向上的 LALR(1)解析器。自底向上意味着你从树叶开始构建解析树,而自顶向下解析器试图从根开始构建树。LALR(1)意味着这种类型的解析器使用最右边的派生从左到右(LA LR (1))处理扫描器 Lex 提供的令牌,并且可以提前一个令牌( LA LR( 1 ))。LR 解析器也称为预测解析器,LALR 是合并两个 LR 集合的结果,这两个 LR 集合的项目除了前瞻集合之外都是相同的。它非常类似于 LR(1)解析器,但是 LALR(1)解析器通常要小得多,因为前瞻标记有助于减少可能的模式数量。
LALR(1)解析器生成器是其他计算领域事实上的标准。但是 Java 解析器更有可能属于 LL(k)类。LL(k)解析器是自上而下的解析器,使用最左边的派生(LL(k))从左到右(LL(k))扫描——这是自上而下的来源——并向前看 k 标记。
许多标准的编译器构造书籍都大量使用 Lex 和 Yacc,而不是任何其他 LL(k)替代方案。参见[
dinosaur.compilertools.net/](http://dinosaur.compilertools.net/)
了解更多信息和一些优秀资源的链接。
新泽西美国电话电报公司·贝尔实验室的斯蒂芬·约翰森编写了 Yacc 的原始版本。自从 20 世纪 80 年代早期的 Berkeley 以来,Lex 和 Yacc 以及 Sed 和 Awk 已经包含在每个 Unix 实现中。Sed 和 Awk 通常用于简单的命令行解析工具,而 Lex 和 Yacc 则用于复杂的解析器。Unix 系统管理员和开发人员通常会不时地使用这些工具中的一些或全部来将输入文件转换或翻译成其他格式。如今,Perl、Python 和 Ruby 已经在很大程度上取代了这些工具,而 Lex 和 Yacc 只被保留用于最困难的任务(如果它们被使用的话)。
Lex 和 Yacc 被复制了很多次,在很多平台上都有。Windows 上有商业和公共领域的变体——例如,来自 MKS 和 GNU (Flex/Bison)的变体。
许多商业编译器都是围绕 Lex 和 Yacc 构建的,这是值得怀疑的,因为它们功能有限,不能处理某些编程语言的古怪方面。例如,Fortran 是标记化的噩梦,因为(除了别的以外)它忽略了空白。
JLex 和 CUP 示例
Lex 和 Yacc 生成 C 代码,而 JLex 和 CUP 是生成 Java 代码的 Lex 和 Yacc 的版本。Elliot Berk 最初在普林斯顿大学开发 JLexJLex 也由 Andrew Appel(也在普林斯顿)维护,他是《Java/ML/C 的现代编译器》(剑桥大学出版社,2002 年)的作者;斯科特·阿纳尼安是“每个孩子一台笔记本电脑”的负责人。
像所有版本的 Lex 一样,JLex 允许您使用正则表达式来分解输入流并将其转换为令牌。它可以与 CUP 结合使用来定义语法,但是首先让我们单独使用 JLex 作为一个简单的扫描器。
Lex,不管是运行在 Unix 还是 DOS 上,用 C 还是用 Java,都是一种预处理语言,把规范或者规则转换成目标语言。在运行 Lex 程序后,C 语言规范变成了lex.yy.c
,而 Java 规范变成了filename.lex.java
。然后需要像编译任何其他 C 或 Java 程序一样编译代码输出。Lex 通常与 Yacc 一起使用,但是它也可以单独用于简单的任务,比如从源代码中删除注释。如果您需要为程序附加任何逻辑,您几乎肯定需要将它连接到某种解析器,比如 Yacc,或者在本例中是 CUP。
前面,这一章提到 Lex 和 Yacc 已经被 Unix 社区的编译器开发人员使用了很多年。如果你习惯于 Lex,JLex 在很多方面确实不同。让我们仔细看看。
JLex 吗
JLex 文件分为三个部分:
- 用户代码
- JLex 准则
- 正则表达式规则
尽管结构(如后面的清单 5-3 所示)不同于通常使用 C 而不是 Java 编译的 Lex 的 Unix 版本,并且定义和宏也非常不同,幸运的是正则表达式规则使用标准的正则表达式。因此,如果你熟悉 Lex,甚至 vi 或 Perl,你似乎不会偏离熟悉的领域太远。如果你以前没有遇到过正则表达式,那么 JLex 手册 ( [www.cs.princeton.edu/~appel/modern/java/JLex/current/manual.html](http://www.cs.princeton.edu/~appel/modern/java/JLex/current/manual.html)
)是一个很好的起点。
第一个%%
之前的一切都是用户代码。它被“原样”复制到生成的 Java 文件中。通常,这是一系列的import
语句。因为您将 JLex 与 CUP 结合使用,所以您的用户代码由以下内容组成:
import java_cup.runtime.Symbol;
接下来是指令部分,从第一个%%
开始,以另一个%%
结束。这一系列指令或标志告诉 JLex 如何操作。例如,如果您使用%notunix
操作系统兼容性指令,那么 JLex 希望换行符由\r\n
表示,而不是像在 Unix 世界中那样由\n
表示。下面列出的其余指令允许您将自己的代码输入到生成文件的各个部分,或者更改生成的 lex 类、函数或类型的默认名称(例如,从yylex
到scanner
):
- 内部代码
- 初始化类别代码
- 文件类结尾
- 宏定义
- 国家声明
- 字符计数
- 行计数
- Java CUP 兼容性
- 组件标题
- 默认令牌类型
- 文件结尾
- 操作系统兼容性
- 字符集
- 文件格式
- 例外代码
- 文件结束返回值
- 要实现的接口
- 公开生成的类
这个例子只对一些指令感兴趣,比如%cup
(CUP 兼容性)指令。出于您的目的,指令部分就像清单 5-2 一样简单。
清单 5-2。 JLex 指令
`%%
%cup
digit = [0-9]
whitespace = [\ \t\n\r]
%%`
正则表达式部分是真正扫描的地方。规则是正则表达式的集合,它将传入流分解成标记,以便解析器完成其工作。作为一个简单的例子,清单 5-3 将行号添加到任何从命令行输入的文件中。
清单 5-3。 给文件添加行号的 JLex 扫描仪
`import java.io.IOException; // include the import
statement in the generated scanner
%% // start of the directives
%public // define the class as
public
%notunix // example is running on
Windows
%class Num // rename the class to
Num
%type void // Yytoken return type is
void
%eofval{ // Java code for execution at
end-of-file
return;
%eofval}
%line // turn line counting on
%{ // internal code to add to the
scanner
// to make it a standalone
scanner
public static void main (String args []) throws IO Exception{
new Num(System.in).yylex();
}
%}
%% // regular expressions section
^\r\n {
System.out.println((yyline+1)); }
\r\n { ; }
\n { System.out.println(); }
.*$ { System.out.println((yyline+1)+"\t"+yytext()); }`
通过从[www.cs.princeton.edu/~appel/modern/java/JLex/](http://www.cs.princeton.edu/~appel/modern/java/JLex/)
获取Main.java
的副本来安装 JLex。将其复制到一个名为JLex
的目录中,并使用您喜欢的 Java 编译器进行编译。保存Num.lex
文件(见清单 5s-3 ),删除所有注释,编译如下:
java JLex.Main Num.lex mv Num.lex.java Num.java javac Num.java
现在,您可以通过键入以下内容向文件中添加行号
java Num < Num.java > Num_withlineno.java
通常,在扫描器/解析器组合中,扫描器作为解析器的输入。在第一个例子中,您甚至没有生成一个令牌,所以没有什么要传递给 CUP,您的 Java 解析器。Lex 生成一个yylex()
函数,该函数吃掉令牌并将它们传递给由 Yacc 生成的yyparse()
。您将把这些函数或方法重命名为scanner()
和parse()
,但想法是一样的。
杯子
CUP 是一个 Yacc 解析器,最接近 LALR(1)(左-右前瞻)解析器。它是为 Java 语言编写的众多 Yacc 解析器生成器之一;BYACC 和吉尔是另外两个例子。如果您更喜欢 LL 解析器,不想使用 LALR 语法,那么您可能想看看 ANTLR 或 JavaCC。
如前所述,Yacc 允许您定义语法规则来解析传入的词法标记,并按照您的语法生成所需的输出。CUP 是 Yacc 的公共域 Java 变体,由于 Java 的可移植性,它可以在任何具有 JVM 和 JDK 的机器上编译。
请注意:CUP 并不具有与为任何 C 编译器编写的 Yacc 语法完全相同的功能或格式,但是它的行为方式有点类似。CUP 可以在任何支持 JDK 的操作系统上编译。
要安装 CUP,请从[
www2.cs.tum.edu/projects/cup/](http://www2.cs.tum.edu/projects/cup/)
复制源文件。通过在 CUP 根目录中键入以下命令进行编译:
javac java_cup/*java java_cup/runtime/*.java
CUP 文件由以下四部分组成:
- 序言或声明
- 用户例程
- 符号或记号列表
- 语法规则
声明部分由一系列 Java package
和import
语句组成,这些语句根据您想要导入的其他包或类而变化。假设 CUP 类位于您的类路径中,添加以下代码行以包含 CUP 类:
import java_cup.runtime*;
所有其他导入或包引用都是可选的。一个start
声明告诉解析器在哪里寻找开始规则,如果你想让它从其他解析器规则开始。默认情况下是使用顶级产生式规则,所以在大多数语法中,您会遇到开始规则是冗余信息。
CUP 中允许四种可能的用户例程:action
和parser
用于插入新代码和覆盖默认的扫描器代码和变量,init
用于任何解析器初始化,scan
由解析器用于调用下一个令牌。所有这些用户程序都是可选的(见清单 5-4 )。
清单 5-4。 杯赛用户套路
`action code {:
// allows code to be included in the parser class
public int max_narrow = 10;
public double narrow_eps = 0.0001;
:};
parser code {:
// allows methods and variables to be placed
// into the generated parser class
public void report_error(String message, Token tok) {
errorMsg.error(tok.left, message);
}
:};
// Preliminaries to set up and use the scanner.
init with {: scanner.init(); :};
scan with {: return scanner.yylex(); :};`
init
和scan
都是常用的,即使只是把扫描器/lexer 的名字改成比Yylex()
更有意义的名字。在请求第一个令牌之前,执行init
中的任何程序。
大多数解析器都在语法部分定义了一系列动作。CUP 将所有这些动作放在一个类文件中。action 用户例程允许您定义变量和添加额外的代码,例如符号表操作例程,它们可以在非公共 action 类中引用。解析器例程用于向生成的解析器类添加额外的代码——除非为了更好地处理错误,否则不要期望经常使用它们。
CUP 和原来的 Yacc 一样,作用就像一个栈机。每次读取一个令牌时,它都被转换成一个符号,并放置或转移到堆栈上。这些标记在符号部分中定义。
未归约的符号称为终结符号,归约为某种规则或表达式的符号称为非终结符号。换句话说,终结符是 JLex 扫描器使用的符号/记号,非终结符是终结符在满足语法部分中的某个模式或规则后变成的。清单 5-5 展示了一个终端和非终端令牌的好例子。
符号可以具有相关联的整数、浮点或字符串值,这些值沿着语法规则向上传播,直到该组记号满足规则并且可以被减少,或者如果没有规则被满足,则使解析器崩溃。
清单 5-5。??Parser.CUP
`import java_cup.runtime.*;
// Interface to scanner generated by JLex.
parser code {:
Parser(Scanner s) { super(); scanner = s; }
private Scanner scanner;
:};
scan with {: return scanner.yylex(); :};
terminal NUMBER, BIPUSH, NEWARRAY, INT, ASTORE_1, ICONST_1;
terminal ISTORE_2, GOTO, ALOAD_1, ILOAD_2, ISUB, IMUL;
terminal IASTORE, IINC, IF_ICMPLE, RETURN, END;
non terminal function, functions, keyword;
non terminal number;
functions ::= function
| functions function
;
function ::= number keyword number number END
| number keyword number END
| number keyword keyword END
| number keyword END
| END
;
keyword ::= BIPUSH
| NEWARRAY
| INT
| ASTORE_1
| ICONST_1
| ISTORE_2
| GOTO
| ALOAD_1
| ILOAD_2
| ISUB
| IMUL
| IASTORE
| IINC
| IF_ICMPLE
| RETURN
;
number ::= NUMBER
;`
解析器的功能取决于它解释输入标记流并将这些标记转换成语言语法所定义的期望输出的能力。为了让解析器有任何成功的机会,它需要在任何移位/归约循环发生之前知道每个符号及其类型。这个符号表是根据上一节中的符号列表生成的。
符号表中的端子列表被 CUP 用来生成自己的 Java 符号表(Sym.java
),需要导入到 JLex 扫描器中,供 JLex 和 CUP 协同工作。
如前所述,CUP 是一个 LALR(1)机器,这意味着它可以预测一个令牌或符号,以尝试满足语法规则。如果满足产生式规则,则符号从堆栈中弹出,并用产生式规则减少。每个解析器的目的都是将这些输入符号转换成一系列简化的符号,回到起始符号或记号。
通俗地说,给定一串记号和一些规则,目标是通过从输入字符串开始,向后工作到开始符号,反向追踪最右边的派生。使用自底向上的解析将一系列非终结符减少为一个终结符。所有输入符号都是终结符,随后使用这种移位/归约原理将其组合成非终结符或其他中间终结符。当每组符号都与一个产生式规则相匹配时,它最终会启动一个动作,该动作会生成在产生式规则动作中定义的某种输出。
清单 5-5 中的解析器解析来自清单 5-6 中所示的主方法字节码的输入。相应的扫描仪如清单 5-7 所示。为了尽可能简单,它不产生任何输出。
清单 5-6。 主方法字节码
0 getstatic #7 <Field java.io.PrintStream out> 3 ldc #1 <String "Hello World"> 5 invokevirtual #8 <Method void println(java.lang.String)> 8 return
清单 5-7。??Decompiler.lex
`package Decompiler; // create a package for
the Decompiler
import java_cup.runtime.Symbol; // import the CUP
classes
%%
%cup // CUP declaration
%%
"getstatic" { return new Symbol(sym.GETSTATIC, yytext());
}
"ldc" { return new Symbol(sym.LDC, yytext()); }
"invokevirtual" { return new Symbol(sym.INVOKEVIRTUAL,
yytext()); }
"Method" { return new Symbol(sym.METHOD, yytext()); }
"return" { return new Symbol(sym.RETURN, yytext()); }
"[a-zA-Z ]+" { return new Symbol(sym.BRSTRING,
yytext()); }
[a-zA-Z.]+ { return new Symbol(sym.BRSTRING, yytext()); }
< { return new Symbol(sym.LABR, yytext()); }
> { return new Symbol(sym.RABR, yytext()); }
( { return new Symbol(sym.LBR, yytext()); }
) { return new Symbol(sym.RBR, yytext()); }
#[0-9]+|[0-9]+ { return new Symbol(sym.NUMBER,
yytext());}
[ \t\r\n\f] { /* ignore white space. */ }
. { System.err.println("Illegal character:
"+yytext()); }`
在某些情况下,输入标记或中间符号可能满足多个产生式规则:这被称为模糊语法。symbol 部分中的关键字precedence
允许解析器决定哪个符号具有更高的优先级——例如,让乘法和除法符号优先于加法和减法符号。
值得一提的是,CUP 允许您出于调试目的转储移位/归约表。生成符号和语法、解析状态机和解析表的可读转储并向显示完整转换的命令如下(一些输出显示在清单 5-8 中):
java java_cup.Main -dump < Parser.CUP
清单 5-8。 偏杯调试输出
-------- ACTION_TABLE -------- From state #0 [term 2:SHIFT(to state 2)] [term 18:SHIFT(to state 5)] From state #1 [term 0:REDUCE(with prod 0)] [term 2:REDUCE(with prod 0)] [term 18:REDUCE(with prod 0)] From state #2 [term 2:REDUCE(with prod 23)] [term 3:REDUCE(with prod 23)] [term 4:REDUCE(with prod 23)] [term 5:REDUCE(with prod 23)] [term 6:REDUCE(with prod 23)] [term 7:REDUCE(with prod 23)] [term 8:REDUCE(with prod 23)] [term 9:REDUCE(with prod 23)] [term 10:REDUCE(with prod 23)] [term 11:REDUCE(with prod 23)] [term 12:REDUCE(with prod 23)] [term 13:REDUCE(with prod 23)] [term 14:REDUCE(with prod 23)] [term 15:REDUCE(with prod 23)] [term 16:REDUCE(with prod 23)] [term 17:REDUCE(with prod 23)] [term 18:REDUCE(with prod 23)] From state #3 [term 3:SHIFT(to state 13)] [term 4:SHIFT(to state 14)] [term 5:SHIFT(to state 8)] [term 6:SHIFT(to state 18)] [term 7:SHIFT(to state 11)] [term 8:SHIFT(to state 10)] [term 9:SHIFT(to state 23)] [term 10:SHIFT(to state 12)] [term 11:SHIFT(to state 17)] [term 12:SHIFT(to state 15)] [term 13:SHIFT(to state 20)] [term 14:SHIFT(to state 9)] [term 15:SHIFT(to state 19)] [term 16:SHIFT(to state 22)] [term 17:SHIFT(to state 21)] From state #4 [term 0:SHIFT(to state 7)] [term 2:SHIFT(to state 2)] [term 18:SHIFT(to state 5)] From state #5 [term 0:REDUCE(with prod 7)] [term 2:REDUCE(with prod 7)] [term 18:REDUCE(with prod 7)] From state #6 [term 0:REDUCE(with prod 2)] [term 2:REDUCE(with prod 2)] [term 18:REDUCE(with prod 2)] From state #7 [term 0:REDUCE(with prod 1)]
反 LR
ANTLR 代表另一种语言识别工具。它可以在[www.antlr.org](http://www.antlr.org)
下载。ANTLR 是递归下降,LL(k),或自顶向下的解析器,而 Yacc 是 LR 或自底向上的解析器。ANTLR 从最上面的规则开始,试图识别标记,向外工作到叶子;Yacc 从树叶开始,一直到最高规则。
ANTLR 与 JLex 和 CUP 的根本区别还在于,lexer 和解析器在同一个文件中。创建标记的词法规则都是大写的(例如,IDENT
),标记解析规则都是小写的(例如,program
)。
下一个例子使用 ANTLR v3,它完全重写了 ANTLR v2。v3 还包括一些非常有用的附加功能,比如从解析器中提取输出语句的StringTemplate
。
图 5-2 显示了 ANTLR 与 Eclipse IDE 的集成。Scott Stanchfield 有一些在 Eclipse 中设置 ANTLR 和在[
vimeo.com/groups/29150](http://vimeo.com/groups/29150)
创建解析器的优秀视频。这是最好的 ANTLR 资源之一,无论您是否使用 Eclipse 作为您的 IDE。如果你不想使用 Eclipse,ANTLRWorks 是一个很好的选择。
图 5-2。Eclipse 的 ANTLR 插件
ANTLR 背后的主要力量特伦斯·帕尔(Terence Parr)也出版了两本关于 ANTLR 的书:权威的 ANTLR 参考文献(语用书架,2007)和语言实现模式(语用书架,2010)。
ANTLR 示例
DexToXML 是第三章中的解析器。DexToXML 完全用 ANTLR v3 编写;在这里,它解析 dedexer 输出并将文本转换成 XML。
DexToXML 解析十六进制数字的方式是一个简单的例子,说明如何将 ANTLR 语法文件放在一起。dedexer 输出的第一行显示了来自classes.dex
文件的幻数标题行:
00000000 : 64 65 78 0A 30 33 35 00 magic: dex\n035\0
与大多数其他解析器一样,ANTLR 在其词法分析器中使用正则表达式(regex)。lexer 将输入流分解成标记,然后由解析器中的规则使用。您可以使用以下正则表达式来识别 ANTLR 语法中的十六进制数字对:
- `HEX_DIGIT
- ('0'..'9'|'a'..'f'|'A'..'F')('0'..'9'|'a'..'f'|'A'..'F') ;`
与 Lex 和 Yacc 不同,在 ANTLR 中,lexer 和 parser 在同一个文件中(见清单 5-9 )。词法分析器使用大写规则,解析器使用小写规则。
清单 5-9。??DexToXML.g
`grammar DexToXML;
options {
language = Java;
}
@header {
package com.riis.decompiler;
}
@lexer::header {
package com.riis.decompiler;
}
rule: HEX_PAIR+;
HEX_PAIR :
('0'..'9'|'a'..'f'|'A'..'F')('0'..'9'|'a'..'f'|'A'..'F') ;
WS : ' '+ {$channel = HIDDEN;};`
每个 ANTLR 解析器都有许多部分。最基本的是,grammar
部分定义了解析器的名称。生成的 ANTLR 解析器的输出语言在options
部分设置为 Java,解析器和词法分析器的包名在@header
和@lexer::header
部分设置。接下来是解析器和词法分析器规则。lexer 识别由两个十六进制数字组成的组,规则识别多组十六进制数字对。任何空白都被WS
规则忽略,并被放置在一个隐藏的通道上。
比如,假设你通过了64 65 78 0A
。图 5-3 展示了解析器看到的东西。
图 5-3。 解析成对的十六进制数字
策略:决定您的解析器设计
ANTLR 被彻底改写,继续发展;它还有许多额外的功能,比如 ANTLRWorks 工具和 Eclipse 插件,这些功能使调试解析器成为一种享受。如您所见,调试 Yacc 输出不适合胆小的人。
因此,您将使用 ANTLR v3 构建解析器。这样做有很多好处:
- 词法分析器和语法分析器在同一个文件中
- 的功能从解析器中提取输出功能
- ANTLRWorks 工具允许您调试解析器
- 大量的解析器模式可供使用
- 简单的 AST 集成
- Eclipse 集成
在图 5-1 的中,将StringTemplate
s 和 AST 添加到原始设计中,产生了一个新的解析器设计,如图 5-4 的所示。
图 5-4。 最终反编译器设计
StringTemplate
s 意味着不再需要解析器中无休止的System.out.println
语句来输出 Java 代码。从程序员的角度来看,过去当我设计解析器时,那些println
总是困扰着我。他们有一股难闻的代码味。允许你从产生输出的逻辑中分离出输出。仅此一点就足以成为选择 ANTLR 而不是 JLex 和 CUP 的理由,因为它极大地增强了您理解解析器语法如何工作的能力。这也是解析器被称为 DexToSource 而不是 DexToJava 的另一个原因:您可以编写自己的模板,通过重定向模板以用不同的语言编写输出,来输出 C#或 C++。
为了简单起见,您可以使用 Android toolkit 中的一个反汇编器,如 DexToXML、baksmali、dedexer 或 dx,以字符流的形式提供字节码,而不是让您的反编译器解析二进制文件。然后您的 ANTLR 解析器会将字节码文件转换成 Java 源代码。
构建一个反编译器的一个重要问题是使它足够通用以处理任意的情况。当 Mocha 遇到一个意想不到的语言习惯用法时,它要么中止,要么抛出非法的goto
语句。理想情况下,你应该能够编写一个通用解决方案的反编译器,而不是一个只不过是一系列标准例程和大量异常情况的程序。您不希望 DexToSource 在任何构造上失败,因此通用解决方案非常有吸引力。
但是,在采用这种方法之前,您需要知道它是否有任何缺点,以及您是否会以输出看起来一点也不像原始源代码的难以辨认的代码为代价来获得更好的解决方案。或者,更糟糕的是,到达那里会花费过多的时间吗?您可以用一个程序计数器和一个单独的while
循环替换程序中的所有控制结构,但是这将破坏映射——并且失去结构或语法的等价性肯定不是您的目标,即使这是一个通用的解决方案。
从我的讨论中,你知道不像在其他语言中,你没有分离数据和指令的麻烦,因为所有的数据都在data
部分。本章的剩余部分和下一章也显示了恢复源代码级表达式是相对容易的。因此,看起来您的主要问题和您使用的任何相应策略主要涉及处理字节码的控制流。
这里的重点是更简单的方法,其中高层结构是硬编码的,因为它非常适合我已经讨论过的解析器方法。但是本章还介绍了一些高级策略,其中反编译器从字节码中的一系列goto
语句中推断出所有复杂的高级结构。
本节着眼于几种不同的策略,您可以使用它们来克服从类似于goto
的原语合成高级控制结构的问题。正如我所说,一个理想的通用解决方案将让你反编译每一个可能的if
- then
- else
或for
循环组合,而不需要任何异常情况,同时保持源代码尽可能接近原始代码。另一种方法是尝试预测所有高级控制习惯用法。
你可能想知道为什么你需要一个策略——为什么你不能在 ANTLR 中建立一个语法,然后看看另一边会出现什么?不幸的是,解析器只能识别顺序指令序列。因此,您可能无法一次解析所有的 Dalvik 指令,因为字节码有可怕的分支习惯。寄存器被用作临时存储区域,当代码分支时,您需要能够控制部分序列会发生什么。ANTLR 本身不提供这种级别的功能,所以您需要弄清楚需要采取什么方法来存储这些部分识别的序列。
选择一个
第一种选择是使用基于克里斯蒂娜·希福恩特斯和 k .约翰·高夫的工作的技术,这些技术在论文“反编译的方法学”中有描述,可从[www.itee.uq.edu.au/~cristina/clei1.ps](http://www.itee.uq.edu.au/~cristina/clei1.ps)
获得。本文描述了英特尔机器上希福恩特斯的 C 程序反编译器dcc
。虽然dcc
恢复的是 C 语言而不是 Java 代码,但是这个通用反编译器的大量讨论和设计直接适用于手头的任务。
选择二
第二种选择更一般——将goto
语句转换成等价的形式。如果您可以在classes.dex
文件上启动 ANTLR 扫描器和解析器,并一次性反编译代码,或者至少免除任何控制流分析,那就简单多了。这正是托德·普罗伯斯汀和斯科特·沃特森在他们的论文“喀拉喀托火山:Java 中的反编译”中试图做的事情,该论文可在[www.usenix.org/publications/library/proceedings/coots97/full_papers/proebsting2/proebsting2.pdf](http://www.usenix.org/publications/library/proceedings/coots97/full_papers/proebsting2/proebsting2.pdf)
获得。
Krakatoa 是一个早期的反编译器,现在是 Sumatra/Toba 项目的一部分,它使用 Ramshaw 算法将goto
转换成循环和多级中断。它声称提供了一个简洁的一次性解决方案,同时仍然保持原来的结构。喀拉喀托方法很有吸引力,因为它不太可能因为控制流分析问题而失败。
选择三
第三个选择来自 IBM Research 的 Daniel Ford,他可能是第一个反编译器 Jive 的一部分——我相信 IBM Research 从来没有推出过 Jive。在他的论文“Jive: A Java Decompiler”中,Ford 提出了一个真正的多通道反编译器,它“将解析状态信息与正在解析的机器指令序列集成在一起。”Jive 通过减少令牌来进行反编译,因为每一次连续的传递都会获得越来越多的信息。
选择四
最后一个选择是使用抽象语法树(AST)的概念来帮助您概括您的反编译器。您可以将标记的含义抽象到一个 AST 中,而不是使用单遍解析器;然后第二个解析器用 Java 源代码写出标记。图 5-5 显示了(2+3)的 AST。你可以用许多不同的方式输出这个,如清单 5-10 中的所示。
图 5-5。【2+3 之 AST】
清单 5-10。 图 5-3 中 AST 的可能输出
2 + 3 (2 3 +) bipush 2 bipush 3 iadd
清单 5-10 中的例子用标准数学符号、逆向波兰符号或后缀符号,最后用 Java 字节码来表达 AST。所有的输出都是同一个 AST 的表示,但是它们没有任何输出语言实现。类似于StringTemplate
s,但是在更高的层次上,输出与解析分离,给你一个更简洁或者更抽象的表示,来表示你试图在解析器中建模的内容。
解析器设计
理解对话和编译器生成机器代码之间的主要区别在于,编译器需要更少的关键字和规则来产生计算机可以理解的输出——这被称为其本机格式。如果编译计算机程序是理解人类语言的一个更小的子集,那么反编译 Dalvik 字节码是一个更小的子集。
关键字的数量和有限的语法规则允许您轻松地对输入进行标记,然后将标记解析成 Java 短语。将它转换回代码需要一些进一步的分析,但是我稍后会谈到。您需要将输入数据流转换成令牌。清单 5-10 显示了来自Casting.java
文件的一些 Dalvik 字节码。
清单 5-10。 Casting.smali
法
sget-object v1, field[2] new-instance v2, type[6] invoke-direct method[4], {v2} const-string v3, string[20] invoke-virtual method[7], {v2, v3}
查看字节码,您会发现令牌可以分为以下类型:
- 标识符:通常,引用采用
{v1}
或{v2}
的形式。 - 整数:通常是操作码后面的数据或数字,组成一个完整的字节码语句。一个很好的例子是放在栈上的数字或者一个
goto
语句的标签,比如goto_1
。 - 关键词:组成字节码语言的大约 200 个操作码。你可以在第六章中看到这些和它们的构造。
- 空白:包括制表符、空格、换行符和回车符,如果你使用的是 DOS 或 Windows 的话。大多数反编译器在真实的
classes.dex
中不会遇到很多空白,但是你需要在 Dalvik 字节码中处理它
所有这些标记对于解析器能够完成其工作并尝试将它们与预定义的语法规则匹配是至关重要的。
反编译器的设计如图图 5-4 所示。一切都从字节码文件或字符流开始。ANTLR 将其解析成一个令牌流,该令牌流被转换成一个 AST。AST 由第二个解析器解析,该解析器将抽象表示转换成 Java 使用StringTemplate
库编写的模板将令牌输出到 Java 文本中。
总结
到目前为止,我已经讨论了可以帮助您创建一个有效的反编译器的工具。你已经看到了你可以选择采用的不同策略。我已经包含了我们设计的替代方案,以防您想采用其他选项中的一个并自己运行它。如果你更喜欢 Lex 和 Yacc,那么你可能会想用 JLex 和 CUP 而不是 ANTLR。个人认为 JLex 和 CUP 近几年变化不大,这也是我现在推荐 ANTLR 的原因。现在您需要实现一个反编译器设计。下一章结束时,你将拥有一个可以处理简单文件的反编译器。第六章着眼于各种内部结构,逐步创建一个比Casting.java
例子处理能力更强的更有效的反编译器。
六、反编译实现
现在,您已经学会了处理单个字节码,并将操作码反编译成部分语句和表达式,最终(无论如何,这是计划),返回完整的源代码块。
如果我对我的读者判断正确的话,这一章,可能还有第五章会吸引大量的读者。这是如何使用 ANTLR 构建的解析器实现反编译器的核心问题。
为了使本章尽可能实用,我们使用了一个简单程序的测试套件,每个程序都有不同的语言结构。对于每一个程序,你都要逐步重建原始源代码,在进行过程中构建反编译器。每个程序都是先编译再反汇编。然后查看 Java 源代码和相应的方法字节码,并为每个示例创建一个解析器规范,将字节码转换回源代码。
因为classes.dex
文件不仅仅是方法字节码,您还需要能够将剩余的信息合并到类文件中,以便从文件的非数据部分恢复导入语句、包名和变量名。
通过完成第三章中的 DexToXML 的实现,您可以开始更加熟悉 ANTLR。DexToXML 是一个基本的 ANTLR 解析器,没有额外的功能。之后,查看 DexToSource(反编译器)将字节码指令反编译回 Java 源代码。
德克斯托 XML
DexToXML 是 ANTLR 解析的简单介绍。它使用 ANTLR 作为解析器技术。这本书的早期版本使用了 JLex 和 CUP,它们很难工作,甚至更难调试——您可能会花费数小时试图找出为什么向规则添加一个简单的更改会破坏整个解析器。自 2004 年以来发生了很多变化,ANTLR 现在提供了与 Eclipse 以及 ANTLRWorks 的出色集成,ANTLRWorks 是一个独立的 ANTLR 工具,它将创建解析器的艺术转变为更简单的编码任务。
ANTLR 也是一种优秀的技术,可以为各种领域特定语言(DSL)工具创建自己的解析器。这些通常是一次性的微型编程语言、规则引擎、绘图工具等等,它们是为解决特定问题而创建的;当 grep、sed 和 awk 等脚本工具不能胜任工作时,通常会用到它们。
让我们首先看看解析dex.log
,它是 dedexer 工具的输出之一。Dedexer 是一个 dex 文件反汇编程序,通常用于在 DDX 文件中生成类似 smali 的反汇编程序输出,但也可以在dex.log
文件中给出classes.dex
的完整输出。您也可以使用 Android SDK 中的 dexdump 文件的输出,但是我个人更喜欢更简单的dex.log
文件的输出。
解析 dex.log 输出
dex.log
是在编译版本的Casting.java
文件上运行以下 dedexer 命令时创建的日志文件:
c:\temp>java -jar ddx1.18.jar -o -d c:\temp casting\classes.dex
dex.log
是一个classes.dex
文件的原始输出,它允许你在没有解析字节开销的情况下进行反编译,这正是你想要的。清单 6-1 显示了classes.dex
文件头的输出。
清单 6-1。 类的头
00000000 : 64 65 78 0A 30 33 35 00 magic: dex\n035\0 00000008 : 62 8B 44 18 checksum 0000000C : DA A9 21 CA 9C 4F B4 C5 21 D7 77 BC 2A 18 4A 38
0D A2 AA FE signature 00000020 : 50 04 00 00 file size: 0x00000450 00000024 : 70 00 00 00 header size: 0x00000070 00000028 : 78 56 34 12 00 00 00 00 link size: 0x00000000 00000030 : 00 00 00 00 link offset: 0x00000000 00000034 : A4 03 00 00 map offset: 0x000003A4 00000038 : 1A 00 00 00 string ids size: 0x0000001A 0000003C : 70 00 00 00 string ids offset: 0x00000070 00000040 : 0A 00 00 00 type ids size: 0x0000000A 00000044 : D8 00 00 00 type ids offset: 0x000000D8 00000048 : 07 00 00 00 proto ids size: 0x00000007 0000004C : 00 01 00 00 proto ids offset: 0x00000100 00000050 : 03 00 00 00 field ids size: 0x00000003 00000054 : 54 01 00 00 field ids offset: 0x00000154 00000058 : 09 00 00 00 method ids size: 0x00000009 0000005C : 6C 01 00 00 method ids offset: 0x0000016C 00000060 : 01 00 00 00 class defs size: 0x00000001
00000064 : B4 01 00 00 class defs offset: 0x000001B4 00000068 : 7C 02 00 00 data size: 0x0000027C 0000006C : D4 01 00 00 data offset: 0x000001D4
让我们先来看看幻数部分,它在文件的开头;参见清单 6-2 。
清单 6-2。 幻数
00000000 : 64 65 78 0A 30 33 35 00
magic: dex\n035\0
所有classes.dex
文件的格式都是一样的。目标是解析幻数和输出
<root><header><magic>dex\n035\0</magic></header></root>
使用这些信息:
- 八个十六进制数字,文件中的地址
- 一个冒号
- 两组八个十六进制数字
magic
关键字- 另一个冒号
classes.dex
神奇的数字
工作中的 ANTLR
ANTLR 的工作方式是首先对输入进行标记,然后通过一系列解析规则,产生所需的输出。第一步是将信息分解成令牌。一个显而易见的标记是一个十六进制数字(HEX_DIGIT
)以及您希望解析器忽略的WS
或空格。用于标记幻数头信息的 ANTLR 解析器如清单 6-3 所示。语法、选项、@header
和@lexer
分别告诉解析器语法的名称、生成解析器的语言以及解析器和词法分析器的包名。
清单 6.3。ANTLR 幻数解析器
`grammar DexToXML;
options {language = Java;}
@header {package com.riis.decompiler;}
@lexer::header {package com.riis.decompiler;}
rule : header
;
header : magic
;
magic: address eight_hex eight_hex IDENT ':' MAGIC_NUM
;
hex_address: '0x' eight_hex
;
- address
- eight_hex ':'
; - eight_hex
- DIGIT DIGIT DIGIT DIGIT DIGIT DIGIT DIGIT DIGIT
;
IDENT: ('a'..'z')+;
MAGIC_NUM: 'dex\n035\0';
DIGIT : ('0'..'9'|'A'..'F');
WS: (' ' | '\t' | '\n' | '\r' | '\f' | ',')+ {$channel = HIDDEN;};`
在清单 6-3 中,从下往上看,WS
定义了发送到隐藏通道并被忽略的空白是什么意思。DIGIT
将十六进制数字定义为 0–f。MAGIC_NUM
是dex\n035\0
的转义版本;最后,IDENT
是任何字符串。解析器规则接受这些标记,并将它们排列成预期的模式。例如,从清单 6-2 中可以知道,十六进制数字被分成八组,分别代表地址和幻数。地址后面有一个冒号。清单 6-2 被标记后看起来有点像清单 6-4 。
清单 6-4。 记号化的幻数
address eight_hex eight_hex
IDENT MAGIC_NUM
规则
在 ANTLR 中,和所有解析器一样,您需要一个rule
来告诉解析器从哪里开始解析。rule
表示传入的文件由一个header
组成;而那只header
,目前有一个神奇的数字。magic
规则期望传入文件的格式如清单 6-4 中的所示。关于如何在 Eclipse 中建立 ANTLR 项目的指导,请参见[
vimeo.com/8001326](http://vimeo.com/8001326)
。使用 Eclipse,来自清单 6-2 的的输入令牌被解析,如图 6-1 中的所示。
图 6-1。??【幻数解析规则】??
既然已经成功解析了幻数,下一步就是以正确的格式输出它。
输出幻数
清单 6-5 用System.out.println
语句更新。使用@init
和@after
ANTLR 语句以正确的顺序打印<root>
和<header>
语句。如果您不喜欢解析器中所有多余的 Java 代码,也可以使用 ANTLR StringTemplate
s 删除所有的println
语句。
清单 6-5。 DexToXML 幻数解析器
**grammar** DexToXML; **options** {language = Java;} @header {**package** com.riis.decompiler;} @lexer::header {**package** com.riis.decompiler;}
`rule
@init {System.out.println("
@after {System.out.println("");}
: header
;
header
@init {System.out.println("
@after {System.out.println("
: magic
;
magic: address eight_hex eight_hex IDENT ':' id=MAGIC_NUM
{System.out.println("
;
hex_address: '0x' eight_hex
;
- address
- eight_hex ':'
; - eight_hex
- DIGIT DIGIT DIGIT DIGIT DIGIT DIGIT DIGIT DIGIT
;
IDENT: ('a'..'z')+;
MAGIC_NUM: 'dex\n035\0';
DIGIT : ('0'..'9'|'A'..'F');
WS: (' ' | '\t' | '\n' | '\r' | '\f' | ',')+ {$channel = HIDDEN;};`
清单 6-6 有从 Eclipse 外部的命令行调用 ANTLR 代码所必需的 Java 代码。这从c:\temp\input.log
获取输入。
清单 6-6。??DexToXML.java
`package com.riis.decompiler;
import java.io.*;
import org.antlr.runtime.ANTLRInputStream;
import org.antlr.runtime.CommonTokenStream;
import org.antlr.runtime.RecognitionException;
import org.antlr.runtime.TokenStream;`
`public class DexToXML {
public static void main(String[] args) throws RecognitionException,
IOException {
DexToXMLLexer lexer = new DexToXMLLexer(new
ANTLRInputStream(System.in));
TokenStream tokenStream = new CommonTokenStream(lexer);
DexToXMLParser parser = new DexToXMLParser(tokenStream);
parser.rule();
}
}`
使用以下命令编译com\riis\decompiler
目录中的代码,确保 ANTLR v3.4 库在您的类路径中。第一个命令生成 lexer 和解析器,第二个命令编译 DexToXML 代码:
java org.antlr.Tool DexToXML.g javac DexToXMLLexer.java DexToXMLParser.java DexToXML.java
将清单 6-2 保存为magic.log
,在顶层目录中运行下面的命令,得到如清单 6-7 所示的 DexToXML 输出:
java com.riis.decompiler.DexToXML < magic.log
清单 6-7。 DexToXML 输出
`
接下来,为报头的其余部分创建语法。最初,您可以设置规则来拆分标题,如清单 6-8 中的所示。
清单 6-8。 表头规则
header @init {System.out.println("<header>");} @after {System.out.println("</header>");} : magic checksum signature file_size
header_size link_size link_offset map_offset string_ids_size string_ids_offset type_ids_size type_ids_offset proto_ids_size proto_ids_offset fields_ids_size fields_ids_offset method_ids_size method_ids_offset class_defs_size class_defs_offset data_size data_offset ;
但是许多模式是重复的——例如,大小和偏移量非常相似,所以您可以通过提取节点的名称来重构解析器。你可以把这些模式放在一起,如清单 6-9 所示,它匹配不同的头条目。
清单 6-9。重构了header_entry
规则
- `header_entry
- address eight_hex IDENT
| address eight_hex xml_id ':' hex_address
| address eight_hex eight_hex xml_id ':' hex_address
;`
将这些代码放到修改后的解析器中,您会得到清单 6-10 中所示的完整头部。
清单 6-10。 重构的 DexToXML 头语法
`grammar DexToXML;
options {language = Java;}
@header {package com.riis.decompiler;}
@lexer::header {package com.riis.decompiler;}
- rule
- header
;
header
: magic
header_entry
signature
header_entry+
;
magic: address eight_hex eight_hex IDENT ':' MAGIC_NUM
;
- header_entry
- address eight_hex IDENT
| address eight_hex xml_id ':' hex_address
| address eight_hex eight_hex xml_id ':' hex_address
; - xml_id
- IDENT IDENT
| IDENT IDENT IDENT
;
signature: address signature_hex 'signature'
;
signature_hex: eight_hex eight_hex eight_hex eight_hex eight_hex
;
hex_address: '0x' eight_hex
;
- address
- eight_hex ':'
; - eight_hex
- DIGIT DIGIT DIGIT DIGIT DIGIT DIGIT DIGIT DIGIT
;
IDENT: ('a'..'z')+;
MAGIC_NUM: 'dex\n035\0';
DIGIT : ('0'..'9'|'A'..'F');
WS: (' ' | '\t' | '\n' | '\r' | '\f' | ',')+ {$channel = HIDDEN;};`
清单 6-11 中的解析了文件的更多内容,一直到code_item
部分。我还包含了输出 XML 的代码,所以您可以看到这是如何完成的。
清单 6-11。 DexToXML ANTLR 语法
`grammar DexToXML;
options {
language = Java;
}
@header {
package com.riis.decompiler;
}
@lexer::header {
package com.riis.decompiler;
}
rule
@init {System.out.println("
@after {System.out.println("
: header
string_ids
type_ids
proto_ids
field_ids
method_ids
class_defs
data
;
header
@init {System.out.println("
@after {System.out.println("
: magic
header_entry
signature
header_entry+
;
magic: address eight_hex eight_hex IDENT ':' id=MAGIC_NUM
{System.out.println("
"
;
- header_entry
- address id1=eight_hex id2=IDENT
{System.out.println("<" + $id2.text + ">" + $id1.text
- "</" + $id2.text + ">");}
| address eight_hex id3=xml_id ':' id4=hex_address
{System.out.println("<" + \(id3.result + ">" + \)id4.text + "</" + $id3.result + ">");}
| address eight_hex eight_hex id5=xml_id ':'
id6=hex_address
{System.out.println("<" + \(id5.result + ">" + \)id6.text + "</" + $id5.result + ">");}
;
- xml_id returns [String result]
- id1=IDENT id2=IDENT
{\(result = id1.getText() + "_" + id2.getText();} | id1=*IDENT* id2=*IDENT* id3=*IDENT* {\)result = id1.getText() + "" + id2.getText() + ""
- id3.getText();}
;
signature: address id=signature_hex 'signature'
{System.out.println("
"
;
signature_hex: eight_hex eight_hex eight_hex eight_hex eight_hex
;
string_ids
@init {System.out.println("<string_ids>");}
@after {System.out.println("</string_ids>");}
: string_address+
;
string_address
: address eight_hex IDENT id1=array_digit ':' 'at'
id2=hex_address
{System.out.println("
"
;
type_ids
@init {System.out.println("<type_ids>");}
@after {System.out.println("</type_ids>");}
: type_address+
;
- type_address
- address eight_hex IDENT id1=array_digit 'index:'
id2=eight_hex '(' id3=proto_type_string ')'
{int addr = Integer.parseInt($id2.text,16);
System.out.println("\n " + $id1.result + \n<string_id>"
"
- addr + "</string_id>\n
" - $id3.text + "
\n");}
;
proto_ids
@init {System.out.println("<proto_ids>");}
@after {System.out.println("</proto_ids>");}
: proto_address+
;
- proto_address
- address eight_hex eight_hex eight_hex IDENT
id1=array_digit ':'
'short signature:' id2=proto_type_string ';'
'return type:' id3=proto_type_string ';'
'parameter block offset:' eight_hex
{System.out.println("\n " + $id1.result + \n
""
- $id3.text + "\n
" + $id2.text +
"\n");}
;
field_ids
@init {System.out.println("<field_ids>");}
@after {System.out.println("</field_ids>");}
: field_address+
;
- field_address
- address eight_hex eight_hex IDENT id1=array_digit ':'
id2=proto_type_string id3=proto_type_string
{System.out.println("\n " + $id1.result + \n
""
- $id2.text + "\n
" + $id3.text + \n");}
;
method_ids
@init {System.out.println("<method_ids>");}
@after {System.out.println("</method_ids>");}
: method_address+
;
- method_address
- address eight_hex eight_hex IDENT id1=array_digit ':'
id2=proto_type_string '(' id3=proto_type_string ')'
{System.out.println("\n " + $id1.result + \n
""
- $id2.text + "\n
" + $id3.text +
"\n");}
;
class_defs
@init {System.out.println("
@after {System.out.println("
: class_address+
;
- class_address
- address id1=eight_hex id2=eight_hex id3=eight_hex
id4=eight_hex id5=eight_hex id6=eight_hex id7=eight_hex
id8=eight_hex id9=IDENT id10=IDENT
{System.out.println("\n"
+"<class_id>" + \(id9.text + " " + \)id10.text + "</class_id>\n"
+"<type_id>" + $id1.text +
"</type_id>\n"
+"<access_flags>" + $id2.text +
"</access_flags>\n"
+"<superclass_id>" + $id3.text +
"
+"<interfaces_offset>" + $id4.text +
"<interfaces_offset>\n"
+"<source_file_id>" + $id5.text +
"<source_file_id>\n"
+"<annotations_offset>" + $id6.text +
"<annotations_offset>\n"
+"<class_data_offset>" + $id7.text +
"<class_data_offset>\n"
+"<static_values_offset>" + $id8.text +
+"<static_values_offset>\n" +
"");}
;
data
@init {System.out.println("");}
@after {System.out.println("");}
: class_+
;
class_
@init {System.out.println("
@after {System.out.println("
: class_data_items
;
class_data_items
@init {System.out.println("<class_data_items>");}
@after {System.out.println("</class_data_items>");}
: class_data_item
;
class_data_item
@init {System.out.println("<class_data_item>");}
@after {System.out.println("</class_data_item>");}
: class_data_item_header static_fields //instance_methods
direct_methods // virtual_methods
encoded_arrays
;
- class_data_item_header
- address HEX_DOUBLE 'static fields size:' id1=DIGIT
address HEX_DOUBLE 'instance fields size:' id2=DIGIT
address HEX_DOUBLE 'direct methods size:' id3=DIGIT
address HEX_DOUBLE 'virtual methods size:' id4=DIGIT
{System.out.println("<static_field_size>" +
$id1.getText()
- "</static_field_size>\n"
+"<instance_field_size>" + $id2.getText() +
"</instance_field_size>\n"
+"<direct_methods_size>" + $id3.getText() +
"</direct_methods_size>\n"
+"<virtual_methods_size>" + $id4.getText() +
"</virtual_methods_size>");}
;
static_fields
@init {System.out.println("<static_fields>");}
@after {System.out.println("</static_fields>");}
: static_field+
;
static_field
@init {System.out.println("<static_field>");}
@after {System.out.println("</static_field>");}
: address id1=HEX_DOUBLE id2=HEX_DOUBLE
{System.out.println("<field_id>" + $id1.getText() +
"</field_id>\n"
+"<access_flags>" + $id2.getText() +
"</access_flags>");}
;
direct_methods
@init {System.out.println("<direct_methods>");}
@after {System.out.println("</direct_methods>");}
: direct_method+
;
direct_method
@init {System.out.println("<direct_method>");}
@after {System.out.println("</direct_method>");}
: address id1=HEX_DOUBLE id2=HEX_DOUBLE
id3=HEX_DOUBLE id4=HEX_DOUBLE id5=HEX_DOUBLE id6=HEX_DOUBLE
{System.out.println("<method_id>" + $id1.getText() +
"</method_id>\n"
+"<access_flags>" + $id2.getText() + $id3.getText()
- $id4.getText()
- "</access_flags>\n"
+"0x" + $id5.getText() + $id6.getText() +
"");}
| address id1=HEX_DOUBLE id2=HEX_DOUBLE id3=HEX_DOUBLE
id4=HEX_DOUBLE
{System.out.println("<method_id>" + $id1.getText() +
"</method_id>\n"
+"<access_flags>" + $id2.getText() +
"</access_flags>\n"
+"0x" + \(id3.getText() + \)id4.getText() + "");}
;
- encoded_arrays
- address HEX_DOUBLE 'array item count:' DIGIT
encoded_array+
; - encoded_array
- address HEX_DOUBLE HEX_DOUBLE IDENT IDENT array_digit ':'
'"' IDENT '"' - proto_type_string
- IDENT
| IDENT ';'
| IDENT '.' IDENT
| IDENT '/' IDENT
| IDENT '/' '<' IDENT '>'
| '<' IDENT '>' '()' IDENT
| IDENT '/' IDENT '/' IDENT ';'
| '[' IDENT '/' IDENT '/' IDENT ';'
| IDENT '()' IDENT '/' IDENT '/' IDENT ';'
| IDENT '/' IDENT '/' IDENT '.' IDENT
| IDENT '/' IDENT '/' IDENT '/' IDENT
| IDENT '/' IDENT '/' IDENT '/' '<' IDENT '>'
| IDENT '(' IDENT '/' IDENT '/' IDENT ';' ')' IDENT
| IDENT '(' '[' IDENT '/' IDENT '/' IDENT ';' ')' IDENT
| IDENT '(' IDENT ')' IDENT '/' IDENT '/' IDENT ';'
| IDENT '(' IDENT '/' IDENT '/' IDENT ';' ')' IDENT '/'
IDENT '/' IDENT ';'
hex_address: '0x' eight_hex
;
- address
- eight_hex ':'
; - eight_hex
- HEX_DOUBLE HEX_DOUBLE HEX_DOUBLE HEX_DOUBLE
; - array_digit returns [String result]
- id=ELEMENT
{String str = id.getText(); $result =
str.substring(1, str.length()-1);}
;
HEX_DOUBLE:
('0'..'9')('0'..'9')|('0'..'9')('A'..'F')|('A'..'F')('0'..'9')|('A
'..'F')('A'..'F');
MAGIC_NUM: 'dex\n035\0';
IDENT: ('a'..'z'|'A'..'Z')+;
DIGIT: ('0'..'9');
ELEMENT: ('[')('0'..'9')+(']');
CONST_4: 'const/4';
CONST_16: 'const/16';
CONST_HIGH_16: 'const/high16';
COMMENT: '//' ~( '\r' | '\n' )* {\(channel = HIDDEN;};
*WS*: (' ' | '\t' | '\n' | '\r' | '\f' | ',' | '-' | '*')+ {\)channel = HIDDEN;};`
清单 6-12 显示了来自清单 6-11 的语法的 XML 输出。这并不包括所有的 XML 节点,因为我们已经在第三章中介绍过了,而且篇幅很长..解析所有classes.dex
文件而不仅仅是Casting.java
的更大更完整的 DexToXML 可以在 Apress 网站([www.apress.com](http://www.apress.com)
)的源代码中找到。
清单 6-12。 DexToXML 输出
`
地塞米松资源
为了实现 Android 反编译器 DexToSource,这一节看三个例子,说明代码是如何编译到classes.dex
文件中的;然后将它逆向工程回 Java,并编写 ANTLR 解析器来自动化这个过程。这三个例子是来自[第二章和第三章的Casting.java
代码;Hello World 安卓;以及来自 WordPress Android 应用(一个开源 Android 应用)的if
声明,可在[
android.svn.wordpress.org/trunk/src/org/wordpress/android/](http://android.svn.wordpress.org/trunk/src/org/wordpress/android/)
获得。
对每个例子的分析都从原始字节码开始,然后分解并解析成类似于原始 Java 源代码的东西。在分离字节码时,有两个资源非常有用:Google 在[www.netmite.com/android/mydroid/dalvik/docs/dalvik-bytecode.html](http://www.netmite.com/android/mydroid/dalvik/docs/dalvik-bytecode.html)
为 Dalvik 虚拟机(DVM)提供的字节码;Gabor Paller 在他的博客中发表了精彩的“Dalvik 操作码”论文,你可以在[
pallergabor.uw.hu/androidblog/dalvik_opcodes.html](http://pallergabor.uw.hu/androidblog/dalvik_opcodes.html)
找到。
例 1:Casting.java
每个例子都从原始 Java 代码开始,然后是您想要从classes.dex
逆向工程的字节码、解析器,最后是逆向工程的 Java 源代码。关于Casting.java
代码,参见清单 6-13 。
清单 6-13。??Casting.java
`public class Casting {
static final String ascStr = "ascii ";
static final String chrStr = " character ";
public static void main(String args[]){
for(char c=0; c < 128; c++) {
System.out.println(ascStr + (int)c + chrStr + c);
}
}
}`
将Casting.java
编译成classes.dex
并通过 dedexer 运行它会产生如清单 6-14 所示的字节码。
清单 6-14。Casting.ddx
`.class public Casting
.super java/lang/Object
.source Casting.java
.field static final ascStr Ljava/lang/String; = "ascii "
.field static final chrStr Ljava/lang/String; = " character "
.method public
.limit registers 1
; this: v0 (LCasting;)
.line 1
invoke-direct {v0},java/lang/Object/
return-void
.end method
.method public static main([Ljava/lang/String;)V
.limit registers 5
; parameter[0] : v4 (Ljava/lang/String;)
.line 8
const/4 v0,0
l1fe:
const/16 v1,128
if-ge v0,v1,l252
.line 9
sget-object v1,java/lang/System.out
Ljava/io/PrintStream;
new-instance v2,java/lang/StringBuilder
invoke-direct {v2},java/lang/StringBuilder/
const-string v3,"ascii "
invoke-virtual
{v2,v3},java/lang/StringBuilder/append ;
append(Ljava/lang/String;)Ljava/lang/StringBuilder;
move-result-object v2
invoke-virtual
{v2,v0},java/lang/StringBuilder/append ;
append(I)Ljava/lang/StringBuilder;
move-result-object v2
const-string v3," character "
invoke-virtual
{v2,v3},java/lang/StringBuilder/append ;
append(Ljava/lang/String;)Ljava/lang/StringBuilder;
move-result-object v2
invoke-virtual
{v2,v0},java/lang/StringBuilder/append ;
append(C)Ljava/lang/StringBuilder;
move-result-object v2` `invoke-virtual
{v2},java/lang/StringBuilder/toString ;
toString()Ljava/lang/String;
move-result-object v2
invoke-virtual {v1,v2},java/io/PrintStream/println
; println(Ljava/lang/String;)V
.line 8
add-int/lit8 v0,v0,1
int-to-char v0,v0
goto l1fe
l252:
.line 11
return-void
.end method`
字节码分析
在开始编写解析器之前,您需要理解字节码。[表 6-1 显示了原始字节码以及相应的操作码和操作数,以及程序计数器(PC)、v0、v1、v2 和 v3 DVM 寄存器中的运行计数。
解析器
Java 代码的大部分外壳,比如类名、字符串名、方法名、字段名等等,见Casting.ddx
文件。6-14 中的清单可以使用清单 6-15 中的解析器进行转换。输出如清单 6-16 中的所示。
清单 6-15。 Casting.java
没有字节码解析器
`grammar DexToSource;
options {language = Java;}
@header {package com.riis.decompiler;}
@lexer::header {package com.riis.decompiler;}
@members{String flag_result = "";}
rule
@after {System.out.println("}");}
: class_name super_ source fields methods+
;
- class_name
- CLASS f1=flags id2=IDENT
{System.out.println($f1.text + " class " + $id2.text
- " {");}
super_: SUPER package_;
source: SOURCE IDENT '.java';
fields: field+ ;
methods: method_start method_end;
field: FIELD f1=flags id2=IDENT p1=package_ ';' '=' '"' id4=IDENT
'"'
{System.out.println($f1.text + " " + \(p1.result + " " +
\)id2.text + " = "" + $id4.text + """ );}
;
method_start: METHODSTRT f1=flags INIT p1=params r1=return_
{System.out.println($f1.text + " " + $r1.result + " init "
- \(p1.result + " {");}
| *METHODSTRT* f1=flags id1=*IDENT* p1=params r1=return_
{System.out.println(\)f1.text + " " + \(r1.result + " " +
\)id1.text + " (" + $p1.result + ") {");}
;
method_end
@after {System.out.println("}");}
: METHODEND
;
flags: flag+;
- flag returns [String flag_result]
- f1='public' {flag_result += $f1.text;}
| f1='static' {flag_result += $f1.text;}
| f1='final' {flag_result += $f1.text;}
; - params returns [String result]
- '(' ')' {\(result = "()";}
| '(' '[L' id1=package_ ';' ')' {\)result = $id1.result + "
args[]";} //([Ljava/lang/String;)
; - package_ returns [String result]
- IDENT '/' IDENT '/' id1=IDENT {$result = id1.getText();}
; - return_ returns [String result]
- 'V' {$result = "void";}
CLASS: '.class';
PUBLIC: 'public';
STATIC: 'static';
FINAL: 'final';
SUPER: '.super';
SOURCE: '.source';
FIELD: '.field';
METHODSTRT: '.method';
METHODEND: '.end method';
INIT: '
IDENT: ('a'..'z'|'A'..'Z')+;
COMMENT: '//' ~( '\r' | '\n' )* {\(channel = HIDDEN;};
*WS*: (' ' | '\t' | '\n' | '\r' | '\f' | ',')+ {\)channel = HIDDEN;};`
在解析任何字节码之前,文件的结构如清单 6-16 所示。
清单 6-16。 Casting.java
不带 Pytecode
public class Casting { static final String ascStr = "ascii" static final String chrStr = "character" public void init () { } public static void main (String args[]) { } }
但是 Java 代码的核心逻辑在 DDX 文件末尾的操作码中。看表 6-1 ,操作码如何映射到目标 Java 代码Casting.java
应该更清楚了;参见清单 6-13 ,这是你在整本书中一直使用的。
方法代码有两部分:for
循环和for
循环中的System.out.println
语句。从解析器的角度来看,您可以创建for
循环,如清单 6-17 所示。注意,保留的关键字如return
有一个下划线,所以生成的 ANTLR 代码编译时没有任何错误。
清单 6-17。 for
循环解析器
`rule: class_name super_ source fields methods+ ;
class_name : CLASS flags IDENT ;
super_: SUPER package_;
source: SOURCE IDENT '.java';
fields: field+ ;
field: FIELD flags IDENT package_ ';' '=' '"' IDENT '"';
methods: method_start scrap* method_end
| method_start scrap* for_start for_body scrap* for_end
method_end
;
method_start: METHODSTRT flags INIT params return_
| METHODSTRT flags IDENT params return_
;
method_end: METHODEND;
for_start : put_in_reg label put_in_reg if_ge scrap*;
const_string: CONST_STRING reg ddx_string;
ddx_string: '"' IDENT '"';
for_end : add_int int_to_char goto_ label scrap*;
new_instance: NEW_INSTANCE reg package_ scrap*;
add_int: ADD_INT reg reg DIGIT;
int_to_char: INT_TO_CHAR reg reg;
goto_: GOTO label;
if_ge: IF_GE reg reg label;
put_in_reg: const_ reg DIGIT;
reg_args: '{' reg+ '}';
label: LABEL
| LABEL ':'
;
invoke_direct: INVOKE_DIRECT regs package_ ;
flags: flag+;
flag : f1='public'
| f1='static'
| f1='final'
;
params : '(' ')'
| '(' '[L' package_ ';' ')'
| '(' IDENT ';' ')'
| IDENT '(' package_ ';' ')'
| IDENT '(' IDENT ')'
| IDENT '(' ')'
;
- package_
- IDENT '/' IDENT '/' IDENT
| IDENT '/' IDENT '/' IDENT '/' IDENT
| IDENT '/' IDENT '/' IDENT '.' IDENT
| IDENT '/' IDENT '/' IDENT '/' ''
| 'L' IDENT '/' IDENT '/' IDENT
;
return_ : 'V';
regs: '{' reg+ '}';
reg : 'v' DIGIT;
const_ : CONST_4
| CONST_16
| CONST_HIGH_16
;
scrap: LIMIT REGISTERS DIGIT
| ';' 'this:' reg params
| LINE DIGIT+
| invoke_direct ';' '
| RETURN_VOID
| ';' 'parameter[' DIGIT ']' ':' reg params
;
CLASS: '.class';
PUBLIC: 'public';
STATIC: 'static';
FINAL: 'final';
SUPER: '.super';
SOURCE: '.source';
FIELD: '.field';
METHODSTRT: '.method';
METHODEND: '.end method';
INIT: '
LIMIT: '.limit';
REGISTERS: 'registers';
LINE: '.line';
INVOKE_DIRECT: 'invoke-direct';
RETURN_VOID: 'return-void';
IF_GE: 'if-ge';
ADD_INT: 'add-int/lit8';
INT_TO_CHAR: 'int-to-char';
GOTO: 'goto';
CONST_STRING: 'const-string';
CONST_4: 'const/4';
CONST_16: 'const/16';
CONST_HIGH_16: 'const/high16';
DIGIT: ('0'..'9')+;
IDENT: ('a'..'z'|'A'..'Z')+;
LABEL: 'l' ('0'..'9'|'a'..'f')('0'..'9'|'a'..'f')('0'..'9'|'a'..'f');
COMMENT: '//' ~( '\r' | '\n' )* {\(channel = HIDDEN;};
WS: (' ' | '\t' | '\n' | '\r' | '\f' | ',')+ {\)channel = HIDDEN;};`
lexer 标记是大写的,解析器规则是小写的。for_start
是一个大于或等于条件,后跟一个标签,如果条件为真,则跳转到该标签。正如您在细分表中看到的,for_end
规则给变量c
加 1,然后跳回到for_start
条件。注意,这不是通用的:它不适用于任何其他 for 循环。我展示它是为了让您了解如何组装解析器。
接下来,您需要为System.out.println
或for_body
语句添加解析器代码,将它们放在for_loop
规则的for_start
和for_end
部分之间;参见清单 6-18 。
清单 6-18。 Casting.java
解析器
`for_body: sget stmt_builder invoke_virtual;
stmt_builder returns : new_instance invoke_move+;
- invoke_move
- invoke_virtual move_result
| const_string invoke_virtual move_result
;
const_string: CONST_STRING reg ddx_string;
ddx_string: '"' IDENT '"';
new_instance: NEW_INSTANCE reg package_ scrap*;
sget : SGET_OBJECT reg package_ package_ ';';
- invoke_virtual
- INVOKE_VIRTUAL reg_args package_ ';' params 'V'
| INVOKE_VIRTUAL reg_args package_ ';' params package_ ';'
;`
现在可以解析操作码了,您可以添加自己的println
语句来输出 Java 代码;参见清单 6-19 。尽管这个清单很长,但它是所提供的最完整的解析器之一,因此完整地回顾它是很重要的。
清单 6-19。 Casting.ddx
解析器
`grammar DexToSource;
options {language = Java;}
@header {package com.riis.decompiler;}
@lexer::header {package com.riis.decompiler;}
@members{String flag_result = "";}
rule
@after {System.out.println("}");}
: class_name super_ source fields methods+
;
- class_name
- CLASS f1=flags id2=IDENT
{System.out.println($f1.text + " class " + $id2.text
- " {");}
;
super_: SUPER package_;
source: SOURCE IDENT '.java';
fields: field+ ;
field: FIELD f1=flags id2=IDENT p1=package_ ';' '=' '"' id4=IDENT
'"'
{System.out.println($f1.text + " " + \(p1.result + " " +
\)id2.text + " = "" + $id4.text + """ );}
;
methods: method_start scrap* method_end
| method_start scrap* for_start for_body scrap* for_end
method_end
;
method_start: METHODSTRT f1=flags INIT p1=params r1=return_
{System.out.println($f1.text + " " + $r1.result + " init "
- \(p1.result + " {");}
| *METHODSTRT* f1=flags id1=*IDENT* p1=params r1=return_
{System.out.println(\)f1.text + " " + \(r1.result + " " +
\)id1.text + " (" + $p1.result + ") {");}
;
method_end
@after {System.out.println("}");}
: METHODEND
for_start : id1=put_in_reg label id2=put_in_reg if_ge scrap*
{System.out.println("for(a=" + $id1.result + "; a < "
- $id2.result + "; a++){");}
;
for_body: id1=sget id3=stmt_builder id2=invoke_virtual
{System.out.println($id1.result + "." + $id2.result +
"(" + $id3.result);}
;
- stmt_builder returns [String result]
- new_instance id1=invoke_move id2=invoke_move
id3=invoke_move
id4=invoke_move id5=invoke_move
{$result = """ + $id1.result + "" + " + \(id2.result + " + \"" + \)id3.result + "" +" + $id4.result + ")";}
; - invoke_move returns [String result]
- id1=invoke_virtual move_result
{$result = \(id1.result;} | id1=const_string invoke_virtual move_result {\)result = $id1.result;}
;
move_result: MOVE_RESULT_OBJECT reg
;
const_string returns [String result]
: CONST_STRING reg id1=ddx_string {$result = $id1.result;}
;
- ddx_string returns [String result]
- '"' id1=IDENT '"' {$result = $id1.getText();}
;
for_end : add_int int_to_char goto_ label scrap*
{System.out.println("}");}
;
new_instance: NEW_INSTANCE reg package_ scrap*;
- sget returns [String result]
- SGET_OBJECT reg id1=package_ id2=package_ ';' {\(result =
\)id1.result;}
; - invoke_virtual returns [String result]
- INVOKE_VIRTUAL reg_args id1=package_ ';' params 'V'
{$result = \(id1.result;} | *INVOKE_VIRTUAL* reg_args package_ ';' id1=params package_ ';' {if (\)id1.result.compareTo("I") == 0) { \(result = "(int)a"; } else {\)result = "(char)a";}}
;
add_int: ADD_INT reg reg DIGIT
;
int_to_char: INT_TO_CHAR reg reg
;
goto_: GOTO label
;
if_ge: IF_GE reg reg label
;
- put_in_reg returns [String result]
- const_ reg id1=DIGIT {$result = $id1.getText();}
;
reg_args: '{' reg+ '}'
;
label: LABEL
| LABEL ':'
;
invoke_direct: INVOKE_DIRECT regs package_
;
flags: flag+;
- flag returns [String flag_result]
- f1='public' {flag_result += $f1.text;}
| f1='static' {flag_result += $f1.text;}
| f1='final' {flag_result += $f1.text;}
; - params returns [String result]
- '(' ')' {\(result = "()";} | '(' '[L' id1=package_ ';' ')' {\)result = \(id1.result + " args[]";} | '(' id2=*IDENT* ';' ')' {\)result = $id2.getText();}
| IDENT '(' id3=package_ ';' ')' {\(result=\)id3.result;}
| IDENT '(' id4=IDENT ')' {$result = $id4.getText();}
| IDENT '(' ')' {$result = "()";}
;
- package_ returns [String result]
- IDENT '/' IDENT '/' id1=IDENT {\(result =
id1.getText();}
| *IDENT* '/' *IDENT* '/' *IDENT* '/' id1=*IDENT* {\)result =
id1.getText();}
| IDENT '/' IDENT '/' id1=IDENT '.' id2=IDENT{\(result = id1.getText() + "." + id2.getText();} | *IDENT* '/' *IDENT* '/' *IDENT* '/' '<init>' {\)result =
"init";}
| 'L' IDENT '/' IDENT '/' id1=IDENT {\(result = \)id1.getText();}
; - return_ returns [String result]
- 'V' {$result = "void";}
;
regs: '{' reg+ '}';
reg : 'v' DIGIT;
const_ : CONST_4
| CONST_16
| CONST_HIGH_16
;
scrap: LIMIT REGISTERS DIGIT
| ';' 'this:' reg params
| LINE DIGIT+
| invoke_direct ';' '
| RETURN_VOID
| ';' 'parameter[' DIGIT ']' ':' reg params
;
CLASS: '.class';
PUBLIC: 'public';
STATIC: 'static';
FINAL: 'final';
SUPER: '.super';
SOURCE: '.source';
FIELD: '.field';
METHODSTRT: '.method';
METHODEND: '.end method';
INIT: '
LIMIT: '.limit';
REGISTERS: 'registers';
LINE: '.line';
INVOKE_DIRECT: 'invoke-direct';
INVOKE_VIRTUAL: 'invoke-virtual';
MOVE_RESULT_OBJECT: 'move-result-object';
NEW_INSTANCE: 'new-instance';
RETURN_VOID: 'return-void';
IF_GE: 'if-ge';
SGET_OBJECT: 'sget-object';
ADD_INT: 'add-int/lit8';
INT_TO_CHAR: 'int-to-char';
GOTO: 'goto';
CONST_STRING: 'const-string';
CONST_4: 'const/4';
CONST_16: 'const/16';
CONST_HIGH_16: 'const/high16';
DIGIT: ('0'..'9')+;
IDENT: ('a'..'z'|'A'..'Z')+;
LABEL: 'l' ('0'..'9'|'a'..'f')('0'..'9'|'a'..'f')('0'..'9'|'a'..'f');
COMMENT: '//' ~( '\r' | '\n' )* {\(channel = HIDDEN;};
*WS*: (' ' | '\t' | '\n' | '\r' | '\f' | ',')+ {\)channel = HIDDEN;};`
Java
生成的 Java 代码如清单 6-20 所示。注意,dedexer 对操作码做了一些细微的修改,因此您丢失了print
语句中的变量。Java 代码也需要一些标签来提高可读性,但是您应该看到classes.dex
已经被转换回 Java。
清单 6-20。 生成Casting.java
public class Casting { static final String ascStr = "ascii" static final String chrStr = "character" public void init () { } public static void main (String args[]) { for(a=0; a < 128; a++){ System.out.println("ascii" + (int)a + "character" +(char)a) } } }
例 2: Hello World
Android SDK 附带了一个简单的 Hello World 应用,如图 6-2 所示。下一个例子获取代码并对其进行逆向工程。
图 6-2。你好安卓屏幕
原始的 Java 代码如清单 6-21 所示。
清单 6-21。??Hello.java
`package org.example.Hello;
import android.app.Activity;
import android.os.Bundle;
public class Hello extends Activity {
/** Called when the activity is first created. /
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main*);
}
}`
相应的 DDX 文件如列表 6-22 所示。
清单 6-22。??HelloWorld.ddx
.class public org/example/Hello/Hello .super android/app/Activity .source Hello.java
`.method public
.limit registers 1
; this: v0 (Lorg/example/Hello/Hello;)
.line 6
invoke-direct {v0},android/app/Activity/
return-void
.end method
.method public onCreate(Landroid/os/Bundle;)V
.limit registers 3
; this: v1 (Lorg/example/Hello/Hello;)
; parameter[0] : v2 (Landroid/os/Bundle;)
.line 10
invoke-super {v1,v2},android/app/Activity/onCreate ;
onCreate(Landroid/os/Bundle;)V
.line 11
const/high16 v0,32515
invoke-virtual
{v1,v0},org/example/Hello/Hello/setContentView ;
setContentView(I)V
.line 12
return-void
.end method`
字节码分析
表 6-2 解释了来自清单 6-22 的每个字节码段的含义,并给出了 v0、v1 和 v2 DVM 寄存器中值的运行计数。
解析器
为了解析HelloWorld
,您需要添加对invoke-super
和new const16/high
关键字以及contentView
结构的支持。解析器如清单 6-23 所示。
清单 6-23。 Hello World 和 Casting Parser
rule : for_loop return_
`| super_stmt return_
;
super_stmt : invoke_super invoke_virtual_content
;
for_loop : put_in_reg+ for_start println for_end
;
for_start: 'if-ge' reg reg HEX_DIGIT+
;
for_end: add_int int_to_char goto_
;
put_in_reg : const_ reg HEX_DIGIT+
;
reg : 'v' HEX_DIGIT
;
const_ : CONST_4
| CONST_16
| CONST_HIGH_16
;
add_int : ADD_INT reg reg HEX_DIGIT
;
int_to_char: 'int-to-char' reg reg
;
goto_: 'goto' HEX_DIGIT+
;
return_: 'return-void'
;
println: sget new_instance invoke_direct const_string
invoke_virtual_move+
;
sget: SGET reg obj
;
new_instance: NEW_INSTANCE reg obj
;
invoke_direct: INVOKE_DIRECT obj param
;
invoke_super: INVOKE_SUPER param
;
invoke_virtual_move: invoke_virtual
| invoke_virtual move_result_object
| invoke_virtual move_result_object const_string
;
invoke_virtual_content: content_view invoke_virtual
;
content_view: const_ reg HEX_DIGIT+
;
invoke_virtual: INVOKE_VIRTUAL obj param
| INVOKE_VIRTUAL param
;
move_result_object: MOVE_RESULT_OBJECT reg
;
const_string: CONST_STRING reg obj
;
obj : IDENT '[' HEX_DIGIT+ ']'
;
param : '{' reg '}'
| '{' reg reg '}'
;
INVOKE_DIRECT: 'invoke-direct';
INVOKE_SUPER: 'invoke-super';
INVOKE_VIRTUAL: 'invoke-virtual';
NEW_INSTANCE: 'new-instance';
MOVE_RESULT_OBJECT: 'move-result-object';
SGET: 'sget-object';
CONST_STRING: 'const-string';
HEX_DIGIT: ('0'..'9'|'A'..'F'|'a'..'f');
IDENT: ('a'..'z')+;
ADD_INT: 'add-int/lit8';
CONST_4: 'const/4';
CONST_16: 'const/16';
CONST_HIGH_16: 'const/high16';`
Java
生成的 Java 代码如清单 6-24 所示。classes.dex
告诉你当方法第一次被调用时savedInstanceState
在 v2 中,并且setContentView
正在调用R.layout.Main
的数值。
清单 6-24。 生成HelloWorld.java
super.onCreate(savedInstanceState); setContentView(32515);
例 3: if 语句
为了完成这些例子,你需要一个if
语句。WordPress 的开源 Android 应用是一个很好的资源,因为它是一个专业的应用,可以让你访问源代码。escapeHTML.java
中的清单 6-25 有一个简单的if
条件。
清单 6-25。 escapeHTML
法
public static void escapeHtml(Writer writer, String string) throws IOException { if (writer == null ) { throw new IllegalArgumentException ("The Writer must not be null."); } if (string == null) { return; } Entities.HTML40_escape.escape(writer, string); }
来自 dex 文件的字节码如清单 6-26 中的所示。
清单 6-26。??escapeHTML.ddx
.method public static escapeHtml(Ljava/io/Writer;Ljava/lang/String;)V .throws Ljava/io/IOException; .limit registers 4 ; parameter[0] : v2 (Ljava/io/Writer;) ; parameter[1] : v3 (Ljava/lang/String;) .line 27 if-nez v2,l7ba4c .line 28 new-instance v0,java/lang/IllegalArgumentException const-string v1,"The Writer must not be null." invoke-direct {v0,v1},java/lang/IllegalArgumentException/<init> ; <init>(Ljava/lang/String;)V throw v0 l7ba4c: .line 30
if-nez v3,l7ba52 l7ba50: .line 34 return-void l7ba52: .line 33 sget-object v0,org/wordpress/android/util/Entities.HTML40_escape Lorg/wordpress/android/util/Entities; invoke-virtual {v0,v2,v3},org/wordpress/android/util/Entities/escape; escape(Ljava/io/Writer;Ljava/lang/String;)V goto l7ba50 .end method
字节码分析
表 6-3 解释了清单 6-23 中每个字节码段的含义,以及 v0、v1、v2 和 v3 DVM 寄存器中值的运行计数。字节码逆向工程中唯一真正的难题是最后一条指令:28fa
。28
操作码翻译成带有操作数fa
的goto
。操作数以二进制补码格式存储(更多信息见[
en.wikipedia.org/wiki/Twos_complement](http://en.wikipedia.org/wiki/Twos_complement)
)。要获得地址,您需要将每个十六进制数字转换为二进制,翻转这些位,然后加 1。在这个例子中,fa
= 11111010,当比特翻转时,它变成 00000101。如果加上 1,数字就是 00000110 或十进制 6。回溯六个单词,你就有了你的地址:000c。
解析器
为了解析if not equal then
分支和goto
语句,您需要将它们添加到解析器中。你还需要给invoke-virtual
语句添加更多的参数选项;参见清单 6-27 。
清单 6-27。 Hello World、Casting 和 If 解析器
`rule : for_loop return_
| super_stmt return_
| if_stmt+
;
if_stmt: if_ new_instance const_string invoke_direct throw_
| if_ return_ goto_stmt
;
goto_stmt: sget invoke_virtual goto_
;
if_ : IF_NEZ reg HEX_DIGIT+
;
throw_ : THROW reg
;
super_stmt : invoke_super invoke_virtual_content
;
for_loop : put_in_reg+ for_start println for_end
;
for_start: 'if-ge' reg reg HEX_DIGIT+
;
for_end: add_int int_to_char goto_
;
put_in_reg : const_ reg HEX_DIGIT+
;
reg : 'v' HEX_DIGIT
;
const_ : CONST_4
| CONST_16
| CONST_HIGH_16
;
add_int : ADD_INT reg reg HEX_DIGIT
;
int_to_char: 'int-to-char' reg reg
;
goto_: 'goto' HEX_DIGIT+
;
return_: 'return-void'
;
println: sget new_instance invoke_direct const_string
invoke_virtual_move+
;
sget: SGET reg obj
;
new_instance: NEW_INSTANCE reg obj
;
invoke_direct: INVOKE_DIRECT obj param
| INVOKE_DIRECT param
;
invoke_super: INVOKE_SUPER param
;
invoke_virtual_move: invoke_virtual
| invoke_virtual move_result_object
| invoke_virtual move_result_object const_string
;
invoke_virtual_content: content_view invoke_virtual
;
content_view: const_ reg HEX_DIGIT+
;
invoke_virtual: INVOKE_VIRTUAL obj param
| INVOKE_VIRTUAL param
| INVOKE_VIRTUAL param obj
;
move_result_object: MOVE_RESULT_OBJECT reg
;
const_string: CONST_STRING reg obj
;
obj : IDENT '[' HEX_DIGIT+ ']'
;
param : '{' reg '}'
| '{' reg reg '}'
| '{' reg reg reg '}'
;
CONST_STRING: 'const-string';
IF_NEZ: 'if-nez';
INVOKE_DIRECT: 'invoke-direct';
INVOKE_SUPER: 'invoke-super';
INVOKE_VIRTUAL: 'invoke-virtual';
NEW_INSTANCE: 'new-instance';
MOVE_RESULT_OBJECT: 'move-result-object';
SGET: 'sget-object';
THROW: 'throw';
HEX_DIGIT: ('0'..'9'|'A'..'F'|'a'..'f');
IDENT: ('a'..'z')+;
ADD_INT: 'add-int/lit8';
CONST_4: 'const/4';
CONST_16: 'const/16';
CONST_HIGH_16: 'const/high16';
WS: (' ' | '\t' | '\n' | '\r' | '\f' | ',')+ {$channel = HIDDEN;};`
Java
生成的 Java 代码如清单 6-28 所示。
清单 6-28。 生成escapeHTML.java
if (writer == null ) { throw new IllegalArgumentException ("The Writer must not be null."); } if (string == null) { return; } Entities.HTML40_escape.escape(writer, string);
重构
当您构建解析器时,很快就可以清楚地看到,您需要将指令放入不同的系列中。否则,解析器可能会变得不可管理,您也将不得不对结构进行硬编码以匹配输入文件。没有一些重构,你永远不会有一个通用的解决方案来逆向工程 Android APKs。Gabor Paller 对指令进行了拆分,如表 6-4 所示。
重构后的解析器如清单 6-29 所示。现在您已经有了示例中的一个小型测试代码套件,您可以用它来测试是否有任何更改破坏了解析器。
清单 6-29。重构的解析器
`rule : for_loop return_
| stmt return_
| stmt+
;
for_loop : put_in_reg+ for_start stmt for_end
;
stmt : if_stmt
| super_stmt
| println
;
if_stmt: if_ new_instance const_string invoke throw_
| if_ return_ goto_stmt
;
println: sget new_instance invoke const_string invoke_move+
;
super_stmt : invoke invoke_content
;
goto_stmt: sget invoke goto_
;
for_start: 'if-ge' reg reg HEX_DIGIT+
;
for_end: add_int int_to_char goto_
;
put_in_reg : const_ reg HEX_DIGIT+
;
add_int : ADD_INT reg reg HEX_DIGIT
;
int_to_char: 'int-to-char' reg reg
;
invoke_move: invoke
| invoke move_result_object
| invoke move_result_object const_string
;
invoke_content: content_view invoke
;
invoke : invoke_virtual
| invoke_direct
| invoke_super
;
invoke_virtual: INVOKE_VIRTUAL obj param
| INVOKE_VIRTUAL param
| INVOKE_VIRTUAL param obj
;
invoke_direct: INVOKE_DIRECT obj param
| INVOKE_DIRECT param
;
invoke_super: INVOKE_SUPER param
;
content_view: const_ reg HEX_DIGIT+
;
sget: SGET reg obj
;
new_instance: NEW_INSTANCE reg obj
;
if_ : IF_NEZ reg HEX_DIGIT+
;
reg : 'v' HEX_DIGIT
;
const_ : CONST_4
| CONST_16
| CONST_HIGH_16
;
move_result_object: MOVE_RESULT_OBJECT reg
;
const_string: CONST_STRING reg obj
;
obj : IDENT '[' HEX_DIGIT+ ']'
;
//helper functions
param : '{' reg+ '}'
;
goto_: 'goto' HEX_DIGIT+
;
throw_ : THROW reg
;
return_: 'return-void'
;
CONST_STRING: 'const-string';
IF_NEZ: 'if-nez';
INVOKE_DIRECT: 'invoke-direct';
INVOKE_SUPER: 'invoke-super';
INVOKE_VIRTUAL: 'invoke-virtual';
NEW_INSTANCE: 'new-instance';
MOVE_RESULT_OBJECT: 'move-result-object';
SGET: 'sget-object';
THROW: 'throw';
HEX_DIGIT: ('0'..'9'|'A'..'F'|'a'..'f');
IDENT: ('a'..'z')+;
ADD_INT: 'add-int/lit8';
CONST_4: 'const/4';
CONST_16: 'const/16';
CONST_HIGH_16: 'const/high16';
WS: (' ' | '\t' | '\n' | '\r' | '\f' | ',')+ {$channel = HIDDEN;};`
目前,解析器只处理 3 个简单的程序结构,没有涵盖所有 Dalvik 字节码。如果包括在内的话,这一章会比书的其余部分更长。但是对于那些想了解更多的人来说,完整的反编译器可以在 Apress 网站([www.apress.com](http://www.apress.com)
)上找到,还有一个更大的测试套件和如何运行它的说明。
总结
在本章中,您已经使用 dedexer 输出创建了 DexToXML 和 DexToSource,这两个输出都可以在 Apress 网站上找到。这些可以用来将classes.dex
文件分别分解成 XML 和 Java 源代码。对于更复杂的测试套件示例,网站上的 DexToSource 代码使用 AST 和StringTemplate
s。
下一章以支持和反对混淆的案例研究以及使用开源或商业混淆器混淆代码的最佳实践来结束本书。
七、非礼勿听、非礼勿视:案例研究
你现在几乎已经到了旅程的终点。到目前为止,您应该对如何反编译以及如何尝试保护代码的总体原则有了很好的理解。话虽如此,我从与客户和同事的合作中发现,即使你理解了反编译和模糊处理的真正含义,它也不能帮助你弄清楚可以采取什么实际措施来保护你的代码。一知半解往往能创造出更多的问题而不是答案。
正如 Java 能力中心(JCC)在其 deCaf 网站 FAQ 上所说:
真的没有人能够反编译我的无咖啡因保护应用吗?不。脱咖啡因不会使反编译成为不可能。这使得真的没有人能够反编译我的无咖啡因保护的应用变得很困难。让反编译不可能是不可能的。
本书的目标是帮助提高门槛,让任何人都更难反编译你的代码。目前在 Android 世界里,似乎有一种“听不到邪恶,看不到邪恶”的反编译方法,但这迟早会改变。读完这本书后,你应该预先得到警告,更重要的是,在给定的具体情况下,你应该预先准备好保护代码的最佳实用方法。
本章通过一个案例研究来帮助解决这个难题。几乎每个试图保护自己代码的人都会使用某种混淆工具。案例研究更详细地研究了这种方法,以帮助您得出如何最好地保护代码的结论。它具有以下格式:
- 问题描述
- 神话
- 建议的解决方案:ProGuard 和 DashO
混淆案例研究
对于许多人来说,担心有人反编译他们的 Android 应用远不是他们最担心的事情。它的排名远远低于安装最新版本的 Maven 或 Ant。当然,他们想防止反编译,但是没人有时间——而且 ProGuard 不处理这个问题吗?
在这种情况下有两个简单的选择:使用模糊处理来保护应用,或者忽略反编译,就好像这不是问题一样。当然,由于显而易见的原因,后者并不是一个值得推荐的选择。
神话
这些年来,我听到了许多不同的关于保护你的代码是否有意义的争论。今天最常见的一条是,如果你创建了一个好的 Android 应用,并继续改进它,这将保护你免受任何人反编译你的代码。人们普遍认为,如果你编写了好的应用,源代码会自我保护——升级和良好的支持是比使用模糊处理或本书中讨论的任何其他技术更好的保护代码的方式。
其他的争论是软件开发是关于你如何应用你的知识,而不是使用别人的应用。如今的原始代码可能来自一个描述良好的设计模式,所以没人在乎它是否被黑了。所有的开发人员(无论如何是好的)在事情完成后总能想到更好的方法,所以为什么要担心呢?很有可能,如果有人缺乏想象力,不得不求助于窃取您的代码,他们将无法在代码的基础上进行构建,并将其转化为有用的东西。而且你也不可能在自己的代码开发出来六个月后阅读它,那么其他人又如何理解它呢?
混乱的代码也很难调试。来自现场的错误报告需要追溯到正确的方法,以便开发人员可以调试和修复代码。如果处理不当,这可能会成为维护的噩梦,并使支持变得困难。
但问题肯定是有人破解了程序——这在 iOS 和 Android 上都可能发生。报纸上并不全是关于人们反编译一个产品并把它重新包装成他们自己的产品的报道;我们总是听到最新的微软漏洞,所以这不成问题。对我来说,这个论点对运行在 web 服务器上的代码有效,但对运行在 Android 设备上的代码无效。在第四章中,您看到了在 APK 中获取代码和资源是多么容易。如果它包含任何访问后端系统的线索,比如 API 密钥或数据库登录,或者如果您的应用有任何需要保护的客户信息,那么您有责任为您的客户采取基本步骤来保护您的代码。
如果使用正确,模糊处理会显著提高门槛,阻止大多数人恢复您的源代码。本章的案例研究使用了上一章中的开源 WordPress Android 应用作为一个很好的混淆示例应用。因为它是开源的,所以你有原始的源代码,你可以和混淆后的代码进行比较,看看混淆是否有效。案例研究考察了 ProGuard(Android SDK 附带的)和 DashO(一个商业混淆器)如何管理类文件。
从[
android.svn.wordpress.org/](http://android.svn.wordpress.org/)
下载 WordPress 源代码。案例研究使用了 2012 年 3 月 17 日的版本。
使用android update project
为您的环境更新项目:
android update project -t android-15 -p ./
解决方案 1:Prog guard
默认情况下,ProGuard 不打开。要为混淆启用 ProGuard,请编辑project.properties
文件并添加以下行:
proguard.config=proguard.cfg
我们将在本章后面更详细地介绍proguard.cfg
中的设置。只有生产或发布的 apk 会被混淆,所以确保在AndroidManifest.xml
文件中android:debuggable
标志被设置为false
。使用ant release
命令编译应用,假设 Ant 是您的构建工具。
SDK 输出
ProGuard 在 Java jar 文件被转换成classes.dex
文件之前对其进行模糊处理。如果你使用 Ant,原始文件和混淆文件可以在bin\proguard
文件夹中找到;如果你使用 Eclipse,可以在project
文件夹下找到\proguard
。
ProGuard 还输出以下文件:
dump.txt
seeds.txt
usage.txt
mapping.txt
有用的是一个类似于代码覆盖工具的混淆覆盖工具,向您显示有多少代码被混淆。但是这样的工具还不存在,所以这些文件是最接近你拥有的覆盖工具。
包含类文件中所有信息的输出,不像 Java 类文件反汇编器;这对你的目的没有多大帮助。seeds.txt
列出没有混淆的类和方法。理解为什么一些代码是模糊的而另一些代码不是非常重要;稍后在“复查你的工作”部分会有更多的介绍。但是你需要检查,例如,带有你的 API 键的方法不在seeds.txt
中,因为否则它们将不会受到任何保护。
ProGuard 不仅会混淆 jar 文件,还会通过删除任何日志文件、类或在原始代码中但从未被调用过的代码来缩小 jar 文件,等等。usage.txt
列出从原始 jar 中去除的所有不必要的信息。因为存储在 Android 设备上非常珍贵,这本身就是在代码上使用混淆器的一个很好的理由。但是要小心,它不会删除您可能想要保留的代码。
mapping.txt
可能是这个目录中最有用的文件,因为它将原始方法名映射到模糊的方法名。与大多数混淆器一样,ProGuard 大量重命名方法;如果您需要在字段中进行任何调试,那么mapping.txt
就有必要追溯到最初的方法。您将在下一节中使用它来看看模糊处理对 WordPress 应用有多有效。
清单 7-1 ,使用 ProGuard 4.4,显示了构建期间的Ant
输出;这对于查看 ProGuard 做了多少工作也很有用。如果obfuscate
部分是空白的,你可以确定 ProGuard 没有被正确地调用。如果您自己尝试这样做,不要担心数字是否略有不同:您可能使用的是更高版本的 ProGuard 和/或 WordPress 代码。
清单 7-1。 蚂蚁输出
-obfuscate: [mkdir] Created dir: G:\clients\apress\chap7\wordpress\bin\proguard [jar] Building jar: G:\clients\apress\chap7\wordpress\bin\proguard\original.jar [proguard] ProGuard, version 4.4 [proguard] ProGuard is released under the GNU General Public License. The authors of all [proguard] programs or plugins that link to it (com.android.ant, ...) therefore [proguard] must ensure that these programs carry the GNU General Public License as well. [proguard] Reading input... [proguard] Reading program jar [G:\clients\apress\chap7\wordpress\bin\proguard\original.jar] [proguard] Reading program jar [G:\clients\apress\chap7\wordpress\libs\CWAC-AdapterWrapper.jar] [proguard] Reading program jar [G:\clients\apress\chap7\wordpress\libs\CWAC-Bus.jar] [proguard] Reading program jar [G:\clients\apress\chap7\wordpress\libs\CWAC-Task.jar] [proguard] Reading program jar [G:\clients\apress\chap7\wordpress\libs\android-support-v4.jar] [proguard] Reading program jar [G:\clients\apress\chap7\wordpress\libs\httpmime-4.1.2.jar] [proguard] Reading program jar [G:\clients\apress\chap7\wordpress\libs\tagsoup-1.2.1.jar] [proguard] Reading library jar [C:\Program Files (x86)\Android\android-sdk\platforms\android-14\android.jar] [proguard] Initializing... [proguard] Note: the configuration refers to the unknown class 'com.android.vending.licensing.ILicensingService' [proguard] Note: there were 1 references to unknown classes. [proguard] You should check your configuration for typos. [proguard] Ignoring unused library classes... [proguard] Original number of library classes: 3133 [proguard] Final number of library classes: 888 [proguard] Printing kept classes, fields, and methods... [proguard] Shrinking... [proguard] Printing usage to [G:\clients\apress\chap7\wordpress\bin\proguard\usage.txt]... [proguard] Removing unused program classes and class elements... [proguard] Original number of program classes: 644
[proguard] Final number of program classes: 469 [proguard] Optimizing... [proguard] Number of finalized classes: 331 [proguard] Number of vertically merged classes: 0 (disabled) [proguard] Number of horizontally merged classes: 0 (disabled) [proguard] Number of removed write-only fields: 0 (disabled) [proguard] Number of privatized fields: 520 (disabled) [proguard] Number of inlined constant fields: 1196 (disabled) [proguard] Number of privatized methods: 163 [proguard] Number of staticized methods: 61 [proguard] Number of finalized methods: 1062 [proguard] Number of removed method parameters: 98 [proguard] Number of inlined constant parameters: 61 [proguard] Number of inlined constant return values: 15 [proguard] Number of inlined short method calls: 9 [proguard] Number of inlined unique method calls: 169 [proguard] Number of inlined tail recursion calls: 2 [proguard] Number of merged code blocks: 6 [proguard] Number of variable peephole optimizations: 1434 [proguard] Number of arithmetic peephole optimizations: 0 (disabled) [proguard] Number of cast peephole optimizations: 31 [proguard] Number of field peephole optimizations: 3 [proguard] Number of branch peephole optimizations: 416 [proguard] Number of simplified instructions: 196 [proguard] Number of removed instructions: 1074 [proguard] Number of removed local variables: 184 [proguard] Number of removed exception blocks: 8 [proguard] Number of optimized local variable frames: 493 [proguard] Shrinking... [proguard] Removing unused program classes and class elements... [proguard] Original number of program classes: 469 [proguard] Final number of program classes: 455
再次检查你的工作
为了了解 ProGuard 有多有效,让我们看看它对你在第六章中使用的EscapeUtils.java
方法做了什么。清单 7-2 显示了原始的 WordPress 源代码。
清单 7-2。 原文EscapeUtils.java
代号
package org.wordpress.android.util;
`import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
public class EscapeUtils
{
public static String escapeHtml(String str) {
if (str == null) {`
`return null;
}
try {
StringWriter writer = new StringWriter
((int)(str.length() * 1.5));
escapeHtml(writer, str);
return writer.toString();
} catch (IOException e) {
//assert false;
//should be impossible
e.printStackTrace();
return null;
}
}
public static void escapeHtml(Writer writer, String
string) throws IOException {
if (writer == null ) {
throw new IllegalArgumentException ("The Writer
must not be null.");
}
if (string == null) {
return;
}
Entities.HTML40_escape.escape(writer, string);
}
public static String unescapeHtml(String str) {
if (str == null) {
return null;
}
try {
StringWriter writer = new StringWriter
((int)(str.length() * 1.5));
unescapeHtml(writer, str);
return writer.toString();
} catch (IOException e) {
//assert false;
//should be impossible
e.printStackTrace();
return null;
}
}
public static void unescapeHtml(Writer writer, String string)
throws IOException {
if (writer == null ) {
throw new IllegalArgumentException ("The Writer must
not be null.");
}
if (string == null) {
return;
}
Entities.HTML40.unescape(writer, string);
}
}`
清单 7-3 显示了由 JD-GUI 反编译的非模糊代码。查看模糊处理效果的最佳方式是,在 jar 文件被转换成classes.dex
文件之前,首先查看来自 jar 文件的反编译代码。这消除了 dx 过程引入的任何意外混淆。你可以看到它和原始代码是一样的。唯一不同的是反编译版本中没有注释。
清单 7-3。EscapeUtils.java
`package org.wordpress.android.util;
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
public class EscapeUtils
{
public static String escapeHtml(String str)
{
if (str == null)
return null;
try
{
StringWriter writer = new StringWriter((int)(str.length() *
1.5D));
escapeHtml(writer, str);
return writer.toString();
}
catch (IOException e)
{
e.printStackTrace();
}return null;
}
public static void escapeHtml(Writer writer, String string)
throws IOException
{
if (writer == null) {
throw new IllegalArgumentException("The Writer must not be
null.");
}
if (string == null) {
return;
}
Entities.HTML40_escape.escape(writer, string);
}
public static String unescapeHtml(String str) {
if (str == null)
return null;
try
{
StringWriter writer = new StringWriter((int)(str.length() *
1.5D));
unescapeHtml(writer, str);
return writer.toString();
}
catch (IOException e)
{
e.printStackTrace();
}return null;
}
public static void unescapeHtml(Writer writer, String string)
throws IOException
{
if (writer == null) {
throw new IllegalArgumentException("The Writer must not be
null.");
}
if (string == null) {
return;
}
Entities.HTML40.unescape(writer, string);
}
}`
清单 7-4 显示了被 ProGuard 混淆的代码。我用mapping.txt
文件得到了混淆文件的名字,是t.java
。文件名的选择有一定的随机性,如果你自己混淆了 WordPress 代码,它很可能不会是t.java
。
清单 7-4。 装糊涂t.java
( EscapeUtils.java
)
`package org.wordpress.android.util;
import java.io.IOException;
import java.io.StringWriter;
public final class t
{
public static String a(String paramString)
{
if (paramString == null)
return null;
try
{
StringWriter localStringWriter;
String str = paramString;
paramString = localStringWriter = new
StringWriter((int)(paramString.length() * 1.5D));
if (str != null)
r.b.a(paramString, str);
return localStringWriter.toString();
}
catch (IOException localIOException)
{
localIOException.printStackTrace();
}
return null;
}
public static String b(String paramString)
{
if (paramString == null)
return null;
try
{
StringWriter localStringWriter;
String str = paramString;
paramString = localStringWriter = new
StringWriter((int)(paramString.length() * 1.5D));
if (str != null)
r.a.b(paramString, str);
return localStringWriter.toString();
}
catch (IOException localIOException)
{
localIOException.printStackTrace();
}
return null;
}
}`
public static String escapeHtml(String str)
和public static String unescapeHtml(String str)
方法看起来与原始方法非常相似。但是public static void escapeHtml(Writer writer, String string)
和public static void unescapeHtml(Writer writer, String string)
方法已经被推送到一个单独的文件r.java
中,让人不知所云(参见清单 7-5 )。
清单 7-5。 r.java
类
`package org.wordpress.android.util;
import java.io.Writer;
final class r
{
private static final String[][] c = { { "quot", "34" }, { "amp",
"38" }, { "lt", "60" }, { "gt", "62" } };
private static final String[][] d = { { "apos", "39" } };
private static String[][] e = { { "nbsp", "160" }, { "iexcl",
"161" }, { "cent", "162" }, { "pound", "163" }, { "curren", "164"
}, { "yen", "165" }, { "brvbar", "166" }, { "sect", "167" }, {
"uml", "168" }, { "copy", "169" }, { "ordf", "170" }, { "laquo",
"171" }, { "not", "172" }, { "shy", "173" }, { "reg", "174" }, {
"macr", "175" }, { "deg", "176" }, { "plusmn", "177" }, { "sup2",
"178" }, { "sup3", "179" }, { "acute", "180" }, { "micro", "181"
}, { "para", "182" }, { "middot", "183" }, { "cedil", "184" }, {
"sup1", "185" }, { "ordm", "186" }, { "raquo", "187" }, {
"frac14", "188" }, { "frac12", "189" }, { "frac34", "190" }, {
"iquest", "191" }, { "Agrave", "192" }, { "Aacute", "193" }, {
"Acirc", "194" }, { "Atilde", "195" }, { "Auml", "196" }, {
"Aring", "197" }, { "AElig", "198" }, { "Ccedil", "199" }, {
"Egrave", "200" }, { "Eacute", "201" }, { "Ecirc", "202" }, {
"Euml", "203" }, { "Igrave", "204" }, { "Iacute", "205" }, {
"Icirc", "206" }, { "Iuml", "207" }, { "ETH", "208" }, { "Ntilde",
"209" }, { "Ograve", "210" }, { "Oacute", "211" }, { "Ocirc",
"212" }, { "Otilde", "213" }, { "Ouml", "214" }, { "times", "215"
}, { "Oslash", "216" }, { "Ugrave", "217" }, { "Uacute", "218" },
{ "Ucirc", "219" }, { "Uuml", "220" }, { "Yacute", "221" }, {
"THORN", "222" }, { "szlig", "223" }, { "agrave", "224" }, {
"aacute", "225" }, { "acirc", "226" }, { "atilde", "227" }, {
"auml", "228" }, { "aring", "229" }, { "aelig", "230" }, {
"ccedil", "231" }, { "egrave", "232" }, { "eacute", "233" }, {
"ecirc", "234" }, { "euml", "235" }, { "igrave", "236" }, {
"iacute", "237" }, { "icirc", "238" }, { "iuml", "239" }, { "eth",
"240" }, { "ntilde", "241" }, { "ograve", "242" }, { "oacute",
"243" }, { "ocirc", "244" }, { "otilde", "245" }, { "ouml", "246"
}, { "divide", "247" }, { "oslash", "248" }, { "ugrave", "249" },
{ "uacute", "250" }, { "ucirc", "251" }, { "uuml", "252" }, {
"yacute", "253" }, { "thorn", "254" }, { "yuml", "255" } };
private static String[][] f = { { "fnof", "402" }, { "Alpha",
"913" }, { "Beta", "914" }, { "Gamma", "915" }, { "Delta", "916"
}, { "Epsilon", "917" }, { "Zeta", "918" }, { "Eta", "919" }, {
"Theta", "920" }, { "Iota", "921" }, { "Kappa", "922" }, {
"Lambda", "923" }, { "Mu", "924" }, { "Nu", "925" }, { "Xi", "926"
}, { "Omicron", "927" }, { "Pi", "928" }, { "Rho", "929" }, {
"Sigma", "931" }, { "Tau", "932" }, { "Upsilon", "933" }, { "Phi",
"934" }, { "Chi", "935" }, { "Psi", "936" }, { "Omega", "937" }, {
"alpha", "945" }, { "beta", "946" }, { "gamma", "947" }, {
"delta", "948" }, { "epsilon", "949" }, { "zeta", "950" }, {
"eta", "951" }, { "theta", "952" }, { "iota", "953" }, { "kappa",
"954" }, { "lambda", "955" }, { "mu", "956" }, { "nu", "957" }, {
"xi", "958" }, { "omicron", "959" }, { "pi", "960" }, { "rho",
"961" }, { "sigmaf", "962" }, { "sigma", "963" }, { "tau", "964"
}, { "upsilon", "965" }, { "phi", "966" }, { "chi", "967" }, {
"psi", "968" }, { "omega", "969" }, { "thetasym", "977" }, {
"upsih", "978" }, { "piv", "982" }, { "bull", "8226" }, {
"hellip", "8230" }, { "prime", "8242" }, { "Prime", "8243" }, {
"oline", "8254" }, { "frasl", "8260" }, { "weierp", "8472" }, {
"image", "8465" }, { "real", "8476" }, { "trade", "8482" }, {
"alefsym", "8501" }, { "larr", "8592" }, { "uarr", "8593" }, {
"rarr", "8594" }, { "darr", "8595" }, { "harr", "8596" }, {
"crarr", "8629" }, { "lArr", "8656" }, { "uArr", "8657" }, {
"rArr", "8658" }, { "dArr", "8659" }, { "hArr", "8660" }, {
"forall", "8704" }, { "part", "8706" }, { "exist", "8707" }, {
"empty", "8709" }, { "nabla", "8711" }, { "isin", "8712" }, {
"notin", "8713" }, { "ni", "8715" }, { "prod", "8719" }, { "sum",
"8721" }, { "minus", "8722" }, { "lowast", "8727" }, { "radic",
"8730" }, { "prop", "8733" }, { "infin", "8734" }, { "ang", "8736"
}, { "and", "8743" }, { "or", "8744" }, { "cap", "8745" }, {
"cup", "8746" }, { "int", "8747" }, { "there4", "8756" }, { "sim",
"8764" }, { "cong", "8773" }, { "asymp", "8776" }, { "ne", "8800"
}, { "equiv", "8801" }, { "le", "8804" }, { "ge", "8805" }, {
"sub", "8834" }, { "sup", "8835" }, { "sube", "8838" }, { "supe",
"8839" }, { "oplus", "8853" }, { "otimes", "8855" }, { "perp",
"8869" }, { "sdot", "8901" }, { "lceil", "8968" }, { "rceil",
"8969" }, { "lfloor", "8970" }, { "rfloor", "8971" }, { "lang",
"9001" }, { "rang", "9002" }, { "loz", "9674" }, { "spades",
"9824" }, { "clubs", "9827" }, { "hearts", "9829" }, { "diams",
"9830" }, { "OElig", "338" }, { "oelig", "339" }, { "Scaron",
"352" }, { "scaron", "353" }, { "Yuml", "376" }, { "circ", "710"
}, { "tilde", "732" }, { "ensp", "8194" }, { "emsp", "8195" }, {
"thinsp", "8201" }, { "zwnj", "8204" }, { "zwj", "8205" }, {
"lrm", "8206" }, { "rlm", "8207" }, { "ndash", "8211" }, {
"mdash", "8212" }, { "lsquo", "8216" }, { "rsquo", "8217" }, {
"sbquo", "8218" }, { "ldquo", "8220" }, { "rdquo", "8221" }, {
"bdquo", "8222" }, { "dagger", "8224" }, { "Dagger", "8225" }, {
"permil", "8240" }, { "lsaquo", "8249" }, { "rsaquo", "8250" }, {
"euro", "8364" } };
private static r g;
private static r h;
public static final r a;
public static final r b;
private s i = new ag();
private void a(String[][] paramArrayOfString)
{
for (int j = 0; j < paramArrayOfString.length; j++)
{
int k = Integer.parseInt(paramArrayOfString[j][1]);
String str = paramArrayOfString[j][0];
this.i.a(str, k);
}
}
public final void a(Writer paramWriter, String paramString)
{
int j = paramString.length();
for (int k = 0; k < j; k++)
{
int m = paramString.charAt(k);
int n = m;
String str;
if ((str = this.i.a(n)) == null)
{
if (m > 127)
{
paramWriter.write("&#");
paramWriter.write(Integer.toString(m, 10));
paramWriter.write(59);
}
else
{
paramWriter.write(m);
}
}
else
{
paramWriter.write(38);
paramWriter.write(str);
paramWriter.write(59);
}
}
}
public final void b(Writer paramWriter, String paramString)
{
int j;
if ((j = paramString.indexOf('&')) < 0)
{
paramWriter.write(paramString);
return;
}
int k = j;
String str1 = paramString;
paramString = paramWriter;
paramWriter = this;
paramString.write(str1, 0, k);
int m = str1.length();
while (k < m)
{
int n;
String str2;
if ((n = str1.charAt(k)) == '&')
{
int i1 = k + 1;
String str4;
if ((str4 = str1.indexOf(';', i1)) == -1)
{
paramString.write(n);
}
else
{
int i2;
if (((i2 = str1.indexOf('&', k + 1)) != -1) && (i2 <
str4))
{
paramString.write(n);
}
else
{
str2 = str1.substring(i1, str4);
n = -1;
if ((i1 = str2.length()) > 0)
if (str2.charAt(0) == '#')
{
if (i1 > 1)
{
n = str2.charAt(1);
try
{
switch (n)
{
case 88:
case 120:
n = Integer.parseInt(str2.substring(2), 16);
break;
default:
n = Integer.parseInt(str2.substring(1), 10);
}
if (n > 65535)
n = -1;
}
catch (NumberFormatException
localNumberFormatException)
{
n = -1;
}
}
}
else
{
String str3 = str2;
n = paramWriter.i.a(str3);
}
if (n == -1)
{
paramString.write(38);
paramString.write(str2);
paramString.write(59);
}
else
{
paramString.write(n);
}
str2 = str4;
}
}
}
else
{
paramString.write(n);
}
str2++;
}
}
static
{
(r.g = new r()).a(c);
g.a(d);
(r.h = new r()).a(c);
h.a(e);
r localr;
(localr = r.a = new r()).a(c);
localr.a(e);
localr.a(f);
b = new r();
(localr = a).a(e);
localr.a(f);
}
}`
从第四章中,你可以看到 ProGuard 通过重命名变量来使用布局混淆,这只是稍微有效。但是它也采用了一些令人印象深刻的数据混淆技术,通过分割变量和将静态数据转换成过程数据。第一轮晋级。
看图 7-1 中左边的菜单,显示了在 JD-GUI 中打开的混淆的 jar 文件。大量的类名没有被混淆。这些方法有一些布局混淆,但是类名包含的信息使人们很容易理解这些方法在做什么。默认情况下,manifest.xml
文件中列出的所有Activity
、Application
、Service
、BroadcastReceiver
和ContentProvider
类都不会被 ProGuard 混淆。最好的解决方案是尽量减少这种类型的类。
图 7-1。混淆了 JD-GUI 中的 WordPress jar 文件
配置
ProGuard 在proguard.cfg
文件中配置。默认配置文件如清单 7-6 所示。最简单的是,该文件告诉 ProGuard 不要使用大小写混合的类名(当 jar 文件被解压缩时,这会在 Windows 上引起问题);不执行预验证步骤;保留Activity
、Application
、Service
、BroadcastReceiver
和ContentProvider
类的类名;不删除任何本机类;还有更多。
清单 7-6。 proguard.cfg
文件为 WordPress App
`-optimizationpasses 5
-dontusemixedcaseclassnames
-dontskipnonpubliclibraryclasses
-dontpreverify
-verbose
-optimizations !code/simplification/arithmetic,!field/,!class/merging/
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.backup.BackupAgentHelper
-keep public class * extends android.preference.Preference
-keep public class com.android.vending.licensing.ILicensingService
-keepclasseswithmembernames class * {
native
}
-keepclasseswithmembers class * {
public
android.util.AttributeSet);
}
-keepclasseswithmembers class * {
public
android.util.AttributeSet, int);
}
-keepclassmembers class * extends android.app.Activity {
public void *(android.view.View);
}
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}`
在[
proguard.sourceforge.net/manual/examples.html#androidapplication](http://proguard.sourceforge.net/manual/examples.html#androidapplication)
可以找到一个很好的 Android APKs 配置设置资源,它反映了许多这些设置。这是非常有用的,特别是如果你的 APK 在使用 ProGuard 后在你的设备上失败了。
一个更简单的选择是使用 ProGuard GUI,它会带您浏览配置设置,并提供更多解释。例如,proguard.cfg
中的优化设置很神秘,但是在 GUI 中更容易理解和设置(见图 7-2 )。
图 7-2。 ProGuard GUI
要启动 GUI,首先要确保你已经在[
proguard.sourceforge.net](http://proguard.sourceforge.net)
从 SourceForge 下载了。解压缩它并在lib
文件夹中执行下面的命令,假设您已经将目标proguard.cfg
文件复制到了proguard\lib
文件夹中。你应该看到许多优化选项直接来自第四章的模糊转换:
java -jar proguardgui.jar proguard.cfg
调试
你可能会发现你的 APK 在被混淆后在战场上失败了。调试代码很困难,因为许多方法的名称都被 ProGuard 更改了。幸运的是,ProGuard 有一个retrace
选项,可以让你回到原来的名字。该命令如下所示:
java -jar retrace.jar mapping.txt stackfile.trace
mapping.txt
在bin\proguard
文件夹中,stackfile.trace
是应用崩溃时保存的堆栈跟踪。
解决方案 2: DashO
ProGuard 不是你唯一的混淆选项。商业混淆器,如抢先的 DashO,可在[www.preemptive.com](http://www.preemptive.com)
获得,是有价值的替代品,比 ProGuard 做更多的控制流和字符串加密混淆。图 7-3 显示了 DashO 界面,包括控制流、重命名和字符串加密混淆选项。
图 7-3。 妫办公会
控制流选项对字节码进行重新排序,目的是使其不可能被反编译。String Encryption 选项对许多字符串进行加密,这对于防止有人窃取 API 密钥或密码非常有用。重载归纳(重命名选项之一)是一种更强烈的类重命名形式:可以将多个类命名为a()
或b()
,因为这样做是合法的 Java,只要这些类具有不同的方法参数。
在 DashO 中混淆 Android 项目最简单的方法是使用 DashO 向导(见图 7-4 )。稍后,您可以使用 GUI 来调整您想要设置的任何选项。
图 7-4。 巫师办公会
输出
DashO 输出一个项目报告文件和一个mapreport
或映射文件到ant-bin\dasho-results
文件夹。例如,mapreport
文件告诉我EscapeUtils.class
已经改名为i_:
org.wordpress.android.i_ public org.wordpress.android.util.EscapeUtils
清单 7-7 显示了反编译后的 JD-GUI 输出。
清单 7-7。 EscapeUtils
,被办公会搞得晕头转向
`package org.wordpress.android;
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;`
`public class i_
{
public static String e(String paramString)
{
if (paramString != null);
try
{
StringWriter localStringWriter = new
StringWriter((int)(paramString.length() * 1.5D));
o(localStringWriter, paramString);
return localStringWriter.toString();
return null;
}
catch (IOException localIOException)
{
localIOException.printStackTrace();
}
return null;
}
public static void o(Writer paramWriter, String paramString)
throws IOException
{
if (paramWriter == null)
break label24;
do
return;
while (paramString == null);
xd.v.v(paramWriter, paramString);
return;
label24: throw new IllegalArgumentException(R.endsWith("Rom)]yeyk}0|g``5xxl9x~<sksl/"
, 554 / 91));
}
// ERROR //
public static String f(String paramString)
{
// Byte code:
// 0: aload_0
// 1: ifnonnull +16 -> 17
// 4: goto +10 -> 14
// 7: astore_1
// 8: aload_1
// 9: invokevirtual 33 java/io/IOException:printStackTrace
()V
// 12: aconst_null
// 13: areturn
// 14: aconst_null
// 15: areturn
// 16: areturn
// 17: new 7 java/io/StringWriter
// 20: dup
// 21: aload_0
// 22: invokevirtual 29 java/lang/String:length ()I
// 25: i2d
// 26: ldc2_w 3
// 29: dmul
// 30: d2i
// 31: invokespecial 30 java/io/StringWriter:
// 34: astore_1
// 35: aload_1
// 36: aload_0
// 37: invokestatic 37 org/wordpress/android/i_:d
(Ljava/io/Writer;Ljava/lang/String;)V
// 40: aload_1
// 41: invokevirtual 32
java/io/StringWriter:toString()Ljava/lang/String;
// 44: goto -28 -> 16
//
// Exception table:
// from to target type
// 17 477 java/io/IOException
}
public static void d(Writer paramWriter, String paramString)
throws IOException
{
if (paramWriter != null)
{
if (paramString != null)
{
xd.t.h(paramWriter, paramString);
return;
}
}
else
throw new IllegalArgumentException(d9.insert(49 * 25,
"\035".l\032<&$4 s9 %#x75/|?;• .4./j"));
}
}`
代码中最明显的是escapeHTML
或unescapeHTML
方法无法反编译。Java 还有一些有趣的用途,比如变量名标签和字符串加密。下面的代码片段是使用 JD-GUI 反编译时令人困惑的代码的一个很好的例子:
label24: throw new IllegalArgumentException(R.endsWith("Rom)]yeyk}0|g``5xxl9x~<sksl/" , 554 / 91));
重新编译这段代码需要一些努力。第二回合达索获胜。
回顾案例研究
我希望这个案例研究向您展示了 JD-GUI 可以从未受保护的代码中恢复多少代码,以及这些代码与原始源代码有多接近。在本章的随机样本中,两个源文件之间唯一的区别是缺少注释。ProGuard 和 DashO 使得任何反编译的代码更加难以理解。最起码,你要把proguard.config=proguard.cfg
加到你的project.properties
文件里;商业混淆器可以提供额外的保护。
始终仔细检查任何敏感信息是否已通过下载生产 APK 并反编译得到保护。如果你有任何后端系统的 API 密钥或用户名,并且它们没有被 ProGuard 或 DashO 隐藏到令你满意的程度,你可能要考虑使用 Android 原生开发工具包(NDK;更多信息见第四章。
总结
当构思这本书的时候,Java 反编译似乎是一个重要的问题。但这从未发生过。当然,有一些桌面应用;但是大部分代码是为 web 服务器编写的,jar 文件被牢牢地锁在防火墙后面。
公平地说,有了 Android,Java 已经超越了它早期的根基。Android APKs 在用户的设备上很容易访问,并且首先为 Java 开发的反编译技术现在使得恢复任何未受保护的 apk 变得非常容易。这些 apk 通常足够小,程序员或黑客可以很快理解它们是如何工作的。如果你想在 APK 藏东西,你需要保护它。
这种情况在不久的将来会改变吗?如果 DVM、JVM 和 Java 代码之间仍然存在链接,就不会这样。我预测工具将会转移到 DVM 上,如果有什么不同的话,情况可能会变得更糟。混淆和反编译之间的军备竞赛将会以很快的速度进行,复制过去 10 年中发生的许多相同的步骤——但这次是在 DVM 上。
这本书的前提是向个人用户展示如何从classes.dex
文件反编译代码,有哪些保护方案,以及它们的含义。一般来说,人们的好奇心比欺诈性强得多,而且不太可能有人会使用反编译器来窃取软件公司的皇冠上的宝石。相反,他们只是想看一眼,看看它们是如何组合在一起的——Java 反编译器使普通程序员能够更深入地了解通常是黑箱的东西。这本书帮助你越过那个边缘。
从这里开始尝试的事情包括使用 ANTLR 和扩展代码,如果它不能反编译您的特定classes.dex
文件的话。网上也有一些开源的反编译器——比如 JODE,可以在[
jode.sourceforge.net](http://jode.sourceforge.net)
找到——它们提供了丰富的信息。位于[
code.google.com/p/smali/](http://code.google.com/p/smali/)
的 Smali 和 baksmali 也是你开始研究的绝佳地点。
我尽了最大努力使这本书易于阅读。我有意识地决定让它更实用,而不是理论上的,同时努力避免它成为傻瓜的 Android 反编译器。我希望你我的努力是值得的。请记住,这里的事情变化很快,所以请关注 Apress 网站的更新。
八、附录一:操作码表
在第二章中,您看到了 Java 字节码位于 Java 类文件的 code 属性部分。表 A-1 列出了所有可能的 Java 字节码。每个操作码的十六进制值与类似汇编程序的操作码助记符一起显示。
表 A-2 列出了在第三章中首先遇到的所有可能的 Dalvik 字节码,然后贯穿全书。每个操作码的十六进制值与类似汇编程序的操作码助记符一起显示。