vmlinuz 到 vmlinux:不碰源码,徒手重建内核 ELF 符号表

玩 Linux 内核调试的朋友,或多或少都碰过这种憋屈事儿------系统崩了、想抓个内核栈、或者想跟一下 sys_call_table 的调用流程,结果打开 GDB 或者 Crash 工具,满屏的十六进制地址,一个函数名都认不出来。原因很简单:你手头那个 /boot/vmlinuz-xxx 是个"裸奔"的内核,符号表被剥得干干净净。

这套方法,说白了就是给这个裸体内核"穿件衣服"。它把压缩的 vmlinuz 解压成 vmlinux,再偷偷把 /boot/System.map 里的符号信息缝回去,生成一个带完整 ELF 符号表的内核文件。有了它,再配合 /proc/kcore,你就能像调试普通用户态程序一样,按符号名去翻内核内存,定位函数、变量,甚至做取证分析。


一、几个必须先搞明白的概念

在深入代码之前,咱们先把几个容易混的东西捋清楚:

1. vmlinuz vs vmlinux:压缩包 vs 原件

  • vmlinuz:是开机时 GRUB 加载的那个压缩镜像,体积小,带自解压头,为了省磁盘空间和启动时间。
  • vmlinux :是编译内核时链接出来的原始 ELF 文件,体积大,理论上可以带调试信息和符号表。但发行版为了省空间,往往会用 strip 把符号表扒掉,再把剩下的 ELF 压缩成 vmlinuz。

2. System.map:内核的"户口本"

编译内核时,链接器会生成一个纯文本文件,里面一行一个符号,格式大概是:

复制代码
ffffffff81000000 T startup_64
ffffffff81001000 D __init_begin

左边是虚拟地址,中间是类型(T 表示代码段函数,D 表示数据段变量),右边是名字。这个文件就是内核的"户口本",地址和名字一一对应。

3. /proc/kcore:整台机器的内存"快照"

这是个特殊的 proc 文件,本质上是一个伪 ELF 文件 。它把整个物理内存(或者说内核能看到的内存)映射成 ELF 的 Program Header,你可以直接用 readelf -l /proc/kcore 看到各个内存段。

但问题是:/proc/kcore 只提供了"内存地图",没有"地名"(符号)。如果你手里没有一张带符号的 vmlinux,调试器就算打开了 /proc/kcore,也只会看到 0xffffffff81000000 这种地址,根本不知道这是 startup_64

4. ELF 符号表:调试器的"字典"

ELF 文件里有两个关键段:

  • .symtab:符号表,每个条目记录符号名、地址、类型、大小、所在段。
  • .strtab:字符串表,存真正的符号名字符串,.symtab 里只存偏移。

整个工具的核心使命,就是手工造出这两个段,塞进 vmlinux。


二、设计思路:为什么非要这么干?

你可能会问:我直接编译一个带调试信息的内核不就行了?理论上可以,但现实很骨感:

  1. 发行版内核没符号:Ubuntu、CentOS 发布的内核镜像都是 strip 过的,vmlinux 要么不提供,要么得额外装几百兆的 debug 包。
  2. 线上系统不能重编:生产环境崩了,你不可能停下来重新编译内核,再重启。
  3. 取证场景要现场分析 :安全事件响应时,你需要立刻分析 /proc/kcore 里的内存现场,没时间折腾源码。

所以这套方法想了个取巧的办法:既然 System.map 里已经有地址和名字的对应关系,我直接把它翻译成 ELF 符号表,缝回 vmlinux 不就行了?


三、代码实现原理:是怎么"缝"符号的?

代码主要分两大块:一块负责解压(kunpress.c),一块负责符号表重建(mk_vmlinux.c)。咱们重点讲后者,按执行顺序把套路拆开讲。

