作者:Tangerine@SAINTSEC
本系列的最后一篇 感谢各位看客的支持 感谢原作者的付出
一直以来都有读者向笔者咨询教程系列问题,奈何该系列并非笔者所写[笔者仅为代发]且笔者功底薄弱,故无法解答,望见谅
如有关于该系列教程的疑问建议联系论坛的原作者ID:Tangerine
0x00 got表、plt表与延迟绑定
在之前的章节中,我们无数次提到过got表和plt表这两个结构。这两个表有什么不同?为什么调用函数要经过这两个表?ret2dl-resolve与这些内容又有什么关系呢?本节我们将通过调试和“考古”来回答这些问题。
我们先选择程序~/XMAN 2016-level3/level3
进行实验。这个程序在main函数中和vulnerable_function中都调用了write函数,我们分别在两个call _write
和一个call _read
上下断点,调试观察发生了什么。
调试 启动后程序断在第一个call _write
处
此时我们按F7跟进函数,发现EIP跳到了.plt表上,从旁边的箭头我们可以看到这个jmp指向了后面的push 18h; jmp loc_8048300
我们继续F7执行到jmp loc_8048300发生跳转,发现这边又是一个push和一个jmp,这段代码也在.plt上。
同样的,我们直接执行到jmp执行完,发现程序跳转到了ld_2.24.so上,这个地址是loc_F7F5D010
到这里,有些人可能已经发现了不对劲。刚刚的指令明明是jmp ds:off_804a008
,这个F7F5D010是从哪里冒出来的呢?其实这行jmp的意思并不是跳转到地址0x0804a008
执行代码,而是跳转到地址0x0804a008
中保存的地址处。同理,一开始的jmp ds:off_804a018
也不是跳转到地址0x0804a018
.OK,我们来看一下这两个地址里保存了什么。
回到call _write
F7跟进后的那张图,跟进后的第一条指令是jmp ds:off_804a018,这个地址位于.got.plt中。我们看到其保存的内容是loc_8048346,后面还跟着一个DATA XREF:_write↑r
. 说明这是一个跟write函数相关的代码引用的这个地址,上面的有一个同样的read也说明了这一点。而jmp ds:0ff_804a008
也是跳到了0x0804a008保存的地址loc_F7F5D010处。
回到刚刚的eip,我们继续F8单步往下走,执行到retn 0Ch,继续往下执行就到了write函数的真正地址
现在我们可以归纳出call write的执行流程如下图:
然后我们F9到断在call _read
,发现其流程也和上图差不多,唯一的区别在于addr1和push num
中的数字不一样,call _read
时push的数字是0
接下来我们让程序执行到第二个call _write
,F7跟进后发现jmp ds:0ff_804a018
旁边的箭头不再指向下面的push 18h
。
我们查看.got.plt,发现其内容已经直接变成了write函数在内存中的真实地址。
由此我们可以得出一个结论,只有某个库函数第一次被调用时才会经历一系列繁琐的过程,之后的调用会直接跳转到其对应的地址。那么程序为什么要这么设计呢?
要想回答这个问题,首先我们得从动态链接说起。为了减少存储器浪费,现代操作系统支持动态链接特性。即不是在程序编译的时候就把外部的库函数编译进去,而是在运行时再把包含有对应函数的库加载到内存里。由于内存空间有限,选用函数库的组合无限,显然程序不可能在运行之前就知道自己用到的函数会在哪个地址上。比如说对于libc.so来说,我们要求把它加载到地址0x1000处,A程序只引用了libc.so,从理论上来说这个要求不难办到。但是对于用了liba,so, libb.so, libc.so……liby.so, libz.so的B程序来说,0x1000这个地址可能就被liba.so等库占据了。因此,程序在运行时碰到了外部符号,就需要去找到它们真正的内存地址,这个过程被称为重定位。为了安全,现代操作系统的设计要求代码所在的内存必须是不可修改的,那么诸如call read一类的指令即没办法在编译阶段直接指向read函数所在地址,又没办法在运行时修改成read函数所在地址,怎么保证CPU在运行到这行指令时能正确跳到read函数呢?这就需要got表(Global Offset Table,全局偏移表)和plt表(Procedure Linkage Table,过程链接表)进行辅助了。
正如我们刚刚分析过的流程,在延迟加载的情况下,每个外部函数的got表都会被初始化成plt表中对应项的地址。当call指令执行时,EIP直接跳转到plt表的一个jmp,这个jmp直接指向对应的got表地址,从这个地址取值。此时这个jmp会跳到保存好的,plt表中对应项的地址,在这里把每个函数重定位过程中唯一的不同点,即一个数字入栈(本例子中write是18h,read是0,对于单个程序来说,这个数字是不变的),然后push got[1]并跳转到got[2]保存的地址。在这个地址中对函数进行了重定位,并且修改got表为真正的函数地址。当第二次调用同一个函数的时候,call仍然使EIP跳转到plt表的同一个jmp,不同的是这回从got表取值取到的是真正的地址,从而避免重复进行重定位。
0x01 符号解析的过程中发生了什么?
我们通过调试已经大概搞清楚got表,plt表和重定位的流程了,但是作为一名攻击者来说,只了解这些东西并不够。ret2dl-resolve的核心原理是攻击符号重定位流程,使其解析库中存在的任意函数地址,从而实现got表的劫持。为了完成这一目标,我们就必须得深入符号解析的细节,寻找整个解析流程中的潜在攻击点。我们可以在https://ftp.gnu.org/gnu/glibc/
下载到glibc源码,这里我用了glibc-2.27
版本的源码。
我们回到程序跳转到ld_2.24.so的部分,这一段的源码是用汇编实现的,源码路径为glibc/sysdeps/i386/dl-trampoline.S(64位把i386改为x86_64),其主要代码如下:
.text
.globl _dl_runtime_resolve
.type _dl_runtime_resolve, @function
cfi_startproc
.align 16
_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风格的语法,可读性比较差,我们对应到IDA中的反汇编结果中修正符号如下_dl_fixup
的实现位于glibc/elf/dl-runtime.c
,我们首先来看一下函数的参数列表
_dl_fixup (
# ifdef ELF_MACHINE_RUNTIME_FIXUP_ARGS
ELF_MACHINE_RUNTIME_FIXUP_ARGS,
# endif
struct link_map *__unbounded l, ElfW(Word) reloc_arg)
忽略掉宏定义部分,我们可以看到_dl_fixup
接收两个参数,link_map
类型的指针l对应了push进去的got[1]
,reloc_arg
对应了push进去的数字。由于link_map *
都是一样的,不同的函数差别只在于reloc_arg
部分。我们继续追踪reloc_arg
这个参数的流向。
如果你真的阅读了源码,你会发现这个函数里头找不到reloc_arg,那么这个参数是用不着了吗?不是的,我们往上面看,会看到一个宏定义
#ifndef reloc_offset
# define reloc_offset reloc_arg
# define reloc_index reloc_arg / sizeof (PLTREL)
#endif
reloc_offset在函数开头声明变量时出现了。
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)];
const ElfW(Sym) *refsym = sym;
void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);
lookup_t result;
DL_FIXUP_VALUE_TYPE value;
D_PTR是一个宏定义,位于glibc/sysdeps/generic/ldsodefs.h中,用于通过link_map结构体寻址。这几行代码分别是寻找并保存symtab, strtab的首地址和利用参数reloc_offset寻找对应的PLTREL结构体项,然后会利用这个结构体项reloc寻找symtab中的项sym和一个rel_addr.我们先来看看这个结构体的定义。这个结构体定义在glibc/elf/elf.h中,32位下该结构体为
typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index */
} Elf32_Rel;
这个结构体中有两个成员变量,其中r_offset参与了初始化变量rel_addr,这个变量在_dl_fixup
的最后return处作为函数elf_machine_fixup_plt
的参数传入,r_offset
实际上就是函数对应的got表项地址。另一个参数r_info参与了初始化变量sym和一些校验,而sy