ret2dlresolve

在学习这个 ROP 利用技巧前,需要首先理解动态链接的基本过程以及 ELF 文件中动态链接相关的结构。读者可以参考 executable 部分 ELF 对应的介绍。这里只给出相应的利用方式。

原理

在 Linux 中,程序使用 _dl_runtime_resolve(link_map_obj, reloc_offset) 来对动态链接的函数进行重定位。那么如果我们可以控制相应的参数及其对应地址的内容是不是就可以控制解析的函数了呢?答案是肯定的。这也是 ret2dlresolve 攻击的核心所在。

具体的,动态链接器在解析符号地址时所使用的重定位表项、动态符号表、动态字符串表都是从目标文件中的动态节 .dynamic 索引得到的。所以如果我们能够修改其中的某些内容使得最后动态链接器解析的符号是我们想要解析的符号,那么攻击就达成了。

思路1 - 直接控制重定位表项的相关内容

由于动态链接器最后在解析符号的地址时,是依据符号的名字进行解析的。因此,一个很自然的想法是直接修改动态字符串表 .dynstr,比如把某个函数在字符串表中对应的字符串修改为目标函数对应的字符串。但是,动态字符串表和代码映射在一起,是只读的。此外,类似地,我们可以发现动态符号表、重定位表项都是只读的。

但是,假如我们可以控制程序执行流,那我们就可以伪造合适的重定位偏移,从而达到调用目标函数的目的。然而,这种方法比较麻烦,因为我们不仅需要伪造重定位表项,符号信息和字符串信息,而且我们还需要确保动态链接器在解析的过程中不会出错。

思路2 - 间接控制重定位表项的相关内容

既然动态链接器会从 .dynamic 节中索引到各个目标节,那如果我们可以修改动态节中的内容,那自然就很容易控制待解析符号对应的字符串,从而达到执行目标函数的目的。

思路3 - 伪造 link_map

由于动态连接器在解析符号地址时,主要依赖于 link_map 来查询相关的地址。因此,如果我们可以成功伪造 link_map,也就可以控制程序执行目标函数。

下面我们以 2015-XDCTF-pwn200 来介绍 32 位和 64 位下如何使用 ret2dlresolve 技巧。

32 位例子

NO RELRO

首先,我们可以按照下面的方式来编译对应的文件。

在这种情况下,修改 .dynamic 会简单些。因为我们只需要修改 .dynamic 节中的字符串表的地址为伪造的字符串表的地址,并且相应的位置为目标字符串基本就行了。具体思路如下

  1. 修改 .dynamic 节中字符串表的地址为伪造的地址

  2. 在伪造的地址处构造好字符串表,将 read 字符串替换为 system 字符串。

  3. 在特定的位置读取 /bin/sh 字符串。

  4. 调用 read 函数的 plt 的第二条指令,触发 _dl_runtime_resolve 进行函数解析,从而执行 system 函数。

代码如下

运行效果如下

Partial RELRO

首先我们可以编译源文件 main.c 得到二进制文件,这里取消了 Canary 保护。

在这种情况下,ELF 文件中的 .dynamic 节将会变成只读的,这时我们可以通过伪造重定位表项的方式来调用目标函数。

在下面的讲解过程中,本文会按照以下两种不同的方式来使用该技巧。

  1. 通过手工伪造的方式使用该技巧,从而获取 shell。这种方式虽然比较麻烦,但是可以仔细理解 ret2dlresolve 的原理。

  2. 利用工具来实现攻击,从而获取 shell。这种方式比较简单,但我们还是应该充分理解背后的原理,不能只是会使用工具。

手工伪造

这题我们不考虑有 libc 的情况。通过分析,我们可以发现程序有一个很明显的栈溢出漏洞,缓冲区到返回地址间的偏移为 112。

在下面的每一个阶段中,我们会一步步地深入理解如何构造 payload。

stage 1

