《Android软件安全权威指南》 阅读学习
2.2 破解流程
APK反编译>smali格式的反汇编代码>阅读并破解smali代码后修改>使用ApkTool重新编译生成APK并签名>再次测试
2.2.3 字符串资源
错误提示属于Android程序中的字符串资源。在开发Android程序时,这些字符串可能会被硬编码到源码中,也可能引用自res\values目录下的 strings.xml文件,该文件在打包时,strings.xml中的字符串被加密存储为 resources.arsc 文件
xml文件中:
而每个字符串都会有一个对应的int类型的索引
2.2.3 smali代码解析
iget-object
读取对象字段的值
iget-object v0, p0 , Lcom/droider/crackme0201/MainActivity$1;->this$0:Lcom/droider/crackme0201/MainActivity;
iget-object:用于获取实例字段的对象引用。v0:存储获取的字段值。p0:当前实例(this),通常是MainActivity$1对象(匿名内部类的实例)。Lcom/droider/crackme0201/MainActivity$1;:字段所属的类,这里是MainActivity$1(MainActivity的匿名内部类)。this$0:字段名,表示对外部类实例的引用。Lcom/droider/crackme0201/MainActivity;:字段类型,这里是MainActivity类。
从 p0(MainActivity$1 实例)中获取 this$0 字段的值(即外部类 MainActivity 的实例),并将其存储到寄存器 v0 中。
const
寄存器赋值为字符串索引或者常量,
const v1 , 0x7f060029
v1=0x7f060029
invoke-virtual
invoke-virtual {p0,v4}, Lcom/droider/circulate/MainActivity;->getSystemService (Ljava/lang/String;)Ljava/lang/Object;
invoke-virtual:用于调用虚方法(即实例方法)。p0:当前实例(this),通常是MainActivity对象。v4:方法的参数,类型为Ljava/lang/String;。Lcom/droider/circulate/MainActivity:目标类的全限定名。getSystemService:方法名。(Ljava/lang/String;)Ljava/lang/Object;:方法签名,表示接受一个String参数并返回Object。
功能:
调用MainActivity实例的getSystemService方法,传入v4作为参数(通常是一个系统服务名称,如Context.WINDOW_SERVICE),并返回对应的系统服务对象。
check-cast
check-cast v0,Landroid/app/ActivityManager;
将寄存器 v0 中的对象引用强制转换为 ActivityManager 类型。
如果 v0 不是 ActivityManager 类型或其子类的实例,则抛出 ClassCastException 异常
new-instance
new-instance v3 , Ljava/lang/StringBuilder;
创建一个 StringBuilder 对象,并将其引用存储在寄存器 v3 中,通常用于后续的字符串操作。
.local
.local Dinfo:Landroid/app/ActivityManager$RunningAppProcessInfo;
声明一个名为 Dinfo 的局部变量,其类型为 ActivityManager.RunningAppProcessInfo
invoke的传参
是不是很奇怪为什么寄存器有时候做对象实例,有时候做传参?
如:
[!NOTE]
invoke-static
调用静态方法invoke-static {v0 , v1 , v3}, Landroid/widget/Toast;->makeText(Landroid/content/Context;II)Landroid/widget/Toast;makeText(v0,v1,v3)
invoke-virtula
invoke-virtual {v0} , Landroid/widget/Toast;->show()Vv0.Toast()
调用v0寄存器中的Toast实例中的show方法,并返回Void类型。v0保存了Toast对象的引用。
invoke-virtual |
调用实例的虚方法 | 第一个寄存器为对象实例,后续为参数 | v0.show(v1,v2,v3) |
invoke-static |
调用实例的静态方法 | 均为参数 | context.(v0,v1,v2) |
invoke-direct |
调用实例的直接方法 | 第一个寄存器为this(当前对象),后续为参数 | p0.<init>()或p0.privateMethod(v1,v2,v3) |
invoke-interface |
调用实例的接口方法 | 类似于invoke-virtual | v0.callback(v1,v2,v3) |
invoke-super |
调用实例的父类方法 |
move-result *
方法调用指令的返回值必须用move-result *指令来获取
invoke-static {}, Landroid/os/Parcel;->obtain()Landroid/os/Parce;
move-result-object v0
3 Dalvik
3.1 Dalvik虚拟机
特点:
- 体积小,占用内存空间少。
- 专有的DEX(DalvikExecutable)可执行文件格式,体积小,执行速度快。
- 常量池采用32位索引值,对类方法名、字段名、常量的寻址速度快。
- 所有的Android程序都运行在Android系统进程中,每个进程都与一个Dalvik虚拟机实例对应。
3.1.2 Dalvik虚拟机与Java虚拟机的区别
- 运行的字节码不同
- Dalvik通过“dx”工具将Java字节码转换为Dalvik字节码,有效减小了可执行文件的体积。
- 具体优化包括:
- 重新排列Java类文件,消除冗余信息,避免虚拟机重复加载和解析;
- 分解并重组类文件中的常量池,消除重复的字符串和常量,使相同内容在DEX文件中仅出现一次,从而缩小文件体积,提升虚拟机解析效率。
- Java虚拟机是基于栈架构的。当程序运行时,Java虚拟机会频繁地对栈进行读写数据的操作。Dalvik虚拟机是基于寄存器架构的,数据的访问直接在寄存器之间传递
JAVA虚拟机 求值栈
Java 虚拟机的指令集也被称为零地址形式的指令集。零地址形式指令的源参数和目标参数都是隐含的,通过Java虚拟机提供的数据结构 “求值栈”来传递。
- 在Java程序中,每个线程执行时都配备了一个PC计数器和一个Java栈。PC计数器记录当前执行位置与方法起点的偏移量,类似于CPU中的PC或IP寄存器,但仅对当前方法有效,指导虚拟机取指令执行。
- Java栈以帧为单位记录方法调用的活动,每调用一个方法便压入一个新栈帧,方法返回时则弹出该帧。
- 栈帧包含局部变量区、求值栈(操作数栈)等信息,局部变量区存储方法参数和局部变量,参数按源码顺序排列;求值栈则用于保存中间结果及方法调用参数。
public class Hello {
public int foo(int a, int b) {
return (a + b) * (a - b);
}
public static void main(String[] args) {
Hello hello = new Hello();
System.out.println(hello.foo(5, 3));
}
}

- iload_1:将
局部变量区的索引 1处的int值放入求值栈栈顶。- 【局部变量区中,索引从0开始计数】
- iadd,从栈顶弹出两个int类型的值并求它们的和,把结果压回栈顶。
Dalvik虚拟机 寄存器列表
Dalvik 虚拟机运行时也为每个线程维护了一个 PC 计数器和一个调用栈。与 Java 虚拟机不同的是,这个调用栈维护了一个寄存器列表,寄存器的数量在方法结构体的regiter字段中给出。
Dalvik虚拟机会根据这个值来创建一份虚拟的寄存器列表。

3.1.3 虚拟机的执行流程 Zygote


3.1.4 虚拟机的执行方式 JIT
即时编译(JIT)是一种动态编译技术,它在程序运行时将字节码转换为机器码,使执行速度提升3至6倍。JIT主要有两种编译方式:
- method方式:以整个函数或方法为单位进行编译,在冷路径上耗费很多的编译时间及内存
- trace方式:专注于编译“热路径”,即执行频率高的代码段,而忽略较少执行的“冷路径”,从而节省编译时间和内存。
Dalvik虚拟机默认采用trace方式,同时也支持method方式,以优化编译效率和性能。
3.2 Dalvik语言基础

