[内核学习]程序执行

/ 0评 / 0赞

fork 复制进程,execve 把它腾空重装:地址空间,代码,数据,栈,全部换掉,PID 不变.这条"进程身份延续,内容全换"的组合是 Unix 哲学的地基.这篇拿一个程序 exec 另一个程序,前后地址空间摆在一起对比,再把内核塞给新程序的"出厂参数" auxv 逐项解码,和 ELF 文件头精确对账.

这篇回答几件事:

实验设计: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 丢输出是同族坑:缓冲数据的命运,交给进程地址空间的生死.

最后留几个容易忽略的细节:

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注