Linux 进程注入:从调试器到武器化的技术演进

一、这到底是什么技术?

简单来说,这项技术就像给正在跑步的人做心脏手术------不需要重启程序,不需要修改磁盘上的文件,就能让目标进程加载并执行你指定的共享库(.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(&regs, __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);  // 放行!

当目标进程恢复执行时,它会:

  1. 跳转到我们的跳板代码
  2. 调用 dlopen 加载共享库
  3. 执行 int3 断点指令,再次暂停
  4. 我们捕获断点,恢复现场

阶段四:无痕缝合(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(&regs, __NR_mmap,
			     0, data_mmap_sz + exec_mmap_sz,
			     PROT_WRITE | PROT_READ, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
	if (ptrace_exec_syscall(pid, &regs, &regs, "mmap-data+exec") < 0)
		return 1;

	data_mmap_addr = ___regs_result(&regs);
	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(&regs, __NR_mprotect,
			     exec_mmap_addr, exec_mmap_sz, PROT_EXEC | PROT_READ);

	long mprotect_ret;
	if (ptrace_exec_syscall(pid, &regs, &regs, "mprotect-rx") < 0)
		return 1;
	mprotect_ret = ___regs_result(&regs);
	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(&regs, __NR_memfd_create, data_mmap_addr, MFD_CLOEXEC);

	if (ptrace_exec_syscall(pid, &regs, &regs, "memfd_create") < 0)
		return 1;

	memfd_remote_fd = ___regs_result(&regs);
	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(&regs, data_mmap_addr, RTLD_LAZY);
	if (ptrace_exec_user_call(pid, exec_mmap_addr, dlopen_tracee_addr, &regs, &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(&regs, dlopen_handle);
	if (ptrace_exec_user_call(pid, exec_mmap_addr, dlclose_tracee_addr, &regs, &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(&regs, __NR_munmap, data_mmap_addr, data_mmap_sz + exec_mmap_sz);
	if (ptrace_exec_syscall(pid, &regs, &regs, "munmap-data+exec") < 0)
		return 1;
	long munmap_ret = ___regs_result(&regs);
	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 操作系统进程间控制能力的极限。它巧妙地组合了多个内核机制:

  1. ptrace 提供进程控制基础
  2. 系统调用拦截 创造干净的执行窗口
  3. 匿名内存映射 解决代码存放问题
  4. memfd 实现无文件传输
  5. 寄存器操控 实现任意函数调用

这种技术的应用边界很广:合法场景下可以做应用监控、动态插桩;但也可能被滥用,因此操作系统也有对应的防护机制(比如 ptrace_scope 限制、SELinux、AppArmor 等)。理解它的原理,不仅能掌握一项实用技术,更能深入理解 Linux 进程运行的底层逻辑。

相关推荐
2401_857918292 小时前
C++与WebAssembly集成
开发语言·c++·算法
2401_879693872 小时前
C++与微服务架构
开发语言·c++·算法
桌面运维家2 小时前
Windows/Linux文件访问权限修改指南
linux·运维·服务器
badhope2 小时前
Docker入门到实战全攻略
linux·python·docker·github·matplotlib
麦芽糖02192 小时前
centos虚拟机忘记密码怎么办
linux·运维·centos
华科大胡子3 小时前
开源项目Git贡献
c++
DX_水位流量监测3 小时前
德希科技农村供水工程水质在线监测方案
大数据·运维·网络·水质监测·水质传感器·水质厂家·农村供水水质监测方案
比昨天多敲两行3 小时前
C++ 继承
开发语言·c++·面试
2501_908329853 小时前
C++中的装饰器模式实战
开发语言·c++·算法
欧云服务器3 小时前
魔方云批量更换ip教程
服务器·网络·tcp/ip