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, ®s); // 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, ®s); // 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:
- PTRACE_SINGLESTEP 的作用类似于 PTRACE_CONT, 也会使子进程继续执行, 但执行下一条指令前会将控制权交给父进程.
- 当 SINGLESTEP 发生时, 父进程通过 WSTOPSIG(status) 会得到一个 SIG_TRAP.
- PTRACE_SINGLESTEP 也需要重复执行才能捕捉到下一次 SINGLESTEP 事件.
1.5. 设置断点
主要是通过 int3 (0xcc) 指令.
当 tracer 需要在某个指令处加断点时, 可以这样做:
- 计算出该指令的地址, 将该指令的第一个字节保存起来, 再通过 PTRACE_PUTDATA 将该地址的第一个字节替换为 0xcc.
- 将被 trace 的进程执行到该指令时, 会取到 0xcc 指令, 这个指令会导致一个 SIG_TRAP 信号, 被 trace 的进程暂停.
- tracer 收到 SIG_TRAP 后可以做一些 debug 相关的操作, 例如显示寄存器等, 但在调用 PTRACE_CONT 前, 需要将之前保存的原指令的第一个字节写回到原来的位置, 以便 PTRACE_CONT 后会执行原始的指令.
实际上 int3 还有一种写法是 0xcd 0x3, 但这种写法会占用两个字节, 碰到原始指令是一个字节的情况就无法替换了.
1.6. 执行任意代码
虽然正常情况下某些 mmap 区域 (例如 .text) 是只读的, 但 ptrace 还是可以通过 POKETEXT 修改这些区域. 所以最简单的执行任意代码的方法可能就是直接使用 POKETEXT 修改 eip 指向的正文段区域就可以了…
复杂一点的, 如果要修改的区域很大, 超出了正文段的范围, 那个可能 POKETEXT 就会失败了. 这时可以采用类似于 libinject 的方法:
在目标进程中找到 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 ); }
- 在 mmap 出来的这块区域上写入 shellcode
- 远程执行这些 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:
网上看到的代码通常都是在 x86 上实现的, 若在 x86_64 上调试, 需要有一些小的修改, 例如, eax, ebx, … 等需要修改为 rax, rbx, …, PEEK_USER 时应该把 4*ORIG_EAX 修改为 8*ORIG_RAX 等