c 复制代码
...
int main(int argc, char **argv)
{
...

	if (argc < 4) {
		printf("%s <vmlinux_input> <vmlinux_output> <system.map>\n", argv[0]);
		exit(0);
	}
	
	meta.infile = strdup(argv[1]); // vmlinux
	meta.outfile = strdup(argv[2]);
	meta.symfile = strdup(argv[3]);	
	
	elf.path = strdup(meta.infile);

	if (access(meta.symfile, R_OK) < 0) {
                fprintf(stderr, "[!] Unable to read file %s: %s\n", meta.symfile, strerror(errno));
                exit(-1);
	}
	
	parse_vmlinux(&elf);
	low_limit = elf.seg_vaddr[TEXT];
#ifdef __x86_64__
	high_limit = elf.seg_vaddr[DATA3];
#else
	high_limit = elf.seg_vaddr[DATA1];
#endif
	
#if DEBUG
	printf("high_limit: %lx low_limit: %lx\n", high_limit, low_limit);
#endif
	meta.symtab_size = calculate_symtab_size(&meta);

#if DEBUG
	printf("Symbol table size: %lx bytes\n", meta.symtab_size);
#endif


	if ((strtab = (char *)malloc(strtab_size)) == NULL) {
		perror("malloc");
		exit(-1);
	}


	for (offset = 0, i = 0; i < meta.ksymcount; i++) {
		strcpy(&strtab[offset], kallsyms_entry[i].name);
		offset += strlen(kallsyms_entry[i].name) + 1;
	}

	if ((symtab = (ElfW(Sym) *)malloc(sizeof(ElfW(Sym)) * meta.ksymcount)) == NULL) {
		perror("malloc");
		exit(-1);
	}
	 
	for (st_offset = 0, i = 0; i < meta.ksymcount; i++) {
		symtype = kallsyms_entry[i].symtype == FUNC ? STT_FUNC : STT_OBJECT;
		symtab[i].st_info = (((STB_GLOBAL) << 4) + ((symtype) & 0x0f));
		symtab[i].st_value = kallsyms_entry[i].addr;
		symtab[i].st_other = 0;
		symtab[i].st_shndx = get_section_index_by_address(&elf, symtab[i].st_value);
		symtab[i].st_name = st_offset;
		symtab[i].st_size = kallsyms_entry[i].size;
		strcpy(&strtab[st_offset], kallsyms_entry[i].name);
		st_offset += strlen(kallsyms_entry[i].name) + 1;
	}
	
	elf.new.symtab = symtab;
	elf.new.strtab = strtab;
	
	create_new_binary(&elf, &meta);
	
	printf("[+] vmlinux has been successfully instrumented with a complete ELF symbol table.\n");
	
	exit(0);	
		
}

If you need the complete source code, please add the WeChat number (c17865354792)

整体流程

复制代码
┌─────────────────┐
│  读取 vmlinux   │  ← 用 mmap 把整个 ELF 文件映射进内存
│  (未压缩的 ELF) │
└────────┬────────┘
         ▼
┌─────────────────┐
│  解析程序头     │  ← 找 PT_LOAD 段,分清代码段、数据段
│  (Program Hdr)  │
└────────┬────────┘
         ▼
┌─────────────────┐
│  读取 System.map│  ← 逐行解析,过滤掉内核地址外的符号
│                 │
└────────┬────────┘
         ▼
┌─────────────────┐
│  计算符号表大小   │  ← 算算 .symtab 和 .strtab 需要多大
└────────┬────────┘
         ▼
┌─────────────────┐
│  构建字符串表   │  ← 把所有符号名拷贝到一个大字符串池
│  (.strtab)      │
└────────┬────────┘
         ▼
┌─────────────────┐
│  构建符号表     │  ← 填充 ElfW(Sym) 数组,设置类型、段索引
│  (.symtab)      │
└────────┬────────┘
         ▼
┌─────────────────┐
│  写新 ELF 文件  │  ← 把原文件内容 + 新符号表 + 新段头拼起来
│  (vmlinux.out)  │
└─────────────────┘

第一步:parse_vmlinux------摸清内核的"户型图"

parse_vmlinux() 函数先把输入的 vmlinux 用 mmap 映射到内存,然后按 ELF 格式解析:

c 复制代码
ehdr = (ElfW(Ehdr) *)mem;           // ELF 文件头
phdr = (ElfW(Phdr) *)(mem + ehdr->e_phoff);  // 程序头表
shdr = (ElfW(Shdr) *)(mem + ehdr->e_shoff);  // 段头表

重点在遍历 Program Header,找 PT_LOAD 类型的段(就是真正会加载进内存的段):

  • RX 段 (PF_R | PF_X):代码段 .text,记为 TEXT
  • RW 段 (PF_R | PF_W):数据段,64 位内核通常有两个,记为 DATA1DATA2
  • RWE 段 (PF_R | PF_W | PF_X):特殊的混杂数据段,记为 DATA3

