Toby's 次元裂缝

关于Linux反调试 (Anti-Debugging) 的脑洞

Linux下常见的两种反调试:

  • 检查 /proc/self/status 中的 TracerPID - 正常运行时为0,在有debugger挂载的情况下变为debugger的PID。因此通过不断读取这个值可以发现是否存在调试器,进行对应处理。

    反制手段:

    • 直接修改程序,将检测逻辑移除
    • hook读取 status 文件的操作,返回伪造的 TracerPID
    • 魔改内核,让不管有没有debugger存在这个值都为0
    • ...
  • fork 出一个子进程 ptrace 自己 - 由于 ptrace 独占 (exclusive) 的特性,程序如果自己提前占坑,用户自己的debugger就无法再attach上去。

    反制手段:

    • 还是可以直接修改程序将检测逻辑移除
    • hook ptrace ,让程序 ptrace 自己失败
    • 直接杀掉这个子进程
    • ...

实际情境往往是上面两种方法的混合和变种玩法,比如 ptrace 可能有多个子进程互相检测互保。但终归还是很弱智,对于稍微有点经验的人最多只是浪费一点时间,其实往往连时间也浪费不了多少。

于是就想到了 ptrace 那个方法的一个变种:与其自己占位但是什么都不做,不如让那个trace自己的子进程做点有意义的事,让程序逻辑依赖于这个tracer,脱离了就不能正常运行,这样用户就不可能简单地干掉然后挂自己debugger上去啦!

顺着这个思路,玩法其实是很多的。我做了一个对 syscall 做手脚的demo,原理是这样:

自定义了一个不存在的 syscall number: 10000

#define SYS_CUSTOM_write 10000

把它当作 write ,并写了一个wrapper

void print_custom(char *str) {
    syscall(SYS_CUSTOM_write, str, 1, strlen(str));
}

注意不仅数字改了,第一个和第二个参数的顺序也对调了

那么程序如果正常这么跑的话显然是不行的:

printf("You shouldn't be able to see anything down below if you managed to attach your own debugger :P\n");
for (int i = 0; i < 10; i++) {
    print_custom("fuck me up pls~\n");
}

因为 10000 并不是有效的 syscall ,所以这样直接运行是肯定看不到使用 print_custom 输出的10次 "fuck me up pls~" 的。

然而我们可以有一个trace自己的子进程,这个子进程可以利用 PTRACE_SYSCALL 在主程序每次 syscall 之前下断点,修改寄存器的状态,然后继续执行修正后的代码,机智的你懂了吗?

fork 出的子进程的核心逻辑:

ptrace(PTRACE_SYSCALL, child_pid, 0, 0); // 下断
waitpid(child_pid, &status, 0); // 等待进程进入暂停状态
ptrace(PTRACE_GETREGS, child_pid, 0, &regs); // 获取寄存器值
if (regs.orig_rax == SYS_CUSTOM_write) { // 判断是不是我们自定义的那个call
    regs.orig_rax = SYS_write; // 修改为正确的write call
    unsigned long long int orig_rdi = regs.rdi;
    regs.rdi = regs.rsi;
    regs.rsi = orig_rdi; // 恢复两个参数的顺序

    ptrace(PTRACE_SETREGS, child_pid, 0, &regs); // 存回寄存器
}
ptrace(PTRACE_SYSCALL, child_pid, 0, 0); // PTRACE_SYSCALL 第二次是断在syscall完成后
waitpid(child_pid, &status, 0); // 我们不需要再执行什么操作所以继续就好

只要子进程不断循环这个逻辑,就可以持续处理。

这样不管你是怎么阻止的程序 ptrace 自己,只要你做到了,程序就无法正常运行了,可以给想调试的人造成 (至少比一开始说的两种原始方法) 更大的麻烦。

当然仅就这个demo而言还是比较容易绕过的,只要逆向出自定义和实际对应call number的映射关系 就可以写入自己的调试器或者直接修改二进制patch回去。所以下面是一些可以进一步提升难度的方法:

  • 自定义一个 libc ,把所有 syscall 号码全部打乱
    • 甚至每次启动时动态生成映射关系
  • 进一步打乱参数顺序
    • 而且这个也可以是动态的
  • 对寄存器的值做一些操作 (加减/XOR/rotations...)
    • 当然也可以是动态的
  • 把相关逻辑都用 OLLVM 编译,让人很难静态分析出来

But ultimately the reversers always win, because programs are boxes of data that must ultimately partially exist in plaintext form when unfolded in a way identical to the target platform.

但是最终的赢家总是逆向工程师,因为程序的逻辑总是要以明文的形式交给用户自己的机器执行的。在你折腾这些用户态反调试的时候,人家可以直接用QEMU

所以何必呢?

why

评论