静态分析Android程序

说明:本文来自于《Android逆向编程》 静态分析是探索Android程序内幕的一种最常见的方法,它与动态调试双剑合璧,帮助分析人员解决分析时遇到的各类“疑难”问题。当然,静态分析技术本身需要分析人员具备较强的代码理解能力,这些都需要在平时的开发过程中不断地积累经验,很难想象一个连Android应用程序源码都看不懂的人去逆向分析Android程序。

1, 什么是静态分析

静态分析(Static Analysis)是指在不运行代码的情况下,采用词法分析、语法分析等各种技术手段对程序文件进行扫描并生成程序的反汇编代码,然后阅读反汇编代码来掌握程序功能的一种技术。在实际的分析过程中,完全不运行程序是不太可能的,分析人员时常需要先运行目标程序来寻找程序的突破口。静态分析强调的是静态,在整个分析的过程中,阅读反汇编代码是主要的分析工作。生成反汇编代码的工具称为反汇编工具或反编译工具,选择一个功能强大的反汇编工具不仅能获得更好的反汇编效果,而且也能为分析人员节省不少时间。在Android的逆向分析中,常见的工具有APkTool、baksmail、dex2jar和Editor等工具,具体分析时需要灵活的使用这些工具。

静态分析Android程序有两种方法:一种方法是阅读反汇编生成的Dalvik字节码,可以使用IDA Pro分析dex文件,或者使用文本编辑器阅读baksmali反编译生成的smali文件;另一种方法是阅读反汇编生成的Java源码,可以使用dex2jar生成jar文件,然后再使用jd-gui阅读jar文件的代码。

2, 如何快速定位程序的关键代码

在逆向一个Android软件时,由于软件的复杂性,如果盲目的进行分析,可能需要阅读成千上万行的反汇编代码才能找到程序的关键点,这无疑是浪费时间的表现,本小节将介绍如何快速的定位程序的关键代码。

2.1反编译apk程序

每个apk文件中都包含有一个AndroidManifest.xml文件,它记录着软件的一些基本信息。包括软件的包名、运行的系统版本、用到的组件等。并且这个文件被加密存储进了apk文件中,在开始分析前,有必要先反编译apk文件对其进行解密。反编译apk的工具使用前面章节介绍过的Apktool。Apktool提供了反编译与打包apk文件的功能。本小节使用到的实例程序为crackme0502.apk,按照前面使用Apktool的步骤,在命令提示符下输入“apktool d crackme0502.apk”即可反编译成功。当然,也可以借鉴一些一键反编译工具。

2.2 程序的主Activity

我们知道,一个Android程序由一个或多个Activity以及其它组件组成,每个Activity都是相同级别的,不同的Activity实现不同的功能。每个Activity都是Android程序的一个显示“页面”,主要负责数据的处理及展示工作,在Android程序的开发过程中,程序员很多时候是在编写用户与Activity之间的交互代码。

每个Android程序有且只有一个主Activity(隐藏程序除外,它没有主Activity),它是程序启动的第一个Activity。例如,打开crackme0502文件夹下的AndroidManifest.xml文件,其中有如下片断的代码。

<activity android:label="@string/title_activity_main" android:name=". MainActivity">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

在程序中使用到的Activity都需要在AndroidManifest.xml文件中手动声明,声明Activity使用activity标签,其中android:label指定Activity的标题,android:name指定具体的Activity类,“.MainActivity”前面省略了程序的包名,完整类名应该为com.droider.crackme0502. MainActivity,intent-filter指定了Activity的启动意图,android.intent.action.MAIN表示这个Activity是程序的主Activity。 android.intent.category.LAUNCHER表示这个Activity可以通过LAUNCHER来启动。如果AndroidMenifest.xml中,所有的Activity都没有添加android.intent.category.LAUNCHER,那么该程序安装到Android设备上后,在程序列表中是不可见的,同样的,如果没有指定android.intent.action.MAIN,Android系统的LAUNCHER就无法匹配程序的主Activity,因此该程序也不会有图标出现。

在反编译出的AndroidManifest.xml中找到主Activity后,可以直接去查看其所在类的OnCreate()方法的反汇编代码,对于大多数软件来说,这里就是程序的代码入口处,所有的功能都从这里开始得到执行,我们可以沿着这里一直向下查看,追踪软件的执行流程。

2.3 重点关注的Application类

如果需要在程序的组件之间传递全局变量,或者在Activity启动之前做一些初始化工作,就可以考虑使用Application类了。使用Application时需要在程序中添加一个类继承自android.app.Application,然后重写它的OnCreate()方法,在该方法中初始化的全局变量可以在Android其它组件中访问,当然前提条件是这些变量具有public属性。最后还需要在AndroidManifest.xml文件的Application标签中添加“android:name”属性,取值为继承自android.app.Application的类名。

鉴于Application类比程序中其它的类启动得都要早,一些商业软件将授权验证的代码都转移到了该类中。例如,在OnCreate()方法中检测软件的购买状态,如果状态异常则拒绝程序继续运行。因此,在分析Android程序过程中,我们需要先查看该程序是否具有Application类,如果有,就要看看它的OnCreate()方法中是否做了一些影响到逆向分析的初始化工作。

2.4 定位关键代码的常用方法

如果盲目的对一个Android程序进行分析,可能需要阅读成千上万行的反汇编代码才能找到程序的关键点,这无疑是浪费时间的表现。

信息反馈法

所谓信息反馈法,是指先运行目标程序,然后根据程序运行时给出的反馈信息作为突破口寻找关键代码。在第2章中,我们运行目标程序并输入错误的注册码时,会弹出提示“无效用户名或注册码”,这就是程序反馈给我们的信息。通常情况下,程序中用到的字符串会存储在String.xml文件或者硬编码到程序代码中,如果是前者的话,字符串在程序中会以id的形式访问,只需在反汇编代码中搜索字符串的id值即可找到调用代码处;如果是后者的话,在反汇编代码中直接搜索字符串即可。

特征函数法

这种定位代码的方法与信息反馈法类似。在信息反馈法中,无论程序给出什么样的反馈信息,终究是需要调用Android SDK中提供的相关API函数来完成的。比如弹出注册码错误的提示信息就需要调用Toast.MakeText().Show()方法,在反汇编代码中直接搜索Toast应该很快就能定位到调用代码,如果Toast在程序中有多处的话,可能需要分析人员逐个甄别。

顺序查看法

顺序查看法是指从软件的启动代码开始,逐行的向下分析,掌握软件的执行流程,这种分析方法在病毒分析时经常用到。

代码注入法

代码注入法属于动态调试方法,它的原理是手动修改apk文件的反汇编代码,加入Log输出,配合LogCat查看程序执行到特定点时的状态数据。这种方法在解密程序数据时经常使用,详细的内容会在本书的第8章介绍。

栈跟踪法

栈跟踪法属于动态调试方法,它的原理是输出运行时的栈跟踪信息,然后查看栈上的函数调用序列来理解方法的执行流程,这种方法的详细内容会在本书的第8章介绍。

Method Profiling

Method Profiling(方法剖析)属于动态调试方法,它主要用于热点分析和性能优化。该功能除了可以记录每个函数占用的CPU时间外,还能够跟踪所有的函数调用关系,并提供比栈跟踪法更详细的函数调用序列报告,这种方法在实践中可帮助分析人员节省很多时间,也被广泛使用,