代码里用 elf->seg_vaddr[]elf->seg_offset[] 把这些段的虚拟地址、文件偏移、大小记下来。后面过滤符号地址范围时要用。

同时,它还遍历 Section Header,把每个 section 的地址范围(sh_addrsh_addr + sh_size)存进 section_ranges[]。这一步很重要,因为后面给符号找"归属段"时,要查这个表。

第二步:calculate_symtab_size------清点"户口本"上的户口

这个函数读 System.map,一行一行解析:

c 复制代码
sscanf(line, "%lx %c %s", &sysmap_entry.addr, &sysmap_entry.c, sysmap_entry.name);

然后做三件事:

1. 地址过滤

c 复制代码
if (!validate_va_range(sysmap_entry.addr)) {
    c--;
    continue;
}

validate_va_range 检查地址是否在 [low_limit, high_limit) 之间。low_limit 是代码段起始地址,high_limit 在 64 位下是 DATA3 的结束地址。说白了,只保留内核主镜像里的符号,把内核模块、vmalloc 区域、外设地址统统过滤掉。

2. 符号类型映射

c 复制代码
switch(toupper(kallsyms_entry[c].c)) {
    case 'T': kallsyms_entry[c].symtype = FUNC; break;      // 函数
    case 'R': 
    case 'D': kallsyms_entry[c].symtype = OBJECT; break;    // 数据
}

System.map 里的 T 表示代码段函数,R 是只读数据,D 是初始化数据,都映射成 ELF 的 STT_FUNCSTT_OBJECT

3. 符号大小估算

这里有个巧思:System.map 本身不记录符号大小,代码里用下一个符号的地址减去当前符号的地址来估算:

c 复制代码
kallsyms_entry[c].size = vaddr - sysmap_entry.addr;

虽然不是 100% 精确(如果中间有空洞或者符号不按顺序),但对调试来说够用了。

最后算出 strtab_size(所有符号名字符串长度 + 1 的总和)和 symtab_size(符号个数 × sizeof(ElfW(Sym)))。

第三步:造字符串表和符号表

这是纯体力活,但逻辑很清晰:

造 .strtab

c 复制代码
for (offset = 0, i = 0; i < meta.ksymcount; i++) {
    strcpy(&strtab[offset], kallsyms_entry[i].name);
    offset += strlen(kallsyms_entry[i].name) + 1;
}

就是一个大字符数组,每个符号名以 \0 结尾,后面符号表条目里存的是在这个数组里的偏移。

造 .symtab

c 复制代码
symtab[i].st_info = (((STB_GLOBAL) << 4) + ((symtype) & 0x0f));
symtab[i].st_value = kallsyms_entry[i].addr;
symtab[i].st_shndx = get_section_index_by_address(&elf, symtab[i].st_value);
symtab[i].st_name = st_offset;
symtab[i].st_size = kallsyms_entry[i].size;
  • st_info:高 4 位是绑定属性(STB_GLOBAL,全局可见),低 4 位是类型(函数/数据)。
  • st_value:符号的虚拟地址,直接从 System.map 来。
  • st_shndx:这个符号属于哪个 Section。代码里通过 get_section_index_by_address() 查前面记录的 section_ranges[],看地址落在哪个 section 的地址区间。
  • st_name:在 .strtab 里的偏移。
  • st_size:前面估算的大小。

第四步:create_new_binary------"拼装"新 ELF

这是最容易出 bug 也最考验 ELF 功底的地方。它不是从头造一个 ELF,而是在原来的 vmlinux 基础上"打补丁"

1. 写原文件主体

c 复制代码
write(fd, elf->mem, elf->shdr_offset);

把原 vmlinux 从文件头到 Section Header 开始前的所有内容原样拷过去。注意:符号表和字符串表要插在原 Section Header 之前

2. 调整 ELF 头

c 复制代码
ehdr->e_shoff += meta->symtab_size + strtab_size;  // 段头表偏移后移
ehdr->e_shnum += 2;                                 // 多了 .symtab 和 .strtab 两个段

因为中间插入了两个新段的数据,后面的 Section Header 表位置必须往后挪,不然文件结构就乱了。

3. 插入新段数据