一种特殊的情况是指令的末尾多出一个字母。
- 如果是字母s,表示指令采用静态链接
- 如果是字母i,表示指令应该被内联处理。
以指令格式标识“22x”为例,
- 第1个数字2表示指令由两个16位字组成
- 第2个数字2表示指令使用两个寄存器
- 字母x表示没有使用额外的数据
在虚拟机指令的参数表示中,遵循以下规则以区分不同类型的数据:
- 寄存器参数:以“
vX”形式表示,如v0、v1等。 - 常量数字:以“
#+X”形式表示,表示一个具体的数字常量。 - 地址偏移量:以“
+X”形式表示,表示相对于当前指令的地址偏移量。 - 常量池索引:以“
kind@X”形式表示,其中“kind”指明常量池的类型,具体包括:string:字符串常量池索引type:类型常量池索引field:字段常量池索引meth:方法常量池索引
3.2.2 DEX反汇编工具
目前主流的 DEX文件反汇编工具有 Android官方的 dexdump 和第三方的 baksmali
3.2.3 Dalvik寄存器
Dalvik使用的寄存器都是32位的,支持所有类型。对64位类型,可以用两个相邻的寄存器来表示。
寄存器的初始值是v0, 因此其取值范围是v0~v65535
3.2.4 寄存器命名法
假设一个函数使用了 M 个寄存器,且有 N 个参数:
-
参数使用最后的 N 个寄存器。
-
局部变量使用从 v0 开始的 M-N 个寄存器。
-
v命名法:统一使用 v 命名局部变量和参数。
-
p命名法:局部变量用 v 命名,参数用 p 命名。
-
参数始终占用最后的寄存器,局部变量从 v0 开始分配。
-
在非静态函数中需要使用一个寄存器保存this指针,一般使用p0寄存器

