Unidbg学习笔记(九):系统调用层补环境

admin 2026-04-22 04:36:05 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细解析Unidbg模拟器中系统调用层的三类核心问题(完全未实现、部分实现、返回值不准确),强调从被动补环境转向主动修复模拟器漏洞的心态转变。关键建议包括优先采用库函数层Hook而非修改SyscallHandler、仅在实际报错时介入、通过参数分支补全和返回值修正提升兼容性。文档提供具体代码示例(如getrusage、clock_gettime的补丁实现)和实战选择策略,帮助开发者高效解决系统调用层兼容性问题。 综合评分: 82 文章分类: 逆向分析,安全工具,移动安全,红队,安全开发


cover_image

Unidbg学习笔记(九):系统调用层补环境

原创

泡泡以安 泡泡以安

泡泡以安

2026年4月21日 09:13 浙江

在小说阅读器读本章

去阅读

系统调用层的问题和 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 内核,它只是一个有限的近似。这个近似有三种”漏洞”:

JNI vs 系统调用 – 心态对比

漏洞 1:有些系统调用根本没实现(比如 getrusage),SO 一调就崩。漏洞 2:有些系统调用只实现了一半(比如 clock_gettime 只支持 CLOCK_REALTIME 和 CLOCK_MONOTONIC,不支持 CLOCK_BOOTTIME),SO 传错参数就崩。漏洞 3:有些系统调用看似正常返回,但返回的值和真机不一致(比如 stat64 返回的 inode、getcpu 始终返回 0),SO 不崩,但拿到的数据是假的。

所以你的角色变了

  • 在 JNI / 文件层,你是演员:从空舞台开始演 Android 系统
  • 在系统调用层,你是修理工:Unidbg 自己想演但演不好的地方,你拿胶带补一下

这个心态变化非常关键。它意味着:

  1. 不要主动出击。如果 Unidbg 默认行为已经够用,碰都不要碰系统调用层
  2. 报错才介入syscall NR=xxx not implemented 这种明确报错才是你的工单
  3. 优先考虑绕开。后面会讲,很多系统调用可以在库函数层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

public class MySyscallHandler extends ARM64SyscallHandler {

    public MySyscallHandler(SvcMemory svcMemory) {
        super(svcMemory);
    }

    @Override
    public void hook(Backend backend, int intno, int swi, Object user) {
&nbsp; &nbsp; &nbsp; &nbsp; Emulator<?> emulator = (Emulator<?>) user;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(intno ==&nbsp;2) { &nbsp;// 软中断, 进入 syscall 流程
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// ARM64 用 x8 传 syscall number
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;int&nbsp;NR = backend.reg_read(Arm64Const.UC_ARM64_REG_X8).intValue();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;switch&nbsp;(NR) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;165: { &nbsp;// getrusage
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; handleGetrusage(emulator, backend);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 其它情况交给父类默认处理
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;super.hook(backend, intno, swi, user);
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;private&nbsp;void&nbsp;handleGetrusage(Emulator<?> emulator, Backend backend)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// x0 = who (RUSAGE_SELF=0 / RUSAGE_CHILDREN=-1 / RUSAGE_THREAD=1)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// x1 = struct rusage* 用户空间指针
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;int&nbsp;who = backend.reg_read(Arm64Const.UC_ARM64_REG_X0).intValue();
&nbsp; &nbsp; &nbsp; &nbsp; Pointer rusagePtr = UnidbgPointer.register(emulator,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Arm64Const.UC_ARM64_REG_X1);

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 简化处理: 把整个 struct rusage 全部置零, 表示资源占用为 0
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// struct rusage 大小: ARM64 上是 144 字节
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(rusagePtr !=&nbsp;null) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; rusagePtr.write(0,&nbsp;new&nbsp;byte[144],&nbsp;0,&nbsp;144);
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 系统调用返回值: 0 表示成功
&nbsp; &nbsp; &nbsp; &nbsp; backend.reg_write(Arm64Const.UC_ARM64_REG_X0,&nbsp;0L);
&nbsp; &nbsp; }
}

注册方式(注意必须替换默认的 SyscallHandler):

Emulator<AndroidFileIO> emulator = AndroidEmulatorBuilder.for64Bit()
&nbsp; &nbsp; .setProcessName("com.example.app")
&nbsp; &nbsp; .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"),&nbsp;new&nbsp;WrapCallback<HookZzArm64RegisterContext>() {
&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;public&nbsp;void&nbsp;preCall(Emulator<?> emulator, HookZzArm64RegisterContext ctx,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;HookEntryInfo info)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 直接在 libc 入口拦下来, 完全不让它走到 SVC 指令
&nbsp; &nbsp; &nbsp; &nbsp; Pointer rusagePtr = ctx.getPointerArg(1);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(rusagePtr !=&nbsp;null) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; rusagePtr.write(0,&nbsp;new&nbsp;byte[144],&nbsp;0,&nbsp;144);
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 设置返回值并直接 return, 跳过原函数
&nbsp; &nbsp; &nbsp; &nbsp; ctx.setXLong(0,&nbsp;0L);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 配合 wrap 的 postCall 跳过原函数...
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 实际通常用 replace 而不是 wrap, 见第十篇详解
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;public&nbsp;void&nbsp;postCall(Emulator<?> emulator, HookZzArm64RegisterContext ctx,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; HookEntryInfo info)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 不需要 post 处理
&nbsp; &nbsp; }
});

