在Linux平台做底层开发时,经常会遇到需要直接操作已编译二进制文件的场景,比如裁剪调试符号、隐藏内部函数名或者为逆向分析做标记。ELF(Executable and Linkable Format)作为Linux下标准的可执行与可重定位文件格式,其符号表记录了函数、变量等实体的名字与地址信息。用C++直接读写这些内容,不仅能避开外部工具的局限,还能在程序里动态完成二进制修补。

ELF符号表的基础结构与读取原理
要读取符号表,首先得理解ELF的布局。一个典型的ELF文件开头是ELF Header,里面包含了魔数、位数(32或64)、字节序以及节区头表的位置。紧接着是若干节区(Section),其中类型为SHT_SYMTAB的节区就是符号表,它由一个Elf32_Sym或Elf64_Sym数组构成,每个元素描述一个符号。符号的名字并不直接存在结构里,而是保存了一个偏移值,指向另一个类型为SHT_STRTAB的字符串表节区。
在C++中我们一般借助<elf.h>头文件里定义好的结构体来映射这些二进制布局。读取时先以二进制模式打开文件,把整个ELF Header读出来,检查e_ident前面的魔数是否是0x7F、'E'、'L'、'F'。随后根据sh_offset和sh_num遍历节区头,找到sh_type等于SHT_SYMTAB的那一项,从中拿到符号表在文件中的偏移和条目数量。再找到它关联的字符串表(通过符号表节区的sh_link字段),就能把每个符号的st_name当成偏移,从字符串表里取出函数或变量名。
下面是一段简化版的64位ELF符号表读取代码,它打印出所有符号的名字和所在值。注意文件读取时要按小端序处理,x86_64上ELF默认就是小端,所以直接用结构体映射通常不会出错。
#include <iostream>
#include <fstream>
#include <elf.h>
#include <string>
#include <vector>
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "usage: read_sym file" << std::endl;
return 1;
}
std::ifstream in(argv[1], std::ios::binary);
if (!in) {
std::cerr << "cannot open file" << std::endl;
return 1;
}
Elf64_Ehdr ehdr;
in.read(reinterpret_cast<char*>(&ehdr), sizeof(ehdr));
if (ehdr.e_ident[0] != 0x7F || ehdr.e_ident[1] != 'E') {
std::cerr << "not an ELF file" << std::endl;
return 1;
}
std::vector<Elf64_Shdr> shdrs(ehdr.e_shnum);
in.seekg(ehdr.e_shoff);
in.read(reinterpret_cast<char*>(shdrs.data()), sizeof(Elf64_Shdr) * ehdr.e_shnum);
Elf64_Shdr* symtab = nullptr;
Elf64_Shdr* strtab = nullptr;
for (auto& sh : shdrs) {
if (sh.sh_type == SHT_SYMTAB) {
symtab = &sh;
strtab = &shdrs[sh.sh_link];
break;
}
}
if (!symtab) {
std::cerr << "no symtab found" << std::endl;
return 1;
}
std::vector<Elf64_Sym> syms(symtab->sh_size / sizeof(Elf64_Sym));
in.seekg(symtab->sh_offset);
in.read(reinterpret_cast<char*>(syms.data()), symtab->sh_size);
std::string strdata;
strdata.resize(strtab->sh_size);
in.seekg(strtab->sh_offset);
in.read(&strdata[0], strtab->sh_size);
for (auto& s : syms) {
const char* name = strdata.c_str() + s.st_name;
std::cout << "symbol: " << name << " value: " << s.st_value << std::endl;
}
return 0;
}
用C++修改符号表信息的实现思路
修改符号表比只读要复杂,因为ELF里很多节区是紧挨着排布的,直接改长度会破坏后续偏移。最常见的轻量修改是改名:把某个内部函数名改成空字符串或者统一前缀,从而加大逆向难度。由于字符串表里的名字是以 分隔的连续字节,缩短名字只需把原名字覆盖成新名字加 ,并把多余字节标记为空闲;若新名字更长,则要在字符串表末尾追加,并同步更新sh_size以及所有依赖该节区偏移的后续节区头。
另一种修改是调整符号的可见性,例如把st_info里的绑定类型从全局改为局部。这种改动不改变节区大小,只改结构体字段,因此不需要移动数据,只需把符号表节区读入内存、改完字段再整体写回原偏移即可。但要注意,若文件被strip过,.symtab和.strtab可能已被删除,此时只能从.dynsym(动态符号表)入手,它的结构和符号表类似,只是节区类型为SHT_DYNSYM,且被动态链接器在运行时使用,修改不当会导致程序无法加载。
下面的示例展示如何把符号表中名为“secret_func”的项改名为“__hidden__”,并写回文件。为了安全,程序先备份再操作,且假设新名字不长于旧名字。
#include <iostream>
#include <fstream>
#include <elf.h>
#include <vector>
#include <cstring>
int main(int argc, char* argv[]) {
if (argc < 2) return 1;
std::fstream file(argv[1], std::ios::in | std::ios::out | std::ios::binary);
Elf64_Ehdr ehdr;
file.read(reinterpret_cast<char*>(&ehdr), sizeof(ehdr));
std::vector<Elf64_Shdr> shdrs(ehdr.e_shnum);
file.seekg(ehdr.e_shoff);
file.read(reinterpret_cast<char*>(shdrs.data()), sizeof(Elf64_Shdr) * ehdr.e_shnum);
Elf64_Shdr* symtab = nullptr;
Elf64_Shdr* strtab = nullptr;
for (auto& sh : shdrs) {
if (sh.sh_type == SHT_SYMTAB) {
symtab = &sh;
strtab = &shdrs[sh.sh_link];
break;
}
}
std::vector<Elf64_Sym> syms(symtab->sh_size / sizeof(Elf64_Sym));
file.seekg(symtab->sh_offset);
file.read(reinterpret_cast<char*>(syms.data()), symtab->sh_size);
std::string strdata(strtab->sh_size, ' ');
file.seekg(strtab->sh_offset);
file.read(&strdata[0], strtab->sh_size);
for (auto& s : syms) {
if (std::strcmp(&strdata[s.st_name], "secret_func") == 0) {
std::strncpy(&strdata[s.st_name], "__hidden__", strlen("__hidden__"));
break;
}
}
file.seekg(strtab->sh_offset);
file.write(strdata.data(), strdata.size());
file.close();
return 0;
}
实战中的注意事项与替代方案对比
在真实项目里手写ELF解析代码,必须小心对齐和字节序。32位与64位结构体内存布局不同,字段宽度也不一样,因此最好用模板或编译期判断来区分Elf32_和Elf64_系列类型。如果目标文件是大端架构(如某些嵌入式PowerPC),直接结构体映射会读错多字节整数,需要自己做字节交换。此外,修改后若文件使用了Section Header String Table,节区名本身也可能暴露意图,必要时一并混淆。
和自行写C++代码相比,GNU binutils里的objcopy、strip等工具能快速删除或重命名符号,但它们粒度较粗,难以针对单条符号做条件判断。而利用libelf等专用库可以省去自己处理文件偏移的麻烦,库会自动维护节区之间的关系。不过在静态二进制加固、定制化链接后处理等场景,自带C++实现意味着不依赖外部动态库,可嵌入到构建系统或保护壳中,灵活度最高。
从稳定性角度看,直接改符号表不会影响到.text代码节,因此程序运行逻辑不变,只是调试信息和符号解析结果变化。但若修改了.dynsym且动态链接器在加载时校验失败,进程会直接报“symbol lookup error”。所以动手前务必在测试环境用ldd和nm验证,并保留原文件哈希值以便回滚。掌握这套读写机制后,你就能在Linux底层开发中自由地探查与定制二进制文件结构。
ELFsymbol_tablecpp修改时间:2026-08-14 20:51:41