第 2 章 如何分析Android程序

第 2 章 如何分析Android程序

分析Android程序是开发Android程序的一个逆过程。然而作为分析人员,掌握分析技术还得从开发学起,这个学习的路线应该是呈线性的、循序渐进的。要想分析一个Android程序,首先应该了解Android程序开发的流程、程序结构、语句分支、解密原理等。本章将以走马观花的形式,从开发Android程序开始,到最终分析并破解这个程序,将这一完整路线展现出来。

2.1 编写第一个Android程序

本节将采用Android官方推荐的Eclipse集成开发环境来编写一个Android应用程序。

2.1.1 使用Eclipse创建Android工程

启动Eclipse,新建一个Android工程。“Application Name”为Crackme0201,“Project Name”为crackme02,“Package Name”为com.droider.crackme0201,其他保持默认,设置好后如图2-1所示。

{%}

图2-1 使用Eclipse创建Android工程

一路单击Next按钮,最后点击Finish按钮完成工程的创建。打开工程的activity_main.xml布局文件,添加用户名与注册码编辑框,修改完成后,最终界面效果如图2-2所示。

{%}

图2-2 crackme程序主界面

接着编写MainActivity类的代码,添加一个checkSN()方法,代码如下:

private boolean checkSN(String userName, String sn) {
    try {
        if ((userName == null) || (userName.length() == 0))
            return false;
        if ((sn == null) || (sn.length() != 16))
            return false;
        MessageDigest digest = MessageDigest.getInstance("MD5");
        digest.reset();
        digest.update(userName.getBytes());
        byte[] bytes = digest.digest();     //采用MD5对用户名进行Hash
        String hexstr = toHexString(bytes, ""); //将计算结果转化成字符串
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < hexstr.length(); i += 2) {
            sb.append(hexstr.charAt(i));
        }
        String userSN = sb.toString(); //计算出的SN
        if (!userSN.equalsIgnoreCase(sn))   //比较注册码是否正确
            return false;
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
        return false;
    }
    return true;
}

这个方法的主要功能是计算用户名与注册码是否匹配。计算的步骤为:使用MD5算法计算用户名字符串的Hash,将计算所得的结果转换成长度为32位的十六进制字符串,然后取字符串的所有奇数位重新组合生成新的字符串,这个字符串就是最终的注册码,最后将它与传入的注册码进行比较,如果相同表示注册码是正确的,反之注册码是错误的。

接着在MainActivity的OnCreate()方法中加入注册按钮点击事件的监听器,如果用户名与注册码匹配就弹出注册成功的提示,不匹配则提示无效的用户名或注册码,代码如下:

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    setTitle(R.string.unregister);  //模拟程序未注册
    edit_userName = (EditText) findViewById(R.id.edit_username);
    edit_sn = (EditText) findViewById(R.id.edit_sn);
    btn_register = (Button) findViewById(R.id.button_register);
    btn_register.setOnClickListener(new OnClickListener() {

        public void onClick(View v) {
            if (!checkSN(edit_userName.getText().toString().trim(),
                    edit_sn.getText().toString().trim())) {
                Toast.makeText(MainActivity.this,   //弹出无效用户名或注册码提示
                        R.string.unsuccessed, Toast.LENGTH_SHORT).show();
            } else {
                Toast.makeText(MainActivity.this,       //弹出注册成功提示
                        R.string.successed, Toast.LENGTH_SHORT).show();
                btn_register.setEnabled(false);
                setTitle(R.string.registered);  //模拟程序已注册
            }
        }
    });
}

2.1.2 编译生成APK文件

首先启动Android程序运行环境,然后在crackme02工程上点击右键,在弹出的菜单中选择“Run As→Android Application”,如果工程没有任何错误,Eclipse会根据默认配置编译并启动crackme02程序。可以通过菜单项“Run As→Run Configuration”更改默认的启动配置选项,如程序启动的第一个Activity、Android平台的版本等。