3,阅读smali文件

3.1 smali文件格式

使用Apktool反编译apk文件后,会在反编译工程目录下生成一个smali文件夹,里面存放着所有反编译出的smali文件,这些文件会根据程序包的层次结构生成相应的目录,程序中所有的类都会在相应的目录下生成独立的smali文件。如上一节中程序的主Activity名为com.droider.crackme0502. MainActivity,就会在smali目录下依次生成com\droider\ crackme0502目录结构,然后在这个目录下生成MainActivity.smali文件。

smali文件的代码通常情况下比较长,而且指令繁多,在阅读时很难用肉眼捕捉到重点,如果有阅读工具能够将特殊指令(例如条件跳转指令)高亮显示,势必会让分析工作事半功倍,为此笔者专门为文本编辑器Notepad++编写了smali语法文件来支持高亮显示与代码折叠,并以此作为smali代码的阅读工具。

无论是普通类、抽象类、接口类或者内部类,在反编译出的代码中,它们都以单独的smali文件来存放。每个smali文件都由若干条语句组成,所有的语句都遵循着一套语法规范。在smali文件的头3行描述了当前类的一些信息,格式如下。

.class <访问权限> [修饰关键字] <类名>
.super <父类名>
.source <源文件名>
打开MainActivity.smali文件,头3行代码如下。
.class public Lcom/droider/crackme0502/MainActivity;
.super Landroid/app/Activity;
.source "MainActivity.java"

第1行“.class”指令指定了当前类的类名。在本例中,类的访问权限为public,类名为“Lcom/droider/crackme0502/MainActivity;”,类名开头的L是遵循Dalvik字节码的相关约定,表示后面跟随的字符串为一个类。

第2行的“.super”指令指定了当前类的父类。本例中的“Lcom/droider/crackme0502/ MainActivity;”的父类为“Landroid/app/Activity;”。

第3行的“.source”指令指定了当前类的源文件名。

前3行代码过后就是类的主体部分了,一个类可以由多个字段或方法组成。smali文件中字段的声明使用“.field”指令。字段有静态字段与实例字段两种。静态字段的声明格式如下。

# static fields
.field <访问权限> static [修饰关键字] <字段名>:<字段类型>

baksmali在生成smali文件时,会在静态字段声明的起始处添加“static fields”注释,smali文件中的注释与Dalvik语法一样,也是以井号“#”开头。“.field”指令后面跟着的是访问权限,可以是public、private、protected之一。修饰关键字描述了字段的其它属性,如synthetic。指令的最后是字段名与字段类型,使用冒号“:”分隔,语法上与Dalvik也是一样的。

实例字段的声明与静态字段类似,只是少了static关键字,它的格式如下。

# instance fields
.field <访问权限> [修饰关键字] <字段名>:<字段类型>

比如以下的实例字段声明。

# instance fields
.field private btnAnno:Landroid/widget/Button;

第1行的“instance fields”是baksmali生成的注释,第2行表示一个私有字段btnAnno,它的类型为“Landroid/widget/Button;”。

如果一个类中含有方法,那么类中必然会有相关方法的反汇编代码,smali文件中方法的声明使用“.method”指令。方法有直接方法与虚方法两种。直接方法的声明格式如下。

# direct methods
.method <访问权限> [修饰关键字] <方法原型>
    <.locals>
    [.parameter]
    [.prologue]
    [.line]
<代码体>
.end method

“direct methods”是baksmali添加的注释,访问权限和修饰关键字与字段的描述相同,方法原型描述了方法的名称、参数与返回值。“.locals”指定了使用的局部变量的个数。“.parameter”指定了方法的参数,与Dalvik语法中使用“.parameters”指定参数个数不同,每个“.parameter”指令表明使用一个参数,比如方法中有使用到3个参数,那么就会出现3条“.parameter”指令。“.prologue”指定了代码的开始处,混淆过的代码可能去掉了该指令。“.line”指定了该处指令在源代码中的行号,同样的,混淆过的代码可能去除了行号信息。

虚方法的声明与直接方法相同,只是起始处的注释为“virtual methods”。

如果一个类实现了接口,会在smali文件中使用“.implements”指令指出。相应的格式声明如下。

# interfaces
.implements <接口名>

“# interfaces”是baksmali添加的接口注释,“.implements”是接口关键字,后面的接口名是DexClassDef结构中interfacesOff字段指定的内容。

如果一个类使用了注解,会在smali文件中使用“.annotation”指令指出。注解的格式声明如下。

# annotations
.annotation [注解属性] <注解类名>
    [注解字段 = 值]
.end annotation

注解的作用范围可以是类、方法或字段。如果注解的作用范围是类,“.annotation”指令会直接定义在smali文件中,如果是方法或字段,“.annotation”指令则会包含在方法或字段定义中。例如下面的代码。

# instance fields
.field public sayWhat:Ljava/lang/String;
    .annotation runtime Lcom/droider/anno/MyAnnoField;
        info = "Hello my friend"
    .end annotation
.end field

实例字段sayWhat为String类型,它使用了com.droider.anno.MyAnnoField注解,注解字段info值为“Hello my friend”。将其转换为Java代码如下所示:

@ com.droider.anno MyAnnoField(info = "Hello my friend")
public String sayWhat;

3.2 如何阅读smali代码

apk文件通过apktool反编译出来的都有一个smali文件夹,里面都是以.smali结尾的文件。smali语言是Davlik的寄存器语言,语法上和汇编语言相似,Dalvik VM与JVM的最大的区别之一就是Dalvik VM是基于寄存器的。基于寄存器的意思是,在smali里的所有操作都必须经过寄存器来进行。

循环语句

循环语句是程序开发中最常用的语句结构,在Android开发过程中,常见的循环结构有迭代器循环、for循环、while循环、do while循环。我们在编写迭代器循环代码时,一般是如下形式的代码。

Iterator<对象> <对象名> = <方法返回一个对象列表>;
for (<对象> <对象名> : <对象列表>) {
    [处理单个对象的代码体]
}

或者:

Iterator<对象> <迭代器> = <方法返回一个迭代器>;
while (<迭代器>.hasNext()) {
    <对象> <对象名> = <迭代器>.next();
    [处理单个对象的代码体]
}

第一种方式的迭代是for关键字中将对象名与对象列表用冒号“:”隔开,然后在循环体中直接访问单个对象,这种方式的代码简练、可读性好,在实际的编程过程中使用颇多。第二种方式是手动获取一个迭代器,然后在一个循环中调用迭代器中的hasNext()方法检测是否为空,最后在代码循环体中调用其next()方法来遍历迭代器。

将本节提供的示例程序Circulate.apk反编译,然后打开反编译工程smali\com\droider\ circulate目录下的MainActivity.smali文件,找到iterator()方法的代码如下。

