Unidbg学习笔记(四):一条 SVC 指令引发的连锁反应
理解 SVC 机制就理解了 Unidbg 的灵魂。所有的 JNI 调用、系统调用、Hook 拦截,最终都汇聚到同一个入口。
从一行汇编开始
打开 IDA 看一段 ARM64 的反汇编,你迟早会撞见这样一行:
.text:0000000000401234 svc #0
短短四个字节(指令值 0xD4000001,内存里的小端字节流是 01 00 00 D4),却是整个用户态程序与内核打交道的唯一通道。open 是它,read 是它,gettimeofday 是它,mmap 也是它。所有看起来天差地别的“系统调用”,到最底层全都收敛成同一条指令。
更妙的是,Unidbg 在这条指令上动了手脚 — 它不只用 SVC 来模拟系统调用,还把 JNI 调用、Hook 回调、虚拟模块的函数实现,统统伪装成了 SVC。一条 ARM 指令,扛起了整个 Unidbg 的对外交互。
理解这个机制,你会明白几件以前可能一直困惑的事:
- 为什么 JNI 报错和 Syscall 报错的栈帧长得几乎一模一样?
- 为什么 Unidbg 的 Hook 能在 Frida 完全够不到的层级生效?
- 为什么
intno=2这个数字会出现在几乎所有 Unidbg 的报错里?
这一篇,我们就拆开这条 SVC 指令,看看它在 Unidbg 内部点燃了一连串什么样的连锁反应。
SVC 指令:用户态通往内核的那道门
ARM 架构中的 SVC
SVC 全称 Supervisor Call(管理调用),在 ARMv7 中曾叫 SWI(Software Interrupt,软件中断),ARMv8 之后统一改叫 SVC。作用只有一个:让 CPU 从用户态主动切换到内核态。
它的指令格式非常朴素:
ARM32: svc #imm24 ; 24 位立即数(实际只用低 8 位)
ARM64: svc #imm16 ; 16 位立即数
机器码示例(内存里的小端字节流 → 反汇编):
ARM64: 01 00 00 D4 → svc #0 ; 整数值 0xD4000001
ARM32: 00 00 00 EF → svc #0 ; 整数值 0xEF000000
立即数本身在大多数 Linux 系统上是没有意义的(按惯例填 0)。系统调用号是通过寄存器传递的:
| 架构 | 系统调用号寄存器 | 参数寄存器 | 返回值寄存器 |
|---|---|---|---|
| ARM32 | r7 | r0–r6 | r0 |
| ARM64 | x8 | x0–x5 | x0 |
举个具体例子,调用 read(fd, buf, count) 在 ARM64 上的样子是这样:
mov x0, #3 ; 第一个参数 fd = 3
mov x1, x19 ; 第二个参数 buf 指针
mov x2, #1024 ; 第三个参数 count = 1024
mov x8, #63 ; 系统调用号 __NR_read = 63 (ARM64 用 x8 装载)
svc #0 ; 触发软件中断 → CPU 从 EL0 进入 EL1
; 内核完成 read 后返回, 结果写在 x0
真实 Android 中发生了什么
在真机上执行这条 svc #0 时,CPU 内部发生的事情大概是:
- CPU 把当前的执行状态(PC、寄存器、PSTATE)保存到内核栈
- 切换异常级别:EL0(用户态)→ EL1(内核态)
- 跳转到内核预先注册的异常向量表(VBAR_EL1 指向的位置)
- 内核根据
x8寄存器查 syscall 表,找到对应的内核函数(如sys_read) - 执行内核函数,结果写回
x0 - 执行
eret指令,恢复寄存器,返回用户态的下一条指令
整条用户态代码并不知道(也不需要知道)内核里到底发生了什么。它只知道:执行 svc #0,返回时 x0 里有结果。SVC 是一道单向门 — 你把请求扔进去,等待门那边的人把答案推回来。
类比一下:SVC 就像银行柜台的取号机。你不需要知道柜员是谁、后台系统是什么、钱从哪个金库取出来。你按下按钮(执行 SVC),等叫号(CPU 恢复执行),结果就在你手上了。柜台后面的世界,对你是完全不透明的。
Unidbg 的天才设计:把所有外部交互都伪装成 SVC
一个棘手的工程问题
现在切换到 Unidbg 作者的视角。你正在写一个 ARM 模拟器,需要让里面跑的 SO 代码能调用到外部的 Java 世界(这是 JNI 的本质)。问题来了:SO 代码是真实的 ARM 机器码,它怎么“跳出”模拟器去调用宿主机上的 Java 函数?
直觉上的方案有几个:
方案 A:扫描 PLT,识别 JNI 调用
Unidbg 在加载 SO 时遍历 PLT 表(Procedure Linkage Table,过程链接表),把每个 JNI 函数的导入项替换成自定义的处理逻辑。理论可行,但实现起来非常脏 — 每加载一个 SO 都要做一次符号解析,而且难以处理动态查表((*env)->FindClass(env, "...") 这种通过函数指针的调用)。
方案 B:拦截特定函数地址
为每个 JNI 函数指定一个伪造的地址(比如 0xdeadbeef00),SO 调用到这个地址时,模拟器抛出“非法访问”异常,然后在异常处理器里识别并分发。能跑,但语义上是“用 Bug 触发功能”,很别扭。
方案 C:复用 SVC 机制
把 JNI 函数表中的每一项,指向一段预先生成的 ARM 代码。这段代码里只有一条 SVC 指令。当 SO 通过函数表调用 JNI 函数时,自然会执行到这条 SVC,触发模拟器的中断回调,然后由 Unidbg 在回调里完成实际工作。
Unidbg 的作者选择了方案 C。这是一个看起来朴素、实际却精妙到令人拍案的决策。
为什么方案 C 这么妙
方案 C 的精妙之处在于,它复用了 ARM 架构本来就有的“用户态 → 特权态切换”语义:
- ARM CPU 设计 SVC 的本意,就是“用户代码主动暂停,把控制权交给上层管理者”
- 在真机上,“上层管理者”是 Linux 内核
- 在 Unidbg 中,“上层管理者”就是 Unidbg 自己
这个等价关系一旦建立,所有事情都顺了:
- SO 代码不需要任何修改 — 它认为自己在调用一个普通的函数指针,不知道背后是 SVC
- 模拟器的中断处理器只需要写一份 — 不管是 JNI 调用还是真正的 syscall,都走同一套分发流程
- 所有外部交互天然地被汇聚到一个入口 — 想加日志、Hook、监控?只在这一个地方加就够了
更精妙的是:Unidbg 不需要给每个 JNI 函数指定一个唯一的 SVC 立即数(如果用立即数区分,立即数空间很快会不够)。它的做法是:用 SVC 指令所在的内存地址本身作为唯一标识。因为每段 SVC 桩代码是动态分配在不同地址上的,PC 值就是天然的“函数 ID”。
JNI 桩代码长什么样
来看一个具体的例子。Unidbg 在初始化 DalvikVM 时,会为约 230 个 JNI 函数(FindClass、GetMethodID、CallObjectMethod、NewStringUTF...)每个都生成一段桩代码:
; FindClass 的 SVC 桩 (ARM64), 由 Arm64Svc.onRegister() 生成
; Unidbg 实际把这类桩分配在高地址区, 例如 0xfffe0030
0xfffe0030: svc #0 ; 触发中断, Unidbg 在回调里以 PC 为 key 查到 FindClass 的 Java handler
0xfffe0034: ret ; handler 执行完后, Unidbg 把结果写到 x0, 这里直接返回到调用者
JNIEnv 函数表(一个连续的指针数组,对应 JNINativeInterface 结构体)的对应槽位被填上 0xfffe0030。SO 代码里的 (*env)->FindClass(env, "java/lang/String") 经过 NDK 编译后大致是这样:
ldr x0, [x19] ; x19 = env, x0 = *env (指向 JNINativeInterface 表)
ldr x8, [x0, #0x30] ; FindClass 在表中的偏移 (ARM64 下是 0x30)
mov x0, x19 ; 第一个参数: env
mov x1, x20 ; 第二个参数: 类名字符串地址
blr x8 ; 跳转 → 0xfffe0030 → svc #0 → 中断
最关键的一步是 blr x8:CPU 跳转到 0xfffe0030,下一条指令就是 svc #0。模拟器的中断回调被触发,Unidbg 检查当前 PC = 0xfffe0030,在自己维护的 Map<Address, Svc> 里查到这个地址对应 FindClass 的 handler,调用它,把结果写回 x0。然后 SO 代码继续从 blr 的下一行执行,它完全不知道刚才发生了什么。
再打个类比:这就像你给朋友发微信,他在群里 @ 了一个机器人。机器人没有真的把消息转给后台 — 它直接在群里回复了你。从你的视角看,你只知道“我发了消息,得到了回复”,并不需要关心机器人是谁、后台在哪。Unidbg 就是那个机器人,SVC 指令就是 @ 它的方式。
一次完整的连锁反应:FindClass 走了哪些地方
把这个机制串起来看,威力才显出来。我们以一次真实的 FindClass("com/example/Util") 调用为例,跟着 CPU 走一遍完整的执行流。
阶段 1:SO 代码触发调用
SO 中的 C 代码:
JNIEXPORT jstring JNICALL Java_com_example_Util_sign(
JNIEnv *env, jobject thiz, jstring input) {
// 第一步: 找到 com.example.Util 类
jclass cls = (*env)->FindClass(env, "com/example/Util");
// ... 后续逻辑
}
经过 NDK 编译,FindClass 这一行会变成几条 ARM64 指令:
; X19 保存了 env 指针
ldr x0, [x19] ; x0 = *env (指向 JNINativeInterface 表)
ldr x8, [x0, #0x30] ; x8 = FindClass 函数指针 (表偏移 0x30)
mov x0, x19 ; arg0 = env
adr x1, aClassName ; arg1 = "com/example/Util" 字符串地址
blr x8 ; 调用 → 跳转到 Unidbg 注册的 SVC 桩地址
阶段 2:跳转到 SVC 桩,触发中断
CPU 跳转到 SVC 桩地址(沿用前面的例子 0xfffe0030):
0xfffe0030: svc #0 ; 关键的一条: 把控制权交给 Backend
0xfffe0034: ret ; handler 执行完后从这里返回到 SO 代码
执行 svc #0 时,Unicorn/Dynarmic 的指令模拟器识别出这是一条特权指令,立即触发预先注册的中断回调。在 Unidbg 中,这个回调由 AbstractEmulator 链路上的中断 handler 接收,最终调用到对应架构的 SyscallHandler。
阶段 3:Unidbg 的中断 handler 开始分发
进入 Java 世界。Unidbg 的中断处理逻辑大致是这样的(伪代码,便于理解):
// 简化后的中断分发逻辑
// 真实签名: hook(Backend backend, int intno, int swi, Object user)
// 第三个参数 swi 是 SVC 指令的立即数 (0xD4000001 中的 imm16), 后面会回到它
public void hook(Backend backend, int intno, int swi, Object user) {
if (intno != ARMEmulator.EXCP_SWI) {
// 不是 SVC 触发的中断, 走其他路径 (如内存访问异常)
return;
}
Emulator<?> emulator = (Emulator<?>) user;
UnidbgPointer pc = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_PC);
// 关键: 用 PC 地址查 svcMemory 中注册的 handler
Svc svc = svcMemory.getSvc(pc.peer);
if (svc != null) {
// 命中 → 这是一个 JNI 调用 / 虚拟模块函数 / Hook 回调
long ret = svc.handle(emulator);
backend.reg_write(Arm64Const.UC_ARM64_REG_X0, ret);
return;
}
// 没命中 → 这是一个真正的 Linux 系统调用
long NR = backend.reg_read(Arm64Const.UC_ARM64_REG_X8).longValue();
handleSyscall(emulator, NR);
}
注意这里的关键决策点:是用“PC 地址”还是“x8 寄存器”来分发,取决于这个 SVC 桩有没有被预先注册过。
- 注册过的 → JNI / 虚拟模块 / Hook → 用地址查表
- 没注册的 → 真正的 syscall → 用 x8 查 syscall 表
这就是 Unidbg 区分两类 SVC 的方式,没有冲突,一套机制。
阶段 4:执行 FindClass 的 Java handler
svcMemory.getSvc(0xfffe0030) 命中,拿到的 Svc 对象是 Unidbg 内部对 FindClass 的包装。它的 handle 方法大致是:
// DalvikVM 中 FindClass 的注册片段 (简化)
Pointer _FindClass = svcMemory.registerSvc(new Arm64Svc("FindClass") {
@Override
public long handle(Emulator<?> emulator) {
RegisterContext context = emulator.getContext();
Pointer env = context.getPointerArg(0); // x0
Pointer namePtr = context.getPointerArg(1); // x1
String className = namePtr.getString(0); // 读出 "com/example/Util"
// 转交给用户的 Jni 实现 (通常是 AbstractJni 子类)
DvmClass dvmClass = checkJni(vm, this).resolveClass(className);
// 把 DvmClass 注册到本地引用表, 返回它的 jobject 句柄
return dvmClass.hashCode();
}
});
这一步进入了“补环境”的领地 — resolveClass 最终会调用到你写的 AbstractJni 子类的 resolveClass 方法。如果你重写了,返回你提供的 DvmClass;如果你没重写,Unidbg 抛出“resolveClass not implemented”的异常。
等等,为什么 JNI 报错的栈帧里总有一行
svc handle? 现在你应该明白了。所有 JNI 调用的“现场”都是这一条 SVC 指令触发的中断回调。报错栈帧的最底部,永远是中断 handler;上面那一长串,才是你写的 Java 代码。
阶段 5:返回值写回,SO 代码继续执行
handle 方法返回一个 long(DvmClass 的句柄)。Unidbg 把它写到 x0 寄存器,然后让模拟器从 SVC 桩的下一条指令(ret)继续执行。ret 指令把控制流返回到 SO 代码中 blr x8 的下一行,整个 FindClass 调用结束。
从 SO 代码的视角看,它就像调用了一个普通的函数:传入参数,得到返回值。中间发生的所有事情 — 中断、异常、查表、回调到 Java、用户的 AbstractJni 处理 — 对它来说是完全透明的。
同一道门的不同来客
SVC 这道门,进出的不只是 JNI 调用。让我们看看 Unidbg 中所有走 SVC 的路径,你会发现这个设计的统一之美。
来客一:JNI 函数表
刚才详细讲过的。JNIEnv 和 JavaVM 函数表里的每一项都是一段 SVC 桩。约 230 个函数,对应 230 多段桩代码。
关键文件:unidbg-android/src/main/java/com/github/unidbg/linux/android/dvm/DalvikVM64.java(ARM64 版)和 DalvikVM.java(ARM32 版),里面密密麻麻的 svcMemory.registerSvc(new ArmSvc(...) {...}) 就是注册过程。
来客二:Linux 系统调用
SO 代码或 libc 内联汇编里的 svc #0。这一类是真的会跑到内核处理函数(Unidbg 的 ARM32SyscallHandler / Arm64SyscallHandler),需要根据 x8(或 r7)的系统调用号分发到具体的实现。
关键文件:unidbg-android/src/main/java/com/github/unidbg/linux/ARM32SyscallHandler.java 和 Arm64SyscallHandler.java。打开看看你会被 case 语句的密度震撼到 — 一个超长的 switch,每个 case 是一个 syscall 编号。
来客三:虚拟模块的导出函数
Unidbg 支持“虚拟模块”(VirtualModule)— 比如 libc.so 中的某些函数(如 __system_property_get),Unidbg 自己用 Java 实现,然后注册成一个看起来像真 SO 的模块。每个被虚拟化的函数的“代码”,其实也是一段 SVC 桩。
当 SO 通过 PLT 跳转调用 __system_property_get 时,跳转的目标就是这段桩代码,于是 SVC 触发,Unidbg 接管。
来客四:HookZz / Whale 的回调
Inline Hook 的实现需要在被 Hook 的函数入口插入一段代码,让原本的执行流转跳到用户的回调函数。Unidbg 的实现里,这段“插入的代码”也是一段 SVC 桩。
也就是说:当你用 HookZz Hook 了某个函数,函数入口被改成了 svc #0; ret,SO 一执行到这里就触发 SVC,Unidbg 在回调里执行你的 Java 代码。
来客五:SystemPropertyHook
这是 Unidbg 内置的、专门拦截 __system_property_get 系统属性获取的机制。它的工作方式是 PLT 符号替换 — SystemPropertyHook 实现 HookListener 接口,在 loadLibrary 解析 libc.so 的导入符号时介入:把 __system_property_get 的导入地址替换成一段新的 SVC 桩,桩 handler 里调用用户注册的 SystemPropertyProvider 来生成返回值。
层级关系是:
SO 调用 __system_property_get
↓
PLT 项指向 SystemPropertyHook 注册的 SVC 桩 (不是原 libc 的 __system_property_get)
↓
svc #0 → 中断回调
↓
PC 命中该桩, 桩 handler 直接调用 SystemPropertyProvider
↓
返回属性值, ret 回 SO 代码
这就回答了一个常见的错误现象:为什么 SystemPropertyHook 必须在 loadLibrary 之前注册? 因为符号替换发生在 loadLibrary 内部的 PLT 解析阶段——HookListener 必须在那一刻已经挂上去,否则 SO 的导入地址会指向原始的 libc 实现,之后再注册 Hook 已经晚了。
这个设计对使用者意味着什么
1. 所有报错都长得很像
你在用 Unidbg 时一定见过这种报错栈:
java.lang.UnsupportedOperationException: callObjectMethod
com/example/Util->getDeviceId(Landroid/content/Context;)Ljava/lang/String;
at com.github.unidbg.linux.android.dvm.AbstractJni.callObjectMethod(AbstractJni.java:...)
at com.github.unidbg.linux.android.dvm.DalvikVM64$XXX.handle(DalvikVM64.java:...)
at com.github.unidbg.linux.ARM64SyscallHandler.hook(ARM64SyscallHandler.java:...)
...
注意最底下那一行 — ARM64SyscallHandler.hook。所有 JNI 报错的栈底,都是 SyscallHandler 的中断回调。第一次看可能觉得“明明是 JNI 问题,关 syscall handler 什么事?”,现在你知道了:JNI 报错和 syscall 报错本质上是同一类报错,都是 SVC 中断里抛出来的。
这给排错带来一个实用技巧:看到任何报错时,先看倒数第二帧(紧挨着 hook 的那一层),它会告诉你这次中断的“性质”是什么 — JNI 函数?syscall?虚拟模块?
2. Hook 能做到 Frida 做不到的事
Frida 是注入到目标进程内的 JavaScript 引擎(默认 QuickJS,可切换为 V8)。它能 Hook 的最底层是用户态的函数入口(PLT、函数符号、内联汇编位置),但它没法 Hook SVC 指令本身。一旦 SO 代码执行了 svc #0,控制权就交给内核了,Frida 看不到内核里发生了什么。
Unidbg 不一样。SVC 是它自己实现的,所有 SVC 中断都从它手里过。你可以:
- 在 SVC 中断回调里加一行日志,记录所有系统调用 — 包括 SO 代码内联汇编里手写的、连 libc 包装函数都没经过的那些
- 在中断回调里改 syscall 编号,把一个 syscall 变成另一个
- 监控某段时间内的 SVC 频率,检测加壳代码的反调试循环
第十二篇会讲的“指令级 Trace”和这一层关系密切:Trace 实现的本质是在 Unicorn 的指令执行回调里加日志,而 SVC 机制让你能在更高的抽象层次(“这是一次外部交互”,而不是“这是一条普通指令”)去做监控。
3. 性能边界与 Backend 的关系
回想第三篇讲的 Backend 选型。SVC 机制能解释一件事:为什么 Dynarmic 不支持指令级 Hook,但仍然支持 SVC 中断?
答案是:SVC 是 ARM 架构定义的特权指令。无论用解释执行(Unicorn)还是 JIT 编译(Dynarmic),SVC 都必须触发宿主机这边的处理器接管 — 它是模拟器和外部世界的契约边界。Dynarmic JIT 编译时,会为 SVC 指令保留一个回调钩子,编译后的本机代码执行到 SVC 时还是会调用 Unidbg 的 Java handler。
这就是为什么 Dynarmic 上 JNI 调用照样能跑、syscall 照样能处理 — 走 SVC 的那部分能力是所有 Backend 共享的。Dynarmic 只是丢掉了“在每一条普通指令前后插入回调”的能力,并没有丢掉“在 SVC 触发时插入回调”的能力。
| 能力 | 实现方式 | 所有 Backend 都支持? |
|---|---|---|
| 普通指令 Hook | 翻译时插入 x86 回调代码 | 否 (仅 Unicorn/Unicorn2) |
| SVC 中断 | ARM 架构定义的特权指令陷入 | 是 |
| Trace | 翻译时记录每条指令 | 否 (仅 Unicorn/Unicorn2) |
| JNI 调用 | 走 SVC 中断 | 是 |
| 系统调用 | 走 SVC 中断 | 是 |
理解了这个矩阵,你就能解释自己之前可能踩过的坑:在 Dynarmic 上跑某个原本在 Unicorn 上工作的脚本,如果用了 traceCode 会失效,但补环境的 JNI 回调依然正常 — 它们走的不是同一条路。
一个反直觉的问题:SVC 立即数到底有没有用?
读到这里你可能会问:既然 Unidbg 用 PC 地址来查 handler,那 SVC 指令的立即数(svc #0 中的那个 0)是不是完全没用?
大致是这样,但有一个例外。在某些场景下,Unidbg 会用立即数来做“快速分类”:
svc #0:默认值,当作“未分类的 SVC”处理,走 PC 地址分发- 某些特殊的立即数:被 Unidbg 用来标识“这是一条 hook 触发的 SVC”或“这是一条 vector 桥接的 SVC”
不同版本的 Unidbg 对立即数的使用习惯不完全一致。但有一点是稳定的:主流路径(JNI/syscall)都是 PC 地址分发,立即数只在少数场景做辅助标记。
如果你需要看具体的立即数语义,去翻 Arm64Svc.onRegister() 和 ArmSvc.onRegister() 的实现 — 它们在生成 SVC 桩代码时会决定立即数填什么。
总结:一个入口,五种来客
Unidbg 的 SVC 机制是一个典型的“用对了原语”的设计:
- ARM 架构本来就有 SVC 这个“用户态 → 特权态”的语义槽
- Unidbg 没有自己发明轮子,而是复用了这个槽 — 把所有需要“跳出模拟器”的场景都映射到 SVC
- 一个统一的中断回调入口,承载了 JNI、syscall、虚拟模块、Hook、SystemProperty 五类完全不同的交互
- 不同 Backend 在 SVC 处理上保持兼容,让“分析能力”和“执行效率”的取舍只发生在 SVC 之外的指令上
下次再看到 Unidbg 报错栈底的 ARM64SyscallHandler.hook,你应该不会再困惑了。那是一道门 — 一道用一条 ARM 指令撑起整个 Unidbg 调度系统的门。