在这一阶段,我们的目的比较简单,就是控制程序直接执行 write 函数。在栈溢出的情况下,我们其实可以直接控制返回地址来控制程序直接执行 write 函数。但是这里我们采用一个相对复杂点的办法,即先使用栈迁移,将栈迁移到 bss 段,然后再来控制 write 函数。因此,这一阶段主要包括两步

  1. 将栈迁移到 bss 段。

  2. 通过 write 函数的 plt 表项来执行 write 函数,输出相应字符串。

这里使用了 pwntools 中的 ROP 模块。具体代码如下

结果如下

stage 2

在这一阶段,我们将会进一步利用 _dl_runtime_resolve 相关的知识来控制程序执行 write 函数。

  1. 将栈迁移到 bss 段。

  2. 控制程序直接执行 plt0 中的相关指令,即 push linkmap 以及跳转到 _dl_runtime_resolve 函数。这时,我们还需要提供 write 重定位项在 got 表中的偏移。这里,我们可以直接使用 write plt 中提供的偏移,即 0x080483C6 处所给出的 0x20。其实,我们也可以跳转到 0x080483C6 地址处,利用原有的指令来提供 write 函数的偏移,并跳转到 plt0。

具体代码如下

效果如下,仍然输出了 sh 对应的字符串。

stage 3

这一次,我们同样控制 _dl_runtime_resolve 函数中的 reloc_offset 参数,不过这次控制其指向我们伪造的 write 重定位项。

鉴于 pwntools 本身并不支持对重定位表项的信息的获取。这里我们手动看一下

可以看出 write 的重定表项的 r_offset=0x0804a01c,r_info=0x00000607。具体代码如下

这次我们在 base_stage+24 处伪造了一个 write 的重定位项,仍然输出了对应的字符串。

stage 4

在 stage3 中,我们控制了重定位表项,但是伪造的重定位表项的内容仍然与 write 函数原来的重定位表项一致。

在这个阶段中,我们将构造属于我们自己的重定位表项,并且伪造该表项对应的符号。首先,我们根据 write 的重定位表项的 r_info=0x607 可以知道,write 对应的符号在符号表的下标为 0x607>>8=0x6。因此,我们知道 write 对应的符号地址为 0x0804822c。

这里给出的其实是小端模式,因此我们需要手工转换。此外,每个符号占用的大小为 16 个字节。

直接执行后发现并不行

发现程序已经崩溃了,通过 coredump,可以发现程序在 ld-linux.so.2 中崩了。

通过逆向分析 ld-linux.so.2

以及源码可以知道程序是在访问 version 的 hash 时出错。

进一步分析可以知道,因为我们伪造了 write 函数的重定位表项,其中 reloc->r_info 被设置成了比较大的值(由于 index_dynsym 离符号表比较远)。这时候,ndx 的值并不可预期,进而 version 的值也不可预期,因此可能出现不可预期的情况。

通过分析 .dynmic 节,我们可以发现 vernum 的地址为 0x80482d8。

在 ida 中,我们也可以看到相关的信息

那我们可以再次运行看一下伪造后 ndx 具体的值

可以发现,ndx_落入了 .eh_frame 节中。

进一步地,ndx 的值为 0x442C。显然不知道会索引到哪里去。

通过动态调试,我们可以发现 l_versions 的起始地址,并且其中一共有 3 个元素。

对应的分别为

此时,计算得到的 version 地址为 0xf7f236b0,显然不在映射的内存区域。

而在动态解析符号地址的过程中,如果 version 为 NULL 的话,也会正常解析符号。

与此同,根据上面的调试信息,可以知道 l_versions 的前两个元素中的 hash 值都为 0,因此如果我们使得 ndx 为 0 或者 1 时,就可以满足要求,我们来在 080487A8 下方找一个合适的值。可以发现 0x080487C2 处的内容为0。

那自然的,我们就可以调用目标函数。

