Lazy Binding & Ret2_dl_runtime_resolve
前言
今天偶然发现很久以前写的一篇文章,高级 ROP ret2_dl_runtime_resolve,发现似乎存在一些低级错误,而且在格式和描述上都不够清晰,于是即兴的结合源码来了一次对延迟绑定机制的分析和学习,写完后发现更新的内容过多,于是重新开了一篇文章来记录。同时因为在早期学习的时候,曾在文章中引用了大量文章的内容,但当时未做详细的记录,所以本文的参考资料部分可能会有遗漏,如果存在这样的情况,请务必告诉我!
延迟绑定
延迟绑定的目的
在动态链接的情况下,程序的模块直接存在大量的函数引用,所以在程序执行之前如果对所有将会用到的函数引用都进行动态链接会耗费不少的时间。例如程序中的一些错误处理函数或者某些特殊的功能(例如用于处理 canary 比对不统一的 __stack_chk_fail 函数)可能调用很少,甚至可能不会被用到,如果一开始就把所有的函数都链接完成实际上是一种浪费。所以 ELF 采用了一种延迟绑定(Lazy Binding)的做法,在函数第一次被用到才进行绑定。所以在程序开始执行时,模块间的函数调用都没有进行绑定,这样的做法可以大大的加快程序的启动速度。
延迟绑定和 RELRO
这样的机制在某些程序中可能可以大大的加快启动速度,但是对于又一些特殊的情况可能不进行延迟绑定是最好的。
延迟绑定的机制注定了 GOT 表必须是可写的一段空间(否则没法更新地址),但是如果 GOT 表可写则意味着攻击者可以利用 GOT 表来劫持程序流程(可以通过 BSS 段溢出到 GOT 表上、任意地址写等手法),这不够安全。面临这些问题,编译器给出了他的缓解方案。
在编译器中的选项表示为 RELRO (Relocation Read Only),在不同的 RELRO 模式下,延迟绑定、GOT 表是否只读都存在区别,这里整理其模式和对应编译选项如下表。
名称 | 编译选项 | 安全保护 |
---|---|---|
NO RELRO | -z norelro | 无保护 |
Partial RELRO | -z relro -z lazy | 强制 GOT 位于 BSS 段之前,防止 BSS 段缓冲区溢出到 GOT 表上;.init_array .fini_array .jcr .dynamic 这些段在被动态加载器初始化后只读 |
FULL RELRO | -z relro -z now | Partial RELRO 具有的功能;不进行延迟绑定;GOT 表只读 |
在网络上我们可以看到大量的文章说到 GCC 默认的配置是 Partial RELRO,但是在我实际测试中,在我 Ubuntu 20.04 上运行的 gcc 9.4.0 编译的一个程序默认开启了 FULL RELRO,而在我 Ubuntu 16.04 上运行的 gcc 5.5.0 版本上默认 Partial RELRO。
GCC 6 引入了 configure-time 选项 --enable-default-pie
:启用该选项的 GCC 预设 -pie
和 -fPIE
。现在,很多 Linux 发行版都启用了该选项作为基础的 security hardening。
简单的延迟绑定调用过程
- 程序要调用一个动态链接的函数,就回去调用这个函数对应的 PLT 位置,PLT 位置的代码是 JMP GOT,也就是直接跳转到了 GOT 所记录的函数地址
- 如果这个函数是第一次调用,由于延迟绑定的原因,在调用这个函数之前,程序还无法得知这个将要调用的函数地址,此时 GOT 表所记录的地址是程序中用于动态绑定生成的辅助函数,他会帮助程序跳转去执行**_dl_runtime_resolve**,并计算得出目标函数地址
- 计算出来的地址将会填充会到 GOT 表中,并且去执行目标函数
- 同时在下次调用时,再次 JMP GOT 就会直接去执行 GOT 表中所储存的函数真实地址,而不会再次进行符号解析。
辅助函数
而我们如果要解析这个过程,那首先就要从程序自身的辅助函数出发,去研究程序做了什么。
下面是辅助函数的具体代码,part1、part2 是我自己进行的重命名,这两个函数都是为了延迟绑定而自动生成的辅助函数。
part1 的地址会在 got 表的初始化阶段被填充进 got 表中,对于每一个需要延迟绑定的函数都会生成一个这样的辅助函数。
part2 是后续调用 _dl_runtime_resolve 的过程,由于所有函数的这部分内容都一致,所以这个函数被单独抽离出来,在 part1 中被调用,其中的 exe_link_map 和 _dl_runtime_resolve_addr 都是在程序加载过程中被初始化赋值的。
|
|
这里不难发现将要调用 _dl_runtime_resolve 的时候,辅助函数会传入两个参数进去(push 顺序和参数顺序相反),第一个参数对应的是 link_map 的地址指针,第二个参数对应的是函数在 .rel.plt 中的偏移,这两个参数就用于帮助**_dl_runtime_resolve** 得知具体要解析的是哪个函数地址。
DYNAMIC
link_map 中 l_info 会指向 DYNAMIC,DYNAMIC 结构与延迟绑定息息相关。
DYNAMIC 结构定义
DYNAMIC 的内容主要是一个 ElfW(Dyn) 数组,在初始化过程中会绑定到 link_map->l_info 数组指针上
其中的 ElfW(Dyn) 是一个宏定义,会根据系统位数来动态的选择 ELF32_Dyn 或者 ELF64_Dyn,为了方便阐述,之后会用 ElfW(Dyn) 来表示这个会随着系统位数而变化的结构体。
|
|
这个功能主要是通过 define 中的 ## 连接来实现的
Elf32_Dyn 和 Elf64_Dyn 结构
|
|
ElfW(Dyn) 结构由一个类型值 d_tag 加上一个附加的数值或指针 d_un,对于不同的类型,后面附加的数值或者指针有着不同的含义。
其中 类型值 d_tag 与 link_map->l_info 的下标相对应,在加载器中想要对 DYNAMIC 结构进行访问,就可以利用 link_map->l_info[d_tag] 的方式来得到 ElfW(Dyn) 结构指针,这里我们列举几个比较常见的类型值。
d_tag | d_un |
---|---|
DT_INIT | 在早期版本中,初始化代码地址 |
DT_FINI | 在早期版本中,结束代码地址 |
DT_INIT_ARRAY | 初始化代码数组地址,在 DT_INIT 前调用 |
DT_INIT_ARRAYSZ | 初始化代码数组大小 |
DT_FINI_ARRAY | 结束代码数组地址,在 DT_FINI 前调用 |
DT_FINI_ARRAYSZ | 结束代码数组大小 |
DT_STRTAB | 动态链接字符串表的地址 |
DT_SYMTAB | 动态链接符号表的地址 |
DT_STRSZ | 动态链接字符串表的大小 |
DT_SYMENT | 动态链接符号表的大小 |
DT_DEBUG | 用于调试,其内容未针对 ABI 指定 |
DT_PLTGOT | GOT 表的地址 |
DT_PLTRELSZ | 与过程连接表关联的 PLT 的总大小(以字节为单位) |
DT_PLTREL | PLT 所引用的重定位条目的类型 |
DT_JMPREL | ELF JMPREL Relocation Table 的地址 |
DT_RELA | ELF RELA Relocation Table 的地址 |
DT_RELASZ | 重定位表的总大小(以字节为单位) |
DT_RELAENT | 重定位条目的大小(以字节为单位) |
DYNAMIC 结构内容
这个结构中有三项内容需要我们特别了解的
名称 | 段名 |
---|---|
DT_STRTAB | .dynstr |
DT_SYMTAB | .dynsym |
DT_JMPREL | .rel.plt |
DT_STRTAB(ELF String Table)
这里储存的就是所有导出函数的名称
- 这个表开头第一字节是 \x00,可以理解成是一个空的字符串
- 每一个名字结尾还有个 \x00,标准的 C 语言字符串结构。
DT_SYMTAB(ELF Symbol Table)
这里放的是一个结构体 ElfW(Sym)
|
|
st_name:符号名称,距离字符串表起始位置的偏移
- STT_OBJECT 表示符号关联到一个数据对象,如变量、数组或指针
- STT_FUNC 表示符号关联到一个函数
- STT_NOTYPE 表示符号类型未指定,用于未定义引用
st_info:类型和绑定属性(低 4 位储存 ST_BIND,高 4 位储存 ST_TYPE)
ST_BIND
- STB_LOCAL:0,Local symbol
- STB_GLOBAL:1,Global symbol
- STB_WEAK:2,Weak symbol
ST_TYPE
- STT_NOTYPE:0,符号类型未指定
- STT_OBJECT:1,数据对象,比如变量、数组等
- STT_FUNC:2,函数或其它可执行代码
- STT_SECTION:3,表示一个 section,主要用于重定位,通常具有 STB_LOCAL 属性
- STT_FILE:4,文件符号,具有 STB_LOCAL 属性,st_shndx 的值为 SHN_ABS。在 ELF 文件的符号表中位于其他 STB_LOCAL 符号的前面
st_shndx:相关节的索引,符号将绑定到该节
- SHN_ABS:0xfff1,指定符号是绝对值,不因重定位而改变
- SHN_UNDEF:0,标识未定义符号
st_value:符号的值
st_size:符号的长度,如一个指针的长度或 struct 对象中包含的字节数。
要注意的是
- ST_NAME 放的不是字符串指针,而是在字符串指针距离 STRTAB 起始位置的偏移
- ST_INFO 的一字节被分成两部分使用
DT_JMPREL(ELF JMPREL Relocation Table)
|
|
我来简单说明一下:
- r_offset:符号指针,如果是用于延迟绑定的函数符号,这里指向 GOT 表的指针,用于解析完毕后把地址写回。
- r_info:要把图中我选中的黄色部分去掉,也就是 (val) » 32 的值(在 32 位下是 (val) » 8),这个值是这个导入的函数在 DT_SYMTAB 中的下标,这个 0x7 结尾是导入函数规定的。
|
|
_dl_runtime_resolve
其中 struct link_map *l
作为第一个参数,偏移值 ElfW(Word) reloc_arg
作为第二个参数
|
|
用 link_map 中的 l_info 访问 DYNAMIC,取出得到 DT_STRTAB, DT_SYMTAB,DT_PLTGOT 的指针,分别记做 symtab,strtab,pltgot
|
|
DT_JMPREL + reloc_arg (第二个参数),求出当前函数的在重定位表项 DT_JMPREL 中 Elf32_Rel 具体位置(指针),记作 reloc
|
|
reloc_offset 函数实际上直接返回了第二个参数
|
|
使用 reloc->r_info » 32(8) 作为 symtab 的下标,求出当前函数在符号表项 (DT_SYMTAB) 中 ElfW(Sym) 的具体位置(指针),记作 sym
|
|
ELFW(R_SYM) 宏定义根据位数不同有不同的解析
|
|
strtab + sym->st_name,得到符号名字符串指针,调用 _dl_lookup_symbol_x 在动态链接库查找这个字符串对应的函数的地址,并且在函数内部把结果给回到 sym 指针中。
|
|
修正结果(估计是解决兼容性问题)
|
|
把结果填回到 reloc_addr,这是先前计算的此函数对应的 got 表地址
|
|
利用方法
1.(No RELRO)直接改写 .dynstr
由于没有开启只读,所以我们可以直接对 .dynstr 进行修改,使得 .dynstr 中对应函数字符串内容为我们想要的内容。 比如修改 free 为 system,这样 free(buf)的时候,如果 buf 的内容是 “/bin/sh”, 那么就实现了 system("/bin/sh")
2.伪造调用时的偏移(第二个参数)
我们知道 _dl_runtime_resolve 中利用我们这个偏移来计算出 rel 的位置,而正好这个偏移是没有考虑到是否越界访问的,所以可以实现让其指向我们伪造的 ElfW(Rela)
结构体,伪造好以后可以手动调用 _dl_runtime_resolve 达到修改的目的,两个参数(link_map, reloc_arg)。
这里要注意,虽然我们说的第一个参数,第二个参数,但是实际上,第二个参数比第一个参数先 push。所以说,即使我们虽然不知道 link_map 的地址(因为使用 ret2dl 大概率是因为没有可以用于泄露的函数),但是我们可以利用辅助函数 part2 中的 push link_map 这段 gadget 来执行调用。
具体可以考虑以下这几个步骤:
- 伪造
reloc_arg
,使得reloc_arg
加上DT_JMPREL
的地址指向可控的地址,在该地址可伪造恶意的ElfW(Rela)
结构体。 - 伪造
ElfW(Rela)
结构体中的r_offset
指向某一可写地址,最终函数地址会写入该地址处(这个地址本来是存放 got 表地址),伪造ELFW(R_TYPE)
为ELF_MACHINE_JMP_SLOT
(0x7) 以绕过类型验证,使得ELFW(R_SYM)
加上DT_SYMTAB
地址指向可控的地址,并在该地址伪造符号表结构体ElfW(Sym)
ELFW(R_TYPE) 和 ELFW(R_SYM) 宏
|
|
位于 _dl_fixup 中的 reloc->r_info
断言
|
|
- 伪造
ElfW(Sym)
结构体中的st_name
,使得DT_STRTAB
的地址加上该值指向可控地址,并在该地址处写入特定函数的函数名,例如system
- 最终系统通过函数名匹配,定位到特定函数地址,获取该地址并写入到伪造的
r_offset
中,实现了目标函数地址的获取。
这里可能会出现一个问题是,在以下代码中会用到我们伪造的内容
|
|
这里按照上文所说的, ELFW(R_SYM) (reloc->r_info)
会被我们伪造成为能够使得 ELFW(R_SYM)
加上 DT_SYMTAB
地址指向可控的地址。但是在 DT_STRTAB
中的用此作为下标是通过单字节增加的(字符差值),而在 DT_VERSYM
中一个字节有两个字节,用此作为下标是通过双字节增加的,这样会导致 DT_VERSYM + offset * 2
会出现访问内存错误。
但是相对来说,在 32 位中这样的问题相对较少一些,通常问题出现在 version = &l->l_versions[ndx]
访问内存错误,因此伪造的 reloc->r_info
,最好能够使得 ndx 为 0,即 vernum[reloc->r_info] 为 0。
所以可以考虑到一种伪造方法是使得 l->l_info[VERSYMIDX(DT_VERSYM)] == NULL
,但是这个地址存在于 ld.so
中,难以修改到,所以用此方法的意义不大。
了解过程后,可以考虑使用 roputils 来解决。
i386:https://github.com/inaz2/roputils/blob/master/examples/dl-resolve-i386.py
x86-64: https://github.com/inaz2/roputils/blob/master/examples/dl-resolve-x86-64.py
3.伪造 link_map(第一个参数)
如果我们可控的范围很大,那么我们可以自己来伪造一个 link_map 结构体,并且利用以下代码中的另一个分支
|
|
我们控制 sym->st_other
使其不为 0,此时会进入另一个分支,这个分支假定已经找到了符号信息,所以会直接计算返回 SYMBOL_ADDRESS(l, sym, true)
|
|
这个值是一个宏展开,解释为
- 当
(ref)->st_shndx == SHN_ABS
时返回 0 - 否则返回
l_addr + sym->st_value
这时候很自然的可以想到,我们可以使得 sym->st_value
指向程序中的某一个具有 libc 地址的函数(例如 got 表上已被解析的位置(__libc_start_main
),或者 DT_DEBUG
上的 libc 指针),并且构造其与 l_addr 相加后的值能够指向例如 system
这样的函数位置。
接下来整理一下思路,看一下我们具体需要怎么构造,这里假定最终得到的地址要写入在 link_map + 0x30
中(任何一个可写地址即可),且选取的 libc 地址指针为 got 表上的 __libc_start_main
- 构造
l->l_addr
为system
到__libc_start_main
的偏移 - 控制
l_info[DT_JMPREL(23)]
指向link_map + 0x80 - 8
,同时在link_map + 0x80
的位置填入link_map + 0x30 - reloc_offset
的值,在link_map + 0x30
构造一个Elf64_Rela
结构体,该结构体r_offset
的位置(+0)填入link_map + 0x30 - l->l_addr
,r_info
的位置(+8)填入0x7
- 构造
l_info[DT_SYMTAB(6)]
指向link_map + 0x70
,同时在link_map + 0x78
的位置填入__libc_start_main_got - 8
。
构造原理
构造 1 是因为:最终写入的函数值是 l_addr + sym->st_value
,sym->st_value
是不可控的 libc 地址,那么我们只能控制 l_addr
使其成为目标地址距离固定的 libc 地址的偏移。
构造 2 是因为:最终写入函数地址位置是 l->l_addr + reloc->r_offset
,所以如果我们想要最终写入在 link_map + 0x30
中,那么 r_offset
就应该为 目标地址(link_map + 0x30) - l->l_addr
sym
是通过 &symtab[ELFW(R_SYM) (reloc->r_info)]
得到的,我们让其 ELFW(R_SYM) (reloc->r_info)
下标为 0,方便后续计算,这里再结合 assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT)
检测,所以 r_info
要填入 0x7。
构造 3 是因为:l_info[0x6]
实际上是 link_map
中 指向 DT_SYMTAB(6)
的指针,同时我们在 link_map + 0x70
伪造一个 Elf64_Dyn
结构体
Elf64_Dyn
的 d_ptr
为 link_map + 0x78
,指向了 __libc_start_main_got - 8
,即 symtab
指向了 __libc_start_main_got - 8
。
Elf64_Sym
结构中的 st_value
成员偏移为 8,结合前面减去的 8,这里的 st_value
正好指向了 __libc_start_main_got
Elf64_Sym
结构中的 st_shndx
成员偏移为 6,结合相对偏移进行计算,这里的 st_other
正好指向了 __libc_start_main_got - 2
,这个位置仍然函数在 got 表中,通常能够满足 sym->st_shndx != SHN_ABS
的条件的
Elf64_Sym
结构中的 st_other
成员偏移为 5,结合相对偏移进行计算,这里的 st_other
正好指向了 __libc_start_main_got - 3
,这个位置仍然函数在 got 表中,通常不为空,因此 sym->st_other
不为 0 的条件也是成立的。
利用过程
覆盖 link_map 地址到我们伪造的 link_map 结构体,再调用任意一个先前没有被调用过的函数来触发 ret2dl_resolve
攻击,此时函数地址的最终写入位置在哪里都无所谓,因为在 _dl_runtime_resolve
的过程结束后就会去调用。
4.部分覆盖 l_addr
由于在最后写入的过程中使用了 l->l_addr + reloc->r_offset
作为最后写入的位置,首先要知道 l->l_addr
地址一般末 12 位都为 0,如果我们可以修改这个结构的末位 1 个字节,那就可以让 _dl_runtime_resolve
计算出的函数地址,写回到距离预期位置(此函数对应的 got 表)最多偏移 0xFF 的地址上。
这里不难想到,我们可以用于修改 got 表上的其它函数,例如,先预先布置好 l->l_addr
,再去首次调用 printf 函数,此时在 _dl_runtime_resolve
过程中就可以把 free 函数的 got 表地址写成 printf 函数,由此就可以进行格式化字符串漏洞攻击。
例题
结合方法 1:hack.lu CTF 2014-OREO
在 NO RELRO 的情况下,修改.dynstr 指针来 ret2_dl_runtime_resolve。 我个人认为这个构造方法挺难的。我整了三四个小时才成功搞定这道题的 ret2_dl_runtime_resolve 的做法。 我这里的方法大概可以描述如下
- 修改.dynstr 指针
- 伪造 system 字符串作为 fake .dynstr
- 修改 [email protected] 为 free@plt + 6,也就是没 dl_runtime_resolve 时候的状态,只有在这个状态下才会执行 dl_runtime_resolve 去解析,否则会直接执行 free。但是覆盖的时候要注意到结尾的\x0A 会覆盖到 fgets 的 got 表,而且 fgets 在接下来会被调用到,所以还要 leak fgets 的内容,并且覆盖成正确的内容,这时候\x0A 会覆盖到一个不会执行到的地方,就没关系了。
- free,这时候 free 传入的地址是前面构造 system 的位置和另一个堆,所以这里选择构造 sh\x00system\x00,并且把 fake .dynstr 往后移动三位。如果这里直接 add 再 free 的话,但是这时候 add 会报错,原因是之前构造指向的所以这个方案不可以。
|
|
结合方法 2:keer’s bug
(借助 roputils)https://blog.wjhwjhn.com/archives/73/
结合方法 3:hitcon2015—blinkroot
详见参考资料 3
结合方法 4:Nightmare
详见参考资料 4