.method private iterator()V
    .locals 7
    .prologue
    .line 34
    const-string v4, "activity"
    invoke-virtual {p0, v4}, Lcom/droider/circulate/MainActivity;->
    getSystemService
         (Ljava/lang/String;)Ljava/lang/Object;    #获取ActivityManager
    move-result-object v0
    check-cast v0, Landroid/app/ActivityManager;
    .line 35
    .local v0, activityManager:Landroid/app/ActivityManager;
    invoke-virtual {v0}, Landroid/app/ActivityManager;->getRunningAppProcesses()
    Ljava/util/List;
    move-result-object v2        #正在运行的进程列表
    .line 36
    .local v2, psInfos:Ljava/util/List;,
        "Ljava/util/List<Landroid/app/ActivityManager$RunningAppProcessInfo;>;"
    new-instance v3, Ljava/lang/StringBuilder;    #新建一个StringBuilder对象
    invoke-direct {v3}, Ljava/lang/StringBuilder;-><init>()V #调用StringBuilder
                                                                      构造函数
    .line 37
    .local v3, sb:Ljava/lang/StringBuilder;
    invoke-interface {v2}, Ljava/util/List;->iterator()Ljava/util/Iterator;    #获取进程列表的迭代器
    move-result-object v4
    :goto_0    #迭代循环开始
    invoke-interface {v4}, Ljava/util/Iterator;->hasNext()Z        #开始迭代
    move-result v5
    if-nez v5, :cond_0    #如果迭代器不为空就跳走
    .line 40
    invoke-virtual {v3}, Ljava/lang/StringBuilder;->toString()Ljava/lang/
    String;
    move-result-object v4    # StringBuilder转为字符串
    const/4 v5, 0x0
    invoke-static {p0, v4, v5}, Landroid/widget/Toast;->makeText
         (Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/ widget/
         Toast;
    move-result-object v4
    invoke-virtual {v4}, Landroid/widget/Toast;->show()V    # 弹出StringBuilder
    的内容
    .line 41
    return-void    #方法返回
    .line 37
    :cond_0
    invoke-interface {v4}, Ljava/util/Iterator;->next()Ljava/lang/Object; 
    #循环获取每一项
    move-result-object v1
    check-cast v1, Landroid/app/ActivityManager$RunningAppProcessInfo;
    .line 38
    .local v1, info:Landroid/app/ActivityManager$RunningAppProcessInfo;
    new-instance v5, Ljava/lang/StringBuilder;    #新建一个临时的StringBuilder
    iget-object v6, v1, Landroid/app/ActivityManager$RunningAppProcessInfo;
        ->processName:Ljava/lang/String;                #获取进程的进程名
    invoke-static {v6}, Ljava/lang/String;->valueOf(Ljava/lang/Object;)
    Ljava/lang/String;
    move-result-object v6
    invoke-direct {v5, v6}, Ljava/lang/StringBuilder;-><init>(Ljava/lang/
    String;)V
    const/16 v6, 0xa    #换行符
    invoke-virtual {v5, v6}, Ljava/lang/StringBuilder;->append(C)Ljava/
    lang/StringBuilder;
    move-result-object v5 #组合进程名与换行符
    invoke-virtual {v5}, Ljava/lang/StringBuilder;->toString()Ljava/lang/
    String;
    move-result-object v5
    invoke-virtual {v3, v5}, Ljava/lang/StringBuilder; #将组合后的字符串添加到
    StringBuilder末尾
        ->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
    goto :goto_0        #跳转到循环开始处
.end method

这段代码的功能是获取正在运行的进程列表,然后使用Toast弹出所有的进程名。获取正在运行的进程列表使用ActivityManager类的getRunningAppProcesses()方法,后者会返回一个List< RunningAppProcessInfo>对象,在上面的代码中,调用了List的iterator()来获取进程列表的迭代器,然后从标号goto_0开始进入迭代循环。在循环中首先调用迭代器的hasNext()方法检测迭代器是否为空,如果迭代器为空就调用Toast弹出所有进程信息,如果不为空,说明迭代器中的内容还没有取完,调用迭代器的next()方法获取单个RunningAppProcessInfo对象,接着新建一个临时的StringBuilder,将进程名与换行符组合后添加到循环开始前创建的StringBuilder中,最后使用goto语句跳转到循环体的开始处。

看完这一段代码,读者肯定会发现它与上面列出的while循环声明非常相似了!没错,第一种迭代器循环展开后就是第二种循环的实现,虽然彼此的Java代码不同,但生成的反汇编代码极具相似。总结一下,迭代器循环有如下特点:

  • 迭代器循环会调用迭代器的hasNext()方法检测循环条件是否满足。
  • 迭代器循环中调用迭代器的next()方法获取单个对象。
  • 循环中使用goto指令来控制代码的流程。
  • for形式的迭代器循环展开后即为while形式的迭代器循环。 下面看看传统的for循环,找到MainActivity.smali文件中的forCirculate()方法,代码如下。
.method private forCirculate()V
    .locals 8
    .prologue
    .line 47
    invoke-virtual {p0}, Lcom/droider/circulate/MainActivity;-
        >getApplicationContext()Landroid/content/Context;
    move-result-object v6
    invoke-virtual {v6}, Landroid/content/Context;        #获取PackageManager
        ->getPackageManager()Landroid/content/pm/PackageManager;
    move-result-object v3
    .line 49
    .local v3, pm:Landroid/content/pm/PackageManager;
    const/16 v6, 0x2000
    .line 48
    invoke-virtual {v3, v6}, Landroid/content/pm/PackageManager;
        ->getInstalledApplications(I)Ljava/util/List;    #获取已安装的程序列表
    move-result-object v0
    .line 50
    .local v0, appInfos:Ljava/util/List;,"Ljava/util/List<Landroid/content/pm
    /ApplicationInfo;>;"
    invoke-interface {v0}, Ljava/util/List;->size()I    #获取列表中ApplicationInfo
    对象的个数
    move-result v5
    .line 51
    .local v5, size:I
    new-instance v4, Ljava/lang/StringBuilder;    #新建一个StringBuilder对象
    invoke-direct {v4}, Ljava/lang/StringBuilder;-><init>()V        #调用
    StringBuilder的构造函数
    .line 52
    .local v4, sb:Ljava/lang/StringBuilder;
    const/4 v1, 0x0
    .local v1, i:I    #初始化v1为0
    :goto_0    #循环开始
    if-lt v1, v5, :cond_0        #如果v1小于v5,则跳转到cond_0    标号处
    .line 56
    invoke-virtual {v4}, Ljava/lang/StringBuilder;->toString()Ljava/
    lang/String;
    move-result-object v6
    const/4 v7, 0x0
    invoke-static {p0, v6, v7}, Landroid/widget/Toast;    #构造Toast
        ->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)
        Landroid/widget/Toast;
    move-result-object v6
    invoke-virtual {v6}, Landroid/widget/Toast;->show()V    #显示已安装的程序列表
    .line 57
    return-void    #方法返回
    .line 53
    :cond_0
    invoke-interface {v0, v1}, Ljava/util/List;->get(I)Ljava/lang/Object; 
    #单个ApplicationInfo
    move-result-object v2
    check-cast v2, Landroid/content/pm/ApplicationInfo;
    .line 54
    .local v2, info:Landroid/content/pm/ApplicationInfo;
    new-instance v6, Ljava/lang/StringBuilder;    #新建一个临时StringBuilder对象
    iget-object v7, v2, Landroid/content/pm/ApplicationInfo;->packageName:
    Ljava/lang/String;
    invoke-static {v7}, Ljava/lang/String;->valueOf(Ljava/lang/Object;)
    Ljava/lang/String;
    move-result-object v7    #包名
    invoke-direct {v6, v7}, Ljava/lang/StringBuilder;-><init>(Ljava/lang/
    String;)V
    const/16 v7, 0xa        #换行符
    invoke-virtual {v6, v7}, Ljava/lang/StringBuilder;->append(C)Ljava/
    lang/StringBuilder;
    move-result-object v6    #组合包名与换行符
    invoke-virtual {v6}, Ljava/lang/StringBuilder;->toString()Ljava/lang
    /String;    #转换为字符串
    move-result-object v6
    invoke-virtual {v4, v6}, Ljava/lang/StringBuilder;-
        >append(Ljava/lang/String;)Ljava/lang/StringBuilder;    #添加到循环外
        的StringBuilder中
    .line 52
    add-int/lit8 v1, v1, 0x1    #下一个索引
    goto :goto_0        #跳转到循环起始处
