ELF
强符号与弱符号(本小节是转别人的)
我们经常在编程中碰到一种情况叫符号重复定义。多个目标文件中含有相同名字全局符号的定义,那么这些目标文件链接的时候将会出现符号重复定义的错误。比如我们在目标文件A和目标文件B都定义了一个全局整形变量global,并将它们都初始化,那么链接器将A和B进行链接时会报错:
1 b.o:(.data+0x0): multiple definition of `global‘
2 a.o:(.data+0x0): first defined here
这种符号的定义可以被称为强符号(Strong Symbol)。有些符号的定义可以被称为弱符号(Weak Symbol)。
对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。我们也可以通过GCC的"__attribute__((weak))"来定义任何一个强符号为弱符号。注意,强符号和弱符号都是针对定义来说的,不是针对符号的引用。比如我们有下面这段程序:
extern int ext;
int weak;
int strong = 1;
__attribute__((weak)) weak2 = 2;
int main()
{
return 0;
}
上面这段程序中,"weak"和"weak2"是弱符号,"strong"和"main"是强符号,而"ext"既非强符号也非弱符号,因为它是一个外部变量的引用。
针对强弱符号的概念,链接器就会按如下规则处理与选择被多次定义的全局符号:
· 规则1:不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号);如果有多个强符号定义,则链接器报符号重复定义错误。
· 规则2:如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
· 规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。比如目标文件A定义全局变量global为int型,占4个字节;目标文件B定义global为double型,占8个字节,那么目标文件A和B链接后,符号global占8个字节(尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)。
弱引用和强引用
目前我们所看到的对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference)。与之相对应还有一种弱引用(Weak Reference),在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。链接器处理强引用和弱引用的过程几乎一样,只是对于未定义的弱引用,链接器不认为它是一个错误。一般对于未定义的弱引用,链接器默认其为0,或者是一个特殊的值,以便于程序代码能够识别。
在GCC中,我们可以通过使用"__attribute__((weakref))"这个扩展关键字来声明对一个外部函数的引用为弱引用,比如下面这段代码:
1 __attribute__ ((weakref)) void foo();
2 int main()
3 {
4 foo();
5 }
6
我们可以将它编译成一个可执行文件,GCC并不会报链接错误。但是当我们运行这个可执行文件时,会发生运行错误。因为当main函数试图调用foo函数时,foo函数的地址为0,于是发生了非法地址访问的错误。一个改进的例子是:
1 __attribute__ ((weakref)) void foo();
2 int main()
3 {
4 if (foo)
5 foo();
6 }
7
这种弱符号和弱引用对于库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数;或者程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用;如果我们去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合。
在Linux程序的设计中,如果一个程序被设计成可以支持单线程或多线程的模式,就可以通过弱引用的方法来判断当前的程序是链接到了单线程的Glibc库还是多线程的Glibc库(是否在编译时有-lpthread选项),从而执行单线程版本的程序或多线程版本的程序。我们可以在程序中定义一个pthread_create函数的弱引用,然后程序在运行时动态判断是否链接到pthread库从而决定执行多线程版本还是单线程版本。
Segment和section
Elf文件里面有segment,有section。我们知道执行的时候,所有的应用程序都是首先通过加载器ld.so加载到内存然后执行的(内部集成了加载器的除外),而所有的elf文件也都是经过了链接的过程形成的。Segment就是提供信息给ld.so这个加载器信息,告诉他怎么加载的,而section就是提供给ld程序,告诉他怎么链接的。其实section本质上就是个记录的作用,elf没有它也完全可以正常工作,因为segment已经提供了工作所需要的信息。但是section是ld链接的时候的工作过程,记录了将不同的内容放到不同的文件位置的分布,section表就是这个分布的一个总体描述。section的最大意义在于让elf有语义的意义,没有section只有segment的话,elf就只是个可以执行的文件,别人没办法分析他的组成,分析他的二进制格式。缺少了二进制层次上的语义,也正因为反正都决定要在二进制文件中保留section了,那么有的segment也就可以用section来组织。毕竟可执行文件的物理组装是通过section完成的,而segment也要告诉ld.so怎么使用这个物理文件,所以两者发生交互就会方便很多。而现在的很多section在运行期间也是有用的,例如.text, .got可以用来找到动态库的符号。
连接器常见的c级是gnu linker,默认是集成在gcc里面的,但是谷歌觉得它太慢,又开发了一个新的链接器:gold。Gnu linker使用的bfd库,而gold只是部分的支持elf的全部特性,去除了谷歌认为不需要的,并且没有使用bfd库,所以可以做到很快。Gold现在也被加入到binutilies包里面了。
生成时从首到尾写,生成完写入section table。而运行是从头到尾读,所以segment table要放在开头。
不固定位置编译
ASLR可以把动态库加载到随机的内存地址,这样就可以增加攻击者的调试难度。但是可执行文件自己在大部分情况却是有固定的开始执行地址的,这就给攻击者提供了方便。但是仍然有办法让这个地址随机,就是PIE(Position Independent Executable),这个可以把二进制编译成位置无关的文件,而是由内核来完成这个位置无关的随机化过程。所以这个特性需要内核支持。而还有一个需求是要求位置无关的,就是动态库和.o这种编译生成的中间代码,这几种所用的技术和思想都是类似的。
如果我们使用-fpic参数,就可以生成位置无关的动态库,而如果我们使用-fpie参数,就可以生成位置无关的可执行文件。这两者在使用上的差别很大,一个是用来给别人加载的,一个是用来直接执行的,但是这两者是技术上差别很小,有两个主要的差别:-fpic生成的文件加上一个PT_INTERP段和一些启动代码,就比较像-fpie生成位置无关进程。而两者甚至可以用同样的启动代码。-fpic由于用来生成动态链接库,所以符号不能直接解析到找到的符号,甚至可以允许找不到符号,动态库本身允许引用外部的库,所以在编译自己的时候不需要链接外部的库,只需要把它使用到的外部的库函数放入PLT表,链接的时候或者加载的时候解析就好,而可执行程序要求所有的符号立即解析,并且不允许有解析不了的符号。
上面的程序是非pie的,可以看到所有的地址都是绝对地址,第一个LOAD指明在二进制文件的开头到0x16f88字节要加载到内存的0x08048000地址,而二进制文件偏移的0x016f88往后的0x01543字节要加载到内存的0x0805ff88位置。所以,这是一个位置固定的可执行程序。
动态库
动态库的核心包含了两个层次的代码共享:编译成的二进制可以不用每个二进制文件都包含一份动态库的拷贝;在执行的时候,动态库的代码段只需要加载一次,后面再有人用到同一个动态库,内核就可以把动态库代码段加载到的页面直接映射到其他需要的进程,这样代码段也不用加载多次(代码段是只读的)。
而加载到内存,由于CPU执行的时候必须要使用相对或者绝对地址,代码段的每个函数虽然在物理地址是一样的,但是他们映射到每个进程内存空间的地址都是不一样的。所以动态库需要有一份符号表,记录了其在代码段的偏移,然后还需要一个全局偏移,意味着其整个符号表在进程地址空间中的偏移。
而一个使用了动态库的可执行文件,其内部有调用这些符号的代码,这种代码是无法在链接期解析出具体的地址偏移的(因为链接器根本没有实际的链接他们),所以他们在二进制文件中只是一个占位符,其地址需要在动态库加载了之后再填充。而这种调用是分散在整个程序中的,所以在加载后就得去搜索找到所有的未解析符号去解析,这样肯定是不合适的,所以就需要有一个表,记录了所有这些没有解析的符号。
动态库在内存中只要执行到任何一行动态库的代码,动态库就可以通过偏移找到本库内的其他符号,因为同一个库的符号偏移,本库内都是知道的。但是可惜的是i386不支持通过当前执行指令(PC)偏移的寻址方式(如果支持就简单了,根本什么重分配都不需要,只需要在执行到的代码中使用偏移就好了)。但是x64是支持的,所以elf在x64时代慢慢的可能要有点变化。
上面分别说了可执行文件和动态库的需求,两者的衔接就是通过.got段和.plt段.got是数据外部解析,.plt是函数外部解析。Elf文件链接完成,将调用动态库的符号放到这两个表里,当动态库加载的时候,加载器要负责查找这个表,将加载的动态库的对应的符号所在内存的地址填充到可执行文件的这两个表,如此完成加载时候的符号绑定。同时解决了动态库位置不固定的问题。
可执行栈
Gcc对c支持一个扩展,就是函数的嵌套定义。而这个定义是通过将嵌套的函数代码放在栈中执行的,这就要求这种栈是有可执行权限的。而如果代码中没有使用这个功能,栈的可执行权限就会开启,所以如果代码的安全性要求比较高,就不要使用嵌套函数。
Elf构造函数与析构函数
编程语言的main函数是程序逻辑的开始,但是一个程序只有逻辑是无法运行的,程序的开头部分需要有一些在main函数执行之前执行的函数,结束的时候也需要有一些在程序逻辑执行完之后执行的内容。比如全局变量的初始化和回收。
这个扩展是gcc添加的(由于加载器也是gcc自带的,所以它认识gcc自己添加的扩展),这是通过在elf文件中添加一个段来实现的:.ctors和.dtors(一个构造,一个是析构)。这两个段里面放的是函数列表,启动和结束的时候会顺序调用。至于里面是什么,那就跟不同的变成语言相关了,有的甚至可以做到不使用这两个段。
除了.ctors和.dtors,完成同样功能的还有.init和.finit两个段,有的系统两种都支持,有的只支持一种。如果两种都支持.init会在.ctors执行之前执行。.init和.finit比较老,格式组织也比较混乱,而.ctors和.dtors只单纯的函数表,在使用上.ctors系列是.init系类的后继改良版本,但是.init系类仍然是elf的标准结构。
函数调用栈
X86与x64提供了栈的寄存器指针,但是并不规定怎么使用这个栈,例如参数入栈的先后顺序,返回值放在哪里,两个调用之间是否要空点空间。
在x86时代,常用的调用栈有:stdcall, thiscall, fastcall, cdecl,这几种在对栈的使用上有区别。在x64时代,只剩下fastcall一种。例如stdcall的调用约定意味着:1)参数从右向左压入堆栈,2)函数自身修改堆栈 3)函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸。Stdcall因为早期用在pascal有此殊荣。c的默认是cdel,cdecl调用约定的参数压栈顺序是和stdcall是一样的,参数首先由右向左压入堆栈。所不同的是,函数本身不清理堆栈,调用者负责清理堆栈。由于这种变化,C调用约定允许函数的参数的个数是不固定的,这也是C语言的一大特色。thiscall是为了解决面向对象的函数调用要默认传输this指针,所以是C++的默认调用方式,参数从右向左入栈。
而fastcall使用寄存器来传递参数,因为在x64环境,寄存器很多,所以规定了fastcall的前4个整数和浮点都放入寄存器中,超过的部分才放入栈中。所以,使用fastcall可以显著的加快调用速度。也是因此,在写代码的时候,尽量使用4个以下的函数参数。fastcall也保留了cdel的灵活,由调用者清理栈,所以也可以做到参数不固定。但是你看你的栈可能会发现有一块额外的空间,x64会默认的在站上分配一个备份空间,用来core dump分析的时候方便。这个空间保存了每次发生函数调用的寄存器情况。如果你开了编译器优化,这个空间一般就不会保留了。