Android 反编译教程(二)

原文:Decompiling Android

协议:CC BY-NC-SA 4.0

四、贸易工具

本章着眼于黑客用来从 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 上检查。步骤如下:

  1. 从 Android Market 下载并安装免费版的 ASTRO 文件管理器。
  2. 将微型 SD 卡插入手机
  3. 打开 ASTRO,按下手机上的菜单键。
  4. 选择工具Image应用管理器/备份。
  5. 选中目标 APK 旁边的复选框,点击备份,参见图 4-1 。
  6. 关闭 ASTRO 文件管理器。
  7. 取出微型 SD 卡,并将其插入电脑。或者,如果你没有 SD 卡,把 APK 用电子邮件发给你自己。

Image

图 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 的步骤如下:

  1. 备份你的手机。
  2. [forum.xda-developers.com](http://forum.xda-developers.com)下载 APK。如果你的电脑上有一个病毒扫描程序,它会弹出一条消息,说你下载了一个带有 RageAgainstTheCage 病毒的文件,这是 Z4Root 用来入侵手机的漏洞。
  3. 将 APK 从您的计算机复制到 SD 卡上。
  4. 将 SD 卡放入手机,使用 ASTRO 文件管理器安装 Z4Root。
  5. 遵循 Z4Root 中的步骤,如图图 4-2 和 4-3 所示。在第一个屏幕上选择 Root 然后,选择临时根来根您的电话,直到您的电话重新启动,或选择永久根来保持根。Z4Root 对你的手机进行 Root 可能需要几分钟的时间,但是如果成功的话,设备会重新启动,手机也会被 root。

Image

图 4-2。 Z4Root 安装

Image

图 4-3。 在 Z4Root 中选择一个临时或永久的根

如果您在手机已经根化之后运行 Z4Root,它会为您提供取消根化手机的选项。这在你需要更换手机又不想让保修失效的情况下很有用(见图 4-4 )。

Image

图 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-toolstools目录中找到很多好东西,包括达尔维克调试监控服务(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/appls /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 所示。

Image

图 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

`<?xml version="1.0" encoding="utf-8"?>
<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 。

Image

图 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.😦)V
0001e4: 7010 0300 0000 |0000: invoke-
direct {v0},
Ljava/lang/Object;.😦)V //
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;.😦)V // method@0004
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:©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 ()V
.registers 1

.prologue
.line 1
invoke-direct {p0}, Ljava/lang/Object;->()V

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;->()V

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©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©.append(” character ").append©.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。

Image

图 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 特别无效的转换类型。

Image

Image

Image

大多数 Java 混淆器只执行布局混淆,有一些有限的数据和控制混淆。这部分是由于 Java 验证过程丢弃了任何非法的字节码语法。如果你主要写小程序,Java 验证器是非常重要的,因为远程代码总是被验证的。如今,当小程序越来越少时,Java 混淆器没有采用更高级的混淆技术的主要原因是混淆后的代码必须在各种 Java 虚拟机(JVM)上工作。

尽管 JVM 规范定义得很好,但是每个 JVM 对规范都有自己的略微不同的解释,这导致了在 JVM 如何处理不再由 Java 源代码表示的字节码时有许多特殊之处。JVM 开发人员不太注意测试这种类型的字节码,您的客户对字节码的语法是否正确不感兴趣——他们只想知道为什么它不能在他们的平台上运行。

请记住,在高级形式的模糊处理中有一定程度的走钢丝——我称之为高级模式模糊处理——所以你需要非常小心这些程序会对你的字节码做什么。模糊处理越激烈,代码就越难被反编译,但是就越有可能使 DVM 崩溃。

最好的混淆器在不破坏 DVM 的情况下执行多次转换。毫不奇怪,混淆公司在谨慎方面犯了错误,这不可避免地意味着对你的源代码的保护更少。

布局混淆

大多数混淆器的工作原理是模糊变量名或打乱类文件中的标识符,试图使反编译的源代码无用。正如你在第三章中看到的,这并不能阻止字节码的执行,因为 DEX 文件使用指向数据段中的方法名和变量的指针,而不是实际的名称。

混淆代码通过使用自动生成的垃圾变量重命名常量池中的变量,同时保持代码语法正确,从而破坏了反编译器的源代码输出。然后,这在 DEX 文件的数据部分结束。实际上,这个过程消除了程序员在给变量命名时给出的所有线索(大多数优秀的程序员选择有意义的变量名)。这也意味着,由于重名,反编译的代码在重新编译之前需要一些返工。

无论有没有变量名的提示,大多数有能力的程序员都可以通过混乱的代码。通过适当的关心和注意,也许借助于剖析器来理解程序流,也许借助于反汇编器来重命名变量,大多数混淆的代码都可以被改回更容易处理的东西,不管混淆有多严重。

早期的混淆器如 JODE 用abcd代替了方法名…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); } } }

大多数混淆器在减少类文件的大小方面比保护源代码要好得多。但是抢先软件拥有一项专利,它打破了原始源代码和混淆代码之间的联系,并在一定程度上保护了您的代码。所有的方法都被重命名为abcd等等。但是与其他程序不同,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 使用breakcontinue语句的方式。问题在于使用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 中实现。假设你想交换两个变量,xy。最简单的方法是使用一个临时变量,如清单 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 = 8y =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=0a2=1,并在表 4-2 中查找,将其转换回布尔值。

Image

(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 总结了本章讨论的方法。值得注意的是,许多最初的混淆工具没有在互联网泡沫破灭后幸存下来,这些公司要么已经倒闭,要么转向其他专业化领域。

Image

最后一句话:非常小心依赖混淆作为唯一的保护方法。请记住,反汇编程序也可以用来拆开您的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 中看到的。它不隐藏用户名和密码,而是重命名方法和字符串,使它们不再向黑客提供任何上下文信息。

Image

图 4-8。 程序保护码

如果你使用 ProGuard,要注意你使用了多少公共类,因为默认情况下它们不会被混淆。使用 ProGuard 时,实践良好的面向对象设计是有回报的。一个很好的经验法则是在混淆后总是反编译你的 APK,以确保它确实在做你认为它在做的事情。

达绍

DashO 是一个商业混淆器,来自于早期 Java 时代就已经存在的抢先软件。它执行布局、控制和数据混淆。使用向导让 DashO 混淆一个 Android 应用。图 4-9 显示了 DashO GUI 在左侧菜单中,您可以看到控制流、重命名和字符串加密选项。

Image

图 4-9。 妫办公会

图 4-10 使用了与图 4-8 中相同的应用,但是这一次用 DashO 代替了 ProGuard。请注意字符串加密。

Image

**图 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.$ = t e l e r i k . telerik. 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);
KaTeX parse error: Expected 'EOF', got '#' at position 3: ('#̲topcontainer').…(‘#topcontainer’));
middle.after(bottom); }

//cleanup
num = null;
pagenum = null;
top = null;
middle = null;
bottom=null;
}

function expandCollapseDiv(id) {
t e l e r i k . telerik. 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 。

Image

图 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 类、函数或类型的默认名称(例如,从yylexscanner):

  • 内部代码
  • 初始化类别代码
  • 文件类结尾
  • 宏定义
  • 国家声明
  • 字符计数
  • 行计数
  • 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 packageimport语句组成,这些语句根据您想要导入的其他包或类而变化。假设 CUP 类位于您的类路径中,添加以下代码行以包含 CUP 类:

import java_cup.runtime*;

所有其他导入或包引用都是可选的。一个start声明告诉解析器在哪里寻找开始规则,如果你想让它从其他解析器规则开始。默认情况下是使用顶级产生式规则,所以在大多数语法中,您会遇到开始规则是冗余信息。

CUP 中允许四种可能的用户例程:actionparser用于插入新代码和覆盖默认的扫描器代码和变量,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(); :};`

initscan都是常用的,即使只是把扫描器/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 是一个很好的选择。

Image

**图 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 展示了解析器看到的东西。

Image

图 5-3。 解析成对的十六进制数字

策略:决定您的解析器设计

ANTLR 被彻底改写,继续发展;它还有许多额外的功能,比如 ANTLRWorks 工具和 Eclipse 插件,这些功能使调试解析器成为一种享受。如您所见,调试 Yacc 输出不适合胆小的人。

因此,您将使用 ANTLR v3 构建解析器。这样做有很多好处:

  • 词法分析器和语法分析器在同一个文件中
  • 的功能从解析器中提取输出功能
  • ANTLRWorks 工具允许您调试解析器
  • 大量的解析器模式可供使用
  • 简单的 AST 集成
  • Eclipse 集成

在图 5-1 的中,将StringTemplate s 和 AST 添加到原始设计中,产生了一个新的解析器设计,如图 5-4 的所示。

Image

图 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 - elsefor循环组合,而不需要任何异常情况,同时保持源代码尽可能接近原始代码。另一种方法是尝试预测所有高级控制习惯用法。

你可能想知道为什么你需要一个策略——为什么你不能在 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 中的所示。

Image

图 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例子处理能力更强的更有效的反编译器。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值