.end method

这段代码的功能是获取所有安装的程序,然后使用Toast弹出所有的软件包名。获取所有安装的程序使用PackageManager类的getInstalledApplications()方法,代码首先创建了一个StringBuilder对象用来存放所有的字符串信息,接着初始化v1寄存器为0作为获取列表项的索引, for循环的起始处是goto_0标号,循环条件的代码为“if-lt v1, v5, :cond_0”,v1为索引值,v5为列表中ApplicationInfo的个数,cond_0标号处的代码为循环体,如果没有索引到最后一项,代码都会跳到cond_0标号处去执行,相反,如果索引完了,代码会顺序执行Toast显示所有的字符串信息。cond_0标号处的第一行代码调用List的get()方法获取列表中的单个ApplicationInfo对象,然后组合包名与换行符后添加到先前声明的StringBuilder中,最后将v1索引值加一后调用“goto :goto_0”语句跳转到循环起始处。

看完了for循环的代码,可以发现它有如下特点:

  • 在进入循环前,需要先初始化循环计数器变量,且它的值需要在循环体中更改。
  • 循环条件判断可以是条件跳转指令组成的合法语句。
  • 循环中使用goto指令来控制代码的流程。 接下来是while循环与do while循环,两者结构差异不大,只是循环条件判断的位置有所不同。并且它们的代码与前面介绍的迭代器循环代码十分相似。笔者在此就不列出了,有兴趣的读者可自行阅读MainActivity.smali文件中的whileCirculate()与dowhileCirculate()方法的代码。

switch分支语句

switch分支也是比较常见的语句结构,经常出现在判断分支比较多的代码中。使用Apktool反编译本书配套源代码中5.5.2小节提供的SwitchCase.apk文件,打开反编译后工程目录中的smali\com\droider\switchcase\MainActivity.smali文件,找到packedSwitch()方法的代码如下。

.method private packedSwitch(I)Ljava/lang/String;
    .locals 1
    .parameter "i"
    .prologue
    .line 21
    const/4 v0, 0x0
    .line 22
    .local v0, str:Ljava/lang/String;    #v0为字符串,0表示null
    packed-switch p1, :pswitch_data_0    #packed-switch分支,pswitch_data_0指
    定case区域
    .line 36
    const-string v0, "she is a person"    #default分支
    .line 39
    :goto_0            #所有case的出口
    return-object v0    #返回字符串v0
    .line 24
    :pswitch_0        #case 0
    const-string v0, "she is a baby"
    .line 25
    goto :goto_0        #跳转到goto_0标号处
    .line 27
    :pswitch_1        #case 1
    const-string v0, "she is a girl"
    .line 28
    goto :goto_0        #跳转到goto_0标号处
    .line 30
    :pswitch_2        #case 2
    const-string v0, "she is a woman"
    .line 31
    goto :goto_0        #跳转到goto_0标号处
    .line 33
    :pswitch_3        #case 3
    const-string v0, "she is an obasan"
    .line 34
    goto :goto_0        #跳转到goto_0标号处
    .line 22
    nop
    :pswitch_data_0
    .packed-switch 0x0        #case 区域,从0开始,依次递增
        :pswitch_0    #case 0
        :pswitch_1    #case 1
        :pswitch_2    #case 2
        :pswitch_3    #case 3
    .end packed-switch
.end method

代码中的switch分支使用的是packed-switch指令。p1为传递进来的int类型的数值,pswitch_data_0为case区域,在case区域中,第一条指令“.packed-switch”指定了比较的初始值为0,pswitch_0~ pswitch_3分别是比较结果为“case 0”到“case 3”时要跳转到的地址。可以发现,标号的命名采用pswitch_开关,后面的数值为case分支需要判断的值,并且它的值依次递增。再来看看这些标号处的代码,每个标号处都使用v0寄存器初始化一个字符串,然后跳转到了goto_0标号处,可见goto_0是所有的case分支的出口。另外,“.packed-switch”区域指定的case分支共有4条,对于没有被判断的default分支,会在代码的packed-switch指令下面给出。

packed-switch指令在Dalvik中的格式如下:

packed-switch vAA, +BBBBBBBB

指令后面的“+BBBBBBBB”被指明为一个packed-switch-payload格式的偏移。它的格式如下。

struct  packed-switch-payload {
    ushort ident;    /* 值固定为0x0100 */
    ushort size;    /* case数目 */
    int first_key;    /* 初始case的值 */
    int[] targets;    /* 每个case相对switch指令处的偏移 */
};

打开IDA Pro找到“packed-switch p1, :pswitch_data_0”指令位于0x2cb1a处,相应的机器码为“2B 02 13 00 00 00”,手动分析机器码如下: 2B为packed-switch的OpCode。

02为寄存器p1。 00000013为偏移量0x13。

Dalvik中计算偏移是以两个字节为单位,因为实际该指令指向的packed-switch-payload结构体的偏移量为0x2cb1a + 2 * 0x13 = 0x2cb40。使用C32asm查看该处的数据如图5-2所示。

第1个ident字段为0x100,标识packed-switch有效的case区域。第2个字段size为4,表明有4个case。第3个字段first_key为0,表明初始case值为0。第4个字段为偏移量,分别为0x6、0x9、0xc、0xf,加上packed-switch指令的偏移值0x2cb1a,计算可得:

case 0位置 = 0x2cb1a + 2 * 0x6 = 0x2cb26 case 1位置 = 0x2cb1a + 2 * 0x9 = 0x2cb2c case 2位置 = 0x2cb1a + 2 * 0xc = 0x2cb32 case 3位置 = 0x2cb1a + 2 * 0xf = 0x2cb38

至此,有规律递增的switch分支就算是搞明白了。最后,将这段smali代码整理为Java代码如下。

private String packedSwitch(int i) {
    String str = null;
    switch (i) {
        case 0:
            str = "she is a baby";
            break;
        case 1:
            str = "she is a girl";
            break;
        case 2:
            str = "she is a woman";
            break;
        case 3:
            str = "she is an obasan";
            break;
        default:
            str = "she is a person";
            break;
    }
    return str;
}

现在我们来看看无规律的case分支语句代码会有什么不同,找到MainActivity.smali文件的sparseSwitch()方法代码如下:

.method private sparseSwitch(I)Ljava/lang/String;
    .locals 1
    .parameter "age"
    .prologue
    .line 43
    const/4 v0, 0x0
    .line 44
    .local v0, str:Ljava/lang/String;
    sparse-switch p1, :sswitch_data_0    # sparse-switch分支,sswitch_data_0
    指定case区域
    .line 58
    const-string v0, "he is a person"    #case default
    .line 61
    :goto_0                #case 出口
    return-object v0        #返回字符串
    .line 46
    :sswitch_0        #case 5
    const-string v0, "he is a baby"
    .line 47
    goto :goto_0    #跳转到goto_0标号处
    .line 49
    :sswitch_1        #case 15
    const-string v0, "he is a student"
    .line 50
    goto :goto_0    #跳转到goto_0标号处
    .line 52
    :sswitch_2        #case 35
    const-string v0, "he is a father"
    .line 53
    goto :goto_0    #跳转到goto_0标号处
    .line 55
    :sswitch_3        #case 65
    const-string v0, "he is a grandpa"
    .line 56
    goto :goto_0    #跳转到goto_0标号处
    .line 44
    nop
    :sswitch_data_0
    .sparse-switch                #case区域
        0x5 -> :sswitch_0        #case 5(0x5)
        0xf -> :sswitch_1        #case 15(0xf)
        0x23 -> :sswitch_2        #case 35(0x23)
        0x41 -> :sswitch_3        #case 65(0x41)
    .end sparse-switch
.end method

代码中的switch分支使用的是sparse-switch指令。按照分析packed-switch的方法,我们直接查看sswitch_data_0标号处的内容。可以看到“.sparse-switch”指令没有给出初始case的值,所有的case值都使用“case值 -> case标号”的形式给出。此处共有4个case,它们的内容都是构造一个字符串,然后跳转到goto_0标号处,代码架构上与packed-switch方式的switch分支一样。

sparse-switch指令在Dalvik中的格式如下:

sparse-switch vAA, +BBBBBBBB

指令后面的“+BBBBBBBB”被指明为一个sparse-switch-payload格式的偏移。它的格式如下。

struct sparse-switch-payload {
    ushort ident;    /* 值固定为0x0200 */
    ushort size;    /* case数目 */
    int[] keys;    /* 每个case的值,顺序从低到高 */
    int[] targets;    /* 每个case相对switch指令处的偏移 */
};

同样地,打开IDA Pro找到“sparse-switch p1, :sswitch_data_0”指令位于0x2cb6a处,相应的机器码为“2C 02 13 00 00 00”,手动分析机器码如下:

2C为sparse -switch的OpCode。

02为寄存器p1。 00000013为偏移量0x13。

因为实际该指令指向的sparse-switch-payload结构体的偏移量为0x2cb6a + 2 * 0x13 = 0x2cb90。该处的数据如图5-3所示。

第1个ident字段为0x200,标识sparse-switch有效的case区域。第2个字段size为4,表明有4个case。第3个字段keys为4个case的值,分别为0x5、0xf、0x23、0x41。第4个字段分别为偏移量,分别为0x6、0x9、0xc、0xf,加上sparse -switch指令的偏移值0x2cb6a,计算可得:

case 0位置 = 0x2cb6a + 2 * 0x6 = 0x2cb76 case 1位置 = 0x2cb6a + 2 * 0x9 = 0x2cb7c case 2位置 = 0x2cb6a + 2 * 0xc = 0x2cb82 case 3位置 = 0x2cb6a + 2 * 0xf = 0x2cb88

最后,将这段smali代码整理为Java代码如下。

private String sparseSwitch(int age) {
    String str = null;
    switch (age) {
        case 5:
            str = "he is a baby";
            break;
        case 15:
            str = "he is a student";
            break;
        case 35:
            str = "he is a father";
            break;
        case 65:
            str = "he is a grandpa";
            break;
        default:
            str = "he is a person";
            break;
    }
    return str;
}

try/catch语句

在实际编写代码过程中,各种预想不到的结果都有可能出现,为了尽可能的捕捉到异常信息,有必要在代码中使用Try/Catch语句将可能发生问题的代码“包裹”起来。使用Apktool反编译随书5.5.3小节提供的TryCatch.apk文件,打开反编译后工程目录中的smali\com\droider\ trycatch\MainActivity.smali文件,找到tryCatch()方法代码如下。

.method private tryCatch(ILjava/lang/String;)V
    .locals 10
    .parameter "drumsticks"
    .parameter "peple"
    .prologue
    const/4 v9, 0x0
    .line 19
    :try_start_0        # 第1个try开始
    invoke-static {p2}, Ljava/lang/Integer;->parseInt(Ljava/lang/String;)I
    #将第2个参数转换为int型
    :try_end_0        # 第1个try结束
    .catch Ljava/lang/NumberFormatException; {:try_start_0 .. :try_end_0} :
    catch_1    # catch_1
    move-result v1    #如果出现异常这里不会执行,会跳转到catch_1标号处
    .line 21
    .local v1, i:I        #.local声明的变量作用域在.local声明与.end local之间
    :try_start_1        #第2个try开始
    div-int v2, p1, v1    #第1个参数除以第2个参数
    .line 22
    .local v2, m:I        #m为商
    mul-int v5, v2, v1    #m * i
    sub-int v3, p1, v5    #v3为余数
    .line 23
    .local v3, n:I
    const-string v5, "\u5171\u6709%d\u53ea\u9e21\u817f\uff0c%d
        \u4e2a\u4eba\u5e73\u5206\uff0c\u6bcf\u4eba\u53ef\u5206\u5f97%d
        \u53ea\uff0c\u8fd8\u5269\u4e0b%d\u53ea"        #格式化字符串
    const/4 v6, 0x4
    new-array v6, v6, [Ljava/lang/Object;
    const/4 v7, 0x0
    .line 24
    invoke-static {p1}, Ljava/lang/Integer;->valueOf(I)Ljava/lang/Integer;
    move-result-object v8
    aput-object v8, v6, v7
    const/4 v7, 0x1
    invoke-static {v1}, Ljava/lang/Integer;->valueOf(I)Ljava/lang/Integer;
    move-result-object v8
    aput-object v8, v6, v7
    const/4 v7, 0x2
    invoke-static {v2}, Ljava/lang/Integer;->valueOf(I)Ljava/lang/Integer;
    move-result-object v8
    aput-object v8, v6, v7
    const/4 v7, 0x3
    invoke-static {v3}, Ljava/lang/Integer;->valueOf(I)Ljava/lang/Integer;
    move-result-object v8
    aput-object v8, v6, v7
    .line 23
    invoke-static {v5, v6}, Ljava/lang/String;
        ->format(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
    move-result-object v4
    .line 25
    .local v4, str:Ljava/lang/String;
    const/4 v5, 0x0
    invoke-static {p0, v4, v5}, Landroid/widget/Toast;
        ->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)
        Landroid/widget/Toast;
    move-result-object v5
    invoke-virtual {v5}, Landroid/widget/Toast;->show()V    #使用Toast显示格
    式化后的结果
    :try_end_1    #第2个try结束
    .catch Ljava/lang/ArithmeticException; {:try_start_1 .. :try_end_1} :
    catch_0        # catch_0
    .catch Ljava/lang/NumberFormatException; {:try_start_1 .. :try_end_1} :
    catch_1        # catch_1
    .line 33
    .end local v1           #i:I
    .end local v2           #m:I
    .end local v3           #n:I
    .end local v4           #str:Ljava/lang/String;
    :goto_0    
    return-void    #方法返回
    .line 26
    .restart local v1       #i:I
    :catch_0        
    move-exception v0
    .line 27
    .local v0, e:Ljava/lang/ArithmeticException;
    :try_start_2        #第3个try开始
    const-string v5, "\u4eba\u6570\u4e0d\u80fd\u4e3a0"    #“人数不能为0”
    const/4 v6, 0x0
    invoke-static {p0, v5, v6}, Landroid/widget/Toast;
        ->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)
        Landroid/widget/Toast;
    move-result-object v5
    invoke-virtual {v5}, Landroid/widget/Toast;->show()V    #使用Toast显示异
    常原因
    :try_end_2        #第3个try结束
    .catch Ljava/lang/NumberFormatException; {:try_start_2 .. :try_end_2} :
    catch_1
    goto :goto_0    #返回
    .line 29
    .end local v0           #e:Ljava/lang/ArithmeticException;
    .end local v1           #i:I
    :catch_1
    move-exception v0
    .line 30
    .local v0, e:Ljava/lang/NumberFormatException;
    const-string v5, "\u65e0\u6548\u7684\u6570\u503c\u5b57\u7b26\u4e32" 
    #“无效的数值字符串”
    invoke-static {p0, v5, v9}, Landroid/widget/Toast;
        ->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)
        Landroid/widget/Toast;
    move-result-object v5
    invoke-virtual {v5}, Landroid/widget/Toast;->show()V    #使用Toast显示异
    常原因
    goto :goto_0    #返回