注意 Android程序运行环境可以使用真实Android设备或Android模拟器,如果读者手上没有Android设备。可以按照本书1.2.7节讲解步骤创建模拟器,然后在命令行下输入以下命令启动它:

emulator –avd <模拟器名称>

以后在讲解过程中启动Android程序运行环境的步骤不再赘述。

程序启动后,输入任意长度的用户名与16位长度的注册码,点击注册按钮,程序会模拟注册软件的执行效果,如图2-3所示。

图2-3 crackme02程序的运行效果

2.2 破解第一个程序

本节将以上一节编写的crackme02程序为例,讲解破解它的完整流程。

2.2.1 如何动手?

破解Android程序通常的方法是将apk文件利用ApkTool反编译,生成Smali格式的反汇编代码,然后阅读Smali文件的代码来理解程序的运行机制,找到程序的突破口进行修改,最后使用ApkTool重新编译生成apk文件并签名,最后运行测试,如此循环,直至程序被成功破解。

在实际的分析过程中,还可以使用IDA Pro直接分析apk文件,或者dex2jar与jd-gui配合来进行Java源码级的分析等,这些分析方法会在本书的第5章进行详细的介绍。

2.2.2 反编译APK文件

ApkTool是跨平台的工具,可以在Windows平台与Ubuntu平台下直接使用。使用前到http://code.google.com/p/android-apktool/下载ApkTool,目前最新版本为1.4.3,Windows平台需要下载apktool1.4.3.tar.bz2与apktool-install-windows-r04-brut1.tar.bz2两个压缩包,将下载后的文件解压到同一目录下,本书的Windows平台将其解压到“D:\tools\Android\apktool”目录,解压完成后将该目录添加到系统的PATH环境变量中,以便在命令行下直接使用,如果是Ubuntu系统则需要下载apktool1.4.3.tar.bz2与apktool-install-linux-r04-brut1.tar.bz2,本书的Ubuntu平台将其解压到“/home/feicong/tools/apktool”目录下。

在Windows平台下,打开一个CMD窗口,在命令行下直接输入apktool会列出程序的用法。

反编译apk文件的命令为:apktool d[ecode] [OPTS] <file.apk> [<dir>]

编译apk文件的命令为:apktool b[uild] [OPTS] [<app_path>] [<out_file>]

在命令行下进入要反编译的apk文件目录,输入命令:“apktool d crackme02.apk outdir”,稍等片刻,程序就会反编译完成,如图2-4所示。

{%}

图2-4 使用Apktool反编译apk文件

在Ubuntu平台下,使用Apktool与在Windows平台下基本相同,具体步骤读者可自行实践。

2.2.3 分析APK文件

反编译apk文件成功后,会在当前的outdir目录下生成一系列目录与文件。其中smali目录下存放了程序所有的反汇编代码,res目录则是程序中所有的资源文件,这些目录的子目录和文件与开发时的源码目录组织结构是一致的。

如何寻找突破口是分析一个程序的关键。对于一般的Android来说,错误提示信息通常是指引关键代码的风向标,在错误提示附近一般是程序的核心验证代码,分析人员需要阅读这些代码来理解软件的注册流程。

错误提示是Android程序中的字符串资源,开发Android程序时,这些字符串可能硬编码到源码中,也可能引用自“res\values”目录下的strings.xml文件,apk文件在打包时,strings.xml中的字符串被加密存储为resources.arsc 文件保存到apk程序包中,apk被成功反编译后这个文件也被解密出来了。

还记得2.1.2节运行程序时的错误提示吗?在软件注册失败时会Toast弹出“无效用户名或注册码”,我们以此为线索来寻找关键代码。打开“res\values\string.xml”文件,内容如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">Crackme0201</string>
    <string name="hello_world">Hello world!</string>
    <string name="menu_settings">Settings</string>
    <string name="title_activity_main">crackme02</string>
    <string name="info">Android程序破解演示实例</string>
    <string name="username">用户名:</string>
    <string name="sn">注册码:</string>
    <string name="register">注 册</string>
    <string name="hint_username">请输入用户名</string>
    <string name="hint_sn">请输入16位的注册码</string>
    <string name="unregister">程序未注册</string>
    <string name="registered">程序已注册</string>
    <string name="unsuccessed">无效用户名或注册码</string>
    <string name="successed">恭喜您!注册成功</string>