这里,我们可以通过调整 base_stage 来达到相应的目的。

  • 首先 0x080487C2 与 0x080487A8 之间差了 0x080487C2-0x080487A8)/2 个 version 记录。

  • 那么,这也就说明原先的符号表偏移少了对应的个数。

  • 因此,我们只需要将 base_stage 增加 (0x080487C2-0x080487A8)/2*0x10,即可达到对应的目的。

最终如下

stage 5

这一阶段,我们将在阶段 4 的基础上,进一步伪造 write 符号的 st_name 指向我们自己构造的字符串。

效果如下

事实上,这里的 index_dynsym 又发生了变化,但似乎并不影响,因此我们也不用再想办法伪造数据了。

stage 6

这一阶段,我们只需要将原先的 write 字符串修改为 system 字符串,同时修改 write 的参数为 system 的参数即可获取 shell。这是因为 _dl_runtime_resolve 函数最终是依赖函数名来解析目标地址的。

需要注意的是,这里我把 /bin/sh 的偏移修改为了 base_stage+82,这是因为 pwntools 会对齐字符串。如下面的 ropchain 所示,0x40 处多了两个 a,比较奇怪。

效果如下

基于工具伪造

根据上面的介绍,我们应该可以理解这个攻击了。

Roputil

下面我们直接使用 roputil 来进行攻击。代码如下

关于 dl_resolve_call 与 dl_resolve_data 的具体细节请参考 roputils.py 的源码,比较容易理解。需要注意的是,dl_resolve 执行完之后也是需要有对应的返回地址的。

效果如下

pwntools

这里我们使用 pwntools 的工具进行攻击。

结果如下

Full RELRO

在开启 FULL RELRO 保护的情况下,程序中导入的函数地址会在程序开始执行之前被解析完毕,因此 got 表中 link_map 以及 dl_runtime_resolve 函数地址在程序执行的过程中不会被用到。故而,GOT 表中的这两个地址均为 0。此时,直接使用上面的技巧是不行的。

那有没有什么办法可以绕过这样的防护呢?请读者自己思考。

64 位例子

NO RELRO

在这种情况下,类似于 32 位的情况直接构造即可。由于可以溢出的缓冲区太少,所以我们可以考虑进行栈迁移后,然后进行漏洞利用。

  1. 在 bss 段伪造栈。栈中的数据为

    1. 修改 .dynamic 节中字符串表的地址为伪造的地址

    2. 在伪造的地址处构造好字符串表,将 read 字符串替换为 system 字符串。

    3. 在特定的位置读取 /bin/sh 字符串。

    4. 调用 read 函数的 plt 的第二条指令,触发 _dl_runtime_resolve 进行函数解析,从而触发执行 system 函数。

  2. 栈迁移到 bss 段。

由于程序中没有直接设置 rdx 的 gadget,所以我们这里就选择了万能 gadget。这会使得我们的 ROP 链变得更长

直接运行,发现不行,经过调试发现程序在 0x7f2512db3e69 处崩了。

经过逐步调试发现,在 _dl_runtime_resolve 会在栈中保存大量的数据

其中 qword_227808 处的值为0x0000000000000380。

当执行完下面的指令后

栈地址到了 0x600a00(我们是将栈迁移到了 bss_addr+0x100,即 0x600C30),即到了 .dynamic 节中,后续在栈中保存数据时会破坏 .dynamic 节中的内容,最后导致了 dl_fixup 崩溃。

或许我们可以考虑把栈再迁移的高一些,但是,程序中与 bss 相关的映射只有 0x600000-0x601000,即一页。与此同时

  • bss 段的起始地址为 0x600B30

  • 伪造的栈的数据一共有 392 (0x188)

所以直接栈迁移到 bss节很容易出现问题。

但经过精细的调节,我们还是避免破坏 .dynamic 节的内容

  • 修改迁移后的栈的地址为 bss_addr+0x200,即 0x600d30

  • 修改迁移后的栈的大小为 0x188