.end method

整段代码的功能比较简单,输入鸡腿数与人数,然后使用Toast弹出鸡腿的分配方案。传入人数时为了演示Try/Catch效果,使用了String类型。代码中有两种情况下会发生异常:第一种是将String类型转换成int类型时可能会发生NumberFormatException异常;第二种是计算分配方法时除数为零的ArithmeticException异常。

代码中的try语句块使用try_start_开头的标号注明,以try_end_开头的标号结束。第一个try语句的开头标号为try_start_0,结束标号为try_end_0。使用多个try语句块时标号名称后面的数值依次递增,本实例代码中最多使用到了try_end_2。

在try_end_0标号下面使用“.catch”指令指定处理到的异常类型与catch的标号,格式如下。

.catch <异常类型> {<try起始标号> .. <try结束标号>} <catch标号>

查看catch_1标号处的代码发现,当转换String到int时发生异常会弹出“无效的数值字符串”的提示。对于代码中的汉字,baksmali在反编译时将其使用Unicode进行编码,因此,在阅读前需要使用相关的编码转换工具进行转换。

仔细阅读代码会发现在try_end_1标号下面使用“.catch”指令定义了catch_0与catch_1两个catch。catch_0标号的代码开头又有一个标号为try_start_2的try语句块,其实这个try语句块是虚构的,假如下面的代码。

private void a() {
    try {
        ……
        try {
            ……
        } catch (XXX) {
            ……
        }
    } catch (YYY) {
        ……
    }      
}

当执行内部的try语句时发生了异常,如果异常类型为XXX,则内部catch就会捕捉到并执行相应的处理代码,如果异常类型不是XXX,那么就会到外层的catch中去查找异常处理代码,这也就是为什么实例的try_end_1标号下面会有两个catch的原因,另外,如果在执行XXX异常的处理代码时又发生了异常,这个时候该怎么办?此时这个异常就会扩散到外层的catch中去,由于XXX异常的外层只有一个YYY的异常处理,这时会判断发生的异常是否为YYY类型,如果是就会进行处理,不是则抛给应用程序。回到本实例中来,如果在执行内部的ArithmeticException异常处理时再次发生别的异常,就会调用外层的catch进行异常捕捉,因此在try_end_2标号下面有一个catch_1就很好理解了。

在Dalvik指令集中,并没有与Try/Catch相关的指令,在处理Try/Catch语句时,是通过相关的数据结构来保存异常信息的。回忆一下上一章讲解dex文件格式时,曾经介绍过的DexCode数据结构,它的声明如下。

struct DexCode {
    u2  registersSize;    /* 使用的寄存器个数 */
    u2  insSize;         /* 参数个数 */
    u2  outsSize;         /* 调用其它方法时使用的寄存器个数 */
    u2  triesSize;         /* Try/Catch个数 */
    u4  debugInfoOff;     /* 指向调试信息的偏移 */
    u4  insnsSize;         /* 指令集个数,以2字节为单位 */
    u2  insns[1];         /* 指令集 */
    /* 2字节空间用于结构对齐 */
    /* try_item[triesSize]  DexTry 结构*/
    /* Try/Catch中handler的个数 */
    /* catch_handler_item[handlersSize] ,DexCatchHandler结构*/
};
该结构下面的try_item就保存了try语句的信息,它的结构DexTry声明如下。
struct DexTry {
    u4  startAddr;          /* 起始地址 */
    u2  insnCount;          /* 指令数量 */
    u2  handlerOff;        /* handler的偏移 */
};

每个DexTry保存了try语句的起始地址和指令的数量,这样就可以计算出try语句块包含的地址范围。在try_item字段的下面就是handler的个数。下面我们来看看在dex文件中存储的Try/Catch信息,该实例的类个数较多,手动查找比较慢,在这里使用Android SDK中的dexdump工具,首先使用解压缩软件取出TryCatch.apk中的classes.dex文件,然后在命令提示符下输入以下命令:

dexdump classes.dex > dump.txt

打开生成的dump.txt文件,搜索tryCatch可找到如下内容。

……
#1              : (in Lcom/droider/trycatch/MainActivity;)
      name          : 'tryCatch'
      type          : '(ILjava/lang/String;)V'
      access        : 0x0002 (PRIVATE)
      code          -
      registers    : 13
      ins           : 3
      outs          : 3
      insns size   : 80 16-bit code units
      catches       : 3
        0x0001 - 0x0004
          Ljava/lang/NumberFormatException; -> 0x0045
        0x0005 - 0x0038
          Ljava/lang/ArithmeticException; -> 0x0039
          Ljava/lang/NumberFormatException; -> 0x0045
        0x003a - 0x0044
          Ljava/lang/NumberFormatException; -> 0x0045
……

从上面的输出信息中,可以发现tryCatch()方法是私有方法,使用了13个寄存器,共80条指令,有3个try语句块,共有2个异常处理Handler。其中,0x0001 - 0x0004为第一个try语句块的代码范围,tryCatch()方法的代码位于0x2cb08,因此计算可得到第1个try语句块的代码范围为:

(0x2cb08 + 1 * 2) ~ (0x2cb08 + 4 * 2) = 0x2cb0a ~ 0x2cb10

同样可计算得到第2与第3个try语句块的代码范围是“0x2cb12 ~ 0x2cb78”与“0x2cb7c ~ 0x2cb90”。最后,将这段smali代码整理为Java代码如下。

private void tryCatch(int drumsticks, String peple) {
    try {
        int i = Integer.parseInt(peple);
        try {
            int m = drumsticks / i;
            int n = drumsticks - m * i;
            String str = String.format(
                "共有%d只鸡腿,%d个人平分,每人可分得%d只,还剩下%d只",
                drumsticks, i, m, n);
            Toast.makeText(MainActivity.this, str, Toast.LENGTH_SHORT).show();
        } catch (ArithmeticException e) {
            Toast.makeText(MainActivity.this, "人数不能为0", Toast.LENGTH_ 
            SHORT).show();
        }
    } catch (NumberFormatException e) {
        Toast.makeText(MainActivity.this, "无效的数值字符串", Toast.LENGTH_ 
        SHORT).show();
    }        
}

4,使用IDA Pro静态分析Android程序

IDA Pro是目前很棒的静态反编译软件,是反编译者不可缺少的利器、巨酷的反编译软件,它可以更好的反汇编和更有深层分析.可以快速到达指定代码位置。

IDA Pro从6.1版本开始,提供了对Android的静态分析与动态调试支持。包括Dalvik指令集的反汇编、原生库(ARM/Thumb代码)的反汇编、原生库(ARM/Thumb代码)的动态调试等。具体可查看IDA Pro官方的更新日志,链接如下:hex-rays.com/ products/ ida/6.1/index.shtml。

4.1 简单使用

以5.2节的crackme0502.apk为例,首先解压出classes.dex文件,然后打开IDA Pro,将classes.dex拖放到IDA Pro的主窗口,会弹出加载新文件对话框,如图5-4所示,IDA Pro解析出了该文件属于“Android DEX File”,保持默认的选项,点击OK按钮,稍等片刻IDA Pro就会分析完dex文件。

IDA Pro支持结构化形式显示数据结构,因此,我们有必要先整理一下反汇编后的数据。dex文件的数据结构大部分在Android系统源码中dalvik\libdex\DexFile.h文件中,笔者将其中的结构整理为dex.idc脚本,在分析dex文件时直接导入即可使用。导入的方法为点击IDA Pro的菜单项“File→Script file”,然后选择dex.idc即可。点击IDA Pro主界面的Structures选项卡,如图5-5所示。

点击IDA View-A选项卡,回到反汇编代码界面,然后点击菜单项“Jump→Jump to address”,或者按下快捷键G,弹出地址跳转对话框,输入0让IDA Pro跳转到dex文件开头。将鼠标定位到注释“# Segment type: Pure data”所在的行,然后点击菜单项“Edit→Structs→ Struct var”,或者按下快捷键ALT+Q,弹出选择结构类型对话框,如图5-6所示,选择DexHeader后点击OK按钮返回。

此时,dex文件开头的0x70个字节就会格式化显示,效果如图5-7所示。同样,读者可以手动对dex文件中其它的结构进行整理,如DexHeader下面的DexStringId结构。

点击菜单项“Jump→Jump to segment”,或者按下快捷键CTRL+S,弹出段选择对话框,如图5-8所示,IDA Pro将dex文件一共分成了9个段,其中前7个段由DexHeader结构给出,最后2个段可以通过计算得出。仔细查看段名,可以发现IDA Pro对其命名不是很好,有3个HEADER段与2个CODE段,笔者觉得第3个段改名为PROTOS更合适一些,还有第6个段改名为CLASSDEFS更好,IDA Pro为什么这样命名我们不得而知,不过,我们需要知道每个段具体所代表的含义。

dex文件中所有方法可以点击Exports选项卡查看。方法的命名规则为“类名.方法名@方法声明”。在Exports选项卡中随便选择一项,如SimpleCursorAdapter.swapCursor@LL,然后双击跳转到相应的反汇编代码处,该处的代码如下。

CODE:0002CFCC     Method 2589 (0xa1d):
CODE:0002CFCC    public android.database.Cursor
CODE:0002CFCC        android.support.v4.widget.SimpleCursorAdapter.swapCursor(
CODE:0002CFCC        android.database.Cursor p0)        #方法声明
CODE:0002CFCC         this = v2    #this引用
CODE:0002CFCC         p0 = v3        #第一个参数
CODE:0002CFCC        invoke-super     {this, p0}, <ref ResourceCursorAdapter.
                        swapCursor(ref) imp. @ _def_ResourceCursorAdapter_
                        swapCursor@LL>
CODE:0002CFD2        move-result-object              v0
CODE:0002CFD4        iget-object  v1, this, SimpleCursorAdapter_mOriginalFrom
CODE:0002CFD8        invoke-direct    {this, v1}, <void SimpleCursorAdapter.
                    findColumns(ref) SimpleCursorAdapter_findColumns@VL>
CODE:0002CFDE
CODE:0002CFDE     locret:
CODE:0002CFDE        return-object                   v0
CODE:0002CFDE        Method End

IDA Pro的反汇编代码使用ref关键字来表示非Java标准类型的引用,如方法第1行的invoke-super指令的前半部分如下。

invoke-super     {this, p0}, <ref ResourceCursorAdapter.swapCursor(ref)

前面的ref是swapCursor()方法的返回类型,后面括号中的ref是参数类型。

后半部分的代码是IDA Pro智能识别的。IDA Pro能智能识别Android SDK的API函数并使用imp关键字标识出来,如第1行的invoke-super指令的后半部分如下。

imp. @ _def_ResourceCursorAdapter_swapCursor@LL

imp表明该方法为Android SDK中的API,@后面的部分为API的声明,类名与方法名之间使用下划线分隔。

IDA Pro能识别隐式传递过来的this引用,在smali语法中,使用p0寄存器传递this指针,此处由于this取代了p0,所以后面的寄存器命名都依次减了1。

IDA Pro能识别代码中的循环、switch分支与Try/Catch结构,并能将它们以类似高级语言的结构形式显示出来,这在分析大型程序时对了解代码结构有很大的帮助。具体的代码反汇编效果读者可以打开5.2节使用到的SwitchCase.apk与TryCatch.apk的classes.dex文件自行查看。

4.2 使用IDA Pro进行破解的实例

使用IDA Pro定位关键代码的方法整体上与定位smali关键代码差不多。

第一种方法是搜索特征字符串。首先按下快捷键CTRL+S打开段选择对话框,双击STRINGS段跳转到字符串段,然后点击菜单项“Search→text”,或者按下快捷键ALT+T,打开文本搜索对话框,在String旁边的文本框中输入要搜索的字符串后点击OK按钮,稍等片刻就会定位到搜索结果。不过目前IDA Pro对中文字符串的显示与搜索都不支持,如果字符串中的中文字符显示为乱码,需要编写相关的字符串处理插件来解决,这个工作就交给读者去完成了。

第二种方法是搜索关键API。首先按下快捷键CTRL+S打开段选择对话框,双击第一个CODE段跳转到数据起始段,然后点击菜单项“Search→text”,或者按下快捷键ALT+T,打开文本搜索对话框,在String旁边的文本框中输入要搜索的API名称后点击OK按钮,稍等片刻就会定位到搜索结果。如果API被调用多次,可以按下快捷键CTRL+T来搜索下一项。

