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

0 阅读1分钟

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

系统调用层的问题和 JNI、文件层有一个本质不同:前两者是 Unidbg 明确把责任交给你,你不补就肯定不行;系统调用层是 Unidbg 自己想干却没干好。理解这个区别,是从“补环境工人”晋级到“模拟器贡献者”的分水岭。


上一篇把你留在了哪里

第八篇讲完文件系统之后,你已经掌握了三个通道:JNI(90% 工作量)、文件(~8%)、系统调用(剩下的小部分但很扎心)。这一篇专门讲系统调用通道。

需要先调整一个心态预期:这一篇不是教你“怎么补一堆系统调用”,而是教你“什么时候该出手、什么时候该绕开”。系统调用层的好消息是问题数量不多,坏消息是每一个都很硬核。


心态切换:你不是在补环境,你是在帮 Unidbg 打补丁

回想一下前面三个通道的角色定位:

通道谁的责任你扮演什么
JNI你的责任 —— Unidbg 直接把请求交给 AbstractJniJava 替身演员
文件你的责任 —— Unidbg 通过 IOResolver 把请求交给你虚拟文件系统提供者
系统调用Unidbg 的责任 —— 它自己有 SyscallHandler 试图处理???

到了系统调用层,分工发生了变化。Unidbg 对系统调用的态度是“我自己来”

  • 它内置了一个 ARM32SyscallHandlerARM64SyscallHandler
  • 实现了大约 100+ 个常见系统调用(read / write / mmap / open / brk / clock_gettime / ...)
  • 大部分时候 SO 调系统调用,你完全感知不到

那为什么还要补?因为 Unidbg 不是 Linux 内核,它只是一个有限的近似。这个近似有三种“漏洞”:

JNI vs 系统调用 - 心态对比

漏洞 1:有些系统调用根本没实现(比如 getrusage),SO 一调就崩。 漏洞 2:有些系统调用只实现了一半(比如 clock_gettime 只支持 CLOCK_REALTIMECLOCK_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](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 而不是 wrapwrap 会让原函数继续执行,你只能在前后观察 / 改写寄存器;这里我们要的是"原函数根本不要跑",正好对应 replace 的语义。两者差别在第十篇会展开讲,这里记一句口诀:想观察用 wrap,想替换用 replace

两种思路怎么选?

标准SyscallHandler 加 caselibc 层 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 覆盖哲学
Unidbg0, 1 完整;其他部分“够用就行,剩下用户补”
Qiling0, 1, 2, 3, 4偏完整,更接近真实 Linux
ExAndroidNativeEmu0, 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 字段拌进哈希就出问题。

类型三的危险性

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

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

  1. 确定 SO 的最终输出(签名 / 加密结果)和真机不一致
  2. Frida 在真机上 hook 所有可疑的 syscall(stat64 / getcpu / uname / clock_gettime / ...),记录返回值
  3. 在 Unidbg 里加 log 打印同样这些 syscall 的返回值
  4. 对照差异,找到值不一样的那一个
// 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 的寄存器传参寄存器返回值寄存器
ARM32r7r0 ~ r6r0
ARM64x8x0 ~ x5x0
// 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 在两个架构上编号天差地别:

syscallARM32 NRARM64 NR
read363
write464
open556(实际上 ARM64 没 open,只有 openat=56)
openat32256
getpid20172
gettimeofday78169
clock_gettime263113
stat64195(没有 stat64,用 newfstatat=79)
getrusage77165

为什么差这么大? 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 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", 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(虚拟动态共享对象),不会真正陷入内核。最常见的是 gettimeofdayclock_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 里处理好了,你什么都不用做

系统调用层的五条心法

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

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

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

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

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