此时,我们发现程序又崩了,通过 coredump

我们发现,在处理 xmm 相关的指令时崩了

由于 xmm 相关指令要求地址应该是 16 字节对齐的,而此时 rsp 并不是 16 字节对齐的。因此我们可以简单地调整一下栈,来使得栈是 16 字节对齐的。

最终执行效果如下

到了这里我们发现,与 32 位不同,在 64 位下进行栈迁移然后利用 ret2dlresolve 攻击需要精心构造栈的位置,以避免破坏 .dynamic 节的内容。

这里我们同时给出另外一种方法,即通过多次使用 vuln 函数进行漏洞利用。这种方式看起来会更加清晰一些。

Partial RELRO

还是利用 2015 年 xdctf 的 pwn200 进行介绍。

这里我们仍然以手工构造和基于工具构造两种方式来介绍 64 位下的 ret2dlresolve。

手工伪造

这里我们就不一步步展示了。直接采用最终的思路。

64 位的变化

首先,我们先来看一下 64 位中的一些变化。

glibc 中默认编译使用的是 ELF_Rela 来记录重定位项的内容

这里 Elf64_Addr、Elf64_Xword、Elf64_Sxword 都为 64 位,因此 Elf64_Rela 结构体的大小为 24 字节。

根据 IDA 里的重定位表的信息可以知道,write 函数在符号表中的偏移为 1(0x100000007h>>32) 。

确实在符号表中的偏移为 1。

在 64 位下,Elf64_Sym 结构体为

其中

  • Elf64_Word 32 位

  • Elf64_Section 16 位

  • Elf64_Addr 64 位

  • Elf64_Xword 64位

所以,Elf64_Sym 的大小为 24 个字节。

除此之外,在 64 位下,plt 中的代码 push 的是待解析符号在重定位表中的索引,而不是偏移。比如,write 函数 push 的是 0。

First Try - leak

根据上述的分析,我们可以写出如下脚本

然而, 简单地运行后发现,程序崩溃了。

通过调试,我们发现,程序是在获取对应的版本号

  • rax 为 0x4003f6,指向版本号数组

  • rdx 为 0x155f1,符号表索引,同时为版本号索引

同时 rax + rdx*2 为 0x42afd8,而这个地址并不在映射的内存中。

那我们能不能想办法让它位于映射的内存中呢。估计有点难

  • bss 的起始地址为 0x601050,那么索引值最小为 (0x601050-0x400398)/24=87517,即 0x4003f6 + 87517*2 = 0x42afb0

  • bss 可以最大使用的地址为 0x601fff,对应的索引值为(0x601fff-0x400398)/24=87684,即0x4003f6 + 87684*2 = 0x42b0fe

显然都在非映射的内存区域。因此,我们得考虑考虑其它办法。通过阅读 dl_fixup 的代码

我们发现,如果把 l->l_info[VERSYMIDX(DT_VERSYM)] 设置为 NULL,那程序就不会执行下面的代码,版本号就为 NULL,就可以正常执行代码。但是,这样的话,我们就需要知道 link_map 的地址了。 GOT 表的第 0 项(本例中 0x601008)存储的就是 link_map 的地址。

因此,我们可以

  • 泄露该处的地址

  • 将 l->l_info[VERSYMIDX(DT_VERSYM)] 设置为 NULL

  • 最后执行利用脚本即可

通过汇编代码,我们可以看出 l->l_info[VERSYMIDX(DT_VERSYM)] 的偏移为 0x1c8

因此,我们可以简单修改下 exp。

然鹅,还是崩溃。但这次比较好的是,确实已经执行到了 system 函数。通过调试,我们可以发现,system 函数在进一步调用 execve 时出现了问题

即环境变量的地址指向了一个莫名的地址,这应该是我们在进行 ROP 的时候破坏了栈上的数据。那我们可以调整调整,使其为 NULL 或者尽可能不破坏原有的数据。这里我们选择使其为 NULL。

