尽管已经可以使用环境变量 LD_PRELOAD
非常方便地向进程注入我们自己的动态库,但偶尔还是会有向已经启动的进程注入代码的需求,这个时候就可以用到 ptrace
工具了
ptrace
系统调用
要成为一个成熟的操作系统,实现「调试器」是一个必不可少的环节,它允许通过程序来控制另一个程序的执行过程,从而找出程序出现问题的地方,或是收集程序的系统调用情况…… 在 Linux 系统中,可以通过 ptrace
系统调用来实现这一功能
实现原理
ptrace
并不能直接进行「注入代码」这种操作,它所提供的都是非常底层的 API,例如读取目标进程的寄存器、从指定地址获取一小段内存等等……
要实现代码注入,还得稍微下点功夫,想办法用这些基本操作组合出所需的功能,下面是具体的实现细节:
附加到目标进程
通过 PTRACE_ATTACH
操作,来附加到一个由 pid 指定的进程,函数返回时子进程不会立即终停止执行,需要通过 waitpid()
来等待子进程真正停下来
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 之后能够恢目标进程的正常运行,需要先将寄存器备份一份出来,后续获取函数返回值的时候,也需要读取寄存器
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 文件分别得到本地和目标进程的动态库基址,加上本地计算得到的偏移量,就是目标进程的函数地址
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
,将控制权交还给调试器,此时再次获取目标进程的寄存器,就能拿到函数的返回值
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;
// 区分 ARM 和 THUMB 模式
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 出一小块内存作为辅助
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
是相同的:
process_vm_writev
Permission to read from or write to another process is governed by a ptrace access mode PTRACE_MODE_ATTACH_REALCREDS check; see ptrace(2).
PTRACE_ATTACH
Permission to perform a PTRACE_ATTACH is governed by a ptrace access mode PTRACE_MODE_ATTACH_REALCREDS check; see below.
因此既然能用 ptrace
附加到目标进程,也应该能用 process_vm_writev
改写其内存,这样在复制一些大数据时能节省不少时间
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 等操作
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 /* RTLD_LAZY */
);
INFO("handle: %p", handle);
恢复环境 & 解除附加
完成以上一系列操作之后,还需要恢复回原来的环境,让目标进程能够继续正常运行,我们需要:
-
munmap
掉辅助空间 -
dlclose
刚刚注入的动态库(防止在 maps 露出马脚,如需 hook 可另 mmap 匿名内存) -
恢复寄存器上下文
-
解除 ptrace 状态
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 进程注入代码,使用下面的命令运行:
make app PID=<PID>