</resources>

开发Android程序时,String.xml文件中的所有字符串资源都在“gen/<packagename>/ R.java”文件的String类中被标识,每个字符串都有唯一的int类型索引值,使用Apktool反编译apk文件后,所有的索引值保存在string.xml文件同目录下的public.xml文件中。

从上面列表中找到“无效用户名或注册码”的字符串名称为unsuccessed。打开public.xml文件,它的内容如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <public type="drawable" name="ic_launcher" id="0x7f020001" />
    <public type="drawable" name="ic_action_search" id="0x7f020000" />
    <public type="layout" name="activity_main" id="0x7f030000" />
    <public type="dimen" name="padding_small" id="0x7f040000" />
    <public type="dimen" name="padding_medium" id="0x7f040001" />
    <public type="dimen" name="padding_large" id="0x7f040002" />
    <public type="string" name="app_name" id="0x7f050000" />
    <public type="string" name="hello_world" id="0x7f050001" />
    <public type="string" name="menu_settings" id="0x7f050002" />
    <public type="string" name="title_activity_main" id="0x7f050003" />
    <public type="string" name="info" id="0x7f050004" />
    <public type="string" name="username" id="0x7f050005" />
    <public type="string" name="sn" id="0x7f050006" />
    <public type="string" name="register" id="0x7f050007" />
    <public type="string" name="hint_username" id="0x7f050008" />
    <public type="string" name="hint_sn" id="0x7f050009" />
    <public type="string" name="unregister" id="0x7f05000a" />
    <public type="string" name="registered" id="0x7f05000b" />
    <public type="string" name="unsuccessed" id="0x7f05000c" />
    <public type="string" name="successed" id="0x7f05000d" />
    <public type="style" name="AppTheme" id="0x7f060000" />
    <public type="menu" name="activity_main" id="0x7f070000" />
    <public type="id" name="textView1" id="0x7f080000" />
    <public type="id" name="edit_username" id="0x7f080001" />
    <public type="id" name="edit_sn" id="0x7f080002" />
    <public type="id" name="button_register" id="0x7f080003" />
    <public type="id" name="menu_settings" id="0x7f080004" />
</resources>

unsuccessed的id值为0x7f05000c,在smali目录中搜索含有内容为0x7f05000c的文件,最后发现只有MainActivity$1.smali文件一处调用,代码如下:

# virtual methods
.method public onClick(Landroid/view/View;)V
    .locals 4
    .parameter "v"
    .prologue
    const/4 v3, 0x0
    ……
    .line 32
    #calls:
    Lcom/droider/crackme0201/MainActivity;->checkSN(Ljava/lang/String;
    Ljava/lang/String;)Z
    invoke-static {v0, v1, v2}, Lcom/droider/crackme0201/MainActivity;->
        access$2(Lcom/droider/crackme0201/MainActivity;Ljava/lang/String;
         Ljava/lang/String;)Z # 检查注册码是否合法
    move-result v0
    if-nez v0, :cond_0  #如果结果不为0,就跳转到cond_0标号处
    .line 34
    iget-object v0, p0, Lcom/droider/crackme0201/MainActivity$1;->
        this$0:Lcom/droider/crackme0201/MainActivity;
    .line 35
    const v1, 0x7f05000c # unsuccessed字符串
    .line 34
    invoke-static {v0, v1, v3}, Landroid/widget/Toast;->
        makeText(Landroid/content/Context;II)Landroid/widget/Toast;
    move-result-object v0
    .line 35
    invoke-virtual {v0}, Landroid/widget/Toast;->show()V
    .line 42
    :goto_0
    return-void
    .line 37
    :cond_0
    iget-object v0, p0, Lcom/droider/crackme0201/MainActivity$1;->
        this$0:Lcom/droider/crackme0201/MainActivity;
    .line 38
    const v1, 0x7f05000d # successed字符串
    .line 37
    invoke-static {v0, v1, v3}, Landroid/widget/Toast;->
        makeText(Landroid/content/Context;II)Landroid/widget/Toast;
    move-result-object v0
    .line 38
    invoke-virtual {v0}, Landroid/widget/Toast;->show()V
    .line 39
    iget-object v0, p0, Lcom/droider/crackme0201/MainActivity$1;->
        this$0:Lcom/droider/crackme0201/MainActivity;
    #getter for: Lcom/droider/crackme0201/MainActivity;->btn_register:Landroid/
        widget/Button;
    invoke-static {v0}, Lcom/droider/crackme0201/MainActivity;->
         access$3(Lcom/droider/crackme0201/MainActivity;)Landroid/widget
         /Button;
    move-result-object v0
    invoke-virtual {v0, v3}, Landroid/widget/Button;->setEnabled(Z)V
    #设置注册按钮不可用
    .line 40
    iget-object v0, p0, Lcom/droider/crackme0201/MainActivity$1;->
        this$0:Lcom/droider/crackme0201/MainActivity;
    const v1, 0x7f05000b# registered字符串,模拟注册成功
    invoke-virtual {v0, v1}, Lcom/droider/crackme0201/MainActivity;->
    setTitle(I)V
    goto :goto_0
.end method

Smali代码中添加的注释使用井号“#”开头,“.line 32”行调用了checkSN()函数进行注册码的合法检查,接着下面有如下两行代码:

move-result v0
if-nez v0, :cond_0

checkSN()函数返回Boolean类型的值。这里的第一行代码将返回的结果保存到v0寄存器中,第二行代码对v0进行判断,如果v0的值不为零,即条件为真的情况下,跳转到cond_0标号处,反之,程序顺利向下执行。

如果代码不跳转,会执行如下几行代码:

.line 34
iget-object v0, p0, Lcom/droider/crackme0201/MainActivity$1;->
    this$0:Lcom/droider/crackme0201/MainActivity;
.line 35
const v1, 0x7f05000c    # unsuccessed字符串
.line 34
invoke-static {v0, v1, v3}, Landroid/widget/Toast;->
    makeText(Landroid/content/Context;II)Landroid/widget/Toast;
move-result-object v0
.line 35
invoke-virtual {v0}, Landroid/widget/Toast;->show()V
.line 42
:goto_0
return-void

“.line 34”行使用iget-object指令获取MainActivity实例的引用。代码中的->this$0是内部类MainActivity$1中的一个synthetic字段,存储的是父类MainActivity的引用,这是Java语言的一个特性,类似的还有->access$0,这一类代码会在本书的第5章进行详细介绍。

“.line 35”行将v1寄存器传入unsuccessed字符串的id值,接着调用Toast;->makeText()创建字符串,然后调用Toast;->show()V方法弹出提示,最后.line 42行调用return-void函数返回。

如果代码跳转,会执行如下代码:

:cond_0
iget-object v0, p0, Lcom/droider/crackme0201/MainActivity$1;->
    this$0:Lcom/droider/crackme0201/MainActivity;
.line 38
const v1, 0x7f05000d    # successed字符串
.line 37
invoke-static {v0, v1, v3}, Landroid/widget/Toast;->
    makeText(Landroid/content/Context;II)Landroid/widget/Toast;
move-result-object v0
.line 38
invoke-virtual {v0}, Landroid/widget/Toast;->show()V
.line 39
iget-object v0, p0, Lcom/droider/crackme0201/MainActivity$1;->
    this$0:Lcom/droider/crackme0201/MainActivity;
#getter for: Lcom/droider/crackme0201/MainActivity;->btn_register:Landroid/
    widget/Button;
invoke-static {v0}, Lcom/droider/crackme0201/MainActivity;->
    access$3(Lcom/droider/crackme0201/MainActivity;)Landroid/widget/Button;