首先,我们可以把读伪造的数据和 /bin/sh 部分的 rop 合并起来,以减少 ROP 的次数

这时,再次尝试一下,发现

这时候 envp 被污染的数据就只有 0x61 了,即我们填充的数据 'a'。那就好办了,我们只需要把所有的 pad 都替换为 \x00 即可。

这时候即可利用成功

Second try - no leak

可以看出,在上面的测试中,我们仍然利用 write 函数泄露了 link_map 的地址,那么,如果程序中没有输出函数,我们是否还能够发起利用呢?答案是可以的。我们再来看一下 _dl_fix_up 的实现

如果我们故意将 __builtin_expect(ELFW(ST_VISIBILITY)(sym->st_other), 0) 设置为 0,那么程序就会执行 else 分支。具体的,我们设置 sym->st_other 不为 0 即可满足这一条件。

此时程序计算 value 的方式为

通过查看 link_map 结构体的定义,可以知道 l_addr 是 link_map 的第一个成员,那么如果我们伪造上述这两个变量,并借助于已有的被解析的函数地址,比如

  • 伪造 link_map->l_addr 为已解析函数与想要执行的目标函数的偏移值,如 addr_system-addr_xxx

  • 伪造 sym->st_value 为已经解析过的某个函数的 got 表的位置,即相当于有了一个隐式的信息泄露

那就可以得到对应的目标地址。

一般而言,至少有 __libc_start_main 已经解析过了。本例中,显然不止这一个函数。

与此同时,通过阅读 _dl_fixup 函数的代码,在设置 __builtin_expect(ELFW(ST_VISIBILITY)(sym->st_other), 0) 为 0 后,我们可以发现,该函数主要依赖了 link_map 中 l_info 的内容。因此,我们同样需要伪造该部分所需要的内容。

利用代码如下

最终执行结果

虽然在这样的攻击中,我们不再需要信息泄露,但是我们需要知道目标机器的 libc,更具体的,我们需要知道目标函数和某个已经解析后的函数之间的偏移。

基于工具伪造

感兴趣的读者可以自行尝试使用相关的工具看是否可以攻击成功。

Full RELRO

2015-hitcon-readable

检查一下文件权限,可以发现,该可执行文件只开启了 NX 保护

也就是说我们其实可以直接修改 dynamic 节的内容。但是,与 2015-xdctf-pwn200 不同,这里栈溢出只能越界读取 16 个字节,而上述例子中所使用的的 ret2csu 则需要大量的字节。因此,直接使用该方法是不行了。

我们来仔细分析下目前的情况,即我们可以越界控制 rbp、返回地址。考虑到 read 是使用 rbp 来索引 buffer 的

那如果我们控制 rbp 为写的地址加上 0x10,即 targetaddr+0x10,然后再跳转到 0x400505,即栈的结构为

那么我们就可以控制程序在目标地址处写 16 个字节。通过不断地这样操作,我们就可以不断地读取 16 个字节,从而达到读取任意长字节的目的。

方法1:modify dynamic section

方法 2 - 标准 ROP

这个方法比较取巧,考虑到 read 函数很短,而且最后会调用系统调用,因此在 libc 的实现中会使用 syscall 指令,而同时我们可以修改 read 的 got 表,那如果我们把 [email protected] 修改为 syscall 的地址,同时布置好相关的参数,即可执行系统调用。这里我们控制 ROP 执行execve("/bin/sh",NULL,NULL)

首先,我们需要爆破来寻找 syscall 具体的地址,我们可以考虑调用 write 函数来看是否真正执行了 syscall指令

即我们控制程序输出 ELF 文件的头,如果输出了,那就说明成功了。此外,这里我们使用了read 函数的返回值来控制 rax 寄存器的值,以便于控制具体想要执行哪个系统调用。运行结果如下

通过对比 libc.so 确实可以看到,对应的偏移处具有 syscall 指令

libc 的版本为

