玩 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。
二、设计思路:为什么非要这么干?
你可能会问:我直接编译一个带调试信息的内核不就行了?理论上可以,但现实很骨感:
- 发行版内核没符号:Ubuntu、CentOS 发布的内核镜像都是 strip 过的,vmlinux 要么不提供,要么得额外装几百兆的 debug 包。
- 线上系统不能重编:生产环境崩了,你不可能停下来重新编译内核,再重启。
- 取证场景要现场分析 :安全事件响应时,你需要立刻分析
/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 位内核通常有两个,记为
DATA1、DATA2 - RWE 段 (PF_R | PF_W | PF_X):特殊的混杂数据段,记为
DATA3
代码里用 elf->seg_vaddr[] 和 elf->seg_offset[] 把这些段的虚拟地址、文件偏移、大小记下来。后面过滤符号地址范围时要用。
同时,它还遍历 Section Header,把每个 section 的地址范围(sh_addr 到 sh_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_FUNC 或 STT_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_GLOBAL、STT_FUNC、STT_OBJECT、st_shndx |
正确构造 ELF 符号条目,让 GDB/readelf 能识别 |
| /proc/kcore | 伪文件系统、ELF 格式内存导出、物理内存映射 | 理解为什么需要带符号的 vmlinux 作为"字典"来解读 kcore |
五、实际能用来干啥?
- 内核调试 :配合 GDB + /proc/kcore,按符号名打断点、查看变量。比如
p sys_call_table直接看系统调用表。 - 崩溃分析:系统 panic 后,用 Crash 工具加载带符号的 vmlinux,分析 vmcore 或实时内存。
- 安全取证 :不用重启机器,直接分析
/proc/kcore,查找 Rootkit 篡改的函数指针、隐藏模块。 - 逆向学习:拿到一个发行版内核,快速恢复符号,用 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【程序猿编码】