一、这到底是什么技术?
简单来说,这项技术就像给正在跑步的人做心脏手术------不需要重启程序,不需要修改磁盘上的文件,就能让目标进程加载并执行你指定的共享库(.so 文件)。
想象一下这个场景:
- 你的服务器上有个程序正在运行
- 你想扩展它的功能,或者监控它的行为
- 传统方法是改代码、重新编译、重启服务
- 而这项技术允许你直接"钻"进进程内存,现场加载你的代码
这在安全研究、动态调试、热更新、甚至恶意软件领域都有应用。本质上,它利用了 Linux 操作系统提供的进程调试接口,把"调试能力"用到了极致。
二、核心知识点地图
在深入代码之前,先了解这项技术涉及的关键领域:
| 领域 | 关键概念 | 作用 |
|---|---|---|
| 进程调试 | ptrace 系统调用 |
让 A 进程控制 B 进程的执行、读写其内存和寄存器 |
| 内存管理 | mmap, mprotect, munmap |
在目标进程中申请内存、改权限、释放内存 |
| 进程间通信 | process_vm_writev |
无需 ptrace 也能跨进程写内存(但受权限限制) |
| 文件描述符传递 | pidfd, memfd_create, pidfd_getfd |
在进程间传递"匿名文件",实现无文件落盘注入 |
| 动态链接 | dlopen, dlclose |
运行时加载/卸载共享库的标准接口 |
| ELF 格式 | 符号表、重定位、GOT/PLT | 解析可执行文件结构,找到函数地址 |
| 调用约定 | x86-64 SysV ABI / ARM64 AAPCS | 规定函数参数怎么通过寄存器传递 |
| 系统调用拦截 | 利用 PTRACE_SYSCALL |
在目标进程执行系统调用时"截胡" |
三、设计思路:四步"手术"流程
整个注入过程可以类比为一次精密的手术操作,分为四个阶段:
阶段一:麻醉(Attach & Seize)
首先要控制住目标进程。就像手术需要病人保持静止一样,我们不能让目标进程在注入过程中乱跑。
c
// 核心操作:PTRACE_SEIZE 比传统 ATTACH 更温和
ptrace(PTRACE_SEIZE, pid, NULL, PTRACE_O_TRACESYSGOOD);
ptrace(PTRACE_INTERRUPT, pid, NULL, NULL);
waitpid(pid, &status, __WALL); // 等待进程停下
这里用到了 PTRACE_SEIZE 而非传统的 PTRACE_ATTACH,区别在于:
SEIZE不会立即停止进程,而是配合INTERRUPT使用- 可以设置选项(如
PTRACE_O_TRACESYSGOOD),让系统调用停止时发送SIGTRAP | 0x80信号,方便识别
阶段二:开切口(Syscall Interception)
这一步是拦截目标进程正在进行的系统调用。为什么选择系统调用拦截点?
因为系统调用是用户态和内核态的交界,进程在这里会"暂停"等待内核处理,给我们一个干净的执行环境。
c
ptrace(PTRACE_SYSCALL, pid, NULL, NULL); // 执行到下一个系统调用入口
waitpid(pid, &status, __WALL); // 等待停止
ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, &iov); // 保存寄存器状态
关键技巧:取消当前系统调用:
- x86-64:把
orig_rax设为 -1 - ARM64:通过
NT_ARM_SYSTEM_CALL设置系统调用号为 -1
这样内核会"跳过"这次系统调用,让进程回到用户态,但控制权在我们手中。
阶段三:植入与执行(Memory Setup & Code Injection)
这是整个技术的核心创新点。我们需要解决三个问题:
1. 内存从哪来?
目标进程不会自动给你内存,我们需要主动申请:
c
// 在目标进程中执行 mmap,申请可读写的内存
___regs_set_sys_args(®s, __NR_mmap,
0, // 让内核选地址
data_sz + exec_sz, // 申请两页:数据页 + 代码页
PROT_WRITE | PROT_READ,
MAP_ANONYMOUS | MAP_PRIVATE,
-1, 0);
2. 代码怎么放进去?
申请到内存后,我们要写入两部分内容:
- 数据区:存放字符串参数(如共享库路径)
- 代码区:存放一段"跳板"代码(trampoline)
跳板代码的作用是让目标进程执行 dlopen:
asm
# x86-64 版本
__inj_call:
call *%rax # 调用 rax 指向的函数(dlopen)
__inj_trap:
int3 # 执行完后触发断点,控制权回到注入器
asm
# ARM64 版本
__inj_call:
blr x8 # 调用 x8 指向的函数
__inj_trap:
brk #0 # 断点指令
3. 怎么让目标进程执行?
通过篡改寄存器实现:
c
// 设置函数参数(x86-64 调用约定:rdi, rsi, rdx, rcx, r8, r9)
regs.rdi = data_mmap_addr; // dlopen 的第一个参数:路径
regs.rsi = RTLD_LAZY; // dlopen 的第二个参数:加载标志
regs.rax = dlopen_tracee_addr; // 要调用的函数地址
regs.rip = exec_mmap_addr; // 指令指针指向我们的跳板代码
ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov);
ptrace(PTRACE_CONT, pid, NULL, NULL); // 放行!
当目标进程恢复执行时,它会:
- 跳转到我们的跳板代码
- 调用
dlopen加载共享库 - 执行
int3断点指令,再次暂停 - 我们捕获断点,恢复现场
阶段四:无痕缝合(Cleanup & Replay)
手术做完了,需要恢复现场,让病人继续正常生活:
c
// 恢复原始寄存器状态(包括调整过的指令指针)
orig_regs.rip -= 2; // x86-64 系统调用指令是 2 字节,需要回退
ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov);
ptrace(PTRACE_SYSCALL, pid, NULL, NULL); // 重新执行原始系统调用
ptrace(PTRACE_DETACH, pid, NULL, NULL); // 脱钩,让进程自由运行
四、关键技术细节解析
1. 无文件注入:memfd_create 的妙用
传统注入需要把共享库写入磁盘,再让目标进程加载。但这样会在文件系统留下痕迹。
现代 Linux(内核 3.17+)提供了 memfd_create,可以创建内存中的匿名文件:
c
// 在目标进程中创建匿名文件
memfd_fd = memfd_create("shlib-inject", MFD_CLOEXEC);
// 通过 pidfd 机制,把目标进程的 fd 映射到当前进程
int local_fd = pidfd_getfd(pid_fd, remote_memfd_fd, 0);
// 直接往这个 fd 写入共享库内容
copy_file_to_fd("libinj.so", local_fd);
// 目标进程通过 /proc/self/fd/<fd> 路径加载
dlopen("/proc/self/fd/3", RTLD_LAZY);
整个过程不落磁盘,规避了文件监控。
2. 跨架构支持:x86-64 vs ARM64
代码中通过宏定义实现了双架构支持,关键差异:
| 特性 | x86-64 | ARM64 |
|---|---|---|
| 调用寄存器 | rax(函数地址), rdi-r9(参数) |
x8(函数地址), x0-x7(参数) |
| 系统调用号 | rax |
x8 |
| 指令长度 | 2 字节(syscall) | 4 字节(svc #0) |
| 断点指令 | int3 (0xCC) |
brk #0 |
| 栈对齐 | 16 字节 + 128 字节红区 | 16 字节 |
| 特殊寄存器集 | 无 | NT_ARM_SYSTEM_CALL |
3. 进程间内存写入:process_vm_writev
相比 ptrace 的逐字写入,process_vm_writev 可以批量传输数据,效率更高:
c
struct iovec local = {
.iov_base = (void *)local_src,
.iov_len = sz
};
struct iovec remote = {
.iov_base = (void *)remote_dst,
.iov_len = sz
};
process_vm_writev(pid, &local, 1, &remote, 1, 0);
注意 :这个系统调用尊重内存权限 ,只能写入可写区域。这也是为什么我们需要先 mmap 申请可写内存。
五、代码流程图解
┌─────────────────┐
│ 启动注入器 │
│ (attach 到 PID) │
└────────┬────────┘
▼
┌─────────────────┐
│ PTRACE_SEIZE │◄──── 温和地"抓住"目标进程
│ PTRACE_INTERRUPT│
└────────┬────────┘
▼
┌─────────────────┐
│ 等待系统调用 │◄──── 在系统调用入口"截停"
│ (syscall-stop) │
└────────┬────────┘
▼
┌─────────────────┐
│ 保存原始寄存器 │◄──── 备份现场,方便恢复
│ GETREGSET │
└────────┬────────┘
▼
┌─────────────────┐
│ 取消当前系统调用 │◄──── 让内核跳过这次调用
│ (orig_rax = -1) │
└────────┬────────┘
▼
┌─────────────────┐ ┌─────────────────┐
│ 注入 mmap 调用 │────►│ 在目标进程中 │
│ (申请内存) │ │ 执行 mmap() │
└─────────────────┘ └────────┬────────┘
▼
┌─────────────────┐
│ 返回内存地址 │
│ (data + exec) │
└────────┬────────┘
▼
┌─────────────────┐ ┌─────────────────┐
│ 写入跳板代码 │◄────│ process_vm_ │
│ (__inj_call) │ │ writev() │
└────────┬────────┘ └─────────────────┘
▼
┌─────────────────┐
│ 注入 mprotect │◄──── 把代码页改成可执行
│ (改内存权限) │
└────────┬────────┘
▼
┌─────────────────┐ ┌─────────────────┐
│ 注入 memfd_ │────►│ 创建匿名文件 │
│ create() │ │ (内存中的"文件") │
└─────────────────┘ └────────┬────────┘
▼
┌─────────────────┐ ┌─────────────────┐
│ 通过 pidfd_ │◄────│ 把共享库内容 │
│ getfd 复制数据 │ │ 写入 memfd │
│ 到 memfd │ │ │
└────────┬────────┘ └─────────────────┘
▼
┌─────────────────┐
│ 篡改寄存器 │◄──── 设置 dlopen 参数
│ 指向跳板代码 │ (路径、标志位)
└────────┬────────┘
▼
┌─────────────────┐
│ PTRACE_CONT │◄──── 放行目标进程
│ (执行跳板) │
└────────┬────────┘
▼
┌─────────────────┐
│ 捕获 SIGTRAP │◄──── 跳板执行完毕,断点触发
│ (执行完成信号) │
└────────┬────────┘
▼
┌─────────────────┐
│ 恢复原始系统调用 │◄──── 让目标进程继续原来的工作
│ PTRACE_DETACH │ (仿佛什么都没发生)
└─────────────────┘
c
...
int main(int argc, char *argv[])
{
...
if (argc != 2) {
printf("Usage: %s <PID>\n", argv[0]);
return 1;
}
pid = atoi(argv[1]);
if (pid <= 0) {
printf("Invalid PID: %s\n", argv[1]);
return 1;
}
printf("Target PID: %d\n", pid);
sa.sa_handler = signal_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction SIGINT");
return 1;
}
if (sigaction(SIGTERM, &sa, NULL) == -1) {
perror("sigaction SIGTERM");
return 1;
}
int pid_fd = syscall(SYS_pidfd_open, pid, 0);
if (pid_fd < 0) {
err = -errno;
fprintf(stderr, "pidfd_open(%d) failed: %d\n", pid, err);
return 1;
}
long libc_self_end = 0;
long libc_self_base = find_libc_base(-1, &libc_self_end);
long libc_tracee_base = find_libc_base(pid, NULL);
if (libc_self_base == 0 || libc_tracee_base == 0)
return 1;
char libc_path[512];
snprintf(libc_path, sizeof(libc_path), "/proc/self/map_files/%lx-%lx", libc_self_base, libc_self_end);
long dlopen_off = find_elf_sym_info(libc_path, "dlopen");
long dlclose_off = find_elf_sym_info(libc_path, "dlclose");
if (dlopen_off == 0 || dlclose_off == 0)
return 1;
long dlopen_tracee_addr = libc_tracee_base + dlopen_off;
long dlclose_tracee_addr = libc_tracee_base + dlclose_off;
printf("Local libc base: 0x%lx (dlopen offset %lx, dlclose offset %lx)\n",
libc_self_base, dlopen_off, dlclose_off);
printf("Remote libc base: 0x%lx (dlopen @ 0x%lx, dlclose @ 0x%lx)\n",
libc_tracee_base, dlopen_tracee_addr, dlclose_tracee_addr);
struct user_regs_struct orig_regs, regs;
printf("Intercepting tracee...\n");
if (ptrace_intercept(pid, &orig_regs, "tracee-intercept") < 0)
return 1;
print_regs(&orig_regs, "ORIG REGS");
const long page_size = sysconf(_SC_PAGESIZE);
const long data_mmap_sz = page_size;
const long exec_mmap_sz = page_size;
long data_mmap_addr = 0;
long exec_mmap_addr = 0;
printf("Executing mmap(data + exec)...\n");
/* void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); */
regs = orig_regs;
___regs_set_sys_args(®s, __NR_mmap,
0, data_mmap_sz + exec_mmap_sz,
PROT_WRITE | PROT_READ, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
if (ptrace_exec_syscall(pid, ®s, ®s, "mmap-data+exec") < 0)
return 1;
data_mmap_addr = ___regs_result(®s);
if (data_mmap_addr <= 0) {
fprintf(stderr, "mmap() inside tracee failed: %ld, bailing!\n", data_mmap_addr);
return 1;
}
exec_mmap_addr = data_mmap_addr + data_mmap_sz;
printf("mmap() returned 0x%lx (data @ %lx, exec @ %lx)\n",
data_mmap_addr, data_mmap_addr, exec_mmap_addr);
err = remote_vm_write(pid, (void *)exec_mmap_addr, __inj_call, __inj_call_sz);
if (err)
return 1;
printf("Executing mprotect(r-x)...\n");
regs = orig_regs;
___regs_set_sys_args(®s, __NR_mprotect,
exec_mmap_addr, exec_mmap_sz, PROT_EXEC | PROT_READ);
long mprotect_ret;
if (ptrace_exec_syscall(pid, ®s, ®s, "mprotect-rx") < 0)
return 1;
mprotect_ret = ___regs_result(®s);
if (mprotect_ret < 0) {
fprintf(stderr, "mprotect(r-x) inside tracee failed: %ld, bailing!\n", mprotect_ret);
return 1;
}
printf("Executing memfd_create()...\n");
char memfd_name[] = "shlib-inject";
int memfd_remote_fd = -1;
err = remote_vm_write(pid, (void *)data_mmap_addr, memfd_name, sizeof(memfd_name));
if (err)
return 1;
regs = orig_regs;
___regs_set_sys_args(®s, __NR_memfd_create, data_mmap_addr, MFD_CLOEXEC);
if (ptrace_exec_syscall(pid, ®s, ®s, "memfd_create") < 0)
return 1;
memfd_remote_fd = ___regs_result(®s);
if (memfd_remote_fd < 0) {
fprintf(stderr, "memfd_create() inside tracee failed: %d, bailing!\n", memfd_remote_fd);
return 1;
}
printf("memfd_create() result: %d\n", memfd_remote_fd);
int memfd_local_fd = syscall(SYS_pidfd_getfd, pid_fd, memfd_remote_fd, 0);
if (memfd_local_fd < 0) {
err = -errno;
fprintf(stderr, "pidfd_getfd(pid %d, remote_fd %d) failed: %d\n", pid, memfd_remote_fd, err);
return 1;
}
err = copy_file_to_fd("libinj.so", memfd_local_fd);
if (err)
return 1;
char memfd_path[64];
snprintf(memfd_path, sizeof(memfd_path), "/proc/self/fd/%d", memfd_remote_fd);
err = remote_vm_write(pid, (void *)data_mmap_addr, memfd_path, sizeof(memfd_path));
if (err)
return 1;
printf("Executing dlopen() injection...\n");
long dlopen_handle;
regs = orig_regs;
___regs_set_func_args(®s, data_mmap_addr, RTLD_LAZY);
if (ptrace_exec_user_call(pid, exec_mmap_addr, dlopen_tracee_addr, ®s, &dlopen_handle, "dlopen") < 0)
return 1;
printf("dlopen() result: %lx\n", dlopen_handle);
if (dlopen_handle == 0) {
fprintf(stderr, "Failed to dlopen() injection library, bailing...\n");
return 1;
}
printf("Replaying original syscall and detaching tracee...\n");
if (ptrace_replay(pid, &orig_regs, "replay-syscall") < 0)
return 1;
sleep(1);
printf("Re-intercepting for cleanup...\n");
if (ptrace_intercept(pid, &orig_regs, "tracee-reintercept") < 0)
return 1;
print_regs(&orig_regs, "ORIG REGS (2)");
printf("Executing dlclose() injection...\n");
long dlclose_ret;
regs = orig_regs;
___regs_set_func_args(®s, dlopen_handle);
if (ptrace_exec_user_call(pid, exec_mmap_addr, dlclose_tracee_addr, ®s, &dlclose_ret, "dlclose") < 0)
return 1;
printf("dlclose() result: %ld\n", dlclose_ret);
if (dlclose_ret != 0) {
fprintf(stderr, "Failed to dlclose() injection library, bailing...\n");
return 1;
}
printf("Executing munmap(data + exec)...\n");
regs = orig_regs;
___regs_set_sys_args(®s, __NR_munmap, data_mmap_addr, data_mmap_sz + exec_mmap_sz);
if (ptrace_exec_syscall(pid, ®s, ®s, "munmap-data+exec") < 0)
return 1;
long munmap_ret = ___regs_result(®s);
if (munmap_ret < 0) {
fprintf(stderr, "munmap() inside tracee failed: %ld, bailing!\n", munmap_ret);
return 1;
}
printf("munmap() result: %ld\n", munmap_ret);
printf("Replaying original syscall and detaching tracee...\n");
if (ptrace_replay(pid, &orig_regs, "replay-syscall-final") < 0)
return 1;
printf("Tracee detached and running...\n");
printf("Press Ctrl-C to exit...\n");
while (!should_exit) {
usleep(50000);
}
printf("Exited gracefully.\n");
return 0;
}
代码运行测试:
打开第一个终端,执行官方指令:
bash
./app
记住这个 PID(比如 1234),也可以用pidof app快速查:
bash
pidof app # 输出数字就是app的PID
执行注入
打开第二个终端,执行官方注入指令:
bash
# 直接用pidof获取app的PID
sudo ./inject `pidof app`
# 手动填PID
sudo ./inject 1234 # 替换成你的app PID
六、安全与防御视角
这项技术虽然是合法的调试/研究工具,但也可能被滥用。了解防御机制同样重要:
1. Yama LSM 的 ptrace_scope
现代 Linux 发行版默认启用 Yama 安全模块,通过 /proc/sys/kernel/yama/ptrace_scope 控制:
0:允许任何进程 ptrace 任何同 UID 进程1:只允许 ptrace 子进程(默认)2:需要CAP_SYS_PTRACE能力(通常是 root)3:完全禁止 ptrace
2. 进程的 dumpable 标志
如果进程调用了 prctl(PR_SET_DUMPABLE, 0),或者执行了 setuid 程序,会变成不可转储状态,此时任何远程内存访问都会被拒绝。
3. Seccomp 沙箱
严格沙箱可以限制进程能使用的系统调用,阻止 mmap 申请可执行内存等操作。
4. 监控检测点
安全团队可以监控以下异常行为:
- 非调试器进程使用
ptrace - 频繁的
process_vm_writev调用 - 进程内存中出现新的可执行区域(通过
/proc/PID/maps监控) - 意外的
memfd_create使用
七、总结
这项技术展示了 Linux 操作系统进程间控制能力的极限。它巧妙地组合了多个内核机制:
- ptrace 提供进程控制基础
- 系统调用拦截 创造干净的执行窗口
- 匿名内存映射 解决代码存放问题
- memfd 实现无文件传输
- 寄存器操控 实现任意函数调用
这种技术的应用边界很广:合法场景下可以做应用监控、动态插桩;但也可能被滥用,因此操作系统也有对应的防护机制(比如 ptrace_scope 限制、SELinux、AppArmor 等)。理解它的原理,不仅能掌握一项实用技术,更能深入理解 Linux 进程运行的底层逻辑。