64位 ret2dl_resolve 总结
0x00 前言
回字有四样写法,你知道么? —- 鲁迅
0x01 分析
程序的逻辑很简单,在 main
函数在初始化之后就调用了 vuln
函数。vuln
函数里面是一个 read(0, buf, 0x1000uLL);
一个直白的栈溢出。
0x02 利用
首先看到这个必然想到 ret2dl_resolve
,ret2dl_resolve
是一个很经典的栈溢出利用方式,在 32 位情况下,只需要满足两个条件就可以稳定拿到 shell:
- 没开 PIE
- NO RELRO 或者 PARTIAL RELRO
很庆幸的是 pwn3 完全符合这两个条件,唯一的区别在于 pwn3 是个 64 位程序。
在较新的 ld.so
中, dl_resolve
中加了 verison table
的查询。这个机制在 32 位下很好绕过,但在64位下,由于 bss 段和 text 段离得太远,所以需要技巧性的绕过。
ret2dl_resolve 的原理
可以看到在 IDA 中, LOAD 段一共有这样几个 table: Hash Table
, Symbol Table
, String Table
, Symbol Version Table
, RELA Table
, JMPREL Table
。
在 ret2dl_resolve
中,需要用到的有 Symbol Table
, String Table
, Symbol Version Table
和JMPREL Table
接下来用通俗的语言描述 ret2dl_resolve
的流程。
在调用 dl_resolve
函数后, 函数首先会通过JMPREL Table
和传入函数的 index 参数找到对应的 JMPREL Table
表项。注意到每个 JMPREL Table
表项由 0x18 字节构成。我们的关注点有两个,第一个是 0~7 字节的 GOT 表地址,另一个是 8~11 字节的 Symbol Table Index
。例如 read
的 Symbol Table Index
是 1, setvbuf
的是 4。
然后,通过 Symbol Table Index
在 Symbol Table
中找到对应的表项。Symbol Table
表项也由 0x18 字节构成,0~7 字节是 Symbol
的名称字符串相对于 Sting Table
的偏移。Funtion Symbol
的 8~16 字节为 0x12,其他位为 0。
最后在 String Table
中找到对应的名称字符串,最关键的是,**dl_resolve
是通过名称字符串在 libc 中找到对应的函数地址**。也就是说,如果我们能劫持 String Table
让 dl_resolve
以为 read
函数叫 system
,我们就可以调用 system
函数拿到 shell 了。但是很可惜,上述的几个表都是只读的,没有办法进行写操作。但是,我们可以另辟蹊径,在 bss 段上伪造 JMPREL Table
, Symbol Table
, String Table
的表项,来达到同样的效果。
惹人厌的 Version Table
远古的 ret2dl_resolve
就是按照上诉的流程来打的。但是… 新的 ld.so
给加了一个 Version Table
。可以看到 Verison Table
的表项顺序和 Symbol Table
完全一致,也就是说在查找 Symbol Table
的同时,程序也会用同样的 index 在 Symbol Table
中查找 Symbol
对应的 version
信息。所以说,我们同样在 bss 段上面伪造一个 Version Table
不就好了?
理想很丰满,现实很残酷,Symbol Table
的表项是 0x18 字节,而 Version Table
的表项是 0x10 字节,在表头地址差距不大,且共用一个 index 的情况下,若想要伪造的 Symbol Table
表项在 bss 段,那么 Version Table
的表项一定在 text 段和 bss 段之间,一定是个不合法的地址,访问它一定会 Segment Fault
。
那我们能不能想办法让它位于映射的内存中呢。估计有点难
bss 的起始地址为 0x601050,那么索引值最小为 (0x601050-0x400398)/24=87517,即 0x4003f6 + 87517*2 = 0x42afb0
bss 可以最大使用的地址为 0x601fff,对应的索引值为 (0x601fff-0x400398)/24=87684,即 0x4003f6 + 87684*2 = 0x42b0fe显然都在非映射的内存区域。
—- CTF Wiki
所以,我们得想办法绕过这个机制。
绕过 Version Table
CTF Wiki 给了两种绕过方式,一种需要泄露 link_map
的地址,一种需要知道远程的 libc
版本。接下来我们结合 exp 看看这两种方法分别怎么实现。
Exp 1
可以看到,在题目所给的 libc 中,setvbuf
函数和 puts
函数的地址非常接近。只有低 16bits 有区别,在地址随机化中,低 12bits 是不变的。我们只需要爆破 4bits,1/16 的概率,就能把 setvbuf
的 GOT 表改成 puts
。
所以思路就是用 puts
函数泄露 link_map
的地址,然后绕过 Version Table
。
通过阅读 dl_fixup 的代码。
1
2
3
4
5
6
7
8
9
10 // 获取符号的版本信息
const struct r_found_version *version = NULL;
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;
}我们发现,如果把 l->l_info[VERSYMIDX(DT_VERSYM)] 设置为 NULL,那程序就不会执行下面的代码,版本号就为 NULL,就可以正常执行代码。但是,这样的话,我们就需要知道 link_map 的地址了。 GOT 表的第 0 项(本例中 0x601008)存储的就是 link_map 的地址。
因此,我们可以
- 泄露该处的地址
- 将 l->l_info[VERSYMIDX(DT_VERSYM)] 设置为 NULL
- 最后执行利用脚本即可
exp 如下 (1/16 的概率打通):
1 | rom pwn import * |
Exp 2
以下转自 CTF Wiki:
可以看出,在上面的测试中,我们仍然利用 write 函数泄露了 link_map 的地址,那么,如果程序中没有输出函数,我们是否还能够发起利用呢?答案是可以的。我们再来看一下 _dl_fix_up 的实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51 >/* 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)
>{
// 获取符号的版本信息
const struct r_found_version *version = NULL;
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;
}
/* 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;
>
// 查询待解析符号所在的目标文件的 link_map
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. */
// 基于查询到的 link_map 计算符号的绝对地址: result->l_addr + sym->st_value
// l_addr 为待解析函数所在文件的基地址
value = DL_FIXUP_MAKE_VALUE(result,
SYMBOL_ADDRESS(result, sym, false));
>}
>else
>{
/* We already found the symbol. The module (and therefore its load
address) is also known. */
value = DL_FIXUP_MAKE_VALUE(l, SYMBOL_ADDRESS(l, sym, true));
result = l;
>}如果我们故意将 __builtin_expect(ELFW(ST_VISIBILITY)(sym->st_other), 0) 设置为 0,那么程序就会执行 else 分支。具体的,我们设置 sym->st_other 不为 0 即可满足这一条件。
1
2
3
4
5
6
7
8
9 >/* How to extract and insert information held in the st_other field. */
>
>/* For ELF64 the definitions are the same. */
>
>/* Symbol visibility specification encoded in the st_other field. */
>
>
>
>此时程序计算 value 的方式为。
1 >value = l->l_addr + sym->st_value通过查看 link_map 结构体的定义,可以知道 l_addr 是 link_map 的第一个成员,那么如果我们伪造上述这两个变量,并借助于已有的被解析的函数地址,比如
- 伪造 link_map->l_addr 为已解析函数与想要执行的目标函数的偏移值,如 addr_system-addr_xxx
- 伪造 sym->st_value 为已经解析过的某个函数的 got 表的位置,即相当于有了一个隐式的信息泄露
那就可以得到对应的目标地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 >struct link_map
{
/* These first few members are part of the protocol with the debugger.
This is the same format used in SVR4. */
ElfW(Addr) l_addr; /* Difference between the address in the ELF
file and the addresses in memory. */
char *l_name; /* Absolute file name object was found in. */
ElfW(Dyn) *l_ld; /* Dynamic section of the shared object. */
struct link_map *l_next, *l_prev; /* Chain of loaded objects. */
/* All following members are internal to the dynamic linker.
They may change without notice. */
/* This is an element which is only ever different from a pointer to
the very same copy of this type for ld.so when it is used in more
than one namespace. */
struct link_map *l_real;
/* Number of the namespace this link map belongs to. */
Lmid_t l_ns;
struct libname_list *l_libname;
/* Indexed pointers to dynamic section.
[0,DT_NUM) are indexed by the processor-independent tags.
[DT_NUM,DT_NUM+DT_THISPROCNUM) are indexed by the tag minus DT_LOPROC.
[DT_NUM+DT_THISPROCNUM,DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM) are
indexed by DT_VERSIONTAGIDX(tagvalue).
[DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM,
DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM+DT_EXTRANUM) are indexed by
DT_EXTRATAGIDX(tagvalue).
[DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM+DT_EXTRANUM,
DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM+DT_EXTRANUM+DT_VALNUM) are
indexed by DT_VALTAGIDX(tagvalue) and
[DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM+DT_EXTRANUM+DT_VALNUM,
DT_NUM+DT_THISPROCNUM+DT_VERSIONTAGNUM+DT_EXTRANUM+DT_VALNUM+DT_ADDRNUM)
are indexed by DT_ADDRTAGIDX(tagvalue), see <elf.h>. */
ElfW(Dyn) *l_info[DT_NUM + DT_THISPROCNUM + DT_VERSIONTAGNUM
+ DT_EXTRANUM + DT_VALNUM + DT_ADDRNUM];
接下来就简单了,按照 Wiki 给出的利用,做一些抄写的工作,Exp 如下:
1 | from pwn import * |
更多的思考
既然在 Exp 1
中已经可以泄露了,为什么不直接泄露 libc
地址, 然后 rop 到 system
呢?
于是就有了 Exp 3。
Exp 3
1 | from pwn import * |
最后的最后
因为最后想要说的利用方式不是我通过常规途径想到的,故也算不得writeup。但是思路十分巧妙,因此简单分析一下思路,不放上完整 Exp 了。
read
函数中有 syscall
指令,将 read
的 GOT 表 partial overwrite 到 syscall
指令对应的地址,即可通过 rop 构造 execve("/bin/sh", 0, env)
但是其中涉及到 rax 的控制,这一点可以用 read
函数的返回值实现,例如 execve 的调用号是 59, 那么 read
59 个字节后 rax 就变成了 59。但是在 partial overwrite 后不能进行下一次 read
,而 read
函数的 GOT 地址减去 59 是个不合法地址。所以不能直接这样控制 rax。好在 read
也可以用 syscall 实现,且系统调用号为 0。而 rax 为 0 的情况还是很常见的。所以 syscall 0 后,输入 59 字节,使 rax 为 59,然后再 syscall 59。