可能很多人跑了几年程序,都没认真想过一个问题:在main函数之前,操作系统到底帮你做了多少事。C++ 全局对象的构造、某些库的启动初始化、甚至一些反调试手段,全都挤在main之前那短短几毫秒里。而这一切的“调度表”,就藏在 ELF 文件一个不起眼的节里——.init_array。
这一篇我把这个节彻底讲透。你会看到它长什么样、链接器怎么生成它、加载器怎么执行它,以及怎么通过工具查看甚至修改它。适合这几类人看:刚入门 ELF 格式、想搞清楚程序启动过程的开发者;正在做二进制分析、安全研究、CTF 逆向的爱好者;以及被“为什么加了构造函数却不在预期时机执行”这类问题折磨过的工程人。
1. .init_array 是个什么东西
1.1 用一条 readelf 命令认识它
先找个普通的可执行文件,直接看它的 Section Header:
$ readelf -S ./hello输出里会有一个节叫.init_array,大致长这样:
[13] .init_array INIT_ARRAY 0000000000403e10 00003e10 0000000000000010 0000000000000008 WA 0 0 8注意几点。第一,它的类型是INIT_ARRAY,这属于 ELF 规范里定义的专用段类型,专门用来标记“启动函数指针数组”。第二,它的Flags是WA,意味着它是可写、可分配的段,运行时会被加载进内存。第三,每个条目大小是 8 字节,正好是一个指针。
不用想得太玄乎。.init_array的本质就是一个函数指针数组。数组里每个元素都指向一个函数地址。程序启动时,加载器会按照数组顺序,挨个调用这些函数。你可以把它理解成一张“启动任务排班表”,谁先谁后,都在这张表里写死。
1.2 一个数组如何决定程序命运
main函数并不是程序第一个执行的代码。在它之前,先有_start,然后有 libc 的启动例程,再然后就要处理.init_array里列出的所有函数。
举个例子,写个最简单的 C++ 程序:
#include <iostream> class Demo { public: Demo() { std::cout << "构造 Demo 对象" << std::endl; } }; Demo demo; int main() { std::cout << "进入 main" << std::endl; return 0; }编译后运行,你会发现“构造 Demo 对象”一定打印在“进入 main”之前。这就是因为编译器把Demo::Demo()这 个构造函数的地址,塞进了.init_array。程序启动时先遍历这个数组,把全局对象构造完,然后才轮到main。
这里有一个生活化的类比:去医院体检,你得先到分诊台报到、然后排队做各项检查,最后才去见医生。.init_array就是那张体检单,上面列着一项项检查的指引。医生(main)最后才见到你,因为前面那些排队项都靠这张单子调度。
1.3 和 .init、.ctors 有什么区别
老手看到.init_array,一定会想起两个老古董:.init和.ctors。
.init节实际上是一段可执行代码,编译期由某些目标文件拼出来的一个函数_init。以前程序启动时,加载器会直接call _init,而_init函数内部会依次调用一堆构造函数。问题在于,.init里的代码是“拼接”出来的,需要链接器脚本做大量魔法操作,比如:
.init : { _init = .; KEEP (*(.init)) }.ctors是更老一套的方案,用__CTOR_LIST__标记起始位置,里面存着一串函数指针,靠一个遍历循环调用。
相比之下,.init_array简单粗暴:它就是纯数据,一个指针数组。加载器不用执行拼接代码,只要拿到数组首地址和长度,遍历一遍就完事。现代工具链默认不再生成.ctors,但你依然能在老二进制或某些定制工具链里见到它。理解这些演变,对分析老程序、处理跨平台兼容问题非常有用。
2. 编译器与链接器如何联手生成 .init_array
2.1attribute((constructor)) 的秘密
在实际开发里,你很少会手动往.init_array里填内容。最常见的接触方式是这个 GCC 扩展:
#include <stdio.h> __attribute__((constructor)) void before_main() { printf("before main, from .init_array\n"); } int main() { printf("in main\n"); return 0; }编译完成后,用readelf看一下.init_array:
$ gcc -o demo demo.c $ readelf -x .init_array demo你会看到里面确实有一个地址,指向before_main函数。
编译器在这里干的事情并不复杂。它看到constructor属性后,会在目标文件里生成一个特殊的重定位项,把函数地址放进一个名叫.init_array的节。链接器最后把所有目标文件里的.init_array节合并成一个整体,形成最终可执行文件里的完整数组。
如果函数比较多,你还可以用优先级来控制执行序:
__attribute__((constructor(101))) void init_early() { printf("priority 101\n"); } __attribute__((constructor(200))) void init_late() { printf("priority 200\n"); }数值越小,优先级越高,越先执行。默认优先级是 65535,所以不写优先级时,它反而排在最后面。这个机制对应到 ELF 上,就是.init_array.00101、.init_array.00200这些带优先级后缀的分节,链接器会按优先级排序后再合并。
2.2 链接脚本里的那些魔法符号
自己写过裸机程序、或者折腾过链接脚本的人,大概率见过这几个符号:
__init_array_start __init_array_end它们由链接器脚本定义,标记.init_array的起始和结束地址。一个典型的链接脚本片段长这样:
.init_array : { __init_array_start = .; KEEP (*(SORT_BY_INIT_PRIORITY(.init_array.*))) KEEP (*(.init_array)) __init_array_end = .; }这里有三个关键点值得展开。
第一,SORT_BY_INIT_PRIORITY告诉链接器,带有优先级后缀的.init_array.*分节要按名字排序。排序规则就是前面说的:数字小的在前。
第二,KEEP是为了防止链接器的垃圾回收机制把这些数组项丢掉。如果你用-ffunction-sections -fdata-sections编译,再配合--gc-sections链接,.init_array里的函数若没有被显式引用,就可能被当作“垃圾”回收。KEEP就是专门拦住这种错误处理的。
第三,__init_array_start和__init_array_end不只是符号。libc 的启动代码或动态加载器遍历.init_array时,全靠这两个符号计算数组长度和首地址。如果你在链接脚本里漏了它们,后果就是找不到数组边界,程序启动时直接就崩了。
2.3 从目标文件到最终二进制
用一个具体例子看整体流程。写两个源文件:
// a.c #include <stdio.h> __attribute__((constructor(101))) void a_init() { puts("a_init"); }// b.c #include <stdio.h> __attribute__((constructor(200))) void b_init() { puts("b_init"); }// main.c int main() { return 0; }分别编译:
$ gcc -c -o a.o a.c $ gcc -c -o b.o b.c $ gcc -c -o main.o main.c $ gcc -o demo main.o a.o b.o在a.o和b.o里,各自有一个.init_array.00101和.init_array.00200分节。链接时,链接器按优先级顺序把它们合并进最终.init_array中:先a_init的地址,再b_init的地址。运行程序,输出顺序就是a_init先、b_init后。
这一点在实际工程里非常关键。不同静态库、不同编译单元里的初始化代码,最终执行顺序并不完全取决于谁先链接,而是优先看构造函数本身带的优先级。这也是为什么大型 C++ 项目里,跨模块的全局对象初始化顺序一直是个老大难问题。.init_array的排序机制,其实是一种规范化的“部分解药”。
3. 从 _start 到 main,.init_array 的执行链路
3.1 静态链接时 libc 做了什么
程序启动不是魔术。在静态链接的可执行文件里,.init_array的遍历由 libc 启动代码完成。简化后的调用链长这样:
_start -> __libc_start_main -> __libc_csu_init -> _init() -> 遍历 __init_array_start 到 __init_array_end -> main__libc_csu_init的逻辑可以概括成这段 C 伪代码:
void __libc_csu_init(int argc, char **argv, char **envp) { _init(); size_t size = __init_array_end - __init_array_start; for (size_t i = 0; i < size; i++) { void (*init_func)(void) = __init_array_start[i]; init_func(); } }注意这里有个细节:遍历数组时,每个函数指针是直接调用的,没有任何参数。虽然有些实现会传argc、argv、envp,但在 x86-64 调用约定下,目标函数如果是无参函数,多传的参数会被安全忽略。所以在.init_array里放一个无参函数,是最稳妥、最通用的做法。
3.2 动态链接器如何介入
动态链接的可执行文件不直接依赖 libc 去遍历。它的执行入口被“解释器”接管了。readelf -l可以看到类似:
INTERP 0x0000000000000318 0x0000000000400318 0x0000000000400318 0x000000000000001c 0x000000000000001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]这个/lib64/ld-linux-x86-64.so.2就是动态链接器。内核加载程序后,先把控制权交给它。动态链接器负责加载所有共享库、处理重定位,然后根据动态段里的DT_INIT_ARRAY和DT_INIT_ARRAYSZ,去遍历并调用初始化函数。
看动态段:
$ readelf -d demo输出中会出现:
0x0000000000000019 (INIT_ARRAY) 0x403e10 0x000000000000001b (INIT_ARRAYSZ) 16 (bytes) 0x000000000000000c (INIT) 0x401000DT_INIT_ARRAY指向数组的虚拟地址,DT_INIT_ARRAYSZ给出数组字节大小。动态链接器拿到这两个值后,用和 libc 差不多的循环把函数挨个执行。
一个值得留意的点是,动态链接器处理完主程序的.init_array之后,才会跳转到_start,进而进入main。如果某个构造函数执行出错,比如访问了未重定位的地址,程序可能在main还没开始的时候就崩溃了。这种崩溃往往很难排查,因为栈回溯里看不到你的业务代码。
3.3 动态库之间的初始化顺序
程序不是只靠主模块活着,它还依赖一堆共享库。每个共享库自己也有.init_array。动态链接器加载这些库时,会按依赖顺序执行它们的初始化函数。
来看一个具体场景。主程序main_app依赖libA.so,而libA.so又依赖libB.so。加载顺序是:先读主程序头部,发现DT_NEEDED里的libA.so,加载libA.so时又发现libB.so,先把libB.so加载完。初始化顺序则沿依赖树从叶子往根走:libB.so的.init_array先执行,libA.so次之,主程序最后。
这种顺序也解释了为什么有些库的__attribute__((constructor))函数明明写了,运行时却发现依赖库还没准备好。如果初始化代码里访问了另一个库里的全局符号,而那个库还在后面,程序就直接段错误了。
dlopen动态打开共享库时也一样。dlopen返回之前,库的.init_array已经被执行过;最后一次dlclose时,对应.fini_array会被执行。所以如果你看到某个库在dlopen之后立刻打印了一堆初始化日志,别惊讶,这就是它的构造函数在跑。
4. 动手实操:查看、修改与注入 .init_array
4.1 用现有工具快速定位关键信息
纸上谈兵不如直接上手。准备一个测试程序:
#include <stdio.h> __attribute__((constructor(100))) void early_init() { printf("early init\n"); } __attribute__((constructor(200))) void late_init() { printf("late init\n"); } int main() { printf("main\n"); return 0; }编译后,依次执行这几条命令:
$ gcc -o demo demo.c $ readelf -S demo | grep init $ readelf -x .init_array demo $ readelf -rW demo | grep init $ objdump -d demo | grep -A20 "<_init>"readelf -x .init_array能看到数组字节内容。如果编译默认开了 PIE,数组里的地址是相对的,直接看不太直观,可以加上-no-pie重新编译:
$ gcc -no-pie -o demo_nopie demo.c $ readelf -x .init_array demo_nopie这次你会看到两个 8 字节的地址,用objdump -d对照符号表,就能确认它们分别指向early_init和late_init。
这里顺带讲一个排查经验:如果你发现.init_array里的地址在readelf里显示为 0,或者所有函数指针长得一模一样,大概率是编译时开了 PIE,你看到的只是离基址的偏移量。分析时要么关掉 PIE,要么使用时把基址加上。
4.2 用 pyelftools 解析数组内容
当二进制很大、节很多时,用命令行工具一个个看效率太低。这时候更专业的做法是写脚本。pyelftools是 Python 生态里最顺手的 ELF 解析库。
下面这段代码能打印.init_array里每个函数指针对应的符号名:
import struct from elftools.elf.elffile import ELFFile from elftools.elf.sections import SymbolTableSection def get_symbol_name(elf, addr): for section in elf.iter_sections(): if isinstance(section, SymbolTableSection): for sym in section.iter_symbols(): if sym['st_value'] == addr and sym['st_info']['type'] == 'STT_FUNC': return sym.name return hex(addr) with open('demo_nopie', 'rb') as f: elf = ELFFile(f) init_sec = elf.get_section_by_name('.init_array') if init_sec is None: print('no .init_array') exit() data = init_sec.data() entry_size = 8 count = len(data) // entry_size for i in range(count): raw = data[i * entry_size : (i + 1) * entry_size] ptr = struct.unpack('<Q', raw)[0] print(f'[{i}] 0x{ptr:016x} -> {get_symbol_name(elf, ptr)}')脚本的思路很简单:取.init_array节的原始字节,每 8 字节解释成一个地址,然后去符号表里反查地址对应的函数名。
解析只是第一步,真正有意思的是修改。
4.3 给一个二进制手工注入启动回调
假设你拿到一个非 PIE、静态链接的测试程序(注意:只能在你自己编译的测试程序或明确授权的靶场环境里实验),想在它main之前调用一个自定义函数。不重新编译,直接改文件。步骤分四步。
第一步,找一个可以放自定义机器码的空闲位置。ELF 文件里各节之间常有对齐填充,比较常见的是.text节之后的填充区。用readelf -S查看各节偏移,寻找两节之间的空洞。
第二步,写一段极简的 x86-64 汇编,功能是打印一行字符然后返回:
section .text global my_hook my_hook: mov eax, 1 ; sys_write mov edi, 1 ; fd = stdout lea rsi, [rel msg] mov edx, len syscall ret msg: db "injected from init_array", 10 len equ $ - msg用 nasm 编译,objcopy提取字节,然后写进文件空洞。
第三步,修改.init_array里的指针。假设数组里原本只有一个函数指针,把它指向my_hook的地址。如果程序非 PIE,.init_array里的值就是绝对虚拟地址,你只需要把新函数在文件中的虚拟地址算出来写进去。
第四步,运行验证。如果一切顺利,程序会先打印自定义消息,再跑业务逻辑。
这种做法本质上就是在改写启动流程。很多安全研究中的启动持久化、自动化辅助工具,用的也是同一套思路。但我不建议在真实业务环境里这么干,一是破坏程序完整性,二是极易把文件改坏。我的建议是:用脚本解析和自动化工具辅助分析,修改操作只放在 CTF、靶场和自我研究场景里。
4.4 一个常见坑:RELRO 对 .init_array 的影响
RELRO(RELocation Read-Only)是现代 Linux 默认启用的一项安全机制,目的是在重定位完成后,把某些段改成只读,防止攻击者篡改指针。
.init_array恰好就属于 RELRO 保护的目标范围。程序启动时,动态链接器先完成重定位,然后立刻把包含.init_array的那个内存页设置成只读。这意味着你无法在运行时通过注入等手段修改.init_array内容。
但在文件层面修改不受影响,因为写的只是磁盘上的文件内容,运行时加载的已经是改好的数据。所以前面说的手工注入方案,只要在启动前改完文件,就没有问题。如果你想在运行时动态修改,那就要找mprotect或其他绕过手段,这属于另一套话题了。
5. 常见问题与排查技巧
5.1 问题速查表
把我在调试中遇到的典型问题整理成一张表,方便你对照:
| 现象 | 常见原因 | 处理思路 |
|---|---|---|
| 构造函数执行顺序不符合预期 | 优先级设置错误或未设置;链接脚本排序规则被改动 | 检查constructor(priority)数值,确认.init_array.*排序逻辑 |
加了__attribute__((constructor))却完全不执行 | 函数被--gc-sections回收;链接脚本里没KEEP | 查找链接脚本,确认.init_array有KEEP,或去掉--gc-sections |
__init_array_start未定义 | 链接脚本缺失 | 自定义链接脚本时补全.init_array起始符号 |
| 共享库构造函数跑在依赖库之前 | 依赖关系配置有问题,或库之间存在循环依赖 | 用ldd查看依赖顺序,调整库之间依赖结构 |
程序启动即崩溃,甚至没进main | 构造函数里访问了未重定位的地址或未初始化依赖 | 用 gdb 断在__libc_csu_init,单步排查 |
修改.init_array后程序不生效且不报错 | PIE 导致地址需要基址修正 | 分析时用-no-pie编译测试,或修正加载基址 |
5.2 怎么用调试器观察初始化过程
.init_array的执行阶段非常早。想在 gdb 里观察它,关键是把断点下对位置。
动态链接程序,可以断在_dl_init:
$ gdb ./demo (gdb) start (gdb) break _dl_init (gdb) run静态链接程序,可以断在__libc_csu_init:
(gdb) break __libc_csu_init (gdb) run更直接的办法是断在数组起始地址。先用readelf拿到__init_array_start的地址,然后在那个地址的每个指针上打断点比较麻烦,我常用的是给__libc_csu_init下断点,进去之后用 gdb 命令查看数组内容:
(gdb) p/x __init_array_start[0]@5这会打印从数组起点开始的 5 个条目,你就能对照函数指针判断执行顺序了。
5.3 安全场景下的观察清单
如果你在做安全分析、恶意代码检测相关工作,.init_array是必查的位置。很多持久化样本会把恶意逻辑藏在这里,因为它在main之前执行、天然具有高隐蔽性。
我在分析样本时,会重点看三件事。第一,.init_array的条目数量是否异常。正常程序通常有几十到几百个条目,如果一个简单程序里有上千个指针,值得怀疑。第二,指针是否指向了非常规区域,比如堆地址、栈地址、.data段内部,这往往说明指针被篡改。第三,数组里的函数是否有对应的可读名字,大量指向匿名地址的函数指针往往可疑。
这里必须强调:以上所有修改和分析手段,都只能用于你自己拥有或明确获得授权的系统。我自己只会在自研工具、学习实验和靶场挑战里折腾这些,绝不建议在未经允许的环境中尝试。
5.4 一个调了一晚上的真实教训
最后分享一个自己的经历。有一回我在排查一个项目的崩溃问题,表现是程序运行不到一秒就闪退,命令行没有任何输出,main里的日志一条都没打。一开始怀疑是入口参数问题,后来在 gdb 里看到_dl_init内部的一条调用崩了,栈里出现了一个__cxa_atexit相关的地址。
顺着地址查下去,发现是某个静态库里有一个全局 C++ 对象,它的构造函数调用了另一个还没完成重定位的函数指针。因为那个静态库的初始化顺序排在非常靠前的位置,执行它时其他依赖还没就绪,一调用就崩。
当时的解决方案是给这个全局对象构造函数加一个更低的优先级,让它晚点执行;同时在代码层面把依赖对象改成懒加载,避免构造函数阶段直接访问。这个坑给我的经验就是:.init_array里的函数不是随便写的,越早执行的函数,能依赖的东西就越少。往后我写插件、写嵌入式初始化代码时,都会本能地权衡一下:这段初始化真的需要那么早跑吗?能不能放到业务层再延迟初始化?
末尾再聊两句
.init_array的位置很隐蔽,但它几乎参与了每个 ELF 程序的“出生仪式”。搞懂它,你才算真正把“程序的起点”这个概念补完整了。如果你以后遇到程序莫名崩溃、全局对象构造顺序玄学、甚至分析可疑样本,不妨先打开 readelf,看看这个数组里到底站着哪些函数。大部分时候,答案早就写在那里了。