Unidbg学习笔记(九):系统调用层补环境
系统调用层的问题和 JNI、文件层有一个本质不同:前两者是 Unidbg 明确把责任交给你,你不补就肯定不行;系统调用层是 Unidbg 自己想干却没干好。理解这个区别,是从“补环境工人”晋级到“模拟器贡献者”的分水岭。
上一篇把你留在了哪里
第八篇讲完文件系统之后,你已经掌握了三个通道:JNI(90% 工作量)、文件(~8%)、系统调用(剩下的小部分但很扎心)。这一篇专门讲系统调用通道。
需要先调整一个心态预期:这一篇不是教你“怎么补一堆系统调用”,而是教你“什么时候该出手、什么时候该绕开”。系统调用层的好消息是问题数量不多,坏消息是每一个都很硬核。
心态切换:你不是在补环境,你是在帮 Unidbg 打补丁
回想一下前面三个通道的角色定位:
| 通道 | 谁的责任 | 你扮演什么 |
|---|---|---|
| JNI | 你的责任 —— Unidbg 直接把请求交给 AbstractJni | Java 替身演员 |
| 文件 | 你的责任 —— Unidbg 通过 IOResolver 把请求交给你 | 虚拟文件系统提供者 |
| 系统调用 | Unidbg 的责任 —— 它自己有 SyscallHandler 试图处理 | ??? |
到了系统调用层,分工发生了变化。Unidbg 对系统调用的态度是“我自己来”:
- 它内置了一个
ARM32SyscallHandler和ARM64SyscallHandler - 实现了大约 100+ 个常见系统调用(read / write / mmap / open / brk / clock_gettime / ...)
- 大部分时候 SO 调系统调用,你完全感知不到
那为什么还要补?因为 Unidbg 不是 Linux 内核,它只是一个有限的近似。这个近似有三种“漏洞”:
漏洞 1:有些系统调用根本没实现(比如 getrusage),SO 一调就崩。
漏洞 2:有些系统调用只实现了一半(比如 clock_gettime 只支持 CLOCK_REALTIME 和 CLOCK_MONOTONIC,不支持 CLOCK_BOOTTIME),SO 传错参数就崩。
漏洞 3:有些系统调用看似正常返回,但返回的值和真机不一致(比如 stat64 返回的 inode、getcpu 始终返回 0),SO 不崩,但拿到的数据是假的。
所以你的角色变了:
- 在 JNI / 文件层,你是演员:从空舞台开始演 Android 系统
- 在系统调用层,你是修理工:Unidbg 自己想演但演不好的地方,你拿胶带补一下
这个心态变化非常关键。它意味着:
- 不要主动出击。如果 Unidbg 默认行为已经够用,碰都不要碰系统调用层
- 报错才介入。
syscall NR=xxx not implemented这种明确报错才是你的工单 - 优先考虑绕开。后面会讲,很多系统调用可以在库函数层hook 掉,根本不用碰 syscall
明白这一点后,下面看具体的三类问题。
三类系统调用问题
按“危险程度从低到高”排列。前两类看得见摸得着,第三类是隐形杀手。
类型一:完全未实现 — 最容易发现的
现象:
java.lang.UnsupportedOperationException: syscall NR=165 not implemented
at com.github.unidbg.linux.ARM64SyscallHandler.hook(ARM64SyscallHandler.java:227)
at com.github.unidbg.arm.backend.UnicornBackend$11.hook(...)
...
NR=165 这个数字就是系统调用号。查一下 ARM64 syscall 表(man 2 syscall 或者 [chromium.googlesource.com 的 syscalls.h](chromium.googlesource.com/linux-sysca… 对应的是 getrusage。
为什么 Unidbg 没实现 getrusage?
因为它在普通 App 里基本用不到。getrusage 是查询进程资源使用情况(CPU 时间、最大内存占用、缺页次数)的接口,主要用在性能分析、运行时统计场景。Unidbg 的设计哲学是“覆盖最常用的 80%”,剩下的 20% 留给用户自己补。
两种处理思路(这里是关键):
思路 A:在 SyscallHandler 加 case
public class MySyscallHandler extends ARM64SyscallHandler {
public MySyscallHandler(SvcMemory svcMemory) {
super(svcMemory);
}
@Override
public void hook(Backend backend, int intno, int swi, Object user) {
Emulator<?> emulator = (Emulator<?>) user;
if (intno == 2) { // 软中断, 进入 syscall 流程
// ARM64 用 x8 传 syscall number
int NR = backend.reg_read(Arm64Const.UC_ARM64_REG_X8).intValue();
switch (NR) {
case 165: { // getrusage
handleGetrusage(emulator, backend);
return;
}
}
}
// 其它情况交给父类默认处理
super.hook(backend, intno, swi, user);
}
private void handleGetrusage(Emulator<?> emulator, Backend backend) {
// x0 = who (RUSAGE_SELF=0 / RUSAGE_CHILDREN=-1 / RUSAGE_THREAD=1)
// x1 = struct rusage* 用户空间指针
int who = backend.reg_read(Arm64Const.UC_ARM64_REG_X0).intValue();
Pointer rusagePtr = UnidbgPointer.register(emulator,
Arm64Const.UC_ARM64_REG_X1);
// 简化处理: 把整个 struct rusage 全部置零, 表示资源占用为 0
// struct rusage 大小: ARM64 上是 144 字节
if (rusagePtr != null) {
rusagePtr.write(0, new byte[144], 0, 144);
}
// 系统调用返回值: 0 表示成功
backend.reg_write(Arm64Const.UC_ARM64_REG_X0, 0L);
}
}
注册方式:Unidbg 没有公开的 setSyscallHandler setter,但有官方扩展点 —— AbstractEmulator.createSyscallHandler(SvcMemory) 是 protected abstract,专门给子类 override。
// 1. 继承 AndroidARM64Emulator, override createSyscallHandler
public class MyAndroidARM64Emulator extends AndroidARM64Emulator {
public MyAndroidARM64Emulator(String processName, File rootDir,
Collection<BackendFactory> factories) {
super(processName, rootDir, factories);
}
@Override
protected UnixSyscallHandler<AndroidFileIO> createSyscallHandler(SvcMemory svcMemory) {
return new MySyscallHandler(svcMemory); // 你的子类
}
}
// 2. 复用一个最小 builder, 或直接 new
AndroidEmulator emulator = new MyAndroidARM64Emulator(
"com.example.app", new File("target"), Collections.emptyList());
实战提示:这是替换 SyscallHandler 唯一干净的姿势。不要用反射改私有字段——Unidbg 升级时字段名可能变;也不要 fork Unidbg 改源码——升级时合并冲突会让你怀疑人生。继承 + override 一行代码就够。
思路 B:在库函数层 hook
// 用 HookZz 在 libc 的 getrusage 入口处 replace
// replace 语义就是"完全跳过原函数, 由你给返回值"——这正是我们想要的
Module libc = emulator.getMemory().findModule("libc.so");
Symbol getrusageSym = libc.findSymbolByName("getrusage", false);
IHookZz hookZz = HookZz.getInstance(emulator);
hookZz.replace(getrusageSym, new ReplaceCallback() {
@Override
public HookStatus onCall(Emulator<?> emulator, long originFunction) {
RegisterContext ctx = emulator.getContext();
Pointer rusagePtr = ctx.getPointerArg(1);
// 直接在 libc 入口拦下来, 完全不让它走到 SVC 指令
// 把 struct rusage 全部置零, 表示零资源占用 (大部分 SO 要的就是"调用成功", 不是真实统计值)
if (rusagePtr != null) {
rusagePtr.write(0, new byte[144], 0, 144);
}
// 返回 0 = 成功; LR 表示直接返回到调用者, 不执行原函数
return HookStatus.LR(emulator, 0);
}
});
为什么用
replace而不是wrap:wrap会让原函数继续执行,你只能在前后观察 / 改写寄存器;这里我们要的是"原函数根本不要跑",正好对应replace的语义。两者差别在第十篇会展开讲,这里记一句口诀:想观察用 wrap,想替换用 replace。
两种思路怎么选?
| 标准 | SyscallHandler 加 case | libc 层 hook |
|---|---|---|
| 适用场景 | 多个 SO 都会调这个 syscall | 只有当前 SO 调,影响范围小 |
| 修改入侵性 | 改 Unidbg 内核(或反射 hack) | 项目本地代码,零侵入 |
| 性能 | 略好(少一层调用) | 可忽略的开销 |
| 可移植性 | 升级 Unidbg 时要重新合并 | 完全不受 Unidbg 升级影响 |
| 推荐 | 几乎不推荐 | 优先选这个 |
实战经验:99% 的情况下选库函数层 hook。除非你在改 Unidbg 上游,否则别折腾 SyscallHandler。第十篇会专门讲库函数层 hook 的所有姿势。
类型二:部分实现 — 参数空间没覆盖全
现象:不像类型一那样直接 not implemented,而是某个 syscall 对部分参数值有实现,对部分没有。
经典例子:clock_gettime
clock_gettime(clockid_t clk_id, struct timespec *tp) 接受一个时钟类型参数 clk_id。Linux 定义了一堆:
#define CLOCK_REALTIME 0 // 真实墙上时间
#define CLOCK_MONOTONIC 1 // 单调时钟, 不会回退
#define CLOCK_PROCESS_CPUTIME_ID 2 // 进程 CPU 时间
#define CLOCK_THREAD_CPUTIME_ID 3 // 线程 CPU 时间
#define CLOCK_MONOTONIC_RAW 4 // 不受 NTP 调整的单调时钟
#define CLOCK_REALTIME_COARSE 5 // 低精度真实时间
#define CLOCK_MONOTONIC_COARSE 6 // 低精度单调时钟
#define CLOCK_BOOTTIME 7 // 包含 suspend 时间的单调时钟
Unidbg 实际实现了 5 个:CLOCK_REALTIME(0) / CLOCK_MONOTONIC(1) / CLOCK_MONOTONIC_RAW(4) / CLOCK_MONOTONIC_COARSE(6) / CLOCK_BOOTTIME(7)。真正的盲区是另外三个:CLOCK_PROCESS_CPUTIME_ID(2) / CLOCK_THREAD_CPUTIME_ID(3) / CLOCK_REALTIME_COARSE(5)——SO 调到这三个会抛 UnsupportedOperationException("clk_id=2") 之类的报错。
处理:补全 CPUTIME 分支
// 仅在 SyscallHandler 子类里 override clock_gettime, 补 case 2/3/5 三个未覆盖分支
@Override
protected int clock_gettime(Emulator<?> emulator) {
int clkId = emulator.getContext().getIntArg(0) & 0x7;
Pointer tp = emulator.getContext().getPointerArg(1);
switch (clkId) {
case 2: // CLOCK_PROCESS_CPUTIME_ID, 进程消耗的 CPU 时间
case 3: // CLOCK_THREAD_CPUTIME_ID, 线程消耗的 CPU 时间
// 简化处理: 用 JMX 拿 JVM 累计 CPU 时间作为近似
long cpuNanos = ManagementFactory.getThreadMXBean()
.getCurrentThreadCpuTime();
tp.setLong(0, cpuNanos / 1_000_000_000L);
tp.setLong(8, cpuNanos % 1_000_000_000L);
return 0;
case 5: // CLOCK_REALTIME_COARSE, 低精度墙钟, 直接降级到普通墙钟
long ms = System.currentTimeMillis();
tp.setLong(0, ms / 1000);
tp.setLong(8, (ms % 1000) * 1_000_000L);
return 0;
default:
// 0/1/4/6/7 由父类已经处理好
return super.clock_gettime(emulator);
}
}
注意:上面这段代码假设你已经按"思路 A"继承了 ARM64SyscallHandler。绝大多数情况下你应该直接用思路 B(库函数层 hook)——把 libc 的 clock_gettime 整个 hook 掉,对 case 2/3/5 给出固定值,根本不进 SyscallHandler。具体见第十篇。
与友邻模拟器的对比
类型二的问题不止 Unidbg 有。其他模拟器也有自己的“覆盖盲区”:
| 模拟器 | clock_gettime 覆盖 | 哲学 |
|---|---|---|
| Unidbg | 0, 1 完整;其他部分 | “够用就行,剩下用户补” |
| Qiling | 0, 1, 2, 3, 4 | 偏完整,更接近真实 Linux |
| ExAndroidNativeEmu | 0, 1 | 极简,适合学习 |
哲学差异决定了行为差异。如果你的 SO 经常踩到 Unidbg 的盲区,可以考虑切换到 Qiling(但 Qiling 性能通常不如 Unicorn 后端的 Unidbg)。
类型三:语义偏差 — 隐形的杀手
现象:syscall 不报错,正常返回。但返回的值和真机不一样。
经典例子 1:stat64
struct stat sb;
if (stat("/system/lib64/libc.so", &sb) == 0) {
// SO 用 sb.st_ino (inode 号) 算签名
// sb.st_size, sb.st_mtime 都可能被用进哈希
}
Unidbg 处理 stat64 时,会返回模拟的 inode 号(通常是一个递增计数器或 hash),这个值和真机上 ext4 文件系统上的真实 inode 完全不同。
结果:SO 不会崩,因为 stat() 调用成功了,结构体也填好了。但你的最终签名和真机不一样,因为 inode 输入不对。
经典例子 2:getcpu
unsigned cpu, node;
syscall(SYS_getcpu, &cpu, &node, NULL);
// SO 用 cpu 编号决定走哪个分支 (针对大小核优化)
Unidbg 的 getcpu 通常硬编码返回 cpu=0, node=0。在真机上,App 可能跑在 cpu=4 上(大核),SO 走的是大核优化分支;在 Unidbg 里它走小核分支,最终结果不同。
经典例子 3:uname
struct utsname uts;
uname(&uts);
// uts.sysname = "Linux"
// uts.release = ??? <- 内核版本, 真机是 "4.14.117-...", Unidbg 可能是 "3.10.0"
Unidbg 默认的 uname 输出可能是个固定的占位字符串,而真机上有完整的 Android 内核版本号。SO 把 release 字段拌进哈希就出问题。
类型三的危险性
没有任何报错。代码继续跑,结果偏差,你不知道哪里出了问题。
怎么发现?只能靠对照真机:
- 确定 SO 的最终输出(签名 / 加密结果)和真机不一致
- Frida 在真机上 hook 所有可疑的 syscall(stat64 / getcpu / uname / clock_gettime / ...),记录返回值
- 在 Unidbg 里加 log 打印同样这些 syscall 的返回值
- 对照差异,找到值不一样的那一个
// Frida 在真机上 trace stat64 的返回值
var statPtr = Module.findExportByName('libc.so', 'stat');
Interceptor.attach(statPtr, {
onEnter: function(args) {
this.path = args[0].readCString();
this.statBuf = args[1];
},
onLeave: function(retval) {
if (retval.toInt32() === 0) {
// 解析 stat 结构体, ARM64 上 st_ino 在 offset 0x10 (16 字节处)
var ino = this.statBuf.add(0x10).readU64();
console.log('[stat] ' + this.path + ' => ino=' + ino);
}
}
});
处理类型三的核心原则:理解 syscall 的完整语义,针对那个具体的偏差点定向修复。不要试图把 Unidbg 改成“完全等价于 Linux”,没那个必要。
系统调用的快速定位法
报错栈给的信息往往很简略:syscall NR=xxx not implemented。要从这个数字快速定位到处理代码,需要一套查找流程。
第一步:识别中断类型
ARM 架构里 SVC 指令是一个软中断。Unidbg 的 SyscallHandler 在 hook 中断时会拿到一个 intno 参数:
@Override
public void hook(Backend backend, int intno, int swi, Object user) {
// intno = 2 表示 SVC 软中断 (即 syscall)
// intno = 1 / 3 / ... 表示其它类型的异常 (调试异常等), 这里不关心
if (intno != 2) {
// 不是系统调用, 交给父类处理
super.hook(backend, intno, swi, user);
return;
}
// 进入 syscall 流程
}
记忆:intno == 2 就是系统调用。看到 not implemented 报错,先确认 intno=2,否则你查的方向就错了(其他 intno 是其他异常)。
第二步:从寄存器拿 NR
ARM32 和 ARM64 用不同的寄存器传 syscall 号:
| 架构 | 传 NR 的寄存器 | 传参寄存器 | 返回值寄存器 |
|---|---|---|---|
| ARM32 | r7 | r0 ~ r6 | r0 |
| ARM64 | x8 | x0 ~ x5 | x0 |
// ARM64
int NR = backend.reg_read(Arm64Const.UC_ARM64_REG_X8).intValue();
// ARM32
int NR = backend.reg_read(ArmConst.UC_ARM_REG_R7).intValue();
第三步:查 syscall 表
ARM32 和 ARM64 的系统调用号完全不同!同一个 syscall 在两个架构上编号天差地别:
| syscall | ARM32 NR | ARM64 NR |
|---|---|---|
| read | 3 | 63 |
| write | 4 | 64 |
| open | 5 | 56(实际上 ARM64 没 open,只有 openat=56) |
| openat | 322 | 56 |
| getpid | 20 | 172 |
| gettimeofday | 78 | 169 |
| clock_gettime | 263 | 113 |
| stat64 | 195 | (没有 stat64,用 newfstatat=79) |
| getrusage | 77 | 165 |
为什么差这么大? ARM64 是后来才设计的 ABI,设计时把“过时的、不必要的、有别名的”调用全部砍掉重排了。例如 ARM64 干脆没有 open,只有更通用的 openat;没有独立的 stat64,只有 newfstatat。所以绝对不能复用 ARM32 的查表结果。
两个常用查表入口:
或者更直接的:在 Unidbg 项目里全文搜 case 165(如果是 ARM64 NR=165),看看 Unidbg 自己怎么处理的,旁边相邻的 case 给你提供“邻居参考”。
第四步:读 man page 理解语义
定位到 syscall 名字之后,不要直接动手写。先 man 2 getrusage 把这个 syscall 的语义、参数、返回值看完一遍。
NAME
getrusage - get resource usage
SYNOPSIS
int getrusage(int who, struct rusage *usage);
DESCRIPTION
who: RUSAGE_SELF / RUSAGE_CHILDREN / RUSAGE_THREAD
usage: 输出参数, 用 struct rusage 描述
RETURN VALUE
0 成功, -1 失败 (errno 设置)
STRUCT
struct rusage {
struct timeval ru_utime; /* user CPU time used */
struct timeval ru_stime; /* system CPU time used */
long ru_maxrss; /* maximum resident set size */
...
};
man page 会告诉你:
- 入参在哪些寄存器(你已经知道,前 6 个走 x0-x5)
- 输出参数指针指向什么结构体(你需要往这块内存写什么)
- 错误码语义(返回 -1 时 errno 该设啥)
- 哪些字段必须填、哪些可以全 0
这一步省不掉。你只读“参数有几个”是不够的,你必须理解“调这个 syscall 是想拿什么”。否则你写出来的实现只是让 SO 不崩,但值是错的(直接掉进类型三的陷阱)。
实战:补一个 getrusage 完整流程
把前面所有东西串起来。假设你的 SO 跑起来报:
java.lang.UnsupportedOperationException: syscall NR=165 not implemented
Step 1:识别 intno=2(看报错栈是从 ARM64SyscallHandler.hook 抛出的)→ 确认是 syscall
Step 2:x8 = 165 → 查 ARM64 syscall 表 → 165 是 getrusage
Step 3:man 2 getrusage → 知道:
- 参数:x0 = who (int), x1 = struct rusage*
- 返回值:x0 = 0 成功
- 结构体大小:ARM64 上 144 字节(注意 ARM32 上是 72 字节,因为 long 不同)
Step 4:决定在哪一层补
- 这个 syscall 全局用得不多,对应的 libc 函数
getrusage容易 hook - 选库函数层 hook
Step 5:写代码
Module libc = emulator.getMemory().findModule("libc.so");
Symbol getrusageSym = libc.findSymbolByName("getrusage", false);
IHookZz hookZz = HookZz.getInstance(emulator);
hookZz.replace(getrusageSym, new ReplaceCallback() {
@Override
public HookStatus onCall(Emulator<?> emulator, long originFunction) {
RegisterContext ctx = emulator.getContext();
int who = ctx.getIntArg(0);
Pointer rusagePtr = ctx.getPointerArg(1);
// 把 struct rusage 全部置零, 表示零资源占用
// 这对大多数 SO 来说够用 - 它们要的是"调用成功"而不是真实的统计
if (rusagePtr != null) {
rusagePtr.write(0, new byte[144], 0, 144);
}
// 返回 0 = 成功; 直接跳过原函数
return HookStatus.LR(emulator, 0);
}
});
Step 6:再跑一次
[+] SO loaded
[+] callStaticJniMethod -> sign(...)
[+] result: 4f3a92b...
报错消失,结果出来了。但别急着庆祝 —— 用 Frida 在真机上跑同样的输入,看签名是否一致。一致 → 真过;不一致 → 这个 SO 在乎 rusage 的具体值,回头补真实数据。
一个特殊提醒:vDSO 在真机和 Unidbg 上的差异
ARM64 真机上有一类系统调用走 vDSO(虚拟动态共享对象),不会真正陷入内核。最常见的是 gettimeofday 和 clock_gettime 这两个高频调用 —— 内核把它们的实现 mmap 到用户空间,bionic libc 优先调 vDSO 函数,失败才回落 SVC。
真机 vs Unidbg 的差异:
- 真机:libc 的
clock_gettime走 vDSO 路径,不触发 SVC,hook__NR_clock_gettime抓不到 - Unidbg:加载的 sdk23 libc.so 里
clock_gettime直接走svc #0,进 SyscallHandler 完全没问题——grep -rn "vdso" unidbg/没有任何结果,Unidbg 没实现 vDSO
对你的影响:
- 不要被真机上的 vDSO 现象误导:在 Unidbg 里给
clock_gettime在 SyscallHandler 加 case 是完全可行的,进得去也生效 - 真正要警惕的 vDSO 场景:极少数 SO 自己用内联汇编直接跳到 vDSO 地址(如
0x7FFFFFFE000)调__kernel_clock_gettime——这种 SO 在 Unidbg 上会跳到无效地址崩掉。这时候必须在库函数层 hook 那段内联代码或者把 vDSO 段手动映射上去 - 常规场景:99% 的 SO 通过 libc 包装函数调 clock_gettime,Unidbg 已经在 SyscallHandler 里处理好了,你什么都不用做
系统调用层的五条心法
- 不要主动出击:Unidbg 默认实现够用就别碰
- 先确认 intno=2:不是 syscall 的报错走错了树
- ARM32 和 ARM64 NR 不同:永远确认架构再查表
- 库函数层优先于 syscall 层:除非你在改 Unidbg 上游
- 类型三最危险:返回值正确不代表语义正确,必须对照真机
总结:四层响应模型走完了一半
到这一篇为止,你应该对前三个通道有了完整理解:
| 篇 | 通道 | 你的角色 | 入口 |
|---|---|---|---|
| 七 | JNI | 替身演员 | AbstractJni override |
| 八 | 文件 | 虚拟文件系统 | IOResolver |
| 九 | 系统调用 | 修理工 | SyscallHandler / libc hook |
四个通道的最后一个 —— 库函数调用 —— 在第十篇。你会发现库函数层不仅是补环境的“第四个通道”,还是前面三个通道的“瑞士军刀”:很多 JNI / 文件 / syscall 的问题,都可以在库函数层用更优雅的方式解决。