
一、课程目标
[ol]
[/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
这个日志包含了定位问题的关键信息:
区分系统调用与 JNI 调用
一个常见的困惑点是,JNI 函数调用失败的日志和系统调用非常相似,因为 unidbg 在底层都使用了 SVC 指令作为跳板。
关键区分点在于 svcNumber:
例如,一个 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 的继任者和升级版。
文件的元数据,就是描述文件属性的信息,例如:
statx 相对于旧版 stat 的主要优势:
1.更丰富的信息:statx 可以获取旧版 stat 无法提供的信息,最典型的就是文件的创建时间(birth time, btime)。
2.更高精度的时间戳:旧版 stat 的时间戳只精确到秒。而 statx 可以提供纳秒(nanosecond)级别的精度,这对于现代文件系统和应用至关重要。
3.更高的效率和灵活性 (Mask 机制):调用旧版 stat 时,内核会把所有元数据一次性全返回给你,即使你只关心文件大小。statx 引入了一个 mask(掩码)参数,允许调用者明确告诉内核:“我只对文件大小和修改时间感兴趣”。内核就会只获取并返回这些信息,避免了不必要的工作,提高了效率
4.更好的扩展性**:它的结构体设计考虑了未来,留有备用字段,方便以后添加新的文件属性而不需要再次设计新的系统调用。
如何填充statx结构:
打开页面后,您会找到 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]操作对象