fork 复制进程,execve 把它腾空重装:地址空间,代码,数据,栈,全部换掉,PID 不变.这条"进程身份延续,内容全换"的组合是 Unix 哲学的地基.这篇拿一个程序 exec 另一个程序,前后地址空间摆在一起对比,再把内核塞给新程序的"出厂参数" auxv 逐项解码,和 ELF 文件头精确对账.
这篇回答几件事:
- execve 从系统调用到新程序第一行代码之间发生了什么
- 新程序的地址空间怎么布局,PIE 随机基址怎么来的
- 初始栈上有什么,argv 和环境变量谁说了算
- auxv 是什么,怎么和 readelf 对上号
实验设计:fork 一个子进程,先打印自己的内存映射(这是从父进程 COW 继承的旧世界),然后 execve 一个新程序,新程序打印自己的映射和参数.前后对照:
[child before exec]
aaaace280000-aaaace281000 r-xp ... /root/execdemo
[target, after exec]
aaaab6500000-aaaab6501000 r-xp ... /root/target
aaaab651f000-aaaab6520000 r--p ... /root/target
aaaab6520000-aaaab6521000 rw-p ... /root/target
旧世界的 execdemo 映射彻底消失,新世界是 target 的三段映射.r-xp 只读可执行(代码),r--p(只读数据),rw-p(可写数据) — 这个布局不是内核拍脑袋,是照着 target 这个 ELF 文件的 LOAD 段一个个映射的,readelf -l 能看到对应的三个 LOAD program header,文件偏移 0x0 和 0xfdc8 都能对上.
内核侧的流程走一遍.do_execve 进来,fs/exec.c 先搭一个 linux_binprm 结构,打开文件,读前 128 字节;然后 search_binary_handler 问一圈注册的加载器(binfmt_elf 是主力,脚本们靠 binfmt_script,#! 开头的文件是它接的),binfmt_elf 的 load_elf_binary 接手:检查魔数和架构,解析 program header,建立全新的 mm_struct — 注意顺序,先建新的,成了才替换旧的,失败的 exec 不毁原进程;映射各 LOAD 段(文件映射加匿名映射,权限照段属性来,多余部分补零页);处理 INTERP 段 — 我们的 target 带这个段,说明它要动态链接器,内核把 ld.so 也当 ELF 映射进来,入口先设成 ld.so 的,等它把共享库收拾完再跳真正的 main;最后构造初始栈,设好返回用户态的 PC 和 SP,一个崭新的程序世界就绪.
初始栈值得细看,它是内核和用户态运行时的交接协议.栈顶往下依次是:程序名字符串,环境变量字符串,argv 字符串,然后是 auxv 辅助向量,envp 数组,argv 数组,栈顶 argc — 一层压一层,glibc 的 _start 一上来就按这个布局干活.auxv 是键值对数组,内核塞的系统参数,实验里抓了三个:
AT_PAGESZ=4096 AT_ENTRY=0xaaaab6500980 AT_RANDOM=0xffffda241688
AT_PAGESZ 告诉运行时页大小(有的架构运行时可变);AT_RANDOM 指向栈上 16 字节随机数,是 stack canary 的种子,防栈溢出的第一道原料就位;最好对账的是 AT_ENTRY — readelf -h 显示 target 的 Entry point address 是 0x980,而 maps 里 target 的加载基址是 0xaaaab6500000,0xaaaab6500000 加 0x980 正好等于 AT_ENTRY 的 0xaaaab6500980,分毫不差.内核把"文件里的偏移入口"加"运行时随机基址"算好告诉程序,这就是 PIE:readelf 里 Type 是 DYN,每次 exec 基址都不同(实验两次运行一个 aaaace28 开头一个 aaaab650 开头),攻击者拿到任意一次的地址都没法预测下一次.
argv 和环境变量的实验结果也很有戏剧性:
[target] argc=2 argv0=假名字-完全由exec决定 argv1=hello-arg
[target] MY_TAG=after-exec
argv[0] 显示的是"假名字-完全由exec决定" — 我在 execve 的参数里就这么写的.argv[0] 不是内核查出来的文件名,是调用者传什么就是什么,ps 显示的进程名与实际执行的程序可以毫无关系,这是安全审计要留心的点.环境变量同理全量替换,干净得像换了台机器 — 顺便这也是 shell 实现"临时环境变量跑一条命令"(VAR=x cmd)的机制:fork,子进程 execve 时塞新 envp.
还有一个我亲手踩的小坑:exec 前打印的"旧世界"日志第一次运行时丢了.execve 只跑 atexit 处理器,不会 flush stdio 的用户态缓冲,而管道输出是全缓冲的,那几行 printf 还躺在缓冲区里,exec 一到连同旧地址空间一起蒸发.在 execve 前老老实实 fflush,日志才回来.和 4.4 的 _exit 丢输出是同族坑:缓冲数据的命运,交给进程地址空间的生死.
最后留几个容易忽略的细节:
- exec 失败(文件不存在,格式不对)时原进程毫发无损,所以 shell 可以放心 exec,失败了才报错 — "失败不伤身"是 exec 语义的护城河.
- PID,打开的 fd,信号处置,当前目录,cgroup 归属,这些 exec 不换:fd 不设 CLOEXEC 就会被新程序继承,这是运维排查"奇怪 fd"的常见来源.
- close-on-exec 那个旗子在 3.1 的 FIOCLEX 见过脸(1.3 篇的 do_vfs_ioctl 清单),socket 和 epoll fd 现代代码默认带上,老代码遗留的裸管道在容器迁移时最容易漏.
- binfmt_misc 允许用户态注册新格式(Java class 直接触发 JVM 就是它干的),/proc/sys/fs/binfmt_misc 里能看到登记表.
- 动态链接器是"内核加载的第一个用户程序",ld.so 自己也是 PIE,它处理完 DT_NEEDED 依赖链把控制权交给程序入口,这一段全是用户态的事,内核只负责把它摆上台.
- AT_EXECFN 保存了实际执行的文件路径,argv[0] 骗人时它是诚实的那一个,读 /proc/self/exe 同效.
- vfork 搭 exec 是历史最优组合,如今 posix_spawn 内部就是 CLONE_VM|CLONE_VFORK 加 exec,上篇说过,这里闭环.