两种思路怎么选?

| 标准 | 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&nbsp;CLOCK_REALTIME &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;0 &nbsp;// 真实墙上时间
#define&nbsp;CLOCK_MONOTONIC &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 1 &nbsp;// 单调时钟, 不会回退
#define&nbsp;CLOCK_PROCESS_CPUTIME_ID &nbsp;2 &nbsp;// 进程 CPU 时间
#define&nbsp;CLOCK_THREAD_CPUTIME_ID &nbsp; 3 &nbsp;// 线程 CPU 时间
#define&nbsp;CLOCK_MONOTONIC_RAW &nbsp; &nbsp; &nbsp; 4 &nbsp;// 不受 NTP 调整的单调时钟
#define&nbsp;CLOCK_REALTIME_COARSE &nbsp; &nbsp; 5 &nbsp;// 低精度真实时间
#define&nbsp;CLOCK_MONOTONIC_COARSE &nbsp; &nbsp;6 &nbsp;// 低精度单调时钟
#define&nbsp;CLOCK_BOOTTIME &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;7 &nbsp;// 包含 suspend 时间的单调时钟

Unidbg 通常实现了 0 和 1,剩下的可能直接抛 not supported clock_id 或者返回错误。SO 如果调 clock_gettime(CLOCK_BOOTTIME, &ts) 拿”开机以来的时间”,就会出问题。

处理:补全分支

private&nbsp;void&nbsp;handleClockGettime(Backend backend, Emulator<?> emulator)&nbsp;{
&nbsp; &nbsp;&nbsp;int&nbsp;clockId = backend.reg_read(Arm64Const.UC_ARM64_REG_X0).intValue();
&nbsp; &nbsp; Pointer tspecPtr = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X1);

&nbsp; &nbsp;&nbsp;long&nbsp;now = System.nanoTime();
&nbsp; &nbsp;&nbsp;long&nbsp;sec = now /&nbsp;1_000_000_000L;
&nbsp; &nbsp;&nbsp;long&nbsp;nsec = now %&nbsp;1_000_000_000L;

&nbsp; &nbsp;&nbsp;switch&nbsp;(clockId) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;0: &nbsp;// CLOCK_REALTIME, 用墙钟
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;long&nbsp;ms = System.currentTimeMillis();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; sec = ms /&nbsp;1000;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; nsec = (ms %&nbsp;1000) *&nbsp;1_000_000L;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;1: &nbsp;// CLOCK_MONOTONIC
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;4: &nbsp;// CLOCK_MONOTONIC_RAW
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;6: &nbsp;// CLOCK_MONOTONIC_COARSE
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 三个 monotonic 在用户态精度需求下可以共用 nanoTime
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;case&nbsp;7: &nbsp;// CLOCK_BOOTTIME, 加一个固定的"开机时长"伪值
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 假设设备已开机 6 小时, 这个值对大多数 SO 来说够用
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; sec +=&nbsp;6&nbsp;*&nbsp;3600;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break;
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;default:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 未知 clock_id, 返回 EINVAL = 22
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; backend.reg_write(Arm64Const.UC_ARM64_REG_X0, -22L);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return;
&nbsp; &nbsp; }

&nbsp; &nbsp;&nbsp;// 写入 timespec { tv_sec; tv_nsec }, ARM64 上各 8 字节
&nbsp; &nbsp; tspecPtr.setLong(0, sec);
&nbsp; &nbsp; tspecPtr.setLong(8, nsec);
&nbsp; &nbsp; backend.reg_write(Arm64Const.UC_ARM64_REG_X0,&nbsp;0L);
}

与友邻模拟器的对比

类型二的问题不止 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&nbsp;stat&nbsp;sb;
if&nbsp;(stat("/system/lib64/libc.so", &sb) ==&nbsp;0) {
&nbsp; &nbsp;&nbsp;// SO 用 sb.st_ino (inode 号) 算签名
&nbsp; &nbsp;&nbsp;// sb.st_size, sb.st_mtime 都可能被用进哈希
}

Unidbg 处理 stat64 时,会返回模拟的 inode 号(通常是一个递增计数器或 hash),这个值和真机上 ext4 文件系统上的真实 inode 完全不同。

结果:SO 不会崩,因为 stat() 调用成功了,结构体也填好了。但你的最终签名和真机不一样,因为 inode 输入不对。

经典例子 2:getcpu

unsigned&nbsp;cpu, node;
syscall(SYS_getcpu, &cpu, &node,&nbsp;NULL);
// SO 用 cpu 编号决定走哪个分支 (针对大小核优化)

Unidbg 的 getcpu 通常硬编码返回 cpu=0, node=0。在真机上,App 可能跑在 cpu=4 上(大核),SO 走的是大核优化分支;在 Unidbg 里它走小核分支,最终结果不同。

经典例子 3:uname

struct&nbsp;utsname&nbsp;uts;
uname(&uts);
// uts.sysname = "Linux"
// uts.release = ??? &nbsp;<- 内核版本, 真机是 "4.14.117-...", Unidbg 可能是 "3.10.0"

Unidbg 默认的 uname 输出可能是个固定的占位字符串,而真机上有完整的 Android 内核版本号。SO 把 release 字段拌进哈希就出问题。

类型三的危险性

没有任何报错。代码继续跑,结果偏差,你不知道哪里出了问题。

怎么发现?只能靠对照真机

  1. 确定 SO 的最终输出(签名 / 加密结果)和真机不一致
  2. Frida 在真机上 hook 所有可疑的 syscall(stat64 / getcpu / uname / clock_gettime / …),记录返回值
  3. 在 Unidbg 里加 log 打印同样这些 syscall 的返回值
  4. 对照差异,找到值不一样的那一个
// Frida 在真机上 trace stat64 的返回值
var&nbsp;statPtr = Module.findExportByName('libc.so',&nbsp;'stat');
Interceptor.attach(statPtr, {
&nbsp; &nbsp;&nbsp;onEnter:&nbsp;function(args)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;this.path = args[0].readCString();
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;this.statBuf = args[1];
&nbsp; &nbsp; },
&nbsp; &nbsp;&nbsp;onLeave:&nbsp;function(retval)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(retval.toInt32() ===&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 解析 stat 结构体, ARM64 上 st_ino 在 offset 0x10 (16 字节处)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;var&nbsp;ino =&nbsp;this.statBuf.add(0x10).readU64();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;console.log('[stat] '&nbsp;+&nbsp;this.path +&nbsp;' => ino='&nbsp;+ ino);
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
});

处理类型三的核心原则理解 syscall 的完整语义,针对那个具体的偏差点定向修复。不要试图把 Unidbg 改成”完全等价于 Linux”,没那个必要。


系统调用的快速定位法

报错栈给的信息往往很简略:syscall NR=xxx not implemented。要从这个数字快速定位到处理代码,需要一套查找流程。

系统调用定位三步法

第一步:识别中断类型

ARM 架构里 SVC 指令是一个软中断。Unidbg 的 SyscallHandler 在 hook 中断时会拿到一个 intno 参数:

@Override
public&nbsp;void&nbsp;hook(Backend backend,&nbsp;int&nbsp;intno,&nbsp;int&nbsp;swi, Object user)&nbsp;{
&nbsp; &nbsp;&nbsp;// intno = 2 表示 SVC 软中断 (即 syscall)
&nbsp; &nbsp;&nbsp;// intno = 1 / 3 / ... 表示其它类型的异常 (调试异常等), 这里不关心
&nbsp; &nbsp;&nbsp;if&nbsp;(intno !=&nbsp;2) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 不是系统调用, 交给父类处理
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;super.hook(backend, intno, swi, user);
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return;
&nbsp; &nbsp; }
&nbsp; &nbsp;&nbsp;// 进入 syscall 流程
}

记忆:**intno == 2 就是系统调用**。看到 not implemented 报错,先确认 intno=2,否则你查的方向就错了(其他 intno 是其他异常)。

第二步:从寄存器拿 NR

ARM32 和 ARM64 用不同的寄存器传 syscall 号:

| 架构 | 传 NR 的寄存器 | 传参寄存器 | 返回值寄存器 | | — | — | — | — | | ARM32 | r7 | r0r6 | r0 | | ARM64 | x8 | x0x5 | x0 |

// ARM64
int&nbsp;NR = backend.reg_read(Arm64Const.UC_ARM64_REG_X8).intValue();

// ARM32
int&nbsp;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 的查表结果

两个常用查表入口

  • 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
&nbsp; &nbsp; &nbsp; &nbsp;getrusage - get resource usage

SYNOPSIS
&nbsp; &nbsp; &nbsp; &nbsp;int getrusage(int who, struct rusage *usage);

DESCRIPTION
&nbsp; &nbsp; &nbsp; &nbsp;who: RUSAGE_SELF / RUSAGE_CHILDREN / RUSAGE_THREAD
&nbsp; &nbsp; &nbsp; &nbsp;usage: 输出参数, 用 struct rusage 描述

RETURN VALUE
&nbsp; &nbsp; &nbsp; &nbsp;0 成功, -1 失败 (errno 设置)

STRUCT
&nbsp; &nbsp; &nbsp; &nbsp;struct rusage {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;struct timeval ru_utime; &nbsp;/* user CPU time used */
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;struct timeval ru_stime; &nbsp;/* system CPU time used */
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;long &nbsp; ru_maxrss; &nbsp; &nbsp; &nbsp; &nbsp; /* maximum resident set size */
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;...
&nbsp; &nbsp; &nbsp; &nbsp;};

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 3man 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",&nbsp;false);

IHookZz hookZz = HookZz.getInstance(emulator);
hookZz.replace(getrusageSym,&nbsp;new&nbsp;ReplaceCallback() {
&nbsp; &nbsp;&nbsp;@Override
&nbsp; &nbsp;&nbsp;public&nbsp;HookStatus&nbsp;onCall(Emulator<?> emulator,&nbsp;long&nbsp;originFunction)&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; RegisterContext ctx = emulator.getContext();
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;int&nbsp;who = ctx.getIntArg(0);
&nbsp; &nbsp; &nbsp; &nbsp; Pointer rusagePtr = ctx.getPointerArg(1);

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 把 struct rusage 全部置零, 表示零资源占用
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 这对大多数 SO 来说够用 - 它们要的是"调用成功"而不是真实的统计
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(rusagePtr !=&nbsp;null) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; rusagePtr.write(0,&nbsp;new&nbsp;byte[144],&nbsp;0,&nbsp;144);
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 返回 0 = 成功; 直接跳过原函数
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;HookStatus.LR(emulator,&nbsp;0);
&nbsp; &nbsp; }
});

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 中断
  • 你的 SyscallHandler 不会被调到
  • 但同时 vDSO 的代码也没被映射到 Unidbg 内存里,所以调用会跳到无效地址

Unidbg 的处理方式:把 vDSO 函数符号化,让它们在符号解析时就指向 Unidbg 自己的内置实现。这意味着 clock_gettime 在 Unidbg 里大概率走的是”libc 包装函数”路径而不是 SVC 路径

对你的影响:补 clock_gettime 时不要在 SyscallHandler 里加 case,因为根本进不去。要在 libc 层(或 vDSO 符号层)hook。这也是为什么前面所有例子都倾向”在库函数层处理”。


系统调用层的五条心法

  1. 不要主动出击:Unidbg 默认实现够用就别碰
  2. 先确认 intno=2:不是 syscall 的报错走错了树
  3. ARM32 和 ARM64 NR 不同:永远确认架构再查表
  4. 库函数层优先于 syscall 层:除非你在改 Unidbg 上游
  5. 类型三最危险:返回值正确不代表语义正确,必须对照真机

总结:四层响应模型走完了一半

到这一篇为止,你应该对前三个通道有了完整理解:

| 篇 | 通道 | 你的角色 | 入口 | | — | — | — | — | | 七 | JNI | 替身演员 | AbstractJni override | | 八 | 文件 | 虚拟文件系统 | IOResolver | | 九 | 系统调用 | 修理工 | SyscallHandler / libc hook |

四个通道的最后一个 —— 库函数调用 —— 在第十篇。你会发现库函数层不仅是补环境的”第四个通道”,还是前面三个通道的”瑞士军刀”:很多 JNI / 文件 / syscall 的问题,都可以在库函数层用更优雅的方式解决。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:泡泡以安 泡泡以安 泡泡以安《Unidbg学习笔记(九):系统调用层补环境》

评论:0   参与:  0