c 复制代码
write(fd, elf->new.symtab, meta->symtab_size);   // 写 .symtab
write(fd, elf->new.strtab, strtab_size);         // 写 .strtab
write(fd, &elf->mem[elf->shdr_offset], ...);     // 把原来的 Section Header 表写回来

4. 追加两个新段头

c 复制代码
shdr[0].sh_type = SHT_SYMTAB;  // .symtab
shdr[0].sh_link = elf->shdr_count + 1;  // 指向 .strtab
shdr[0].sh_offset = elf->shdr_offset;   // 数据偏移位置
shdr[0].sh_size = meta->symtab_size;

shdr[1].sh_type = SHT_STRTAB;  // .strtab
shdr[1].sh_offset = soff;      // 紧跟在 .symtab 后面

最后把这两个新段头追加到文件末尾。至此,一个"穿衣打扮"后的 vmlinux 就诞生了。


四、相关领域知识点盘点

这套方法虽然代码量不大,但涉及的知识点很杂,咱们做个总结:

领域 涉及知识点 在工具中的作用
ELF 格式 ELF Header、Program Header、Section Header、.symtab.strtab、段类型(SHT_SYMTAB/SHT_STRTAB) 整个文件操作的基础,需要精确计算偏移和大小
内核编译流程 vmlinuz 生成过程(vmlinux → strip → objcopy → compress)、System.map 生成时机 理解为什么发行版内核没有符号,以及 System.map 从哪来
内存布局 内核虚拟地址空间、TEXT/DATA/BSS 段分布、64 位 vs 32 位差异 过滤符号地址、区分段类型、处理不同架构的 Program Header
mmap/文件 IO mmap 映射文件、write 拼装文件、fstat 获取大小 高效读写大文件,避免频繁拷贝
符号表机制 STB_GLOBALSTT_FUNCSTT_OBJECTst_shndx 正确构造 ELF 符号条目,让 GDB/readelf 能识别
/proc/kcore 伪文件系统、ELF 格式内存导出、物理内存映射 理解为什么需要带符号的 vmlinux 作为"字典"来解读 kcore

五、实际能用来干啥?

  1. 内核调试 :配合 GDB + /proc/kcore,按符号名打断点、查看变量。比如 p sys_call_table 直接看系统调用表。
  2. 崩溃分析:系统 panic 后,用 Crash 工具加载带符号的 vmlinux,分析 vmcore 或实时内存。
  3. 安全取证 :不用重启机器,直接分析 /proc/kcore,查找 Rootkit 篡改的函数指针、隐藏模块。
  4. 逆向学习:拿到一个发行版内核,快速恢复符号,用 IDA/Ghidra 打开看内核逻辑,不用对着纯地址硬啃。

六、源码层面的运行架构

这个工具实际上是个入口包装器,内部串着两个动作:

复制代码
┌─────────────────────────────────────────┐
│  用户执行:./my_kdress vmlinuz vmlinux System.map │
└─────────────────────────────────────────┘
                   │
                   ▼
        ┌────────────────────┐
        │  第1步:kunpress.c  │  ← 把 vmlinuz 解压成裸 vmlinux
        │  (vmlinuz → vmlinux.raw) │
        └────────────────────┘
                   │
                   ▼
        ┌────────────────────┐
        │  第2步:mk_vmlinux.c│  ← 把 System.map 缝进 ELF
        │  (vmlinux.raw + System.map → vmlinux) │
        └────────────────────┘

参数对应关系:

  • 参数1 vmlinuz-xxx:输入的压缩内核(带自解压头的 bzImage/zImage)
  • 参数2 vmlinux:输出的目标文件名(最终带符号的 ELF)
  • 参数3 System.map-xxx:编译内核时生成的符号地址对照表

实际编译与运行步骤

1. 编译两个组件

bash 复制代码
# 编译解压模块(需要 zlib,因为内核通常是 gzip 压缩)
gcc -o kunpress kunpress.c -lz

# 编译符号表重建模块(你贴的那段代码)
gcc -o mk_vmlinux mk_vmlinux.c -D_GNU_SOURCE

2. 手动模拟 kdress 的执行流程

如果你没那个主入口脚本,可以手动分两步跑,效果完全一样:

第一步:解压

bash 复制代码
sudo ./kunpress /boot/vmlinuz-$(uname -r) vmlinux.raw