第三种方法是通过方法名来判断方法的功能。这种办法比较笨拙,对于混淆过的代码,定位关键代码比较困难。比如,我们知道crackme0502.apk程序的主Activity类为MainActivity,于是在Exports选项卡页面上输入Main,代码会自动定位到以Main开头的所在行,如图5-9所示,可粗略判断出每个方法的作用。


下面我们来尝试破解一下crackme0502.apk。首先安装运行apk程序,程序运行后有两个按钮,点击“获取注解”按钮会Toast弹出3条信息。在文本框中输入任何字符串后,点击“检测注册码”按钮,程序弹出注册码错误的提示信息。这里我们以按钮事件响应为突破口来查找关键代码,通过图5-9我们可以发现有两个名为OnClick()的方法,那具体是哪一个呢?我们分别进去看看。前者调用了MainActivity.access$0()方法,在IDA Pro的反汇编界面双击MainActivity_access可以看到它其实调用了MainActivity的getAnnotations()方法,看到这里应该可以明白,MainActivity$1.onClick()方法是前面按钮的事件响应代码。接下来查看MainActivity$2.onClick()方法,双击代码行,来到相应的反汇编代码处,按下空格键切换到IDA Pro的流程视图,如图5-10所示,代码的“分水岭”就是“if-eqz v2, loc_2D0DC”。图中左边红色箭头表示条件不满足时执行的路线,右边的绿色箭头是条件满足时执行的路线。

虽然不知道这堆乱码字符串分别是什么,但通过最后调用的Toast来看,直接修改if-eqz即可将程序破解。将鼠标定位到指令“if-eqz v2, loc_2D0DC”所在行,然后点击IDA Pro主界面的“Hex View-A”选项卡,可看到这条指令所在的文件偏移为0x2D0BE,相应的字节码为“38 02 0f 00”,通过前面的学习,我们知道只需将if-eqz的OpCode值38改成if-nez的OpCode值39即可。说干说干,使用C32asm打开classes.dex文件,将0x2D0BE的38改为39,然后保存退出。接着按照本书4.6小节的介绍,将dex文件进行Hash修复后导入apk文件,对apk重新签名后安装测试发现程序已经破解成功了。

为了让读者看到一种常见的Android程序的保护手段,这里更换一下破解思路。通过图5-10可发现,MainActivity$SNChecker.isRegistered()方法实际上返回一个Boolean值,通过判断它的返回值来确定注册码是否正确。现在的问题是,如果该程序是一个大型的Android软件,而且调用注册码判断的地方可能不止一处,这种情况时,通常有两种解决方法:第一种是使用IDA Pro的交叉引用功能查找到所有方法被调用的地方,然后修改所有的判断结果;第二种方法是直接给isRegistered()方法“动手术”,让它的结果永远返回为真。很显然,第二种方法解决问题更利落,而且一劳永逸。

下面尝试使用这种方法进行破解,首先按下空格键切换到反汇编视图,发现直接修改方法的第二条指令为“return v9 ”即可完成破解,对应机器码为“0F 09”,将其修改完成后重新修复与签名,安装测试发现程序启动后就立即退出了。这时最先怀疑的是程序是否修改正确,使用IDA Pro重新导入修改过的classes.dex文件,发现修改的地方没错,看来是程序采取了某种保护措施!回想一下前面提到的两种程序退出方法: Context的finish()方法与android.os.Process的killProcess()方法,按下快捷键CTRL+S并双击CODE回到代码段,接着按下快捷键ALT+T搜索finish与killProcess,最后在MyApp类的onCreate()方法中找到了相应的调用,查看相应的反汇编代码,发现这段代码使用Java的反射机制,手工调用isRegistered()方法检查字符串“11111”是否为合法注册码,如果是或者调用isRegistered()失败都说明程序被修改过,从而调用killProcess()来杀死进程。明白了保护手段,解决方法就简单多了,直接将两处killProcess()的调用直接nop掉(修改相应地方的指令为0)就可以了。

5,分析工具包Androguard

对于Android恶意软件分析人员来说,提起Androguard应该不会感到陌生,Androguard提供了一组工具包来辅助分析人员快速鉴别与分析APK文件。

6,阅读反编译的Java代码

在分析大型软件时,为了弄清程序的结构框架,需要花费掉大量的时间与精力来阅读smali代码,这无疑是分析成本的一大开销。然而,Android程序大多数情况下是采用Java语言开发的,传统意义上的Java反汇编工具依然能够派上用场。

6.1 使用dex2jar生成jar文件

dex2jar的官网是sourceforge.net/project,目前最新版本为020.0,将下载下来的dex2jar压缩包解压,然后将解压后的文件夹添加到系统的PATH环境变量中,在命令提示符下输入以下命令:

d2j-dex2jar xxx.apk

稍等片刻就会在同目录下生成一个jar文件。dex2jar是一个工具包,除了提供dex文件转换成jar文件外,还提供了一些其它的功能,每个功能使用一个bat批处理或sh脚本来包装,只需在Windows系统中调用bat文件、在Linux系统中调用sh脚本即可。

d2j-apk-sign用来为apk文件签名。命令格式:d2j-apk-sign xxx.apk。
d2j-asm-verify用来验证jar文件。命令格式:d2j-asm-verify -d xxx.jar。
d2j-dex2jar用来将dex文件转换成jar文件。命令格式:d2j-dex2jar xxx.apk
d2j-dex-asmifier用来验证dex文件。命令格式:d2j-dex-asmifier xxx.dex。
d2j-dex-dump用来转存dex文件的信息。命令格式:d2j-dex-dump xxx.apk out.jar。
d2j-init-deobf用来生成反混淆jar文件时的初始化配置文件。
d2j-jar2dex用来将jar文件转换成dex文件。命令格式:d2j-jar2dex xxx.apk。
d2j-jar2jasmin用来将jar文件转换成jasmin格式的文件。命令格式:d2j-jar2jasmin xxx.jar
d2j-jar-access用来修改jar文件中的类、方法以及字段的访问权限。
d2j-jar-remap用来重命名jar文件中的包、类、方法以及字段的名称。
d2j-jasmin2jar用来将jasmin格式的文件转换成jar文件。命令格式:d2j-jasmin2jar dir
dex2jar为d2j-dex2jar的副本。
dex-dump为d2j-dex-dump的副本。

6.2 使用jd-gui查看jar文件的源码

为了达到源码级的反编译效果,可以使用Java反编译工具JAD将jar文件转换成Java源文件,目前JAD官网已经无法访问,可以通过varaneckas.com/jad/下载到JAD的可执行文件。

在这里,笔者推荐使用jd-gui。jd-gui是一款用 C++ 开发的 Java 反编译工具,支持Windows、Linux和苹果Mac OS三个平台。jd-gui是免费的,而且反编译效果不错,该工具省掉了将jar文件转换成Java源文件的步骤,直接以源码的形式显示jar文件中的内容,可以从官方免费获取。

除了反编译功能外,jd-gui还带有强大的搜索功能,在主界面按下快捷键CTRL+F,会在程序的状态栏显示一个搜索工具条,输入要搜索的内容,当前打开的反编译窗口会高亮显示搜索结果。除此之外,点击菜单项“Search→Search”会弹出搜索对话框,搜索框列举出了isRegistered()方法在哪些文件中被引用过。

发布于 2019-10-01

文章被以下专栏收录