系统调用层的问题和 JNI、文件层有一个本质不同:前两者是 Unidbg 明确把责任交给你,你不补就肯定不行;系统调用层是 Unidbg 自己想干却没干好。理解这个区别,是从"补环境工人"晋级到"模拟器贡献者"的分水岭。
上一篇把你留在了哪里
第八篇讲完文件系统之后,你已经掌握了三个通道:JNI(90% 工作量)、文件(~8%)、系统调用(剩下的小部分但很扎心)。这一篇专门讲系统调用通道。
需要先调整一个心态预期:**这一篇不是教你"怎么补一堆系统调用",而是教你"什么时候该出手、什么时候该绕开"**。系统调用层的好消息是问题数量不多,坏消息是每一个都很硬核。
心态切换:你不是在补环境,你是在帮 Unidbg 打补丁
回想一下前面三个通道的角色定位:
| | |
|---|
| 你的责任 —— Unidbg 直接把请求交给 AbstractJni | |
| 你的责任 —— Unidbg 通过 IOResolver 把请求交给你 | |
| Unidbg 的责任 —— 它自己有 SyscallHandler 试图处理 | |
到了系统调用层,分工发生了变化。**Unidbg 对系统调用的态度是"我自己来"**:
- 它内置了一个
ARM32SyscallHandler 和 ARM64SyscallHandler - 实现了大约 100+ 个常见系统调用(read / write / mmap / open / brk / clock_gettime / ...)
那为什么还要补?因为 Unidbg 不是 Linux 内核,它只是一个有限的近似。这个近似有三种"漏洞":
JNI vs 系统调用 - 心态对比漏洞 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),165 对应的是 getrusage。
为什么 Unidbg 没实现 getrusage?
因为它在普通 App 里基本用不到。getrusage 是查询进程资源使用情况(CPU 时间、最大内存占用、缺页次数)的接口,主要用在性能分析、运行时统计场景。Unidbg 的设计哲学是"覆盖最常用的 80%",剩下的 20% 留给用户自己补。
两种处理思路(这里是关键):
思路 A:在 SyscallHandler 加 case
publicclassMySyscallHandlerextendsARM64SyscallHandler{
publicMySyscallHandler(SvcMemory svcMemory){
super(svcMemory);
}
@Override
publicvoidhook(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) {
case165: { // getrusage
handleGetrusage(emulator, backend);
return;
}
}
}
// 其它情况交给父类默认处理
super.hook(backend, intno, swi, user);
}
privatevoidhandleGetrusage(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, newbyte[144], 0, 144);
}
// 系统调用返回值: 0 表示成功
backend.reg_write(Arm64Const.UC_ARM64_REG_X0, 0L);
}
}
注册方式(注意必须替换默认的 SyscallHandler):
Emulator<AndroidFileIO> emulator = AndroidEmulatorBuilder.for64Bit()
.setProcessName("com.example.app")
.build();
// Unidbg 没有公开的 setSyscallHandler 接口
// 实战中通常通过反射或者继承 EmulatorBuilder 来替换
// 或者直接修改 Unidbg 源码后自己 build 一份
实战提示:替换 SyscallHandler 不像替换 IOResolver 那样有干净的注册接口。大多数项目要么在 addIOResolver 之后再用反射改 syscallHandler 字段,要么直接 fork Unidbg 在 ARM64SyscallHandler 里加 case。后者对维护更友好。
思路 B:在库函数层 hook
// 用 HookZz 在 libc 的 getrusage 入口处 hook
IHookZz hookZz = HookZz.getInstance(emulator);
hookZz.wrap(module.findSymbolByName("getrusage"), new WrapCallback<HookZzArm64RegisterContext>() {
@Override
publicvoidpreCall(Emulator<?> emulator, HookZzArm64RegisterContext ctx,
HookEntryInfo info){
// 直接在 libc 入口拦下来, 完全不让它走到 SVC 指令
Pointer rusagePtr = ctx.getPointerArg(1);
if (rusagePtr != null) {
rusagePtr.write(0, newbyte[144], 0, 144);
}
// 设置返回值并直接 return, 跳过原函数
ctx.setXLong(0, 0L);
// 配合 wrap 的 postCall 跳过原函数...
// 实际通常用 replace 而不是 wrap, 见第十篇详解
}
@Override
publicvoidpostCall(Emulator<?> emulator, HookZzArm64RegisterContext ctx,
HookEntryInfo info){
// 不需要 post 处理
}
});
两种思路怎么选?
实战经验: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 通常实现了 0 和 1,剩下的可能直接抛 not supported clock_id 或者返回错误。SO 如果调 clock_gettime(CLOCK_BOOTTIME, &ts) 拿"开机以来的时间",就会出问题。
处理:补全分支
privatevoidhandleClockGettime(Backend backend, Emulator<?> emulator){
int clockId = backend.reg_read(Arm64Const.UC_ARM64_REG_X0).intValue();
Pointer tspecPtr = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X1);
long now = System.nanoTime();
long sec = now / 1_000_000_000L;
long nsec = now % 1_000_000_000L;
switch (clockId) {
case0: // CLOCK_REALTIME, 用墙钟
long ms = System.currentTimeMillis();
sec = ms / 1000;
nsec = (ms % 1000) * 1_000_000L;
break;
case1: // CLOCK_MONOTONIC
case4: // CLOCK_MONOTONIC_RAW
case6: // CLOCK_MONOTONIC_COARSE
// 三个 monotonic 在用户态精度需求下可以共用 nanoTime
break;
case7: // CLOCK_BOOTTIME, 加一个固定的"开机时长"伪值
// 假设设备已开机 6 小时, 这个值对大多数 SO 来说够用
sec += 6 * 3600;
break;
default:
// 未知 clock_id, 返回 EINVAL = 22
backend.reg_write(Arm64Const.UC_ARM64_REG_X0, -22L);
return;
}
// 写入 timespec { tv_sec; tv_nsec }, ARM64 上各 8 字节
tspecPtr.setLong(0, sec);
tspecPtr.setLong(8, nsec);
backend.reg_write(Arm64Const.UC_ARM64_REG_X0, 0L);
}
与友邻模拟器的对比
类型二的问题不止 Unidbg 有。其他模拟器也有自己的"覆盖盲区":
哲学差异决定了行为差异。如果你的 SO 经常踩到 Unidbg 的盲区,可以考虑切换到 Qiling(但 Qiling 性能通常不如 Unicorn 后端的 Unidbg)。
类型三:语义偏差 — 隐形的杀手
现象:syscall 不报错,正常返回。但返回的值和真机不一样。
经典例子 1:stat64
structstatsb;
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
structutsnameuts;
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
publicvoidhook(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 号:
// 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 在两个架构上编号天差地别:
| | |
|---|
| | |
| | |
| | 56(实际上 ARM64 没 open,只有 openat=56) |
| | |
| | |
| | |
| | |
| | (没有 stat64,用 newfstatat=79) |
| | |
为什么差这么大? ARM64 是后来才设计的 ABI,设计时把"过时的、不必要的、有别名的"调用全部砍掉重排了。例如 ARM64 干脆没有 open,只有更通用的 openat;没有独立的 stat64,只有 newfstatat。所以绝对不能复用 ARM32 的查表结果。
两个常用查表入口:
- ARM32:arch/arm/include/uapi/asm/unistd.h
- ARM64:include/uapi/asm-generic/unistd.h
或者更直接的:在 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)
- 输出参数指针指向什么结构体(你需要往这块内存写什么)
这一步省不掉。你只读"参数有几个"是不够的,你必须理解"调这个 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*
- 结构体大小:ARM64 上 144 字节(注意 ARM32 上是 72 字节,因为 long 不同)
Step 4:决定在哪一层补
- 这个 syscall 全局用得不多,对应的 libc 函数
getrusage 容易 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, newbyte[144], 0, 144);
}
// 返回 0 = 成功; 直接跳过原函数
return HookStatus.LR(emulator, 0);
}
});
Step 6:再跑一次
[+] SO loaded
[+] callStaticJniMethod -> sign(...)
[+] result: 4f3a92b...
报错消失,结果出来了。但别急着庆祝 —— 用 Frida 在真机上跑同样的输入,看签名是否一致。一致 → 真过;不一致 → 这个 SO 在乎 rusage 的具体值,回头补真实数据。
一个特殊提醒:vDSO 和 vsyscall
ARM64 上有一类系统调用走的是 vDSO(虚拟动态共享对象),不会真正陷入内核。最常见的是 gettimeofday 和 clock_gettime 这两个高频调用 —— 内核把它们的实现 mmap 到用户空间,用户态直接调,不需要 SVC 指令。
Unidbg 里的影响:
- 如果 SO 走 vDSO 路径调
clock_gettime,根本不会触发 SVC 中断 - 但同时 vDSO 的代码也没被映射到 Unidbg 内存里,所以调用会跳到无效地址
Unidbg 的处理方式:把 vDSO 函数符号化,让它们在符号解析时就指向 Unidbg 自己的内置实现。这意味着 clock_gettime 在 Unidbg 里大概率走的是"libc 包装函数"路径而不是 SVC 路径。
对你的影响:补 clock_gettime 时不要在 SyscallHandler 里加 case,因为根本进不去。要在 libc 层(或 vDSO 符号层)hook。这也是为什么前面所有例子都倾向"在库函数层处理"。
系统调用层的五条心法
- 先确认 intno=2:不是 syscall 的报错走错了树
- ARM32 和 ARM64 NR 不同:永远确认架构再查表
- 库函数层优先于 syscall 层:除非你在改 Unidbg 上游
- 类型三最危险:返回值正确不代表语义正确,必须对照真机
总结:四层响应模型走完了一半
到这一篇为止,你应该对前三个通道有了完整理解:
| | | |
|---|
| | | |
| | | |
| | | SyscallHandler / libc hook |
四个通道的最后一个 —— 库函数调用 —— 在第十篇。你会发现库函数层不仅是补环境的"第四个通道",还是前面三个通道的"瑞士军刀":很多 JNI / 文件 / syscall 的问题,都可以在库函数层用更优雅的方式解决。