这会把压缩内核里的 ELF payload 提取出来,得到 vmlinux.raw。此时它是个被 strip 过的裸 ELF ,用 readelf -s 看符号表基本是空的。

第二步:注入符号

bash 复制代码
sudo ./mk_vmlinux vmlinux.raw vmlinux /boot/System.map-$(uname -r)

这步就是你贴的那段代码的主逻辑:

  • 读取 vmlinux.raw 的 ELF 结构(段头、程序头)
  • 读取 System.map,过滤有效地址范围内的符号
  • 计算 .symtab.strtab 的大小
  • 构造新的 ELF 文件 vmlinux,在原文件末尾(或 shdr 前)插入符号表

验证测试方法

源码里的 Example 已经给出了最直观的验证方式:

验证1:符号表是否真的存在

bash 复制代码
sudo readelf -s vmlinux | grep sys_call_table

预期输出:

复制代码
33268: ffffffff81801400  4368 OBJECT  GLOBAL DEFAULT    4 sys_call_table
33421: ffffffff81809ca0  2928 OBJECT  GLOBAL DEFAULT    4 ia32_sys_call_table

这说明 .symtab 里已经能找到 sys_call_table,地址、大小、类型(OBJECT)、绑定属性(GLOBAL)都对上了。

验证2:符号地址和 System.map 是否一致

bash 复制代码
grep sys_call_table /boot/System.map-$(uname -r)

对比 readelf -s 输出的地址,应该完全一致。如果一致,说明符号表重建时地址映射没跑偏。

验证3:段归属是否正确

bash 复制代码
readelf -s vmlinux | grep " sys_call_table"

看最后一列的 4(或对应的段号),表示这个符号被分配到了第 4 个 Section(通常是 .data 或特定的数据段)。这说明 get_section_index_by_address() 函数工作正常。

验证4:配合 /proc/kcore 实战调试

这是终极验证,确认符号表能真正用于内存分析:

bash 复制代码
# 用 GDB 加载带符号的 vmlinux,同时打开 /proc/kcore
sudo gdb ./vmlinux /proc/kcore

# 在 GDB 里执行:
(gdb) p sys_call_table
$1 = (void **) 0xffffffff81801400

(gdb) x/5xg sys_call_table
0xffffffff81801400: 0xffffffff8108a000 0xffffffff8108a020 ...

(gdb) info symbol 0xffffffff81801400
sys_call_table in section .data

如果 GDB 能正确解析出符号名而不是显示 <no symbol>,说明整个流程彻底跑通了。

总结

这套方法的巧妙之处,在于它没有试图去解析复杂的内核压缩算法 (解压部分由独立模块负责),也没有去碰内核源码 ,而是直接利用编译时已经产生的 System.map,在二进制层面完成符号表的"移植"。

这就像是给一栋已经装修好的房子补一份"户型说明书"------房子本身没动,但有了说明书,后面来的工人(调试器、取证工具)就知道哪扇门后面是厨房,哪面墙能敲了。

如果你手头正好有个线上环境想抓内核现场,或者想深入研究某个发行版内核的内部结构,这条"给内核穿衣"的路子,绝对值得放进你的工具箱。

Welcome to follow WeChat official account【程序猿编码

相关推荐
Par@ish1 小时前
Ubuntu Apache日志存储周期变更
linux·ubuntu·apache
简单点好呀1 小时前
Valgrind 报告干干净净,内存却在涨——我用 GDB 揪出了 47000 个泄漏的 Lua 闭包
linux
闲猫1 小时前
从0到1完整开发Smartshell最后沉淀出的Cursor开发规则
linux·运维·堡垒机·cursor·vibecoding
炘爚1 小时前
Phase 4:业务线程池 — IO/计算解耦
linux·c++
AOwhisky1 小时前
MySQL 学习笔记(第七期):高可用架构进阶与综合项目实战
linux·运维·笔记·学习·mysql·高可用·mha
张小姐的猫1 小时前
【Linux】多线程 —— 线程池 | 单例模式 | 常见锁
linux·运维·服务器·c++·单例模式·设计模式·策略模式
无限进步_1 小时前
【Linux】进程状态、僵尸与孤儿、进程调度
linux·运维·服务器·开发语言·数据结构·算法
着迷不白1 小时前
七、Linux网络管理
服务器·网络·php
加油码2 小时前
Linux IO 多路转接详解:从 select、poll 到 epoll
linux·c++