news 2026/10/22 3:04:08

深入理解ELF中的.init_array:程序启动前那段隐藏的执行链

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解ELF中的.init_array:程序启动前那段隐藏的执行链

可能很多人跑了几年程序,都没认真想过一个问题:在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) 0x401000

DT_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,看看这个数组里到底站着哪些函数。大部分时候,答案早就写在那里了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 3:03:54

Qt+MinGW编译PCL全攻略:依赖链、CMake配置与避坑指南

简介&#xff1a;这份资源面向需要在Windows平台使用Qt与MinGW工具链构建三维点云应用的C开发者&#xff0c;提供PCL&#xff08;Point Cloud Library&#xff09;及其全部依赖库的完整编译成果。PCL专注点云采集、预处理、特征提取与表面重建&#xff0c;Qt负责跨平台图形界面…

作者头像 李华
网站建设 2026/10/22 3:03:40

影院管理系统全栈实战:架构设计、并发锁座与部署避坑指南

1. 为什么做一个影院管理系统&#xff1f;终结选择困难症的完整实践如果你最近在准备毕业设计或实训项目&#xff0c;一定经历过这种选择困难&#xff1a;电商系统太烂大街&#xff0c;博客系统太简单&#xff0c;社交App又怕做不完。我当时的解决方案是做了一套影院管理系统&a…

作者头像 李华
网站建设 2026/10/22 3:02:38

K8s十年签证:掌握Kubernetes底层逻辑与工程实践

我最早听到“K8s 十年签证”这个说法&#xff0c;还是在一次技术聚会上。有位前辈开玩笑说&#xff0c;现在招人&#xff0c;有Kubernetes经验的人&#xff0c;相当于手里攥着一张有效期十年的“签证”&#xff0c;走到哪家公司都有人认。当时大家都笑了&#xff0c;但笑完仔细…

作者头像 李华
网站建设 2026/10/22 3:02:11

详解C++ new-handler机制

前言 new 失败时会发生什么&#xff1f;多数人的回答是「抛出 std::bad_alloc」。这只说对了一半。在抛出异常之前&#xff0c;operator new 会先调用一个由用户安装的新处理器&#xff08;new-handler&#xff09;&#xff0c;给它一次「抢救」的机会&#xff1a;释放缓存、把…

作者头像 李华
网站建设 2026/10/22 3:02:09

Debian Functions全解析:从维护脚本到自建函数库的融合实践

最近在折腾 Debian 服务器自动化的事&#xff0c;绕来绕去总绕不开“Debian Functions”这个词。可能有人觉得这就是个脚本函数集合&#xff0c;实际上它在 Debian 系系统里承载了两层含义&#xff1a;一层是打包维护脚本&#xff08;postinst、prerm 那些&#xff09;背后官方…

作者头像 李华
网站建设 2026/10/22 3:01:57

基于人脸识别的校园考勤请假选课系统设计与实践

做了几年JavaWeb方向的项目&#xff0c;我越来越觉得很多校园管理系统的问题不在功能少&#xff0c;而在“流程对不上现场”。就拿考勤来说&#xff0c;传统刷卡、点名、钉钉打卡都绕不开一个尴尬场景&#xff1a;学生完全可以“代打”&#xff0c;老师课后还要花时间核对表格。…

作者头像 李华