ptrace

Table of Contents

1. ptrace

1.1. 监控信号1

#include <sys/ptrace.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

int main() {
    pid_t child;
    child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        // PTRACE_TRACEME 会导致后续的 execl 调用时父进程会收到
        // SIG_TRAP 信号 (实际上是一个 syscall trap), 确保父进程能开始
        // 进行 trace
        execl("/bin/sleep", "sleep", "100", NULL);

        // 若不调用 execl, 就需要子进程自己发个初始的信号了, 例如:
        // kill (getpid(), SIG_STOP);
        // sleep(100);
    } else {
        int status;
        while (1) {
            waitpid(child, &status, 0);
            // waitpid 返回的 status 可以通过许多宏来测试:
            // 1. WIFEXITED, 若子进程退出了, 则这个宏为真, 且通过
            // WEXITSTATUS 可以获得 exit code
            // 2. WIFSTOPPED, 若子进程收到信号, 则这个宏为真, 且通过
            // WSTOPSIG 可以获得 signal code

            if (WIFEXITED(status)) {
                printf("child exit\n");
                break;
            }
            if (WIFSTOPPED(status)) {
                printf("child stopped due to signal: %d\n", WSTOPSIG(status));
            }

            // 当子进程被 trace 时, 每次收到信号时都会停止运行, 将控制
            // 权交给父进程, 父进程通过 PTRACE_CONT 使子进程继续运行,
            // 其中最后一个参数表示是否将某个信号发送给子进程.
            // 所以通过 PTRACE_CONT, 可以使子进程无视各种信号
            // (SIG_KILL 例外)
            ptrace(PTRACE_CONT, child, NULL, NULL);
        }
    }
    return 0;
}

1.2. 监控系统调用

#include <sys/ptrace.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

int do_child(int argc, char **argv);
int do_trace(pid_t child);

int main(int argc, char **argv) {
    pid_t child = fork();
    if (child == 0) {
        return do_child(argc-1, argv+1);
    } else {
        return do_trace(child);
    }
}

int do_child(int argc, char **argv) {
    // 这里会导致父进程拦截到一个 SIG_TRAP
    ptrace(PTRACE_TRACEME);
    return execvp(argv[0], argv);
}

int wait_for_syscall(pid_t child);

int do_trace(pid_t child) {
    struct user_regs_struct regs;
    int status, syscall, retval;

    // 这里会收到 execvp 导致的 syscall trap, 直接被忽略掉了 (
    // 后续的 wait_for_syscall 会直接通过 PTRACE_SYSCALL 使子进程继续执行)
    // 所以这个 ministrace 实际上的输出会缺少一次对应于 execvp 的记录 (syscall code: 59)
    waitpid(child, &status, 0);

    // system call 发生时, 父进程收到的实际上是一个 SIG_TRAP 信号, 为
    // 了将真正的 SIG_TRAP 与 system call 进行区分, 应用可以指定
    // PTRACE_O_TRACESYSGOOD 选项, 它的作用是:
    // 对应 system call, 不返回 SIG_TRAP, 而是返回 SIG_TRAP | 0x80 作
    // 为 signal code (称为 syscall trap)
    // 注意的是 system call 的类型并不包含在 status 中...需要通过
    // PTRACE_GETREGS 查找 ORIG_EAX 的值为确定 system call 的类型.

    ptrace(PTRACE_SETOPTIONS, child, 0, PTRACE_O_TRACESYSGOOD);
    while(1) {
        // 对于 system call, wait 一共会发生两次:
        // 第一次发生在 system call 执行之前, 此时用户可以通过修改内存
        // 或寄存器的值为达到修改 system call 的目的.
        // 第二次发生在 system call 执行完毕, 返回之前, 此时用户可以修
        // 改 system call 的返回值.

        if (wait_for_syscall(child) != 0) break;
        // system call 执行之前...
        ptrace(PTRACE_GETREGS, child, 0, &regs);
        // orig_eax 中保存着 system call 的类型
        syscall = regs.orig_rax;
        fprintf(stderr, "syscall(%d) = ", syscall);

        if (wait_for_syscall(child) != 0) break;
        // system call 执行完毕, 但尚未返回
        ptrace(PTRACE_GETREGS, child, 0, &regs);
        // regs.rax 保存着 system call 的返回值
        fprintf(stderr, "%d\n", regs.rax);
    }
    return 0;
}

int wait_for_syscall(pid_t child) {
    int status = 0;
    while (1) {
        // PTRACE_SYSCALL 的功能是:
        // 1. 与 PTRACE_CONT 功能类似, 使子进程继续执行, 其最后一个参数表示是否发送相应信号给子进程
        // 2. 发生 system call 相关的事件 (system call 开始, system call 结束) 时子进程需要通知父进程.
        // 要注意的是每次子进程被暂停后都需要重新调用 PTRACE_SYSCALL 以便下一次的 system call 事件会被
        // 捕捉到. 
        ptrace(PTRACE_SYSCALL, child, 0, 0);
        waitpid(child, &status, 0);
        if (WIFSTOPPED(status) && WSTOPSIG(status) & 0x80) {
            return 0;       
        }

        if (WIFEXITED(status)) {
            return 1;
        }
        // 这里忽略了所有的信号, 只处理系统调用
    }
}

