尽管已经可以使用环境变量 LD_PRELOAD 非常方便地向进程注入我们自己的动态库,但偶尔还是会有向已经启动的进程注入代码的需求,这个时候就可以用到 ptrace 工具了。
ptrace 系统调用
要成为一个成熟的操作系统,实现「调试器」是一个必不可少的环节,它允许通过程序来控制另一个程序的执行过程,从而找出程序出现问题的地方,或是收集程序的系统调用情况…… 在 Linux 系统中,可以通过 ptrace 系统调用来实现这一功能。
实现原理
ptrace 并不能直接进行「注入代码」这种操作,它所提供的都是非常底层的 API,例如读取目标进程的寄存器、从指定地址获取一小段内存等等……
要实现代码注入,还得稍微下点功夫,想办法用这些基本操作组合出所需的功能,下面是具体的实现细节:
附加到目标进程
通过 PTRACE_ATTACH 操作,来附加到一个由 pid 指定的进程,函数返回时子进程不会立即停止执行,需要通过 waitpid() 来等待子进程真正停下来
1 2 3 4 5 6 7 8 9
| void ptrace_attach(pid_t pid) { if (ptrace(PTRACE_ATTACH, pid, nullptr, nullptr) == -1) { ERROR("attach"); } if (waitpid(pid, nullptr, WUNTRACED) != pid) { ERROR("waitpid"); } INFO("attached to pid: %d", pid); }
|
备份寄存器环境
为了 detach 之后能够恢目标进程的正常运行,需要先将寄存器备份一份出来,后续获取函数返回值的时候,也需要读取寄存器。
1 2 3 4 5 6 7 8 9
| void ptrace_get_regs(pid_t pid, pt_regs *regs) { iovec iov { .iov_base = regs, .iov_len = sizeof(*regs) }; if (ptrace(PTRACE_GETREGS, pid, NT_PRSTATUS, &iov) == -1) { ERROR("backup regs"); } }
|
在目标进程中定位函数地址
系统中大部分可执行文件都是动态链接的,我们可以利用其中的函数来更方便地完成一些操作,通过解析 maps 文件分别得到本地和目标进程的动态库基址,加上本地计算得到的偏移量,就是目标进程的函数地址
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| uintptr_t get_module_base(pid_t pid, std::string libpath) { static std::map<std::pair<pid_t, std::string>, uintptr_t> cache;
std::pair<pid_t, std::string> key(pid, libpath); if (cache.contains(key)) { return cache[key]; }
char maps_path[PATH_MAX]; sprintf(maps_path, "/proc/%d/maps", pid);
FILE *maps = fopen(maps_path, "r"); if (maps == nullptr) { ERROR("open maps"); return 0; }
void *addr = nullptr; char path[PATH_MAX], perms[8], offset[16];
while (fscanf(maps, "%p-%*p %s %s %*s %*s %[^\n]", &addr, perms, offset, path) != EOF) { if (perms[2] != 'x') continue; if (strcmp(path, libpath.c_str()) != 0) continue; INFO("%s: %p, offset: %s", libpath.c_str(), addr, offset); break; }
fclose(maps);
return cache[key] = (uintptr_t) addr - strtoull(offset, nullptr, 16); }
uintptr_t get_func_addr(pid_t pid, std::string libpath, uintptr_t local_func) { uintptr_t local_base = get_module_base(getpid(), libpath); uintptr_t remote_base = get_module_base(pid, libpath); uintptr_t offset = local_func - local_base; INFO("function offset: %lx", offset); return remote_base + offset; }
|
远程调用目标进程的函数
通过控制目标进程的寄存器,将 PC 指针指向目标函数地址,并在 R0~R5 传递参数,就可以实现远程函数调用了。同时将 LR 寄存器的值设置为 0,这样远程函数返回时就会触发 SIGSEGV,将控制权交还给调试器,此时再次获取目标进程的寄存器,就能拿到函数的返回值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| template<class... T> void call_remote(pid_t pid, pt_regs *regs, uintptr_t addr, T... args) { size_t index = 0;
INFO("calling function at %lx", addr); for (int64_t it : { args... }) { INFO("args[%zu] = %ld", index, it); regs->regs[index++] = it; }
regs->ARM_pc = addr;
if (regs->ARM_pc & 1) { regs->ARM_pc &= ~1; regs->ARM_cpsr |= CPSR_T_MASK; } else { regs->ARM_cpsr &= ~CPSR_T_MASK; }
regs->ARM_lr = 0;
ptrace_set_regs(pid, regs);
int status = 0; while (status != ((SIGSEGV << 8) | 0x7f)) { ptrace_continue(pid); waitpid(pid, &status, WUNTRACED); INFO("substatus: 0x%08x", status); }
ptrace_get_regs(pid, regs); }
|
在目标进程 mmap 分配一块内存空间
有了上面这些工具函数,我们就可以调用目标进程 libc 中的方法了,但为了能够调用一些以字符串作为参数的函数以及 shellcode 的注入,还需要 mmap 出一小块内存作为辅助
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| USE_REMOTE_FUNC(libc, mmap)
int main() { ... void *buffer = mmap_remote<void *>( pid, ®s, (int64_t) nullptr, (int64_t) getpagesize(), (int64_t) PROT_READ | PROT_WRITE | PROT_EXEC, (int64_t) MAP_ANONYMOUS | MAP_PRIVATE, 0L, 0L ); INFO("buffer: %p", buffer); ... }
|
向目标进程地址空间写入数据
不少文章都用 PTRACE_POKEDATA 来改写目标进程的内存,每次只能写入几个字节,效率未免有些低下,我认为这里完全可以用 process_vm_writev,查看 man page 可知这个系统调用的权限检查和 PTRACE_ATTACH 是相同的:
Permission to read from or write to another process is governed by a ptrace access mode PTRACE_MODE_ATTACH_REALCREDS check; see ptrace(2).
Permission to perform a PTRACE_ATTACH is governed by a ptrace access mode PTRACE_MODE_ATTACH_REALCREDS check; see below.
因此既然能用 ptrace 附加到目标进程,也应该能用 process_vm_writev 改写其内存,这样在复制一些大数据时能节省不少时间
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| void ptrace_write_data(pid_t pid, void *addr, void *buffer, size_t bufsize) { iovec from { .iov_base = buffer, .iov_len = bufsize }; iovec to { .iov_base = addr, .iov_len = bufsize }; ssize_t count; if ((count = process_vm_writev(pid, &from, 1, &to, 1, 0)) == -1) { ERROR("write memory"); } INFO("copied %zd bytes of data to target", count); }
|
调用 dlopen 打开要注入的动态链接库
万事俱备,现在可以来调用 dlopen 了,只要加载了「坏蛋」动态库,init_array 中的函数就会自动执行,然后便可以做进一步的 inline hook、PLT hook 等操作
1 2 3 4 5 6 7 8 9
| const char *INJECT = "/proc/self/cwd/hack.so"; ptrace_write_data(pid, (void *) buffer, (void *) INJECT, strlen(INJECT));
void *handle = dlopen_remote<void *>( pid, ®s, (int64_t) buffer, 1L ); INFO("handle: %p", handle);
|
恢复环境 & 解除附加
完成以上一系列操作之后,还需要恢复回原来的环境,让目标进程能够继续正常运行,我们需要:
1 2 3 4 5 6 7 8 9 10 11 12 13
| munmap_remote<int>( pid, ®s, (int64_t) buffer, (int64_t) getpagesize() );
dlclose_remote<int>( pid, ®s, (int64_t) handle );
ptrace_set_regs(pid, &backup_regs); ptrace_detach(pid);
|
源代码
完整代码已上架 GitHub,可以在 Termux 环境中通过 make 命令运行。以及我并没有做任何诸如「绕过 dlopen 限制」的操作,所以在其他地方跑或许会出问题。 参考 这篇文章 所述的实现方式,调用 dlopen 时将 LR 寄存器的值设置为 libc 的基址,就可以实现绕过系统对 dlopen 函数的限制
现已支持对任意 ARM64 APP 进程注入代码,使用下面的命令运行:
