ret2dl_resolve
Link & Load
对于一个使用gnu套件编译的elf文件,我们回顾一下它的文件结构
ELF头
首先使用readelf -h命令读出它的elf头, 其中除了开头的magic number外, 还有两个字段特别值得关注, 即start of program headers和start of section headers, 程序头起始地址及节头起始地址, 这两个地址分别指向文件中的段头表和节头表。

程序头,也称段头(segment),是给loader加载器以及interpreter解释器使用,代表着进程运行时的内存布局。
而节头(section),是给linker链接器在文件链接阶段使用,代表程序的文件结构布局,在load过程中并不会被加载到内存。
根据elf头信息,就能得到一个标准elf文件的文件结构,如图所示:

首先是文件开头部分的elf头,紧接着elf头的就是段头,然后是各个节section,最后文件以节头表结尾
节头表
对于文件的各个section节,我们可以通过readelf -S命令读出, 其中有几个值得关注的节:

首先是.interp节,该节的内容只有一个字符串,即解释器的路径,也就是动态链接器,表示后续内容需要使用该解释器进行动态链接。
其次是.bss节,有时因为翻译的问题提到bss也会用段来表示,但在这里,我们严格区分段segment和节的section的区别。
bss,一种解释是best save space,即更好地节省空间,用来存放未初始化或初始化为0的全局变量。正因如此,.bss节实际上是没有内容的,在静态文件结构中,并不会为其分配空间,只有当程序被加载进内存时,才会开始为其分配若干个页大小的内存空间。
在.bss节的后面还有几个节,用来存放调试信息或静态链接信息(用于在链接过程使用),这些节包括最后的节头表,在程序加载时都会被抛弃,并不会加载到内存中。
段头表
现在我们来看一下程序的加载过程。
刚才提到,加载器loader通过且仅通过段头表来加载程序,通过readelf -l命令可以读出程序的段头表:

段规定了程序的每一部分加载进内存的位置以及读写权限,一个段包含若干个连续的节,加载器将这些节作为整体打包进内存并设置权限。关于节和段的映射关系readelf在给出段头表的同时也会给出。

段有不同的类型,而只有LOAD段才会有实际加载的动作,其他段都是在LOAD段的基础上对其进行覆盖,用来设置段信息或额外权限,四个LOAD段会将刚才说的除.bss节下面部分的其余全部文件内容依次加载到内存中。
首先是第一个load段,该段权限为只读,包含程序加载以及动态链接所需的全部信息。
第二个load段,包含程序中所有可执行代码,因为该段的权限为可读可执行, 所以也称为代码段,注意与.text代码节的区分。
然后是第三个load段,权限为只读,即只读数据段,保存程序中的只读数据, 如字符串常量及异常中断表等。
最后一个load段为数据段,权限为可读可写,保存如got表、.data节以及.bss节等可读可写数据。
其他段包括段头表段和解释器段刚才已经提过了。
最后还有两个特殊的段,stack段和RELRO(reloacate readonly)段。
首先是stack段,它并没有加载位置以及段大小,因为此时还没有开辟栈空间,但它的作用是为栈设置权限,当权限设置为RW时,栈就只有可读可写权限,并没有可执行权限,即我们常说的栈不可执行NX,checksec命令也正是根据这一段的信息进行输出的。
最后是重定位只读段,它是对第四个LOAD段数据段的覆盖,将覆盖到的部分权限由可读可写设置为只读,用以重定位保护,当它覆盖的大小为0时,即No RELRO,当其覆盖部分为从数据段开始到got表的第三项时,即为右边看到的Partial RELRO,即部分重定位只读,当覆盖范围包含到整个got表时,即为full RELRO。

PLT & GOT
当程序需要链接动态库,即存在INTERP段时,就无法在load阶段甚至动态链接阶段前得知诸如动态链接函数的具体地址。而解析动态链接符号是一个相对比较耗时的工作,因此,为提高加载效率,在一般的动态链接过程中,会将符号解析推迟到第一次使用该符号时进行,即延迟绑定(Lazy binding)。
延迟绑定需要两个额外的section,首先是位于RW LOAD段的.got节,即global offset table, 全局偏移表。实际上, .got节还分为两个子节,分别为数据表和过程表,而我们在延迟绑定中只关注过程表, 因此后续所说的got表均指的是.got.plt节的内容。got表是一个8字节数组,其中前三项分别保存着.dynamic节起始地址、link_map结构体链表头以及_dl_runtime_resolve解析函数,从第四项开始,依次保存着需要动态链接的函数的地址,但由于是延迟绑定,这个地址只有在解析函数解析后才会被写回got表,在加载阶段,这里存放的是其他地址值。

接着是位于RE LOAD段的.plt节,即procedure linkage table, 过程链接表, 保存动态链接函数桩代码以及解析器桩代码。.plt也分为两个子节,其中.plt.sec子节的每一项都是一个只有一条jmp指令的plt桩代码,与got表中待解析函数的每一项一一对应,jmp的目的地址即为got表中存放的地址。而.plt子节的每一项也是一段桩代码,包含一条push指令(用于传递参数), 以及一条jmp指令,除第一项外,其余每一项的jmp指令都是跳转到plt[0], 而plt[0]则是跳转到got表中存放的_dl_runtime_resolve解析函数。而got表中解析函数的每一项最开始存放的正是plt表中对应的每一项。

此时,如果在代码节.text执行过程中需要调用一个动态链接函数,实际上会先跳转到.plt.sec节对应的桩函数。桩函数取出got表中的地址作为跳转目的地址,而由于开始时got表存放的并不是动态链接函数的实际地址,而是.plt节对应的表项,因此实际上就会发生控制流从.plt.sec节的对应项到.plt对应项的跳转。而每一项plt备用桩都会在push一个index参数后跳转到plt[0]项解析器桩,解析器桩在push另外一个参数,即got[1]中的link_map结构体后,从got[2]中取出_dl_runtime_resolve解释函数地址并跳转过去。至此,完成控制流从elf文件到libc的交接,_dl_runtime_resolve在完成动态解析后,将解析后的地址写回got表中, 此即plt与got表实现动态函数解析的过程。

而后续调用就简单许多: 在代码节中出现一个call指令,先跳转到.plt.sec节的对应项的桩函数,其中的jmp指令从got表中取出地址,该地址已经被解析为动态链接函数的地址,因此直接就跳到该函数上,而不会再向上解析。

ret2dl_resolve
要理解ret2dl_resolve的攻击原理, 我们还需要再来了解一下解析器函数的具体实现。在此之前,我们先来看一些特殊的节上存储的表。
首先是动态链接表,其中保存着其他各种动态链接过程所需要的表的起始地址。

然后是动态链接字符串表,保存着动态链接符号对应的字符串。

接着是动态链接符号表,其中保存着每个符号的字符串在字符串表中的偏移。

最后是.rela节,我们只关注其中的过程动态链接重定位表,其中每个表项的r_offset字段是一个指向got表对应表项的指针,而r_info字段保存着该项在符号表对应的符号索引,知道了该索引,就可以进一步找到字符串表中的符号字符串。该表的每个表项都与got表以及符号表的表项一一对应。回想一下,每个动态函数的解析过程都在plt部分的桩代码push过两次参数,第一次是每个动态链接函数对应的自己的桩代码,push了一个index,这个index即为.rela.plt表的索引值。

而第二个参数,是所有动态链接函数均会执行的plt[0]上的解析器桩所push的link_map链表。link_map是一个结构体,每个加载的ELF模块(包括主程序和.so文件)都对应一个link_map结构,它们以双向链表的形式链接起来,链表头即为主程序的link_map。其中的l_ld字段保存着模块中动态链接表的起始地址。