[!示例]
示例:
foo()函数
- 使用了 5 个寄存器(M=5)。
- 有 2 个显式整型参数,且作为非静态方法,隐式传入一个 Hello 对象引用,因此实际参数数量 N=3。
v命名法:
v0,v1,v2,v3,v4v0,v1用于表示函数的局部变量寄存器v2用于表示被传入的Hello对象引用v3,v4用于表示两个传入的整型参数。p 命名法:
v0、v1、p0、p1、p2v0,v1用于表示函数的局部变量寄存器p0用于表示被传入的Hello对象引用p1,p2用于表示两个传入的整型参数
3.2.5 Dalvik字节码
类型
Dalvik字节码只有两种类型,分别是基本类型和引用类型
表3-3:Dalvik字节码类型描述符:
| 语法 | 含义 |
|---|---|
| V | void,只用于返回值类型 |
| Z | boolean |
| B | byte |
| S | short |
| C | char |
| I | int |
| J | long(64位) |
| F | float |
| D | double(64位) |
| L | Java 类类型(如 Ljava/lang/String;) |
| [ | 数组类型(如 [I 表示 int[]) |
说明:
- L 类型:表示 Java 类类型,格式为
Lpackage/name/ObjectName;,例如Ljava/lang/String;表示java.lang.String [类型:表示数组类型,[后紧跟基本类型描述符,例如[I表示int[],[[I表示int[][],多维数组最多支持 255 维- J 和 D:分别表示 64 位的
long和double,占用两个寄存器
每个Dalvik寄存器都是32位的。
- 对长度小于或等于32位的类型来说,只用一个寄存器就可以存放该类型的值,
- 而对 J、D等64 位的类型来说,它们的值要使用相邻的两个寄存器来存储,例如v0与v1、v3与v4。
方法
Lpackage/name/ObjectName;->MethodName(III)Z
Lpackage/name/ObjectName;:表示方法的类型,即所属的Java类MethodName:表示方法的具体名称。(III)Z:表示方法的签名部分:(III):括号内的内容表示方法的参数类型,例如III表示三个整型参数。Z:表示方法的返回类型,例如Z表示boolean类型
method(I[[IILjava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
对应的Java代码为:
String method(int, int[][], int, String, Object[])
- 该实例中没有方法的类型,直接就跟着方法名了
method:表示方法的具体名称。(I[[IILjava/lang/String;[Ljava/lang/Object;):括号里面的为方法的参数类型,不同的参数中间用分号间隔Ljava/lang/String;括号后面跟着的是返回类型
Baksmali生成的方法代码
Baksmali生成的方法代码以.method指令开始,以.end method指令结束。根据方法类型的不同,可能会在方法指令前添加注释:
# virtual methods:表示这是一个虚方法。# direct methods:表示这是一个直接方法
字段
字段的表示格式与方法类似,但字段没有方法签名域中的参数和返回值,取而代之的是字段的类型。
字段的格式如下:
Lpackage/name/ObjectName;->FieldName:Ljava/lang/String;
- 类型:
Lpackage/name/ObjectName;表示字段所属的类,其中L表示类类型,package/name/表示类所在的包,ObjectName是类的名称,分号;表示类名结束 - 字段名:
FieldName表示字段的名称。 - 字段类型:
Ljava/lang/String;表示字段的类型,字段名与字段类型之间用冒号:分隔
Baksmali生成的字段代码
Baksmali生成的字段代码以 .field 指令开头,并根据字段类型的不同,在字段指令前可能会用 # 添加注释:
# instance fields:表示这是一个实例字段。# static fields:表示这是一个静态字段
示例:
.field private instanceField:Ljava/lang/String; # instance fields
.field public static staticField:I # static fields
3.3 Dalvik指令集
一、数据操作指令
- move:寄存器间数据传递
move vA, vB:复制 32 位数据到寄存器 vA := vBmove-wide:处理 64 位数据(如 long/doublemove-object:传递对象引用
- const:加载常量到寄存器
const/4:加载 4 位整型常量const-string:加载字符串常量(通过索引)
二、返回指令
- return:方法返回值处理
return-void:无返回值return vAA:返回 32 位非对象值(如 int)return-wide vAA:返回 64 位值(如 long/double)return-object vAA:返回对象引用
三、数据定义指令
- 类型定义:
const-class vAA, type@BBBB:加载类引用到寄存器const/high16:加载高 16 位常量(用于 float 或地址初始化)
四、对象与数组操作
-
对象指令
new-instance vAA, type@BBBB:创建类实例check-cast:强制类型转换(失败抛出异常)instance-of:检查对象是否为某类实例
-
数组指令
new-array:创建数组array-length:获取数组长度aget/aput:数组元素读写(支持基本类型和对象)
五、跳转与条件分支
- 无条件跳转:
goto:按偏移量跳转
- 条件跳转:
if-eq/if-ne:等于/不等于时跳转if-lt/if-ge:小于/大于等于时跳转if-eqz/if-nez:寄存器值为零/非零时跳转
六、比较指令
- 数值比较:
cmpl-float/cmpl-double:浮点数比较(处理 NaN 的特殊逻辑)cmpg-float/cmpg-double:反向浮点数比较cmp-long:长整型比较
七、字段与方法调用
-
字段操作
iget/iput:读写实例字段sget/sput:读写静态字段
-
方法调用
invoke-virtual:调用虚方法(普通实例方法)invoke-static:调用静态方法invoke-direct:调用直接方法(如构造函数)
八、异常处理
throw vAA:抛出异常对象move-exception:捕获异常到寄存器
九、同步与锁
monitor-enter/monitor-exit:获取/释放对象监视锁(用于同步代码块)
十、指令格式说明
Dalvik 指令格式通常为 XX|op AAAA BBBB,其中:
- XX:指令占用的 16 位字数
- op:操作码
- AAAA/BBBB:寄存器或常量索引
十一、数据转换与数据运算指令
- 类型转换:支持基本类型之间的转换,如
int-to-long、float-to-double等 - 算术运算:
add-int:两个整数相加sub-float:两个浮点数相减mul-long:两个长整数相乘div-double:两个双精度浮点数相除
- 位运算:
and-int:两个整数按位与or-long:两个长整数按位或xor-int:两个整数按位异或
- 比较运算:
cmp-long:比较两个长整数,结果为 1、0 或 -1cmpl-float:比较两个浮点数,结果为 1、0 或 -1(处理 NaN 的特殊逻辑)
4 常见的Android文件格式
jar包:存放编译后的Java代码的class文件的集合
arr文件:除了可以包含代码, 还可以包含任何在开发中使用的资源数据。
apk文件:
- AndroidManifest.xml:编译好的AXML二进制格式的文件。
- META-INF目录:用于保存APK的签名信息。
- classes.dex:程序的可执行代码。如果开启了MutliDex,则会有多个DEX文件。
- res目录:程序中使用的资源信息。针对不同分辨率的设备,可以使用不同的资源文件。
- resources.arsc:编译好的二进制格式的资源信息。
- assets目录:如果程序使用Asset系统来存放Raw资源,所有的资源都将存放在这个目录下。
apk文件如何生成:使用aapt打包资源,处理AndroidManifest.xml和 XML 布局文件,生成R.java。接着,aidl解析 AIDL 接口并生成对应的 Java 文件。调用 Java 编译器生成class文件,通过dx将所有class文件和 jar 包打包为 DEX 文件,再使用apkbuilder将资源与 DEX 文件合并成 APK。最后,对 APK 进行对齐和签名处理。这一流程基于 ADT 时代的工具集。
在 Android Studio 时代,官方改用gradle作为构建工具。
4.3 class.dex
classes.dex中包含APK的可执行代码
4.3.1DEX文件结构
DEX文件使用的数据类型


DEX 文件由多个结构体组合而成,包含 7 个主要部分:
dex header:DEX 文件头,定义文件属性并记录其他数据结构的物理偏移。string_ids到class_def:索引结构区,用于存储字符串、类型、方法等索引信息。data:数据区,存放实际数据。link_data:静态链接数据区。
如图 4-4 所示,DEX 文件的结构清晰划分了索引与数据存储区域,便于虚拟机快速解析和执行。


4.3.4 MultiDex
MultiDex,即“DEX分包技术”,用于解决早期DEX文件格式中方法数量限制为64KB的问题。随着APK功能扩展,方法数量常超过此限制,导致编译失败。
第三方开发人员率先推出MultiDex方案,Android官方在Android Studio 21.1版本中正式支持MultiDex,其实现位于源码的frameworks/multidex目录下。
配置完成后,AndroidStudio在生成DEX文件时就会自动生成classes1.dex、classes2.dex、classes3.dex,依此类推。
ApkTool会对不同的DEX文件分别进行反编译,并将反编译结果放到独立的目录中。在本例中,会生成smali和smali_classes2目录,其中分别存放了classes.dex和classes2.dex的反编译结果。
4.4 AndroidManifest.xml
AndroidManifest.xml文件中存放了APK的大量配置信息,包括软件的名称、图标、主题、包名、组件配置等。
Android Studio 编译 APK 时,会将 AndroidManifest.xml 转换为二进制格式并打包,生成的文件称为 AXML。
解压 APK 后,直接打开 AXML 会显示乱码,因其采用二进制格式而非纯文本 XML。AXML 的主要目的是提升 APK 加载性能,尤其在内存和能耗有限的移动设备上,其分析速度和内存占用优于纯文本 XML。
学习 AXML 格式时,可结合 010Editor 编辑器的 AXML 解析模板 AXMLTemplate.bt 进行可视化分析,抽象数据结构与可视化展示相结合,有助于深入理解二进制文件格式。
4.4.3 AXML文件的修改
部分 APK 保护工具和厂商加固方案利用 Android 系统解析 AXML 的漏洞,在编译时构造畸形 AXML,使系统能正常安装 APK,但阻止 ApkTool 等反编译工具运行。
针对这种情况,最直接的修改方式是使用 010Editor 和 AXML 模板 查看文件格式,定位并修复异常部分。对于已知的 AXML 加固方案,也可以使用现成工具进行修改。
4.5 resources.arsc
在 Android 开发中,app 工程下的 java/res 目录至关重要,存放软件使用的各类资源文件。编译 APK 时,这些资源会被打包到 APK 的 res 目录下:
- 图片文件(如
jpg、png)按原样存放。 - XML 配置文件(如
layout、drawable、color目录下的文件)以 AXML 格式存放。
R.java 是连接代码与资源的桥梁,由编译器自动生成,存储各类资源的 ID 值。这些 ID 值通过 res/values/public.xml 文件定位资源所属。
例如,第 2 章中通过 public.xml 定位字符串 “unsuccessed” 的 ID 值。
resources.arsc 则存储不同语言环境下 res 目录中资源的类型、名称、ID 及其所属 Package 的信息。
resources.arsc 文件的格式称为“ARSC 文件格式”。
修改 ARSC 文件的目的主要有两个:
- 阻止或修正 APK 反编译:通过特殊处理 ARSC,使其在 APK 安装时能被系统正常加载,同时阻止 ApkTool 等工具的反编译。
- 修改字符串或其他资源信息:通常用于软件汉化或资源修改。针对此类需求,已有工具如 ArscEditor,掌握 ARSC 文件格式后,编写类似工具并不困难。
4.6 META-INF 目录
该目录中存储了一些与APK签名有关的信息,
4.7 ODEX
为提升 DEX 文件执行效率,Dalvik 会在 APK 首次安装、系统升级或重启时,解析 DEX 文件并生成优化的 ODEX 文件,存放于 /data/dalvik-cache 目录。
后续运行程序时,直接加载 ODEX 文件,避免重复优化,显著减少运行时的性能开销。
系统生成ODEX的方法是内部调用系统命令dexopt。
为了将ODEX文件还原成DEX文件,需要先将ODEX文件反编译成smali文件,再将smali
文件编译成DEX文件。我们将这个过程称为“deodex”。
4.8 OAT
OAT 文件在 Android 4.4 中引入,是优化后供 ART 虚拟机执行的 DEX 文件,类似于 Dalvik 的 ODEX 文件。
ART 源自 Google 收购的 Flexycore 公司,旨在为 Android 提供更高性能的虚拟机。它采用 AOT(提前编译)技术,在 APK 首次安装、系统升级或重启时,通过 dex2oat 命令将 DEX 文件静态编译为 OAT 文件,存放于 /data/dalvik-cache 或 /data/app/package 目录。与 dexopt 不同,dex2oat 更像编译器,将 Dalvik 字节码转换为 Native 机器码,从而提升 APK 启动速度。
从 Android 5.0 开始,ART 成为默认虚拟机,显著提升了程序运行速度。然而,AOT 的静态编译会降低 APK 安装效率,导致 Android 4.4 及以上版本安装时间较长。
为平衡运行与安装效率,Android 7.0 引入了 JIT(Just-in-Time 即时编译),使 APK 安装速度明显快于 Android 5.0 和 6.0。新版本 Android 采用基于 JIT on AOT 的编译技术。
5 静态分析Android程序
静态分析 Android 程序有两种主要方法:
分析 Dalvik 字节码:
- 使用 IDA Pro 直接分析 DEX 文件
- 或通过 baksmali 反编译生成 smali 文件,用文本编辑器阅读。
分析 Java 源码: - 使用 dex2jar 将 DEX 文件转换为 JAR 文件,然后通过 jd-gui 阅读反编译后的 Java 代码。
ApkTool和baksmali反编译APK或DEX的结果就是smali文件
虽然 DEX 文件使用 Dalvik 指令集,但其开发基于 Java 语言,保留了类、方法、字段、注解等概念。
Java 编译时,每个类(包括内部类)会单独保存为 .class 文件;
类似地,DEX 文件反编译时,每个内部类也会生成单独的 .smali 文件。
以Crackme0201.apk为例,使用ApkTool将其反编译后,查看它的目录内容,发现存在
MainActivity$1.smali、R$anim.smali 这类文件名中夹着美元符号的 smali 文件,这些文件就是内部类 smali 文件。

smali代码结构
5.2.2 循环语句
解读以下代码时,主要用到的跳转方法:
goto :goto_0 //相当于jmp xxx
:goto_0 //要跳转到的地方xxx
if-nez v5 , :cond_0 //如果v5 not equal zero 就跳到cond_0
在for关键字中将对象名与对象列表用冒号“:”隔开,然后在循环体中直接访问单个对象。这种方式的代码简练、可读性好
Iterator<对象> <对象名> = <方法返回一个对象列表>;
for (<对象> <对象名> : <对象列表>) {
[处理单个对象的代码体]
}
初始化循环计数器变量,然后自增

或者
是手动获取一个迭代器,然后在一个循环中调用迭代器中的hasNext()方法检测其是否为空,最后在代码循环体中调用其next()方法来遍历迭代器。
Iterator<对象> <迭代器> = <方法返回一个迭代器>;
while (<迭代器>.hasNext()) {
<对象> <对象名> = <迭代器>.next();
[处理单个对象的代码体]
}
跳转到循环开始处使用goto语句

循环开始处调用hasNext方法

调用迭代器的next()方法获取单个xxx对象

总结一下,迭代器循环有如下特点。
- 迭代器循环会调用迭代器的hasNext()方法检测循环条件是否满足。
- 迭代器循环会调用迭代器的next()方法获取单个对象
- 迭代器循环会使用goto指令来控制代码的流程。
for循环的代码有如下特点。 - 在进入循环前,需要初始化循环计数器变量,且它的值需要在循环体中更改。
- 循环条件判断可以是由条件跳转指令组成的合法语句。
- (循环条件的代码为
if-lt v1 , v5, :cond_0)
- (循环条件的代码为
5.2.3 switch分支语句
经常出现在判断分支比较多的代码中
第1条指令packed-switch指定了比较的初始值为0,pswitch_0~pswitch_3分别是比较结果为case0~case3时跳转的目的地址。
第一行的:pswitch_data_0为switch的跳转表,再根据跳转表跳转到对应标号处

或是这样的:

5.2.4 try/catch语句
在 Dalvik 字节码(smali)中,异常处理通过 .catch 指令实现,其语法格式如下:
.catch <异常类型> {<try起始标号>..<try结束标号>} <catch标号>//后面跟着的catch起到告示作用,如果抛出异常,则跳到下面相关的catch标号处执行异常处理!
<catch 标号>
该指令用于指定 try 代码块的作用范围及对应的异常处理逻辑
-
<异常类型>- 指定要捕获的异常类,例如
Ljava/lang/Exception;(所有异常)或具体异常如Ljava/lang/NullPointerException;。 - 若省略异常类型(如
.catch {}),表示捕获所有异常。
- 指定要捕获的异常类,例如
-
{<try起始标号>..<try结束标号>}try起始标号:以try_start_X标记try块的起始位置(如try_start_0)。try结束标号:以try_end_X标记try块的结束位置(如try_end_0)。- 该范围包含可能抛出异常的代码区域。
-
<catch标号>- 指定处理异常的代码块位置,通常以
:catch_X标记(如:catch_0)。
- 指定处理异常的代码块位置,通常以
示例代码
.method public test()V
.registers 2
:try_start_0
invoke-virtual {p0}, Ljava/lang/Object;->hashCode()I # 可能抛出异常的代码
:try_end_0
.catch Ljava/lang/Exception0; { :try_start_0 .. :try_end_0 } :catch_0
.catch Ljava/lang/Exception1; { :try_start_0 .. :try_end_0 } :catch_1
return-void
:catch_0
const-string v0, "捕获 Exception0"
invoke-static {v0}, Ljunit/framework/Assert;->fail(Ljava/lang/String;)V
:catch_1
const-string v0, "捕获Exception1"
invoke-static {v0}, Ljunit/framework/Assert;->fail(Ljava/lang/String;)V
.end method
- 说明:
try_start_0到try_end_0是可能抛出异常的代码段。- 第一个
.catch捕获Exception0,跳转至:catch_0处理。 - 第二个
.catch捕获Exception1,跳转至:catch_1处理。 - 优先级:异常处理按
.catch指令的声明顺序检查,需将特定异常(如NullPointerException)放在通用异常(如Exception)之前。
5.3 阅读Java 代码
5.3.1 将DEX文件转换成jar包
dex2jar提供了DEX文件与jar包之间互转的命令行工具 d2j-dex2jar 和 d2j-jar2dex
5.3.2 jar 分析工具
目前使用最多的jar分析工具是jd-gui
jadx 是一款直接支持 APK 和 DEX 文件反编译的 Java 分析工具,无需通过 dex2jar 转换即可查看 Java 伪代码。
其特色在于实时反编译 APK 中的类和方法,但这也导致了一些性能问题:
当查看未缓存结果的类时,需要实时反编译,尤其在处理类和字符串操作时,大量反编译操作会显著降低速度
bytecode-viewer 与 jadx 类似,支持直接打开 APK 和 DEX 文件查看 Java 伪代码。其底层会根据用户选择调用 dex2jar 或 enjarify 进行反编译,因此后续操作速度较快。
虽然与 jd-gui 和 jadx 一样不支持直接修改 jar 包,但 bytecode-viewer 提供了插件功能,并内置了多款强大的反混淆插件,使其在实际分析中的表现优于 jd-gui 和 jadx。
5.4.1 入口分析法
一个Android程序由一个或多个Activity及其他组件组成,每个Activity 的级别都是相同的,不同的Activity用于实现不同的功能。每个Activity都是Android程序的一个显示“面”,主要负责数据的处理及展示工作。在 Android 程序的开发过程中,很多时候程序员是在编写用户与Activity之间的交互代码。
每个Android程序有且只有一个主Activity(隐藏程序除外,因为它没有主Activity),它是程序启动的第一个Activity。打开任意一个程序进行反编译,得到的AndroidManifest.xml文件中都有如下代码片段。

intent-filter指定了Activity的启动意图, android.intent.action.MAIN表示这个Activity是程序的主 Activity
由于 Application 类 比其他类更早启动,一些商业软件会将授权验证代码移到该类中。例如,在 onCreate() 方法中检测软件购买状态,若状态异常则阻止程序运行。
因此,在分析 Android 程序时,应先检查是否存在 Application 类,并查看其 onCreate() 方法是否进行了影响逆向分析的初始化操作。
5.4.2 信息反馈法
信息反馈法是通过运行目标程序,根据其反馈信息(如弹窗提示)寻找关键代码。例如,输入错误注册码时提示“无效用户名或注册码”,这类字符串通常存储在 String.xml 或硬编码在代码中。
- 若为前者,在反汇编代码中搜索字符串的
id值即可定位调用代码; - 若为后者,直接搜索字符串即可。
5.4.3 特征函数法
特征函数法通过定位特定 API 调用来分析代码。例如,弹窗提示通常调用 Toast.MakeText().Show(),在反汇编代码中搜索“Toast”即可定位相关代码。若存在多个 Toast,需逐一甄别。类似地,按钮事件通常使用 OnClickListener(),搜索“OnClickListener”可列出所有按钮事件,进一步筛选可快速定位目标事件。
5.5 使用JEB进行静态分析
5.6 使用IDA Pro进行静态分析
IDAPro从6.1版本开始,提供了对Android的静态分析与动态调试的支持,包括Dalvik指令集的反汇编、原生库(ARM/Thumb代码)的反汇编、原生库(ARM/Thumb代码)的动态调试等。
6 动态分析Android程序
6.2.1 代码注入法
代码注入 是通过反编译 Android 程序,在生成的 smali 文件中插入 Log 调用代码,然后重新打包并运行程序,观察日志输出以分析程序行为。
如:发现.line 42处的if-eqz v3 , :cond_2是程序的关键部分。
要想获取真实的注册码,只需要在.line 42处添加Log.v()输出v0寄存器的值。

const-string v3, "SN"
invoke-static {v3, v0}, Landroid/util/Log;->v(Ljava/lang/String;Ljava/lang/String;)I
尽管此时仍然会弹出注册码错误的提示,但Log.v()方法偷偷地输出了正确的注册码,
6.2.2栈跟踪法
尽管 LogCat 配合代码注入 在程序分析中效果显著,但需要分析人员阅读大量反汇编代码并多次手动注入 Log 输出代码,尤其在分析大型程序时效率低下。
为此,栈跟踪法 提供了一种更高效的替代方案。它也属于代码注入范畴,但只需大致确定注入点,且反馈信息比 Log 注入更详细,能够快速定位程序关键点。

- 在
.line 27调用Toast的代码下方(.line 29处)插入上述 smali 代码。
.line 29
new-instance v0, Ljava/lang/Exception; # 创建 Exception 实例
const-string v1, "print trace" # 加载异常信息字符串
invoke-direct {v0, v1}, Ljava/lang/Exception;-><init>(Ljava/lang/String;)V # 调用 Exception 构造函数
invoke-virtual {v0}, Ljava/lang/Exception;->printStackTrace()V # 打印栈跟踪信息
等价于:
new Exception("print trace").printStackTrace();
- 栈跟踪信息为 WARN 级别,Tag 为
System.err。 - 使用以下命令过滤输出:
adb logcat -s System.err:V *:W - 输出结果如下:

栈跟踪信息记录了从程序启动到printStackTrace()方法执行期间所有被调用过的方法。从下往上查看栈跟踪信息,找到第1条以“com.droider.stackTrace"开头的信息,发现开始调用的是OnCreate()方法,然后依次是a()、b()、c()。这样一来,函数的执行流程就一清二楚了。
6.2.4 UI检查
在很多时候,当我们打开一个程序的Activity 后,很想知道它在APK中到底属于哪个Activity类、它的UI布局是什么样的。这时,可以使用UI检查工具进行辅助分析。
6.3 使用JDB动态调试 APK
开启 APK 动态调试支持的方法
-
检查
AndroidManifest.xml中的android:debuggable属性- 如果
Application标签的android:debuggable为"true",则可以直接进行动态调试。 - 如果未设置或为
"false",则需满足系统的ro.debuggable属性为1,否则动态调试会失败。
- 如果
-
对不可调试的 APK 开启调试支持的方法
- 使用 Android SDK 模拟器:
- SDK 模拟器的
ro.debuggable属性默认为1,即使 APK 的android:debuggable为"false",也可直接进行动态调试。
- SDK 模拟器的
- Root 并修改 Android 设备:
- 对 Root 后的设备,手动修改
ro.debuggable属性为1,从而开启所有 APK 的动态调试支持。
- 对 Root 后的设备,手动修改
- 安装调试支持的插件:
- 网上已有 Xposed 插件可开启所有 APK 的调试支持。如果不想修改系统,可以安装此类插件。
- 重新打包 APK:
- 重新打包 APK,在
AndroidManifest.xml的Application标签下设置android:debuggable属性为"true"。
- 重新打包 APK,在
- 使用 Android SDK 模拟器:
6.4 使用JEB动态调试APK
6.5 使用IDAPro动态调试APK
第7章 ARM 反汇编基础
ARM 公司为满足不同需求,推出了多种基于通用架构的处理器,分为 Classic、Embedded 和 Application 三大类。
- Classic 经典系列:早期处理器以数字命名(ARM1 到 ARM11)。
- Cortex 系列:ARM11 后改用“Cortex”命名,分为:
- Cortex-A:用于智能手机、上网本等设备,基于 ARMv7-A 架构。主流 Android 手机多采用 32 位的 Cortex-A15 和 Cortex-A17。
- Cortex-M 和 Cortex-R:分别用于嵌入式系统和实时应用。
2011 年,ARM 发布 ARMv8 架构,标志着 64 位 CPU 的出现:
- Cortex-A32:首款 ARMv8-A 架构的 32 位 CPU。
- Cortex-A35 及之后:均为 64 位 CPU。
到了Android5.0时代,ART虚拟机代替了Dalvik虚拟机,支持64位程序的运行,Android NDK也正式增加了对arm64-v8a指令集的支持,而该指令集所对应的就是ARMv8-A处理器架构。
7.2 Android ARM EABI
不同的 Android 手机使用不同的 CPU,因此指令集也可能不同。每种 CPU 与指令集的组合都有特定的 应用二进制接口(ABI),定义了机器代码在运行时如何与系统交互。嵌入式设备的 ABI 称为 嵌入式应用二进制接口(EABI),典型 EABI 包含以下信息:
- 机器代码使用的 CPU 指令集。
- 内存存储和加载的字节顺序。
- 可执行二进制文件(如程序和共享库)的格式及支持的内容类型。
- 数据解析约定,包括对齐限制、堆栈使用和函数调用时的寄存器处理。
- 运行时可用的函数符号列表。
Android 平台支持多种 EABI,包括 ARMEABI(基于 ARM)、x86EABI 和 MIPSEABI。本节主要讨论 Android 支持的 ARMEABI。
7.4 ARM汇编语言
在 ARM 汇编中,声明子程序的完整格式如下:
.global 子程序名 @ 声明子程序为全局符号,可被外部调用
.type 子程序名, %function @ 定义子程序的类型为函数
子程序名: @ 子程序的入口标签
.fnstart @ 函数开始标记
<汇编指令语句…> @ 子程序的具体指令
.fnend @ 函数结束标记
.global 子程序名:声明子程序为全局符号,使其可被其他模块调用。.type 子程序名, %function:定义子程序的类型为函数。子程序名::子程序的入口标签,表示子程序的起始位置。.fnstart和.fnend:分别标记函数的开始和结束,用于调试和异常处理。<汇编指令语句…>:子程序的具体实现代码。
示例
.global myFunction
.type myFunction, %function
myFunction:
.fnstart
mov r0, #1 @ 将立即数 1 存入寄存器 r0
bx lr @ 返回调用者
.fnend
ARM 处理器采用 精简指令集(RISC),其特点是所有指令长度相同:
- ARM 指令集:32 位指令。
- Thumb 指令集:16 位指令。
与 Intel x86 的变长指令不同,ARM 的定长指令设计使得程序执行时取指令速度更快,执行效率更高。
7.4.3 寄存器
在 ARM 汇编中,寄存器是处理器的高速存储部件,用于暂存指令、数据和地址。
高级语言中的变量、常量等数据在 ARM 汇编中体现为寄存器中的值或内存地址。
寄存器分类
- 32 位 ARM 处理器 共有 37 个 32 位寄存器:
- 31 个通用寄存器:用于常规操作。
- 6 个状态寄存器:如 CPSR(当前程序状态寄存器)。
运行模式
ARM 处理器支持多种运行模式,部分模式为特权模式,可访问受保护的系统资源。主要模式包括:
- 用户模式(usr):正常程序执行状态。
- 快速中断模式(fiq):用于高速数据传输。
- 外部中断模式(irq):通用中断处理。
- 管理模式(svc):操作系统保护模式。
- 数据访问终止模式(abt):数据或指令预取终止时进入。
- 系统模式(sys):运行特权操作系统任务。
- 未定义指令中止模式(und):执行未定义指令时进入。
用户模式下的寄存器
在 用户模式 下,处理器可访问的寄存器包括:
- 不分组寄存器:R0~R7。
- 分组寄存器:R8~R14。
- 程序计数器(PC):R15。
- 当前程序状态寄存器(CPSR)。
工作状态
ARM 处理器有两种工作状态,可自由切换:
- ARM 状态:执行 32 位对齐的 ARM 指令。
- Thumb 状态:执行 16 位对齐的 Thumb 指令。
寄存器命名差异
在 Thumb 状态下,寄存器命名与 ARM 状态有所不同:
- R0~R7:与 ARM 状态相同。
- CPSR:与 ARM 状态相同。
- FP:对应 ARM 状态的 R11。
- IP:对应 ARM 状态的 R12。
- SP:对应 ARM 状态的 R13。
- LR:对应 ARM 状态的 R14。
- PC:对应 ARM 状态的 R15。
不同模式下可访问的寄存器不同。用户模式下主要使用 R0~R15 和 CPSR,ARM 和 Thumb 状态下的寄存器命名有部分差异。
第8章 Android原生程序开发与逆向分析
随着 Android 平台上应用复杂度的增加,仅使用 Java 语言开发已无法满足需求,例如:
- 高性能计算:如音视频解码器需要 CPU 高效运算。
- 跨平台移植:C/C++ 编写的游戏可能面临代码重写问题。
- 代码保护:Java 程序易被逆向破解,需更强的保护手段。
为解决这些问题,Google 提供了 Android NDK(Native Development Kit,原生开发套件)。
- 功能:将 C/C++ 代码与 Android 应用的图形界面结合,解决跨平台问题。
- 优势:
- 通过 JNI 调用直接与 CPU 交互,提升应用性能。
- 将核心功能封装到原生模块中,增强软件安全性。
8.1.3 JNI
Java 本地接口(JNI) 是 Android 程序 Java 层与 Native 原生层之间的桥梁,支持双向调用。JNI 是 Java 规范的一部分,提供了一套接口 API 供原生程序使用。
JNI 方法规则
- 命名规范:JNI 方法名必须以
Java开头,完整方法签名中的/用_代替。例如:Java_com_droider_jnidemo_MainActivity_stringFromJNI。 - Java 层声明:在 Java 层,JNI 方法必须声明为
native,表示这是一个原生方法。若未在原生代码中实现,程序会因找不到方法而崩溃。
JNI 的跨平台性
JNI 是跨平台的接口,在 Windows、macOS、Linux 上,只要遵循 JNI 标准接口编写原生程序,基于 JVM 的 Java 程序均可通过 JNI 与其交互。
JNI 实现
- 头文件:所有 JNI 方法在
jni.h头文件中定义。 - 版本支持:
- Java 官方支持的最高版本:
JNI_VERSION_1_9。 - Android 支持的最高版本:
JNI_VERSION_1_6。
- Java 官方支持的最高版本:
- 方法指针:所有 JNI 方法在
JNINativeInterface结构体中定义,该结构体保存了 JNI 方法的指针。 - JNIEnv 结构体:原生程序的第一个参数为
JNIEnv结构体指针,其第一个成员是JNINativeInterface结构体。通过JNIEnv的内存布局可定位所有 JNI 方法。
8.3 原生程序入口函数
8.3.1 原生程序入口函数分析
在调用 _libc_init() 时,main() 是第三个参数,第四个参数是一系列数组,每个数组对应一个独立的 Section,包含特定函数指针。
.init_array:若方法插入__attribute__((constructor))属性,编译器会将其地址记录到.init_array。.fini_array:若方法插入__attribute__((destructor))属性,编译器会将其地址记录到.fini_array。.init:旧版 Android 平台存在此 Section,但新版已移除,可能是其与.init_array执行优先级相同所致。
按照linker 中的加载顺序,preinit_array_中的函数指针数组是最先被执行的,然后是init_array_,再然后是 main()。程序执行结束,如果有注册的fini_array_,则会调用其中的函数指针。
在分析 so 动态库 时,可以通过检查 .init_array 中是否存在函数指针,判断是否包含初始化代码。
- 通过
dlopen()加载动态库:初始化工作到.init_array执行结束即完成。 - 通过
System.loadLibrary()加载动态库:链接器会查找并调用动态库中的JNI_OnLoad()函数(如果存在)。
因此,so 动态库的初始化顺序为:
- 执行
.init_array中的函数指针数组。 - 执行
JNI_OnLoad()函数。
8.4.2 AArch64ELF文件格式
ELF根据链接与执行阶段所需的文件信息,提供了链接视图(LinkingView)与执行视图(ExecutionView),如图8-1所示

8.4.7 符号表
共享目标文件和原生可执行文件通常包含两张符号表:.symtab 和 .dynsym。
.symtab:位于.symtab节区,包含所有符号信息,但未设置SHF_ALLOC标志位,因此不会被加载到内存中。.dynsym:位于.dynsym节区,是.symtab的简化版,设置了SHF_ALLOC标志位,是链接器和运行时使用的符号表。
两张符号表的原因
- 内存优化:
.symtab包含所有符号信息,但许多符号并非运行时必需。加载到内存会造成浪费。 - 运行时需求:
.dynsym仅包含运行时必需的符号,由.symtab复制而来,用于链接和运行阶段。
优化
- 移除无用节区:使用工具(如
aarch64-linux-android-strip)可以移除.symtab和.strtab字符串表,因为它们对运行并非必需。
总结
ELF 文件包含 .symtab 和 .dynsym 两张符号表,前者用于调试和链接,后者用于运行时。通过移除 .symtab 和 .strtab 等无用节区,可以优化 ELF 文件的大小和内存使用。
8.4.8 got表与 plt表
如果在编译时开启了PIC与PIE选项,会在ELF文件中生成与地址无关的代码。这样,在链接器加载程序时,就可以将ELF文件加载到任意的地址空间了。这项动态加载技术的实现,离不开ELF文件中的got表与plt表。
8.7.2 C++类的逆向分析
在C++对象模型中,类在内存中的对象,其开头的指针就是类的方法指针列表,保存了类的虚方法列表,在代码中用vtable表示,vtable下面则是其他的类方法与字段。
C++类的构造与使用,在编译成汇编代码后,遵循如下准则进行。
- 由 new()完成类大小字节的内存空间申请。
- 执行当前类的初始化方法。
- 如果当前类包含父类,则在初始化方法中对基类的vtable与成员变量(字段)进行初始化。
- 如果当前类的父类还包含父类,则重复执行上一步,直到所有父类被初始化。
- 初始化当前类的vtable与成员变量。
- 初始化完成后,通过访问vtable中的指针调用当前类的虚方法。
示例分析:
1.内存申请
v4 = operator new(0x10uLL);
new() 申请了 0x10 字节内存,其中 8 字节用于虚表指针,剩余 8 字节可能是函数指针或成员变量


2.调用构造函数
sub_D60(v4);
根据 C++ 构造准则,sub_D60 应为 Student::Student(),接收 new() 返回的内存地址(即 Student 指针)。
3.虚表初始化
v1->vtable = (int64)&off_113F0;
在 Student::Student() 中,虚表指针被初始化为 off_113F0,将其重命名为 stu_vtable。

4.父类初始化
sub_DE4() 是父类的初始化函数,其中 *(_DWORD*)(a1+8) = 31; 表示对 32 位成员变量赋值

5.结构体定义
新建结构体 Person,虚表占 8 字节,成员变量占 4 字节

在这里如何得知使用了虚表?
虚表的存在条件
- 并非所有类都有虚表,只有当子类继承父类并重写父类虚方法时,才会生成虚表。
- 虚表存储一个或多个虚方法的函数地址。
8.7.3 C++ 程序的 RTTI
RTTI(Run-Time Type Identification,运行时类型信息) 提供了两个重要操作符:
typeid:返回指针或引用所指的实际类型。dynamic_cast:将基类类型的指针或引用安全地转换为派生类型的指针或引用。
当 C++ 程序使用多态特性(如 typeid 获取类名或 dynamic_cast 动态转换类)时,会包含 RTTI。这些 RTTI 信息以特定格式保存在二进制代码中,对逆向分析非常有帮助。
8.8 原生 so动态库逆向分析
原生 so 动态库可以使用 C 或 C++ 开发,并通过 JNI 接口函数 实现 Java 层与 Native 层的通信。
JNI 方法的调用机制
JNI 方法的调用基于指针偏移量,以下是一个典型的调用示例:
v4 = (*(_int64 (_fastcall **)(_int64, _int64))(*(_QWORD *)v1 + 1336LL))(v1, v2);
通过分析指针偏移量,可以识别具体的 JNI 方法。
JNI 方法的识别与还原
JNI 所有方法在 NDK 的 jni.h 头文件中声明。将 jni.h 导入 IDA Pro 后,可以引入 JNI 相关结构体,从而正确识别 JNI 方法。例如:
ret = env->functions->NewStringUTF(&env->functions, (const char *)v2);
IDA Pro 已正确识别该方法为 NewStringUTF()。
第9章 Android原生程序动态调试
通过分析可以得出结论:ELF 的 .got.plt 符号填充过程是在 linker64 加载和链接依赖库时完成的。
所有外部函数符号的真实地址在依赖库加载后立即设置,这表明 Android 系统的链接器未使用延迟绑定技术(Lazy Binding)
延迟绑定技术:【首次调用函数时才解析函数地址,即plt->got表】
第10章 Hook与注入
Hook 技术通过替换目标函数实现动态修改,可绕过防篡改系统的签名检查,解决重新打包后的签名问题。
Java层:
- Dalvik Hook
- ART Hook
Native层:
- LD_PRELOAD Hook
- GOT Hook
- Inline Hook
10.1.1 Dalvik Hook
由于 Dalvik 虚拟机运行的系统版本低于 Android 5.0,早期 Java 层 Hook 技术主要集中在该虚拟机上,其实现依赖于以下三个方面:
-
Java 反射机制:
- Hook 的本质是对 Java 的
Method类进行操作。 - 通过反射机制,DEX 文件中的代码可以访问内存中任何类的方法信息。
- 在 Dalvik 虚拟机中,每个 Java 方法对应
java/lang/reflect/Method类。
- Hook 的本质是对 Java 的
-
Dalvik 虚拟机底层实现:
- Java 方法的定义位于 Android 源码文件
dalvik/vm/oo/Object.h。 Method结构体中的字段完整描述了 Java 方法的信息,通过修改这些字段的值,可以实现“移花接木”的效果。
- Java 方法的定义位于 Android 源码文件
-
进程内存修改能力:
- Linux 类操作系统的进程对自身内存空间有绝对控制权。
- 这一特性是各类 Android 热补丁插件实现的基础。
10.1.2 ART Hook
随着 Android 5.0 及更高版本中 ART 虚拟机的引入,对 ART Hook 的需求也随之出现。ART Hook 与 Dalvik Hook 在技术原理上相似,主要区别在于方法结构体的不同:
-
API 23 及以下版本:
- 调用
getDeclaredMethod()后返回的是java/lang/reflect/Method。
- 调用
-
API 24 及以上版本:
- 调用
getDeclaredMethod()后返回的是AbstractMethod抽象方法对象。 - 需通过
getDeclaredField()获取artMethod字段,进一步获取具体的方法类。
- 调用
-
底层实现:
- Java 层方法在底层对应
ArtMethod类,其定义位于 Android 源码文件art/runtime/art_method.h。 - 不同版本的 ART 虚拟机中,
ArtMethod的实现和各字段偏移量可能不同,因此在实现 ART Hook 时需考虑版本兼容性问题。
- Java 层方法在底层对应
10.1.3 LD_PRELOAD Hook
LD_PRELOAD 是 Linux 系统中的环境变量,用于优先加载指定的动态链接库(.so),从而实现函数替换(Hook)。
动态链接与 LD_PRELOAD 的作用
-
静态链接 vs 动态链接
- 静态链接:函数直接编译到可执行文件中,运行时直接调用。
- 动态链接:函数在运行时从动态库(
.so)加载,由动态加载器(如/system/bin/linker)解析函数地址。
-
LD_PRELOAD 的机制
- 通过设置
LD_PRELOAD环境变量,可强制程序优先加载指定的动态库。 - 加载器会使用该库中的函数替换原程序的函数(“偷梁换柱”),从而劫持程序逻辑。
- 通过设置
示例
LD_PRELOAD=/path/to/hook.so ./target_program
程序运行时会优先加载 hook.so,并执行其中定义的函数而非原函数。
进行LD_PRELOAD Hook的前提是程序中使用的函数必须是从外部导出的。对非导出的函数这种Hook方式就无能为力了。
10.1.4 GOT Hook
GOT Hook 通过修改 ELF 文件的全局偏移表(GOT)实现函数劫持,适用于动态链接的 so 库。
原理
-
PIC 技术:
- Android so 默认使用
-fPIC编译,依赖 PLT(过程链接表) 和 GOT(全局偏移表) 实现动态链接。 - PLT(
.text节区):只读代码段,用于跳转到目标函数。 - GOT(
.data节区):存储外部函数地址,可读写。
- Android so 默认使用
-
GOT 结构:
- 32 位 ARM:GOT 位于
.got节区。 - AArch64:GOT 分散在
.got和.got.plt节区,外部符号地址存于.got.plt。
- 32 位 ARM:GOT 位于
实施步骤
(1)对自身程序的 GOT Hook
- 编写替换函数。
- 程序加载后,解析 Section Header Table,定位
.got和.got.plt。 - 查找目标函数地址。
- 将 GOT 中原函数地址替换为自定义函数地址。
(2)对外部程序的 GOT Hook
- 将替换函数编译为 so 动态库。
- 注入 so 到目标进程。
- 定位目标进程的
.got和.got.plt。 - 替换目标函数地址为 so 中的函数地址。
10.1.5 Inline Hook(内联 Hook)
Inline Hook 直接修改指令流,灵活性高但实现复杂,需处理指令集兼容性、线程安全和缓存同步等问题。精简版方案适用于自身进程的 ARM 模式 Hook。
Inline Hook 通过直接修改目标函数起始处的机器指令实现劫持,相比 GOT Hook 实现更复杂,需考虑以下问题:
挑战
- 多指令集兼容:需适配 ARM/Thumb/x86 等不同指令集架构。
- 跳转指令处理:
- ARM/Thumb 模式下的跳转指令不同。
- 需确保修改指令后不影响后续指令执行。
- 多线程安全:避免因 Hook 导致系统库函数崩溃。
- 小函数限制:若目标函数指令过少(如 <5 字节),可能破坏相邻代码。
- 缓存同步:ARM 平台需调用
cacheflush()同步指令缓存。
精简版 Inline Hook 实现步骤(32 位 ARM 模式)
- 编写替换函数:定义需 Hook 的逻辑。
- 修改内存权限:
mprotect(target_func_addr, size, PROT_READ | PROT_WRITE | PROT_EXEC); - 保存现场:
- 备份目标函数前几条指令(覆盖跳转指令长度)。
- 保存寄存器状态。
- 插入跳转指令:
- ARM 模式示例:
LDR PC, [PC, #offset]跳转到替换函数。 - 刷新缓存:
cacheflush(target_func_addr, size, ICACHE | DCACHE);
- ARM 模式示例:
- 恢复内存权限:
mprotect(target_func_addr, size, PROT_READ | PROT_EXEC);
10.2 Xposed Hook 框架
Xposed 是 Android 系统中最常用的 Java 层 Hook 框架,支持 Dalvik 和 ART 虚拟机,但不支持 Native 层 Hook。
安装条件:
- 需 Root 权限(因需修改系统文件)。
- 需确认系统版本兼容性(不同 Android 版本需对应 Xposed 版本)。
核心功能
通过编写插件 Hook Java 方法,支持以下操作:
- 方法执行前插入逻辑:
beforeHookedMethod(param) { /* 前置代码 */ } - 方法执行后插入逻辑:
afterHookedMethod(param) { /* 后置代码 */ } - 完全替换方法:
XC_MethodReplacement.replaceOriginalMethod(/* 自定义实现 */);
特点
- 非侵入式:无需修改 APK 文件,动态注入代码。
- 灵活性高:可拦截任意 Java 方法(包括系统 API)。
- 兼容性风险:不同 Android 版本需适配特定 Xposed 版本。
示例:
Xposed Hook 实例代码,用于 Hook com.droider.crackme0201.MainActivity 类的 checkSN 方法:
public class CheckSNHook implements IXposedHookLoadPackage {
static final String TAG = "Crackme0201Hooker";
@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
// 只处理目标包
if (!lpparam.packageName.equals("com.droider.crackme0201")) {
return;
}
XposedBridge.log("Hooking checkSN in package: " + lpparam.packageName);
try {
Class<?> clazz = XposedHelpers.findClass(
"com.droider.crackme0201.MainActivity",
lpparam.classLoader
);
XposedHelpers.findAndHookMethod(
clazz,
"checkSN",
String.class,
String.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("CheckSN beforeHookedMethod called");
// 可以在这里修改输入参数
// param.args[0] = "modified_sn1";
// param.args[1] = "modified_sn2";
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("CheckSN afterHookedMethod called");
// 获取原始参数
String s1 = (String) param.args[0];
String s2 = (String) param.args[1];
// 打印日志
XposedBridge.log(TAG + " s1: " + s1);
XposedBridge.log(TAG + " s2: " + s2);
Log.d(TAG, "s1: " + s1);
Log.d(TAG, "s2: " + s2);
// 强制返回true,绕过验证
param.setResult(true);
}
}
);
} catch (Exception e) {
XposedBridge.log("Hook failed: " + e.getMessage());
Log.e(TAG, "Hook failed", e);
}
}
}
- 实现了 IXposedHookLoadPackage 接口来处理模块加载
- 通过 lpparam.packageName 过滤目标应用包名
- 使用 XposedHelpers.findClass 查找目标类
- 使用 findAndHookMethod Hook 目标方法
- 在 afterHookedMethod 中:
- 获取方法参数
- 打印调试信息
- 通过 param.setResult(true) 强制返回验证通过
10.4 动态注入
动态注入是指将外部代码或可执行文件加载到目标进程的内存中,并修改其执行流程以运行注入的代码。与 Hook 的区别在于:
- Hook:修改原函数指针,使其跳转到替换函数。
- 动态注入:直接向目标进程写入新代码,并修改指令指针(如 PC/EIP)执行该代码。
核心步骤
- 获取目标进程权限:
- 通过
ptrace附加进程,或利用进程漏洞(如zygote注入)。
- 通过
- 内存操作:
- 在目标进程分配内存(如
mmap)。 - 写入 Shellcode 或动态库(
.so)。
- 在目标进程分配内存(如
- 执行控制:
- 修改寄存器或跳转指令(如
dlopen加载库,或修改 PC 执行 Shellcode)。
- 修改寄存器或跳转指令(如
- 恢复执行:
- 清理痕迹(如恢复寄存器/指令),继续原流程。
10.4.1 SO 动态库注入
SO 动态库注入是将编译好的 .so 文件加载到目标进程内存,并执行其中函数的技术,完整流程如下:
1. 开发阶段
- 编写 Native 代码
使用 Android NDK 开发.so,定义需执行的函数(如void inject_entry())。 - 编译生成
.so文件
通过 Android Studio 或ndk-build生成目标架构的.so。
2. 注入阶段
- 附加目标进程
ptrace(PTRACE_ATTACH, pid, NULL, NULL); - 保存寄存器状态
struct pt_regs regs; ptrace(PTRACE_GETREGS, pid, NULL, ®s); - 分配内存
- 获取目标进程的
mmap地址(通过/proc/<pid>/maps解析)。 - 调用
mmap分配内存空间:long mmap_addr = find_mmap_addr(pid); ptrace_call(pid, mmap_addr, [参数], ®s);
- 获取目标进程的
- 写入
.so到目标内存ptrace_write_data(pid, allocated_addr, so_data, so_size); - 加载
.so- 获取
dlopen/dlsym地址(从目标进程的libdl.so解析)。 - 调用
dlopen加载.so:ptrace_call(pid, dlopen_addr, [so_path_addr, RTLD_NOW], ®s);
- 获取
- 执行目标函数
void *func_addr = ptrace_call(pid, dlsym_addr, [so_handle, "inject_entry"], ®s); ptrace_call(pid, func_addr, [参数], ®s); - 恢复并脱离进程
ptrace(PTRACE_SETREGS, pid, NULL, ®s); ptrace(PTRACE_DETACH, pid, NULL, NULL);
关键函数说明
| 函数 | 作用 |
|---|---|
ptrace_attach() |
附加目标进程 |
ptrace_getregs() |
保存寄存器状态 |
ptrace_writedata() |
向目标进程写入数据 |
dlopen()/dlsym() |
动态加载 .so 并获取函数地址 |
注意事项
- 权限要求:需 Root 或
ptrace权限。 - 架构兼容性:
.so需与目标进程架构(ARM/x86)匹配。 - 稳定性风险:错误操作可能导致进程崩溃。
10.4.2 DEX 注入
DEX 注入是将 DEX 或 APK 文件加载到目标进程并执行的技术,核心流程与 SO 注入类似,但需通过 JNI 调用 DexClassLoader 加载 DEX。
注入步骤
- 准备 DEX 文件
- 用 Android Studio 编写 Java 代码,编译为 DEX/APK。
- 示例代码(需包含目标类和方法)。
- 编写 SO 加载器
- 使用 NDK 开发 SO,包含 JNI 函数调用
DexClassLoader
- 使用 NDK 开发 SO,包含 JNI 函数调用
- 注入 SO 到目标进程
- 使用
ptrace附加进程,写入 SO 并调用load_dex(同 SO 注入步骤)。
- 使用
与 SO 注入的区别
| 关键点 | SO 注入 | DEX 注入 |
|---|---|---|
| 注入内容 | 原生代码(.so) | Java 代码(DEX/APK) |
| 加载方式 | dlopen + dlsym |
JNI + DexClassLoader |
| 适用场景 | 底层 Hook/补丁 | Java 层逻辑扩展(如插件化) |
10.5 注入框架 Frida
...
11 常见软件保护技术分类
1. 软件水印技术
- 目的:在软件中嵌入唯一标识(如开发者信息、用户ID),防止非法分发。
- 特点:多用于专属软件,Android 平台较少见。
2. 软件混淆技术
- 目的:增加逆向分析难度,分为以下类型:
- 代码混淆:变量/方法名随机化(如 ProGuard)。
- 资源混淆:加密资源文件路径(如 AndResGuard)。
- 二进制混淆:DEX/原生代码指令混淆(如 OLLVM)。
- Android 细分:源码级、编译期、DEX 层混淆。
3. 软件防篡改技术
- 目的:防止代码或资源被篡改,主要手段:
- 完整性校验:校验 APK 签名或文件哈希值。
- 资源保护:加密 assets/so 文件。
- 软件壳保护:加壳(如 DexProtector、梆梆加固)。
4. 反调试技术
- 目的:阻止动态调试,常见方法:
- 进程防护:禁止
ptrace附加。 - 调试器检测:检查
TracerPid或调试端口。 - 父进程检测:识别调试器父进程(如 gdb、lldb)。
- 进程状态监控:轮询
/proc/self/status。
- 进程防护:禁止
5. 运行环境检测
- 目的:防止沙箱分析或非授权环境运行,包括:
- 模拟器检测:检查硬件特征(如
ro.build.fingerprint)。 - Root 检测:查找
su文件或 Magisk 痕迹。 - Hook 检测:扫描内存中的 Xposed/FRIDA 模块。
- 模拟器检测:检查硬件特征(如




浙公网安备 33010602011771号