move-result-object v0
invoke-virtual {v0, v3}, Landroid/widget/Button;->setEnabled(Z)V    #设置注册按钮不可用
.line 40
iget-object v0, p0, Lcom/droider/crackme0201/MainActivity$1;->
    this$0:Lcom/droider/crackme0201/MainActivity;
const v1, 0x7f05000b    # registered字符串,模拟注册成功
invoke-virtual {v0, v1}, Lcom/droider/crackme0201/MainActivity;->setTitle(I)V
goto :goto_0

这段代码的功能是弹出注册成功提示,也就是说,上面的跳转如果成功意味着程序会成功注册。

注意 Smali代码的语法与格式会在本书第3章进行详细介绍。

2.2.4 修改Smali文件代码

经过上一小节的分析可以发现,“.line 32”行的代码“if-nez v0, :cond_0”是程序的破解点。if-nez是Dalvik指令集中的一个条件跳转指令,类似的还有if-eqz、if-gez、if-lez等。这些指令会在本书第3章进行介绍,读者在这里只需要知道,与if-nez指令功能相反的指令为if-eqz,表示比较结果为0或相等时进行跳转。

用任意一款文本编辑器打开MainActivity$1.smali文件,将“.line 32”行的代码“if-nez v0, :cond_0”修改为“if-eqz v0, :cond_0”,保存后退出,代码就算修改完成了。

2.2.5 重新编译APK文件并签名

修改完Smali文件代码后,需要将修改后的文件重新进行编译打包成apk文件。2.2.2小节中我们已经知道编译apk文件的命令格式为“apktool b[uild] [OPTS] [<app_path>] [<out_file>]”,打开CMD命令提示符窗口,进入到outdir同目录,执行以下命令。

apktool b outdir

不出意外的话,程序就会编译成功。命令输出结果如图2-5所示,编译成功后会在outdir目录下生成dist目录,里面存放着编译成功的apk文件。

{%}

图2-5 使用ApkTool重新打包Android程序

编译生成的crackme02.apk没有签名,还不能安装测试,接下来需要使用signapk.jar工具对apk文件进行签名。signapk.jar是Android源码包中的一个签名工具。代码位于Android源码目录下的/build/tools/signapk/SignApk.java文件中,源码编译后可以在/out/host/linux- x86/framework目录中找到它。使用signapk.jar签名时需要提供签名文件,我们在此可以使用Android源码中提供的签名文件testkey.pk8与testkey.x509.pem,它们位于Android源码的build/target/product/security目录。新建signapk.bat文件,内容为:

java -jar "%~dp0signapk.jar" "%~dp0testkey.x509.pem" "%~dp0testkey.pk8" %1 signed.apk

将signapk.jar、signapk.bat、testkey.x509.pem、testkey.pk8等4个文件放到同一目录并添加到系统PATH环境变量中,然后在命令提示符下输入如下命令对APK文件进行签名。

signapk crackme02.apk

签名成功后会在同目录下生成signed.apk文件。

2.2.6 安装测试

现在是时候测试修改后的成果了。

启动一个Android AVD,或者使用数据线连接手机与电脑,然后在命令提示符下执行以下命令安装破解后的程序。

adb install signed.apk

不出意外会得到以下输出提示:

adb install signed.apk
822 KB/s (39472 bytes in 0.046s)
     pkg: /data/local/tmp/signed.apk
Success

安装完成后启动crackme02,在用户名与注册码输入框中输入任意字符,点击注册按钮,程序会弹出注册成功提示,并且标题栏字符会变成已注册字样。如图2-6所示。

图2-6 测试破解后的程序

2.3 本章小结

本章通过一个实例介绍了Android程序的一般分析与破解流程。在实际的分析过程中,接触到的代码远比这些要复杂得多,有些代码甚至经过混淆处理过,很难阅读,这样就需要使用其它手段如动态调试结合一些其它的技巧来辅助分析,这些更深入的内容会在本书的后续章节中进行介绍。

目录