文章总结: 本文详细解析Linux系统中指定PID启动程序的原理与实现方法。内核通过位图分配PID,默认递增寻找空闲值。文档提出四种技术方案:暴力枚举循环fork消耗PID至目标值前;修改nslastpid文件直接干预内核变量;利用clone3系统调用的set_tid字段原子性指定PID;通过ptrace注入shellcode覆盖现有进程。前两种需root权限,后两种适用于高阶容器化或安全攻防场景,均附完整C代码示例。 综合评分: 87 文章分类: 安全工具,技术标准,解决方案,红队,内网渗透
如何在 Linux 中指定 PID 启动程序?原理分析与代码实现
原创
爱坤 爱坤
爱坤sec
2026年5月11日 02:31 重庆
在小说阅读器读本章
去阅读
在 Linux 系统编程中,创建新进程的标准姿势是调用 fork() 或 clone()。系统内核会按照自己的规则,从当前已分配的 PID 继续递增,为新进程分配一个独一无二的进程标识符(PID)。
但是,在某些特殊的场景下(例如:进程热迁移与恢复 CRIU、高阶容器化定制、或者安全攻防中的进程伪装),我们可能需要打破内核的自动分配规则,强制以一个指定的 PID 来启动一个程序。
Linux 并没有直接提供 fork_with_pid(int pid) 这样的系统调用。那么,我们要如何实现这个“逆天改命”的操作呢?
一、 原理分析:Linux 是如何分配 PID 的?
要接管 PID,首先要懂内核是怎么发 PID 的。 在 Linux 内核源码中(kernel/pid.c),PID 的分配主要由 alloc_pid() 函数负责。内核维护了一个位图(Bitmap)来跟踪哪些 PID 是空闲的。默认情况下:
内核会记录上一次分配出去的 PID(last_pid)。
下一次 fork 时,内核会从 last_pid + 1 开始往后寻找第一个空闲的 PID 分配给新进程。
当 PID 达到最大值(/proc/sys/kernel/pid_max,默认通常是 32768 或 4194304)时,会回卷(Wrap-around)到 300 重新开始寻找空闲位。
二、如何实现指定pid运行程序
1.暴力枚举(普通用户就可以实现)
由于分配pid的过程是内核锁定的,用户态程序无法直接干预。早期黑客的做法是“暴力 Fork”:不断循环 fork 然后立刻 exit,直到内核的 last_pid 走到我们想要的数字前一个,然后再启动真正的业务逻辑。这种方法不仅极度消耗资源,而且在现代多任务系统中极大概率会被其他并发进程“截胡”。但是优点是不需要高权限就可以实现,以下是简单的实现代码
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/wait.h>
// 动态获取系统 PID 最大值int get_pid_max() { FILE *fp = fopen("/proc/sys/kernel/pid_max", "r"); int max; fscanf(fp, "%d", &max); fclose(fp); return max;}
int main(int argc, char *argv[]) { if (argc < 2) { printf("用法: %s <目标PID>\n", argv[0]); return 1; }
int target_pid = atoi(argv[1]); int pid_max = get_pid_max();
printf("正在从 PID %d 推进至 %d (上限: %d)...\n", getpid(), target_pid, pid_max);
while (1) { pid_t p = fork(); if (p < 0) { perror("fork 失败"); exit(1); }
if (p == 0) { // 子进程立即退出,释放 PID 供循环使用 exit(0); } else { // 父进程回收子进程,防止产生僵尸进程 waitpid(p, NULL, 0);
// 检查下一个分配的 PID 是否为目标 PID // 注意:由于系统繁忙,可能会跳过目标值,需判断 (p + 1) % pid_max if (p == target_pid - 1) { pid_t final_p = fork(); if (final_p == 0) { printf("成功获取 PID: %d\n", getpid()); // 在这里启动你的程序 execl("/bin/sh", "sh", NULL); } waitpid(final_p, NULL, 0); break; } } } return 0;}
2.修改 ns_last_pid(需要 Root)
这是目前实现指定 PID 启动最优雅、最稳健的方式。由于直接修改内核变量在用户态是被禁止的,但 Linux 内核为了支持 CRIU(Checkpoint/Restore In Userspace) 技术,专门留下了一个合法的“后门”。
在 Linux 内核中,PID 的分配由 struct pid_namespace 结构体管理。内核内部维护着一个名为 last_pid 的变量,记录着上一次成功分配出去的 PID 号。
每当系统执行 fork() 时,内核逻辑如下:
读取当前命名空间的 last_pid。
计算下一个候选 PID(即 last_pid + 1)。
检索位图(PID Bitmap),确认该 ID 是否空闲。
分配并更新 last_pid。
通过 /proc/sys/kernel/ns_last_pid 接口,具备CAP_SYS_ADMIN权限的用户可以直接覆盖内核中的 last_pid 变量。
核心逻辑:如果我们向该文件写入值 8887,内核的“记事本”就会被强行修改。在下一次 fork 发生的瞬间,内核会从 8887 + 1 开始寻找,从而精准地将 8888 分配给新进程。
简单实现代码
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <fcntl.h>#include <string.h>#include <sys/wait.h>
int main(int argc, char *argv[]) { if (argc < 2) { printf("用法: sudo %s <目标PID>\n", argv[0]); return 1; }
char buf[32]; // 写入目标 PID - 1,因为内核分配的是写入值 + 1 int target = atoi(argv[1]) - 1; sprintf(buf, "%d", target);
// 尝试打开内核接口 int fd = open("/proc/sys/kernel/ns_last_pid", O_WRONLY); if (fd < 0) { perror("无法打开 ns_last_pid (检查是否是 Root 或系统是否过旧)"); return 1; }
// 写入预期的前一个 PID if (write(fd, buf, strlen(buf)) < 0) { perror("写入 ns_last_pid 失败"); close(fd); return 1; }
close(fd);
// fork 出来的子进程将尝试获取目标 PID pid_t p = fork(); if (p < 0) { perror("fork 失败"); return 1; }
if (p == 0) { // 子进程逻辑 printf("子进程已启动! 它的 PID 是: %d\n", getpid()); // 验证一下是否拿到了你想要的 PID execl("/bin/sh", "sh", NULL); } else { // 父进程回收子进程 waitpid(p, NULL, 0); }
return 0;}
三、 方案 3:clone3 系统调用(推荐,需 Root + Linux 5.5+)
如果说前两种方法是“钻空子”或“打补丁”,那么 clone3 就是内核开发者专门为你打开的大门。在传统的 Linux 系统编程中,fork() 和 clone() 无法指定新进程的 PID。为了彻底解决这一痛点(特别是为 CRIU 这种进程迁移技术提供原生支持),Linux 内核从5.5 版本开始引入了全新的系统调用:clone3()。
1. 原理分析:结构化参数与 set_tid
clone3() 与旧版本最大的不同在于它不再使用繁琐的参数列表,而是通过一个结构体 struct clone_args 来传递参数。
在该结构体中,内核专门设计了一个名为 set_tid 的数组字段:
字段作用:set_tid 允许用户直接指定新进程在特定 PID 命名空间中的预期 PID。
权限限制:由于这涉及到对系统资源的精确接管,只有拥有CAP_SYS_ADMIN权限的进程(通常是 Root)才能使用该功能。
优势:这种方式是由内核原子性执行的,相比于修改 ns_last_pid 后再 fork,它大大降低了被其他进程竞态抢占的风险。
简单代码实现
#define _GNU_SOURCE#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/syscall.h>#include <linux/sched.h>#include <stdint.h>#include <sys/wait.h>
// 结构体定义(如果头文件太老需要手动定义)struct clone_args_extended { uint64_t flags; uint64_t pidfd; uint64_t child_tid; uint64_t parent_tid; uint64_t exit_signal; uint64_t stack; uint64_t stack_size; uint64_t tls; uint64_t set_tid; // 指向期望的 PID 数组 uint64_t set_tid_size; // 数组长度 uint64_t cgroup;};
int main(int argc, char *argv[]) { if (argc < 2) { printf("用法: sudo %s <目标PID>\n", argv[0]); return 1; }
pid_t target_pid = atoi(argv[1]);
// clone3 参数设置 struct clone_args_extended args = {0}; args.flags = 0; // 可以在这里加 CLONE_NEWPID 等 args.exit_signal = SIGCHLD; args.set_tid = (uintptr_t)&target_pid; args.set_tid_size = 1; // 对应 PID 命名空间的层级
// 调用 clone3 long p = syscall(435, &args, sizeof(args)); // 435 是 x86_64 的 clone3 系统调用号
if (p < 0) { perror("clone3 失败"); return 1; }
if (p == 0) { printf("子进程启动成功,PID: %d\n", getpid()); execl("/bin/sh", "sh", NULL); } else { waitpid(p, NULL, 0); }
return 0;}
4.进程注入
这是一种更具“侵略性”的硬核手段。严格来说,它并不是“启动”一个具有指定 PID 的新进程,而是“强行占领”一个已经存在的、且符合你 PID 要求的进程。这种方式主要利用了 Linux 提供的 ptrace 系统调用
实现逻辑:
- 挂载(Attach):使用
PTRACE_ATTACH附加到目标 PID 进程上。 - 停顿(Stop):强行暂停目标进程的运行。
- 注入(Injection):
- 写内存:将一小段精心构造的
Shellcode写入目标进程的内存空间。 - 改寄存器:修改指令寄存器(RIP/EIP),使其指向我们的代码。
- 执行(Exec):让 Shellcode 执行
execve系统调用,加载并运行你真正的程序镜像。
此时,原有的进程镜像被彻底覆盖,但进程 ID (PID) 保持不变。
以下是简单代码实现
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <sys/ptrace.h>#include <sys/user.h>#include <sys/wait.h>#include <unistd.h>#include <sys/syscall.h>
// 辅助函数:注入数据void poke_text(pid_t child, long addr, void *str, int len) { long *laddr = (long *)str; int i = 0; while (i < len) { if (ptrace(PTRACE_POKEDATA, child, addr + i, laddr[i/sizeof(long)]) < 0) { perror("POKEDATA 失败"); exit(1); } i += sizeof(long); }}
int main(int argc, char *argv[]) { if (argc < 3) { printf("用法: sudo %s <PID> <程序路径> [参数1] [参数2]...\n", argv[0]); return 1; }
pid_t target_pid = atoi(argv[1]); struct user_regs_struct regs;
ptrace(PTRACE_ATTACH, target_pid, NULL, NULL); waitpid(target_pid, NULL, 0); ptrace(PTRACE_GETREGS, target_pid, NULL, ®s);
// 1. 在栈上开辟空间存放字符串和指针数组 // 我们把空间开在 RSP 下方 1024 字节处,比较安全 long base_addr = (regs.rsp - 1024) & ~0x7; long current_str_addr = base_addr + 256; // 字符串放在后面 long argv_array_addr = base_addr; // 指针数组放在前面
int num_args = argc - 2; // newelf 路径算第一个参数 (argv[0]) long argv_ptrs[num_args + 1];
for (int i = 0; i < num_args; i++) { char *arg_str = argv[i + 2]; int len = strlen(arg_str) + 1;
// 注入字符串内容 poke_text(target_pid, current_str_addr, arg_str, len);
// 记录该字符串在目标内存中的地址 argv_ptrs[i] = current_str_addr; current_str_addr += (len + 7) & ~0x7; // 8字节对齐 } argv_ptrs[num_args] = 0; // 以 NULL 结尾
// 2. 注入指针数组本身 poke_text(target_pid, argv_array_addr, argv_ptrs, sizeof(argv_ptrs));
// 3. 植入 syscall 机器码 ptrace(PTRACE_POKETEXT, target_pid, regs.rip, 0x050f);
// 4. 配置寄存器 regs.rax = SYS_execve; regs.rdi = argv_ptrs[0]; // filename regs.rsi = argv_array_addr; // argv 数组地址 regs.rdx = 0; // envp = NULL ptrace(PTRACE_SETREGS, target_pid, NULL, ®s);
printf("[+] 正在执行带参数的夺舍: %s %s...\n", argv[2], argc > 3 ? argv[3] : "");
ptrace(PTRACE_SINGLESTEP, target_pid, NULL, NULL); waitpid(target_pid, NULL, 0); ptrace(PTRACE_DETACH, target_pid, NULL, NULL);
printf("[+] 完成!\n"); return 0;}
效果如图所示
更多精彩文章与工具分享 欢迎关注
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:爱坤sec 爱坤 爱坤《如何在 Linux 中指定 PID 启动程序?原理分析与代码实现》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论