在将这两个参数压入栈中后,plt就开始从主程序到libc的跳转,取出got[2]中的地址,将控制权交给_dl_runtime_resolve函数。
解析器函数从栈中取出参数,根据传入的link_map, 取出其中的.dynamic动态链接表,继而找到.rela.plt重定位表、.dynsym符号表以及.dynstr字符串表,然后根据第二个参数index向上查找到对应的字符串,也就是说,这里主要就做了一件事:根据函数对应的索引值找到对应的字符串。

接着,解析器根据这个字符串遍历link_map链表,刚刚提到,每一个加载的elf模块都对应一个link_map结构体,这里就依次遍历这些模块链表,从各自的符号表中匹配函数名字符串。
当找到目标函数后,将地址写回got表对应表项。
最后通过jmp指令跳转到目标函数执行。
注意这里的jmp,自始至终,只有最开始的.text节中使用了call指令,其余每次控制权转移使用的都是jmp指令,也就是说只有最开始那里将下一条返回地址压栈,而这里jmp到目标函数里面执行后,最后的ret指令返回就会直接返回到最开始call的下一条指令,这就完成了动态解析到执行的全过程。

到这里,利用思路就很明显了,就是通过伪造解析器函数要查询的字符串,使其返回我们需要的函数地址。
首先是没有重定位保护的情况,此时整个数据段都是可写的,我们可以直接改写诸如动态链接表中的字符串表的地址,使其指向我们伪造的一张字符串表,这样解析器最后拿到的就是我们伪造的函数名字符串,比如system,然后返回system函数对应的地址。
这里我们主要介绍在开启部分重定位保护的情况,此时数据段的前面部分直到got表的前三项都是只读的,无法直接修改各个表的地址,但注意到传给解析器函数的第二个参数index,这是.rela.plt重定位表的索引,如果我们伪造一个比较大的索引值作为参数传递,使其指向一个我们能控制的区域(比如.bss节)上的伪造的重定位表项,然后这个伪造的重定位表项再指向伪造的符号表表项,最终指向一个伪造的字符串表项,这样解析器最后得到的就是我们伪造的字符串,然后返回这个伪造的字符串对应的函数地址。
至于怎么将伪造的重定位表项作为参数传递给解析器,我们可以提前在栈上写入该索引,然后将栈中返回地址设置为plt[0]项,使其直接返回到解析器桩跳转到解析器,即ret2dl_resolve。
EXP
我们来看一个例子,题目很简单,main函数调用两次setvbuf设置完输入输出缓冲后即调用vuln函数,其中存在一个read缓冲区溢出漏洞。

我们的目标大概就是执行到**system(“/bin/sh”)**函数调用拿到shell
看一下保护,开启了栈不可执行,但PIE没开,且只是部分重定位只读保护。

因此可以通过ret2dl_resolve将read或者setvbuf函数的got表伪造成system函数。
这里注意到setvbuf函数接收了一个stdout和stdin参数,其是一个存放在数据段的、指向libc中FILE结构体的指针,刚好我们ret2dl_resolve也需要先将栈迁移到一个我们能控制的区域。

那这里的利用就很清晰了,先通过将栈迁移到stdout附近,然后覆写这个指针使其指向**/bin/sh**这个字符串
接着构建我们伪造的字符串表、符号表以及重定位表,并将伪造的重定位表项索引放入栈中
最后将plt[0]的地址写入栈中返回地址,执行ret2dl_resolve。
下面具体流程,首先将栈迁移到bss节:
# stack pivot |
然后覆写stdout使其指向下面的**/bin/sh**字符串,以及在新栈中填入伪造的三个表,并进行第二次栈迁移
# construct fake .dynstr entry |
这里进行第二次栈迁移的原因在于,_dl_runtime_resolve函数会开辟一个约400个字节大小的栈空间,而当前栈的上面就是只读数据段了,因此我们需要将栈再向下迁一段,跟解析器函数预留足够的栈空间
最后将plt[0]返回地址和伪造的重定位表索引压栈即可。
plt0 = elf.get_section_by_name(".plt").header.sh_addr |
