
一直有个细节我每次做ELF二进制审查都会提醒自己当一个程序真正跑起来的时候main其实已经是“下半场”了。真正的用户代码起点往往藏在.init_array这样一个不起眼的节里。作为ELF文件系列的第四十三篇这篇不聊那些常客专门把.init_array从头到尾讲透。它解决的问题很朴素在main被调用之前有些初始化必须要完成比如C静态对象的构造、注册清理函数、某些库的启动前置逻辑。把这些任务打包成一组函数指针按顺序放进一个数组再交给加载器依次调用就是.init_array的核心设计动机。适合读这篇的除了还在啃ELF格式的初学者还有做逆向分析、崩溃定位和启动性能优化的人。读完你至少能回答三个问题怎么在ELF里找到它动态链接器拿它做了什么以及为什么一个看似普通的数据节会在整个加载链路里反复被提及。1. 为什么说.init_array是程序真正的起跑线1.1 main之前的初始化链条很多开发者第一次接触ELF时都有一个直觉程序入口就是main。这个直觉在应用层是对的但在二进制层面错得离谱。一个典型动态链接的可执行文件从内核开始执行到最后调用main中间隔着一条很长的初始化链路。先从宏观顺序说起内核把ELF文件映射进内存之后如果这是一个动态链接程序入口并不是程序自己的代码而是ld.so——也就是动态链接器。ld.so负责加载依赖库、完成重定位、做符号解析然后才把控制权交给可执行文件自己的入口点。这个入口点通常由C运行时库的启动代码构成也就是_start随后调用__libc_start_main。在这个函数内部的某个阶段就会触发.init_array的遍历。可以理解为内核接棒给ld.sold.so加载依赖并完成重定位ld.so调用程序入口_start_start进入__libc_start_main初始化运行时环境遍历.init_array调用里面的每个构造函数最后才是main。也就是说你在.init_array里放一个函数它会在main之前执行。这听起来只是顺序问题但在实际工程中这个顺序往往是很多诡异问题的根源。1.2 三个初始化兄弟节点的分工.init、.init_array、.fini_arrayELF里和初始化直接相关的节不止一个。很多人把它们混在一起这很正常因为它们长得太像了。但分工其实相当明确节名类型本质调用时机.initSHT_PROGBITS单入口函数指针最早由ld.so或启动代码直接调用.init_arraySHT_INIT_ARRAY函数指针数组紧随.init之后按数组顺序依次调用.fini_arraySHT_FINI_ARRAY函数指针数组程序退出阶段按数组顺序调用.init在传统工具链里更像一个“代号”它本身是一个入口函数不是数组。你通常会在反汇编里看到_init这个符号它内部会调用__gmon_start__之类的系统初始化钩子然后跳到.init_array的遍历逻辑。新的工具链已经把越来越多的初始化工作移入.init_array因为数组天然支持多个构造函数、有序调用、动态重定位比单一入口函数灵活得多。.fini_array是清理函数的数组对应C的析构和atexit逻辑和.init_array形成一组镜像关系。理解了.init_array.fini_array基本就懂了一半它们的机制是同一个模子刻出来的。2. 在二进制里定位.init_array从节头到动态标签的完整走查2.1 节头表里的身份信息在ELF文件里定位一个节第一步永远是看节头表。一个典型的.init_array节头会长这样[ 37] .init_array INIT_ARRAY 0000000000403de0 003de0 000008 00 WA 0 0 8这里的几个字段值得逐个拆开看。节类型是INIT_ARRAY对应枚举值14。节标志是WA也就是SHF_WRITE和SHF_ALLOC。SHF_ALLOC表示这个节在运行时会被映射进内存SHF_WRITE表示它在运行时可写。这两个标志组合起来说明了.init_array的本质它是一块位于内存映像中的数据而不是可执行代码。节大小只有8字节说明这个数组当前只有一个元素。地址对齐是8字节对应64位系统的指针长度。SHF_WRITE这个细节容易被忽略但它其实很重要。.init_array里的每一项都是函数指针在程序加载阶段动态链接器可能会通过重定位对这些指针进行修正。明明看似是“只读”的初始化数据却需要运行时写入权限正是这个原因。2.2 动态链接器为什么只看DT_INIT_ARRAY标签节头表是给工具链和调试器看的真正在加载时发挥作用的是动态段PT_DYNAMIC里的标签。动态链接器并不会扫描整个文件去找.init_array节它只信任动态段里的一对标签DT_INIT_ARRAY和DT_INIT_ARRAYSZ。用readelf -d可以清楚地看到它们0x0000000000000019 (INIT_ARRAY) 0x403de0 0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)DT_INIT_ARRAY存的是.init_array在进程虚拟地址空间中的位置DT_INIT_ARRAYSZ存的是数组总字节数。把INIT_ARRAYSZ除以8或者除以4就能得到数组项数具体看目标架构。这里的机制有点像开会时的点名册。节头表相当于会议室墙上贴的人员分布图方便你找人动态段则是一份打印好的点名册主持人动态链接器只拿着点名册操作不会去研究墙上的分布图。你甚至可以把节头表全部strip掉只要动态段还在程序照样能跑.init_array也照样会被执行。所以在实际二进制分析中我习惯于同时看两个地方readelf -SW看节的位置和大小readelf -d看动态标签。两者互相印证偶尔会出现节头表被修改、动态段计数对不上的情况那基本就是有东西在刻意隐藏启动逻辑了。2.3 重定位的伪装你看到的也许不是最终函数地址这里有一个当初让我困惑很久的现象在一个PIE或动态库里直接用objdump -s -j .init_array去dump数组内容看到的数值看起来并不像正常的函数地址。原因在于.init_array本身是需要参与重定位的。对于非PIE可执行文件链接器可以把最终绝对地址直接写在数组里但对于PIE和动态库文件加载的基址在运行时才确定数组项在一开始只是“相对于基址的偏移”甚至是一段占位值。真实地址要等动态链接器执行重定位之后才会被填进数组。用readelf -r可以看到对应的重定位项00000000403de0 000000000008 R_X86_64_RELATIVE 400620这个R_X86_64_RELATIVE表示把当前模块基址加上0x400620写入偏移0x403de0处的这个数组项。做完这一步.init_array里才真正有了一个可调用的函数地址。这就是为什么在做静态审查时光看.init_array的原始内容是远远不够的。要看“运行时最终生效”的函数列表必须结合重定位表或者直接用调试器在加载完成后去读内存。很多新手在这里栽了跟头以为自己拿到了函数地址列表结果那只是一堆待修正的偏移。3. 动态链接器如何把一截数据变成一长串函数调用3.1 从入口到call_init的调用逻辑动态链接器找到.init_array之后怎么把一排地址变成实实在在的函数调用在glibc的实现中初始化序列会走到一个叫call_init的逻辑。用伪代码说核心逻辑极其简单size_t n DT_INIT_ARRAYSZ / sizeof(void *); for (size_t i 0; i n; i) init_array[i](argc, argv, envp);你没看错就是循环逐个调用。动态链接器会把启动时拿到的argc、argv、envp依次传给.init_array里的每个函数。这个传参约定非常容易被忽略如果你把.init_array项声明成一个无参函数完全没问题ABI上多余参数会被被调函数忽略但如果你想早期获取环境信息完全可以把它声明成这样的形式__attribute__((constructor)) void early_init(int argc, char **argv, char **envp) { // argc、argv、envp 在这里是可用的 }至于先执行.init还是先执行.init_array顺序是先.init后.init_array。传统工具链里.init内部会做一部分旧式构造工作然后才轮到数组里的新式构造函数。整体来看ld.so对每个加载模块都会做一遍这个流程只是顺序有讲究先初始化依赖库再初始化本模块。对动态库而言.init_array的执行时机是dlopen进来的那一刻。也就是说.so文件一旦被加载它的构造函数立刻就会跑这比符号解析都早一步。3.2 优先级与排序constructor属性背后是节排序规则__attribute__((constructor))可以直接把一个普通函数变成.init_array项这大概是绝大多数人接触这个节最常用的入口。但真正控制顺序的不是这个属性本身而是链接期的节排序规则。GCC和Clang在处理带优先级的构造函数时会生成带后缀的节名。比如__attribute__((constructor(101))) void init_a(void) { } __attribute__((constructor(102))) void init_b(void) { }这两个函数不会出现在同一个.init_array节里而是分别出现在.init_array.00101和.init_array.00102这样的独立节中。链接脚本在合并这些节时会对.init_array.*按节名排序数字小的节先合并。于是101优先级的函数就会排在102优先级的前面。这个规则的底层逻辑是把“代码层面的优先级”翻译成了“链接器兵不血刃的排序问题”。代价是优先级是有限制的GCC文档明确建议0到100保留给系统运行时使用普通用户从101开始用。如果你写了一个constructor(1)它不是不能编译而是可能和系统初始化函数撞在一起顺序完全不可控。优先级数值越小越先执行 0-100 系统保留区间不要用 101-65535 用户可用区间同一优先级下多个构造函数的相对顺序并不由源码书写顺序保证而是看链接器当时收到输入文件的顺序。这一点在跨编译单元时尤其明显后面踩坑记录里我会专门展开。3.3 边界情况空数组、静态链接和.fini_array镜像有些程序会存在一个“空”的.init_array节节头正常、动态标签也有但数组长度为零。这在动态链接器看来完全合法循环体直接跳过不值得奇怪。静态链接的程序则完全是另一套逻辑。静态链接没有ld.so参与.init_array的遍历逻辑被编译进C运行时启动代码里实际在__libc_start_main内部的初始化路径中完成。虽然调用者也变了但数组本身的结构和排序规则没有变。.fini_array的出现频率通常比.init_array低很多但机制完全对应。动态链接器在模块卸载或进程退出阶段会按数组顺序遍历.fini_array。注意这里的顺序方向析构数组也是从头到尾正序调用的和C析构顺序的“后构造先析构”并不天然一致C编译器内部通过额外机制保证语言语义底层不会帮你自动反转数组顺序。4. 动手实验让函数抢在main之前跑起来4.1 最小构造C属性与汇编两种写法理解了理论不如亲手做一个最小验证程序。先看最常规的C写法#include stdio.h __attribute__((constructor)) static void early_init(void) { puts(early init ran); } int main(void) { puts(main ran); return 0; }编译后运行会得到early init ran main ran如果你想让这层机制更“可见”可以绕开编译器的属性直接在汇编里把函数地址塞进.init_array.section .txt,ax .globl my_init my_init: lea msg(%rip), %rdi call puts ret .section .init_array,aw .quad my_init .section .data msg: .asciz asm init ran两种写法最终都会生成同样的结构一个位于.init_array中的函数指针项指向一个构造函数。4.2 用readelf和gdb验证调用时机编译之后先用readelf -S确认节头信息再用readelf -d确认动态标签最后用readelf -x .init_array直接dump节内容hex dump of section .init_array: 0x003de0 4006200000 000000这里0x400620正是early_init的地址非PIE场景下。为了真正看到“谁调用了它”可以用gdb在early_init上下断点gdb a.out (gdb) break early_init (gdb) run (gdb) backtrace断点命中后查看调用栈你会看到栈底不是main而是ld.so内部的一长串加载逻辑。这个瞬间对理解整个ELF程序如何启动特别有帮助相当于亲眼看到了自己在main之前那条隐藏路径。4.3 顺序控制的正确做法和翻车示例现在构造三个构造函数全部加优先级__attribute__((constructor(101))) void first(void) { puts(first); } __attribute__((constructor(102))) void second(void) { puts(second); } __attribute__((constructor(103))) void third(void) { puts(third); }运行结果稳定输出first、second、third与预期一致。可一旦你把某两个函数的优先级设成同一个值比如都设成101__attribute__((constructor(101))) void a(void) { puts(A); } __attribute__((constructor(101))) void b(void) { puts(B); }你可能会以为源码里A在前就A先跑其实不一定。同一优先级下两个函数会进入同一个合并后的.init_array节节内顺序在合并阶段取决于对象的输入顺序。在同一个源文件里通常还能保住声明顺序跨源文件时改一下链接顺序就能看到输出翻转。所以我的建议是不要把同优先级的顺序当作语言语义来依赖真要严格排序就把优先级错开。5. 逆向与安全视角init_array里究竟藏了什么5.1 一条命令列出所有提前运行的函数从逆向角度.init_array是最该第一批看的节因为它往往包含程序加载后最早执行的用户逻辑。很多程序会把字符串解密、环境自检、反调试开关全都塞在这里。实际操作流程并不复杂# 1. 查看动态标签确定数组地址和大小 readelf -d a.out | grep -E INIT_ARRAY|INIT_ARRAYSZ # 2. dump数组内容 readelf -x .init_array a.out # 3. 在.text段里反汇编这些地址 objdump -d --start-address0x400620 --stop-address0x400650 a.out有了函数地址之后直接去.text段定位代码能非常快理解程序“启动即执行”的逻辑。配合nm或者readelf -s还能看出里面有多少个函数有符号、多少个函数是无名函数。这里有个实用技巧如果一个函数在.init_array里被引用但在符号表里完全没有名字它往往值得格外注意。正常编译产物里的构造函数大多有符号或至少能从DWARF信息里找到来源完全匿名的函数地址出现在.init_array中大概率是手工注入或者做过了混淆处理。5.2 防御与检测视角下重点观察的地方从安全防御角度.init_array是检测恶意行为的重点区域。原因很直接恶意逻辑放到main之后执行会被调试器跟踪、会被行为监控捕获放到main之前却常有更少的保护。于是大量样本在.init_array里完成调试器检测、调用栈校验、加密Payload加载。检测方也会把这一个数组当作“早期可疑逻辑”的集中排查点。作为普通开发者的防御习惯至少可以做的有两件事。第一在自研程序中利用.init_array做启动自检比如校验关键函数的完整性、检测调试器是否附着一旦发现异常程序在main之前就可以拒绝继续运行。这比把自检逻辑写在main开头要更早一步。第二代码审查时养成看.init_array的习惯。如果代码仓库里没有对应的构造函数源码但最终的二进制里却多出了.init_array项那就要警惕是不是构建链被污染了。我处理过几次“现场和源码对不上”的诡异问题最后根源都在构建产物被塞了不该有的初始化钩子。需要强调的是这些讨论全部停留在防御和自查的视角。理解机制本身没有善恶关键是把它用在对程序可靠性和安全性的提升上。6. 踩坑记录我在这篇文章主题上栽过的几个跟头6.1 static构造被优化掉数组里少了一项第一次遇到这个问题是在一个优化等级拉满的编译环境下。代码里写了不少static修饰的构造函数当时我默认它们肯定会在.init_array里。结果用readelf一数数组项数量明显少于源码里的构造函数数量有几个函数直接“消失”了。原因是如果构造函数是static的没有任何外部引用编译器在特定优化等级下会认为它是无用代码整段移除。即便它有constructor属性也并不总是能保住这条命。解决方案是给函数加__attribute__((used))显式告诉编译器这个东西虽然看起来没被引用但请务必保留。__attribute__((used)) __attribute__((constructor)) static void keep_me(void) { }从那以后我养成了一个习惯凡是靠.init_array机制生效的函数不管是不是static都习惯性地加used宁多勿缺。6.2 同优先级函数的顺序和源码声明完全不一致另一个让我头疼的问题是同优先级构造函数的顺序。在大型工程里不同源文件各自声明了一个constructor(101)的初始化函数代码里明明想让A模块先初始化结果运行日志总是先打出B模块的内容。排查到最后发现顺序取决于链接器合并.init_array.00101这类节时的输入文件排序。改动Makefile里的链接对象顺序初始化顺序就跟着变了。这个行为可能在不同链接器、不同版本之间有差异但共同点是它不受源码顺序保证。这个坑的解决方案很简单——不要在同一个优先级下依赖顺序。如果模块之间存在严格初始化依赖就把优先级错开让依赖方拿一个更小的数值。数值越小越先执行这是可靠且跨工具的约定。6.3 动态库自己的.init_array跑在了依赖库前面第三个坑和动态库的依赖有关。当时我写了一个模块库A它在.init_array里需要调用另一个基础库B的初始化接口。编译链接都正常运行却偶发崩溃而且只在特定加载路径下出现。排查时用LD_DEBUGlibs,init跑了一遍启动流程日志清晰显示库A的初始化动作先于库B的初始化发生。原因在于加载顺序和依赖声明的关系比我想象中复杂链接时依赖信息不完整或者通过dlopen手动加载时加载顺序过于随意都可能打破“先依赖后自己”的默认秩序。从那以后凡是在.init_array里需要跨库调用我都会在构造函数里做一个显式的版本检查或者接口可用性检查而不是假设依赖一定已经就绪。动态链接器并不保证在复杂加载路径下所有依赖都已完成初始化盲信任这个前提迟早会被安排一次凌晨三点的紧急排查。最后再分享一点我个人的习惯每次翻一个陌生二进制我总是先看.init_array然后才去看main。原因很简单main是明牌真正需要琢磨的早期逻辑往往藏在数组里。建议你也写一个十几行的小程序把这篇文章里的命令逐个跑一遍。你会发现当ELF从“格式”变成“地图”的那一瞬间很多之前觉得玄乎的启动行为一下子就说得通了。