如何在Linux中指定PID启动程序?原理分析与代码实现

admin 2026-05-12 05:56:34 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细解析Linux系统中指定PID启动程序的原理与实现方法。内核通过位图分配PID,默认递增寻找空闲值。文档提出四种技术方案:暴力枚举循环fork消耗PID至目标值前;修改nslastpid文件直接干预内核变量;利用clone3系统调用的set_tid字段原子性指定PID;通过ptrace注入shellcode覆盖现有进程。前两种需root权限,后两种适用于高阶容器化或安全攻防场景,均附完整C代码示例。 综合评分: 87 文章分类: 安全工具,技术标准,解决方案,红队,内网渗透


cover_image

如何在 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&nbsp;<stdio.h>#include&nbsp;<stdlib.h>#include&nbsp;<unistd.h>#include&nbsp;<sys/wait.h>
// 动态获取系统 PID 最大值int&nbsp;get_pid_max()&nbsp;{&nbsp;FILE *fp =&nbsp;fopen("/proc/sys/kernel/pid_max",&nbsp;"r");&nbsp;int&nbsp;max;&nbsp;fscanf(fp,&nbsp;"%d", &max);&nbsp;fclose(fp);&nbsp;return&nbsp;max;}
int&nbsp;main(int&nbsp;argc,&nbsp;char&nbsp;*argv[])&nbsp;{&nbsp;if&nbsp;(argc <&nbsp;2) {&nbsp;printf("用法: %s <目标PID>\n", argv[0]);&nbsp;return&nbsp;1;&nbsp;}
&nbsp;int&nbsp;target_pid =&nbsp;atoi(argv[1]);&nbsp;int&nbsp;pid_max =&nbsp;get_pid_max();
&nbsp;printf("正在从 PID %d 推进至 %d (上限: %d)...\n",&nbsp;getpid(), target_pid, pid_max);
&nbsp;while&nbsp;(1) {&nbsp;pid_t&nbsp;p = fork();&nbsp;if&nbsp;(p <&nbsp;0) {&nbsp;perror("fork 失败");&nbsp;exit(1);&nbsp;}
&nbsp;if&nbsp;(p ==&nbsp;0) {&nbsp;// 子进程立即退出,释放 PID 供循环使用&nbsp;exit(0);&nbsp;}&nbsp;else&nbsp;{&nbsp;// 父进程回收子进程,防止产生僵尸进程&nbsp;waitpid(p,&nbsp;NULL,&nbsp;0);
&nbsp;// 检查下一个分配的 PID 是否为目标 PID&nbsp;// 注意:由于系统繁忙,可能会跳过目标值,需判断 (p + 1) % pid_max&nbsp;if&nbsp;(p == target_pid -&nbsp;1) {&nbsp;pid_t&nbsp;final_p = fork();&nbsp;if&nbsp;(final_p ==&nbsp;0) {&nbsp;printf("成功获取 PID: %d\n",&nbsp;getpid());&nbsp;// 在这里启动你的程序&nbsp;execl("/bin/sh",&nbsp;"sh",&nbsp;NULL);&nbsp;}&nbsp;waitpid(final_p,&nbsp;NULL,&nbsp;0);&nbsp;break;&nbsp;}&nbsp;}&nbsp;}&nbsp;return&nbsp;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&nbsp;<stdio.h>#include&nbsp;<stdlib.h>#include&nbsp;<unistd.h>#include&nbsp;<fcntl.h>#include&nbsp;<string.h>#include&nbsp;<sys/wait.h>
int&nbsp;main(int&nbsp;argc,&nbsp;char&nbsp;*argv[])&nbsp;{&nbsp;if&nbsp;(argc <&nbsp;2) {&nbsp;printf("用法: sudo %s <目标PID>\n", argv[0]);&nbsp;return&nbsp;1;&nbsp;}
&nbsp;char&nbsp;buf[32];&nbsp;// 写入目标 PID - 1,因为内核分配的是写入值 + 1&nbsp;int&nbsp;target =&nbsp;atoi(argv[1]) -&nbsp;1;&nbsp;sprintf(buf,&nbsp;"%d", target);
&nbsp;// 尝试打开内核接口&nbsp;int&nbsp;fd =&nbsp;open("/proc/sys/kernel/ns_last_pid", O_WRONLY);&nbsp;if&nbsp;(fd <&nbsp;0) {&nbsp;perror("无法打开 ns_last_pid (检查是否是 Root 或系统是否过旧)");&nbsp;return&nbsp;1;&nbsp;}
&nbsp;// 写入预期的前一个 PID&nbsp;if&nbsp;(write(fd, buf,&nbsp;strlen(buf)) <&nbsp;0) {&nbsp;perror("写入 ns_last_pid 失败");&nbsp;close(fd);&nbsp;return&nbsp;1;&nbsp;}
&nbsp;close(fd);
&nbsp;// fork 出来的子进程将尝试获取目标 PID&nbsp;pid_t&nbsp;p = fork();&nbsp;if&nbsp;(p <&nbsp;0) {&nbsp;perror("fork 失败");&nbsp;return&nbsp;1;&nbsp;}
&nbsp;if&nbsp;(p ==&nbsp;0) {&nbsp;// 子进程逻辑&nbsp;printf("子进程已启动! 它的 PID 是: %d\n",&nbsp;getpid());&nbsp;// 验证一下是否拿到了你想要的 PID&nbsp;execl("/bin/sh",&nbsp;"sh",&nbsp;NULL);&nbsp;}&nbsp;else&nbsp;{&nbsp;// 父进程回收子进程&nbsp;waitpid(p,&nbsp;NULL,&nbsp;0);&nbsp;}
&nbsp;return&nbsp;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&nbsp;_GNU_SOURCE#include&nbsp;<stdio.h>#include&nbsp;<stdlib.h>#include&nbsp;<unistd.h>#include&nbsp;<sys/syscall.h>#include&nbsp;<linux/sched.h>#include&nbsp;<stdint.h>#include&nbsp;<sys/wait.h>
// 结构体定义(如果头文件太老需要手动定义)struct&nbsp;clone_args_extended&nbsp;{&nbsp;uint64_t&nbsp;flags;&nbsp;uint64_t&nbsp;pidfd;&nbsp;uint64_t&nbsp;child_tid;&nbsp;uint64_t&nbsp;parent_tid;&nbsp;uint64_t&nbsp;exit_signal;&nbsp;uint64_t&nbsp;stack;&nbsp;uint64_t&nbsp;stack_size;&nbsp;uint64_t&nbsp;tls;&nbsp;uint64_t&nbsp;set_tid;&nbsp;// 指向期望的 PID 数组&nbsp;uint64_t&nbsp;set_tid_size;&nbsp;// 数组长度&nbsp;uint64_t&nbsp;cgroup;};
int&nbsp;main(int&nbsp;argc,&nbsp;char&nbsp;*argv[])&nbsp;{&nbsp;if&nbsp;(argc <&nbsp;2) {&nbsp;printf("用法: sudo %s <目标PID>\n", argv[0]);&nbsp;return&nbsp;1;&nbsp;}
&nbsp;pid_t&nbsp;target_pid =&nbsp;atoi(argv[1]);
&nbsp;// clone3 参数设置&nbsp;struct&nbsp;clone_args_extended&nbsp;args = {0};&nbsp;args.flags =&nbsp;0;&nbsp;// 可以在这里加 CLONE_NEWPID 等&nbsp;args.exit_signal = SIGCHLD;&nbsp;args.set_tid = (uintptr_t)&target_pid;&nbsp;args.set_tid_size =&nbsp;1;&nbsp;// 对应 PID 命名空间的层级
&nbsp;// 调用 clone3&nbsp;long&nbsp;p =&nbsp;syscall(435, &args,&nbsp;sizeof(args));&nbsp;// 435 是 x86_64 的 clone3 系统调用号
&nbsp;if&nbsp;(p <&nbsp;0) {&nbsp;perror("clone3 失败");&nbsp;return&nbsp;1;&nbsp;}
&nbsp;if&nbsp;(p ==&nbsp;0) {&nbsp;printf("子进程启动成功,PID: %d\n",&nbsp;getpid());&nbsp;execl("/bin/sh",&nbsp;"sh",&nbsp;NULL);&nbsp;}&nbsp;else&nbsp;{&nbsp;waitpid(p,&nbsp;NULL,&nbsp;0);&nbsp;}
&nbsp;return&nbsp;0;}

4.进程注入

这是一种更具“侵略性”的硬核手段。严格来说,它并不是“启动”一个具有指定 PID 的新进程,而是“强行占领”一个已经存在的、且符合你 PID 要求的进程。这种方式主要利用了 Linux 提供的 ptrace 系统调用

实现逻辑:

  1. 挂载(Attach):使用 PTRACE_ATTACH 附加到目标 PID 进程上。
  2. 停顿(Stop):强行暂停目标进程的运行。
  3. 注入(Injection)
  • 写内存:将一小段精心构造的 Shellcode 写入目标进程的内存空间。
  • 改寄存器:修改指令寄存器(RIP/EIP),使其指向我们的代码。
  1. 执行(Exec):让 Shellcode 执行 execve 系统调用,加载并运行你真正的程序镜像。

此时,原有的进程镜像被彻底覆盖,但进程 ID (PID) 保持不变

以下是简单代码实现

#include&nbsp;<stdio.h>#include&nbsp;<stdlib.h>#include&nbsp;<string.h>#include&nbsp;<sys/ptrace.h>#include&nbsp;<sys/user.h>#include&nbsp;<sys/wait.h>#include&nbsp;<unistd.h>#include&nbsp;<sys/syscall.h>
// 辅助函数:注入数据void&nbsp;poke_text(pid_t&nbsp;child,&nbsp;long&nbsp;addr,&nbsp;void&nbsp;*str,&nbsp;int&nbsp;len)&nbsp;{&nbsp;long&nbsp;*laddr = (long&nbsp;*)str;&nbsp;int&nbsp;i =&nbsp;0;&nbsp;while&nbsp;(i < len) {&nbsp;if&nbsp;(ptrace(PTRACE_POKEDATA, child, addr + i, laddr[i/sizeof(long)]) <&nbsp;0) {&nbsp;perror("POKEDATA 失败");&nbsp;exit(1);&nbsp;}&nbsp;i +=&nbsp;sizeof(long);&nbsp;}}
int&nbsp;main(int&nbsp;argc,&nbsp;char&nbsp;*argv[])&nbsp;{&nbsp;if&nbsp;(argc <&nbsp;3) {&nbsp;printf("用法: sudo %s <PID> <程序路径> [参数1] [参数2]...\n", argv[0]);&nbsp;return&nbsp;1;&nbsp;}
&nbsp;pid_t&nbsp;target_pid =&nbsp;atoi(argv[1]);&nbsp;struct&nbsp;user_regs_struct&nbsp;regs;
&nbsp;ptrace(PTRACE_ATTACH, target_pid,&nbsp;NULL,&nbsp;NULL);&nbsp;waitpid(target_pid,&nbsp;NULL,&nbsp;0);&nbsp;ptrace(PTRACE_GETREGS, target_pid,&nbsp;NULL, &regs);
&nbsp;// 1. 在栈上开辟空间存放字符串和指针数组&nbsp;// 我们把空间开在 RSP 下方 1024 字节处,比较安全&nbsp;long&nbsp;base_addr = (regs.rsp -&nbsp;1024) & ~0x7;&nbsp;&nbsp;long&nbsp;current_str_addr = base_addr +&nbsp;256;&nbsp;// 字符串放在后面&nbsp;long&nbsp;argv_array_addr = base_addr;&nbsp;// 指针数组放在前面
&nbsp;int&nbsp;num_args = argc -&nbsp;2;&nbsp;// newelf 路径算第一个参数 (argv[0])&nbsp;long&nbsp;argv_ptrs[num_args +&nbsp;1];
&nbsp;for&nbsp;(int&nbsp;i =&nbsp;0; i < num_args; i++) {&nbsp;char&nbsp;*arg_str = argv[i +&nbsp;2];&nbsp;int&nbsp;len =&nbsp;strlen(arg_str) +&nbsp;1;
&nbsp;// 注入字符串内容&nbsp;poke_text(target_pid, current_str_addr, arg_str, len);
&nbsp;// 记录该字符串在目标内存中的地址&nbsp;argv_ptrs[i] = current_str_addr;&nbsp;current_str_addr += (len +&nbsp;7) & ~0x7;&nbsp;// 8字节对齐&nbsp;}&nbsp;argv_ptrs[num_args] =&nbsp;0;&nbsp;// 以 NULL 结尾
&nbsp;// 2. 注入指针数组本身&nbsp;poke_text(target_pid, argv_array_addr, argv_ptrs,&nbsp;sizeof(argv_ptrs));
&nbsp;// 3. 植入 syscall 机器码&nbsp;ptrace(PTRACE_POKETEXT, target_pid, regs.rip,&nbsp;0x050f);
&nbsp;// 4. 配置寄存器&nbsp;regs.rax = SYS_execve;&nbsp;regs.rdi = argv_ptrs[0];&nbsp;// filename&nbsp;regs.rsi = argv_array_addr;&nbsp;// argv 数组地址&nbsp;regs.rdx =&nbsp;0;&nbsp;// envp = NULL&nbsp;ptrace(PTRACE_SETREGS, target_pid,&nbsp;NULL, &regs);
&nbsp;printf("[+] 正在执行带参数的夺舍: %s %s...\n", argv[2], argc >&nbsp;3&nbsp;? argv[3] :&nbsp;"");
&nbsp;ptrace(PTRACE_SINGLESTEP, target_pid,&nbsp;NULL,&nbsp;NULL);&nbsp;waitpid(target_pid,&nbsp;NULL,&nbsp;0);&nbsp;ptrace(PTRACE_DETACH, target_pid,&nbsp;NULL,&nbsp;NULL);
&nbsp;printf("[+] 完成!\n");&nbsp;return&nbsp;0;}

效果如图所示

更多精彩文章与工具分享 欢迎关注


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:爱坤sec 爱坤 爱坤《如何在 Linux 中指定 PID 启动程序?原理分析与代码实现》

靶机渗透-DC3 网络安全文章

靶机渗透-DC3

文章总结: 该文档详细记录了DC-3靶机渗透测试全过程,通过信息收集发现Joomla3.7.0框架存在SQL注入漏洞,利用sqlmap获取管理员哈希后经John
评论:0   参与:  0