1.3. 修改进程的数据

通过 PTRACE_PEEKUSER, PTRACE_POKEUSER 以及 PTRACE_GETREGS, PTRACE_SETREGS, 父进程可以获得和修改子进程的寄存器值.

通过 PTRACE_PEEKDATA, PTRACE_POKEDATA, 父进程可以获取和修改子进程的任意内存的数据.

1.4. 单步执行

单步执行和监控系统调用类似, 只不过是把 PTRACE_SYSCALL 换成 PTRACE_SINGLESTEP:

  1. PTRACE_SINGLESTEP 的作用类似于 PTRACE_CONT, 也会使子进程继续执行, 但执行下一条指令前会将控制权交给父进程.
  2. 当 SINGLESTEP 发生时, 父进程通过 WSTOPSIG(status) 会得到一个 SIG_TRAP.
  3. PTRACE_SINGLESTEP 也需要重复执行才能捕捉到下一次 SINGLESTEP 事件.

1.5. 设置断点

主要是通过 int3 (0xcc) 指令.

当 tracer 需要在某个指令处加断点时, 可以这样做:

  1. 计算出该指令的地址, 将该指令的第一个字节保存起来, 再通过 PTRACE_PUTDATA 将该地址的第一个字节替换为 0xcc.
  2. 将被 trace 的进程执行到该指令时, 会取到 0xcc 指令, 这个指令会导致一个 SIG_TRAP 信号, 被 trace 的进程暂停.
  3. tracer 收到 SIG_TRAP 后可以做一些 debug 相关的操作, 例如显示寄存器等, 但在调用 PTRACE_CONT 前, 需要将之前保存的原指令的第一个字节写回到原来的位置, 以便 PTRACE_CONT 后会执行原始的指令.

实际上 int3 还有一种写法是 0xcd 0x3, 但这种写法会占用两个字节, 碰到原始指令是一个字节的情况就无法替换了.

1.6. 执行任意代码

虽然正常情况下某些 mmap 区域 (例如 .text) 是只读的, 但 ptrace 还是可以通过 POKETEXT 修改这些区域. 所以最简单的执行任意代码的方法可能就是直接使用 POKETEXT 修改 eip 指向的正文段区域就可以了…

复杂一点的, 如果要修改的区域很大, 超出了正文段的范围, 那个可能 POKETEXT 就会失败了. 这时可以采用类似于 libinject 的方法:

  1. 在目标进程中找到 mmap 函数的地址, 通过设置 eip 的值来远程的调用 mmap.

    在目标进程中找 mmap 函数的地址时 libinject 使用了一种很 tricky 的方法:

    void* get_remote_addr( pid_t target_pid, const char* module_name, void* local_addr  /* mmap 函数在本地进程的地址 */) { 
        void* local_handle, *remote_handle;
    
        local_handle = get_module_base( -1, module_name );
        remote_handle = get_module_base( target_pid, module_name );
    
        DEBUG_PRINT( "[+] get_remote_addr: local[%x], remote[%x]\n", local_handle, remote_handle );
    
        return (void *)( (uint32_t)local_addr + (uint32_t)remote_handle - (uint32_t)local_handle );
    }
    
  2. 在 mmap 出来的这块区域上写入 shellcode
  3. 远程执行这些 shellcode 来完全更复杂的操作, 例如载入一个 so 库等.

1.7. HOOK 其他函数

通过上一步把一个 so 注入到目标进程并执行 so 中的代码, 我们可以对目标进程中的任意函数进行 HOOK:在注入的 so 的 constructor 中可以通过修改目标进程中其他 so 对应的 GOT 表就可以达到 HOOK 的目的.

1.8. ATTACH

除了 fork -> PTRACE_TRACEME 方法外, 还可以通过 PTRACE_ATTACH attach 到任意其它进程并对它进行 trace.

需要注意的是, 被 attach 的进程会收到一个 SIG_STOP 信号, 以便 tracer 可以在 wait 时第一次返回从而进行其它 trace 操作.

Backlinks

GDB Breakpoint (GDB Breakpoint > software breakpoint): gdb 会用 ptrace catch 住 INT3 触发的 SIGTRAP, 从而实现断点的功能.

GDB Target Arch (GDB Target Arch > Overview): 2. target side, 其上层任务 (execution control, stack frame analysis 等) 都依赖于 底层 target 的功能来实现. 例如, amd64_linux_nat_target 这个 target 是通过 ptrace 实现的, 而 remote_target 是通过 RSP 实现的.

Footnotes:

1

网上看到的代码通常都是在 x86 上实现的, 若在 x86_64 上调试, 需要有一些小的修改, 例如, eax, ebx, … 等需要修改为 rax, rbx, …, PEEK_USER 时应该把 4*ORIG_EAX 修改为 8*ORIG_RAX 等

Author: [email protected]
Date: 2017-03-31 Fri 00:00
Last updated: 2023-01-30 Mon 18:25

知识共享许可协议