需要注意,不同 libc 的偏移可能不一样。

完整代码如下

2015-hitcon-quals-blinkroot

简单看一下程序开的保护

发现程序开启了 Canary 保护。

程序的基本逻辑为

  • 在 bss 指定位置处读取 1024 个字节

  • 关闭标准输入,标准输出,标准错误输出。

  • 然后可以在任意 16 字节对齐地址处设置16个字节,其中低8字节固定为 0x10,高 8 字节完全可控。

显然这里是没有信息泄露的,当然我们没有办法覆盖返回地址来控制程序的执行流。但是既然程序没有开启 RELRO 保护,我们可以考虑修改 ELF 文件的字符串表。同时我们观察到

程序会执行 puts 函数,而 puts 函数的具体地址为 data 变量偏移 0x10。

因此,我们可以控制 s 为 /bin/sh,同时控制字符串表中的 puts 函数为 system 函数,那就可以调用 system 函数了。然而,理想很好,但是,我们发现

字符串表并不是 16 字节对齐的,因此不太行。那我们尝试使用在开启 Partial RELRO 下的思路吧。

由于不能泄露地址信息,所以我们可以采用伪造 linkmap 的思路,即

  • 利用题目提供的任意写的思路修改 linkmap 指向已经解析的地址

  • 通过题目中接下来将要调用的 puts 函数来实现劫持控制流的目的

这里我们可以发现 linkmap 存储的地址为 0x600B48,因此我们可以从 0x600B40 开始设置数据。

此外,需要注意的是 puts 函数在重定位表中的索引为 1。因此,在构造 linkmap 时需要注意。

利用脚本如下

需要注意这里的 data_addr+43 为伪造的 linkmap 的地址。执行效果如下

上面的这种方式为伪造 link_map 的 l_addr 为目标函数和已解析函数之间的偏移。

根据之前的介绍,我们还可以伪造 l_addr 为已解析函数的地址, st_value 为已解析函数和目标函数之间的偏移。

这里,由于 .got.plt 的下方没多远就是 bss 段 data 的位置。当我们控制 linkmap 的地址位于 got 表附近时,同时我们还需要利用 link_map 的几个动态表指针,偏移从 0x68 开始。因此我们需要仔细构造对应的数据。这里我们选择伪造 link_map 到 0x600B80。

因此,我们可以控制的 puts 的参数的长度最大为 0x18。

需要注意的是,在伪造 linkmap 的时候,我们是从偏移 0x68 开始构造的,所以在最后对齐的时候设置 linkmap.ljust(0xf8-0x68, 'A')

执行效果

总结

修改 dynamic 节的内容
修改重定位表项的位置
伪造 linkmap

主要前提要求

无信息泄漏时需要 libc

适用情况

NO RELRO

NO RELRO, Partial RELRO

NO RELRO, Partial RELRO

注意点

确保版本检查通过;确保重定位位置可写;确保重定位表项、符号表、字符串表一一对应

确保重定位位置可写;需要着重伪造重定位表项、符号表;

总的来说,与 ret2dlresolve 攻击最为相关的一些动态节为

  • DT_JMPREL

  • DT_SYMTAB

  • DT_STRTAB

  • DT_VERSYM

题目

  • pwnable.kr unexploitable

  • pwnable.tw unexploitable

  • 0CTF 2018 babystack

  • 0CTF 2018 blackhole

参考

  1. http://pwn4.fun/2016/11/09/Return-to-dl-resolve/ ,深入浅出。

  2. https://www.math1as.com/index.php/archives/341/

  3. https://veritas501.space/2017/10/07/ret2dl_resolve%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/

  4. https://blog.csdn.net/seaaseesa/article/details/104478081

  5. https://github.com/pwning/public-writeup/blob/master/hitcon2015/pwn300-readable/writeup.md

  6. https://github.com/pwning/public-writeup/tree/master/hitcon2015/pwn200-blinkroot

Last updated