《安卓逆向这档事》第二十六课、Unidbg之补完环境我就睡(下)

查看 63|回复 7
作者:smileli   

一、课程目标
[ol]
  • 掌握 Unidbg 中处理不同场景的系统调用(Syscall)Hook 策略
  • 学习通过高层与底层 Hook 联动,模拟复杂的库函数(如 popen)
  • 应用 Dobby 等工具对关键库函数(如 gettid)进行修补,以绕过环境检测
    [/ol]
    二、工具
    1.教程 Demo
    2.IDEA
    3.IDA
    三、课程内容
    一. 补系统调用
    1.补系统调用环境的核心概念
    系统调用(Syscall)补环境是 Unidbg 模拟执行中一个基础且高级的环节。当原生库(. So 文件)为了性能、对抗或功能需要,绕过标准库(libc)函数,通过 SVC 等底层指令直接向操作系统内核发起请求时,Unidbg 必须能够拦截并模拟这些内核级别的行为。由于 Unidbg 并非一个完整的操作系统,其对内核的模拟是不完备的。如果 so 文件请求了一个 Unidbg 未实现或模拟不完善的系统调用(通过系统调用号 NR 区分),通常会导致模拟流程出错、返回无效数据(如 fstat 对目录返回全零),或进入非预期的逻辑分支,最终使模拟失败或结果失真。因此,系统调用补环境的目的就是识别并接管这些对内核的底层请求,通过自定义 SyscallHandler 提供一个符合目标 so 逻辑预期的模拟行为或返回值,从而“欺骗”so 文件,使其相信自己正与一个真实的 Linux/Android 内核交互。
    2.解读 unidbg 的警告日志
    当 unidbg 遇到一个它无法处理的软中断(SVC)时,通常会打印出如下格式的 WARN 日志:
    [00:46:49 186]  WARN [com.github.unidbg.linux.ARM64SyscallHandler] (ARM64SyscallHandler:399) - handleInterrupt intno=2, NR=165, svcNumber=0x0, PC=RX@0x401ba3d4[libc.so]0x6a3d4, LR=RX@0x40000770[libdemo.so]0x770, syscall=null
    这个日志包含了定位问题的关键信息:
  • handleInterrupt intno=2: intno=2 代表这是一个 EXCP_SWI(Software Interrupt),即软中断,通常由 SVC 指令触发,这是系统调用的入口。
  • NR=165: 这是最重要的字段,系统调用号 (Syscall Number)。它唯一标识了是哪个系统调用。你需要根据 CPU 架构(ARM 32/ARM 64)去查找对应的系统调用表。例如,在 ARM 64 下,165 号系统调用是 getrusage。你可以访问这个网站来查询。
  • svcNumber=0x0: 这是 SVC 指令后的立即数。在 unidbg 的设计中,约定 svcNumber 为 0 的才是真正的系统调用
  • PC 和 LR: PC (Program Counter) 指示了当前执行到的地址,通常在 libc.so 内部。LR (Link Register) 指示了调用该系统调用的返回地址,通常在你的目标 so 中,这能帮你快速定位到是 so 中哪块代码触发了该系统调用。
  • syscall=null: 如果 unidbg 对这个 NR 有部分认知,可能会在这里显示系统调用的名字,但大多数未实现的情况这里都是 null。
    区分系统调用与 JNI 调用
    一个常见的困惑点是,JNI 函数调用失败的日志和系统调用非常相似,因为 unidbg 在底层都使用了 SVC 指令作为跳板。
    关键区分点在于 svcNumber
  • 系统调用: svcNumber 等于 0x0。
  • JNI 调用: svcNumber 不等于 0x0,它是一个用于内部索引 JNI 函数的值。
    例如,一个 JNI 调用失败的日志可能如下,注意 svcNumber=0x16f:
    [01:00:43 681]  WARN [com.github.unidbg.linux.ARM64SyscallHandler] ... svcNumber=0x16f ...
    java.lang.UnsupportedOperationException: com/aliyun/TigerTally/A->ct()Landroid/content/Context;
    并且,JNI 调用失败通常会紧跟着抛出 UnsupportedOperationException,并带有清晰的 JNI 方法签名。

    3.核心方法论:自定义 SyscallHandler
    解决系统调用问题的通用范式是创建一个继承自 unidbg 原生 SyscallHandler 的子类,并重写或补充其中的方法。
    首先,你需要定义一个自己的 SyscallHandler。以 ARM 64 为例:
    import com.github.unidbg.Emulator;
    import com.github.unidbg.linux.ARM64SyscallHandler;
    import com.github.unidbg.memory.SvcMemory;
    // 继承自ARM64SyscallHandler,如果是32位则继承ARM32SyscallHandler
    public class MySyscallHandler extends ARM64SyscallHandler {
        public MySyscallHandler(SvcMemory svcMemory) {
            super(svcMemory);
            setVerbose(true); // 可按需开启详细日志
        }
        // 在这里重写或添加你的处理逻辑
    }
    然后,在构建 Emulator 时,通过 AndroidEmulatorBuilder 将其替换掉默认的处理器。
    import com.github.unidbg.AndroidEmulator;
    import com.github.unidbg.file.linux.AndroidFileIO;
    import com.github.unidbg.linux.android.AndroidARM64Emulator;
    import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
    import com.github.unidbg.memory.SvcMemory;
    import com.github.unidbg.unix.UnixSyscallHandler;
    AndroidEmulatorBuilder builder = new AndroidEmulatorBuilder(true) {  
        @Override  
        public AndroidEmulator build() {  
            return new AndroidARM64Emulator(processName, rootDir, backendFactories) {  
                @Override  
                protected UnixSyscallHandler createSyscallHandler(SvcMemory svcMemory) {  
                    return new MySyscallHandler(svcMemory);  
                }  
            };  
        }  
    };  
    builder.setProcessName("com.zj.wuaipojie");  
    emulator = builder.build();
    4.场景一:系统调用未实现
    这是最常见的情况,unidbg 对某个 NR 完全没有实现,直接在 handleInterrupt 处抛出警告。
    案例 1:getcpu (NR=168)
    在我们的 MySyscallHandler 中,重写 handleUnknownSyscall 方法,捕获未被处理的 NR。
    // In MySyscallHandler.java
    import com.github.unidbg.Emulator;
    import com.github.unidbg.arm.backend.Backend;
    import com.github.unidbg.pointer.UnidbgPointer;
    import com.sun.jna.Pointer;
    import unicorn.Arm64Const;
    @Override  
    protected boolean handleUnknownSyscall(Emulator emulator, int NR) {  
        System.err.println(">>> MySyscallHandler is processing syscall NR = " + NR);  
        Backend backend = emulator.getBackend();  
        switch (NR) {  
            /** getcpu (NR=168): x0=cpu*, x1=node* */  
            case 168: {  
                Pointer cpuPtr = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X0);  
                Pointer nodePtr = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X1);  
                // C++ 代码会多次调用 getcpu,期望 cpu 号码会变化且不为0  
                // 我们用一个随机数来模拟 CPU 核心的切换  
                int currentCpu = rng.nextInt(8); // 模拟8核CPU  
                if (cpuPtr != null) {  
                    cpuPtr.setInt(0, currentCpu);  
                }  
                if (nodePtr != null) {  
                    // NUMA 节点通常为 0                nodePtr.setInt(0, 0);  
                }  
                // 成功返回 0            writeX(backend, Arm64Const.UC_ARM64_REG_X0, 0);  
                return true;  
            }  
        }  
        return super.handleUnknownSyscall(emulator, NR);  
    }
    案例 2:statx (NR=291)
    statx 是 Linux 中一个现代化的、用于获取文件元数据(metadata)的系统调用。它是 stat, fstat, lstat 的继任者和升级版。
    文件的元数据,就是描述文件属性的信息,例如:
  • 文件大小 (size)
  • 文件类型 (是普通文件、目录、还是链接?)
  • 权限 (mode, 例如 rwx-r-x--x)
  • 所有者 (UID, GID)
  • 时间戳 (访问时间 atime、修改时间 mtime、状态变更时间 ctime)

    statx 相对于旧版 stat 的主要优势:
    1.更丰富的信息:statx 可以获取旧版 stat 无法提供的信息,最典型的就是文件的创建时间(birth time, btime)
    2.更高精度的时间戳:旧版 stat 的时间戳只精确到秒。而 statx 可以提供纳秒(nanosecond)级别的精度,这对于现代文件系统和应用至关重要。
    3.更高的效率和灵活性 (Mask 机制):调用旧版 stat 时,内核会把所有元数据一次性全返回给你,即使你只关心文件大小。statx 引入了一个 mask(掩码)参数,允许调用者明确告诉内核:“我只对文件大小和修改时间感兴趣”。内核就会只获取并返回这些信息,避免了不必要的工作,提高了效率
    4.
    更好的扩展性**:它的结构体设计考虑了未来,留有备用字段,方便以后添加新的文件属性而不需要再次设计新的系统调用。
    如何填充statx结构:  
  • statx 在线手册: https://man7.org/linux/man-pages/man2/statx.2.html
    打开页面后,您会找到 struct statx 的 C 语言定义,它长这样:

    struct statx {
        __u32 stx_mask;        /* Mask of fields returned */
        __u32 stx_blksize;     /* Block size for filesystem I/O */
        __u64 stx_attributes;  /* Extra file attribute hints */
        __u32 stx_nlink;       /* Number of hard links */
        __u32 stx_uid;         /* User ID of owner */
        __u32 stx_gid;         /* Group ID of owner */
        __u16 stx_mode;        /* File type and mode */
        __u16 __spare0[1];
        __u64 stx_ino;         /* Inode number */
        __u64 stx_size;        /* Total size in bytes */
        __u64 stx_blocks;      /* Number of 512B blocks allocated */
        __u64 stx_attributes_mask;
        /* The following fields are copied from struct statx_timestamp */
        struct statx_timestamp stx_atime; /* Last access */
        struct statx_timestamp stx_btime; /* Creation */
        struct statx_timestamp stx_ctime; /* Last status change */
        struct statx_timestamp stx_mtime; /* Last modification */
        /* If this structure is extended, then constants described in
           statx(2) will be defined to describe the new fields. */
    };
    struct statx_timestamp {
        __s64 tv_sec;
        __u32 tv_nsec;
        __s32 __spare;
    };
    接下来就是把上面的结构发给 AI 来伪造
    /** statx (NR=291): x1=path, x4=statx* */
    case 291: {
            // 1. 获取参数:路径指针和用于接收结果的 statx 结构体指针
            Pointer pathPtr = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X1);
            Pointer stx = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X4);
            String path = pathPtr != null ? pathPtr.getString(0) : null;
            if (stx != null) {
                    // 2. 核心技巧:使用ByteBuffer来构建内存中的结构体
                    //    分配足够空间,并务必设置为LITTLE_ENDIAN(小端序),匹配ARM架构
                    ByteBuffer bb = ByteBuffer.allocate(0x100).order(ByteOrder.LITTLE_ENDIAN);
                    // 3. 按照 statx 结构体的定义,依次填充字段
                    //    具体填充什么值,取决于目标so关心哪些字段。通常给一些非零的、看起来合理的值即可。
                    bb.putInt(0x000007ff);  // stx_mask: STATX_ALL
                    bb.putInt(4096);        // stx_blksize
                    bb.putLong(0);          // stx_attributes
                    bb.putInt(1);           // stx_nlink (链接数)
                    bb.putInt(1000);        // stx_uid (user id)
                    bb.putInt(1000);        // stx_gid (group id)
                    // 根据路径判断是目录还是文件,并设置对应的模式
                    int S_IFDIR = 0x4000, S_IFREG = 0x8000;
                    int mode = (path != null && path.endsWith("/")) ? (S_IFDIR | 0755) : (S_IFREG | 0644);
                    bb.putShort((short) mode); // stx_mode
                    while (bb.position()
    5.场景二:系统调用模拟不完善或过于简单
    Unidbg 实现了该系统调用,但存在缺陷。
    案例 1:clock_gettime (NR=113, ARM 64)
    clock_gettime(clockid_t clk_id, struct timespec *tp) 用于获取特定时钟的时间。Unidbg 实现了对 CLOCK_REALTIME (clk_id=0) 的处理,但没有实现 CLOCK_PROCESS_CPUTIME_ID (clk_id=2),导致传入 2 时抛出异常。
    解决方案:重写 clock_gettime 方法。
    @Override  
    protected int clock_gettime(Emulator emulator) {  
        Backend backend = emulator.getBackend();  
        long clkId = readX(backend, Arm64Const.UC_ARM64_REG_X0); // x0 = clk_id  
        Pointer tp = UnidbgPointer.register(emulator, Arm64Const.UC_ARM64_REG_X1); // x1 = timespec*  
        if (tp == null) {  
            // 按 Linux 约定:失败返回 -errno;这里给个通用 EFAULT        
            return -14; // -EFAULT  
        }  
        // 构造各类时钟的返回值(64-bit timespec: tv_sec(8) + tv_nsec(8))  
        long nowMs = System.currentTimeMillis();  
        long nowNs = System.nanoTime();  
        long sec, nsec;  
        switch ((int) clkId) {  
            case 0: // CLOCK_REALTIME  
            case 8: // CLOCK_REALTIME_ALARM  
                sec  = nowMs / 1000L;  
                nsec = (nowMs % 1000L) * 1_000_000L;  
                break;  
            case 1: // CLOCK_MONOTONIC  
            case 4: // CLOCK_MONOTONIC_RAW(有的系统是 4)  
            case 7: // CLOCK_BOOTTIME  
            case 9: // CLOCK_BOOTTIME_ALARM  
                sec  = nowNs / 1_000_000_000L;  
                nsec = nowNs % 1_000_000_000L;  
                break;  
            case 2: // CLOCK_PROCESS_CPUTIME_ID  
            case 3: // CLOCK_THREAD_CPUTIME_ID  
                // 进程/线程 CPU 时间:给一个非零的、单调增长的“小值”即可  
                // 这里简单用 nanoTime 的低位模拟  
                sec = 0L;  
                nsec = (nowNs % 50_000_000L) + 10_000L; // ~0~50ms,避免全 0            break;  
            default:  
                // 未识别的 id:退化成 REALTIME,避免抛异常  
                sec = nowMs / 1000L;  
                nsec = (nowMs % 1000L) * 1_000_000L;  
                break;  
        }  
        tp.setLong(0, sec);  
        tp.setLong(8, nsec);  
        return 0; // 成功  
    }
    案例 2: sched_getaffinity (NR=123)  
    有时候,我们会发现某个系统调用的实现在 Unidbg 的父类 ARM64SyscallHandler 中,但该实现方法被声明为 final,导致我们无法像 clock_gettime 那样直接 @Override 它。此外,有些系统调用是在一个巨大的 switch 语句(如 handleSyscall 方法)中处理的,我们只想修改其中一个 case 的行为,同样无法直接重写。
    在这种情况下,我们需要在更早的阶段介入。系统调用的最顶层入口是 hook() 方法,它负责接收所有 SVC 中断,解析出系统调用号(NR),然后再分发给具体的处理方法。通过重写 hook(),我们可以在 Unidbg 分发之前“截胡”我们关心的系统调用,实现自定义逻辑。  
    sched_getaffinity 用于检测当前进程可以运行在哪些 CPU 核心上,这常被用于环境检测或设备指纹生成。
    解决方案: 在 hook() 方法中,抢先处理目标 NR,然后“屏蔽”它,避免父类重复执行。
    @Override
    public void hook(Backend backend, int intno, int swi, Object user) {
        // 1. 在顶层入口,首先读取X8寄存器,拿到系统调用号 NR
        int nr = ((Number) backend.reg_read(Arm64Const.UC_ARM64_REG_X8)).intValue();
        // 2. 判断是否是我们想“截胡”的系统调用
        if (nr == 123) { // __NR_sched_getaffinity (arm64)
            // 读取参数:X1 = cpusetsize, X2 = mask 地址
            long cpusetsize = ((Number) backend.reg_read(Arm64Const.UC_ARM64_REG_X1)).longValue();
            long maskAddr    = ((Number) backend.reg_read(Arm64Const.UC_ARM64_REG_X2)).longValue();
            // 3. 实现自定义的模拟逻辑
            if (maskAddr != 0 && cpusetsize > 0) {
                final int cores = 8;                      // 模拟一个8核CPU
                final int size  = (int) cpusetsize;
                byte[] buf = new byte[size];              // 初始化为全0的字节数组
                int maxBits   = size * 8;
                int bitsToSet = Math.min(cores, maxBits); // 计算需要设置的位数
                // 构造CPU亲和度的bitmask (例如8核,就把低8位置为1)
                for (int cpu = 0; cpu
    案例 3:fstat (NR=80)
    fstat 和 statx 的核心目标都是获取文件元数据,但它们在设计、功能和使用方式上存在显著差异。简单来说,statx 是 fstat 的现代、功能更全面的“超级升级版”
    [table]
    [tr]
    [td]特性 / 方面[/td]
    [td]fstat (旧版)[/td]
    [td]statx (现代版)[/td]
    [/tr]
    [tr]
    [td]操作对象

    系统, 时间

  • c1026287787   

    学习一下
    three   

    继续学习
    lambchasr   

    厉害的我的兄弟,连续更新了, 学习了,哈哈哦
    ZZ730605   

    好的表弟,key已收到
    three   


    涛之雨 发表于 2025-10-9 12:14
    好的表弟,key已收到

    差评,你怎么能只关注key?
    lambchasr   

    学习收藏打卡
    ZZ730605   

    学习一个。
    您需要登录后才可以回帖 登录 | 立即注册

    返回顶部