- A+
0×01 题目
这个题目考的是代码审计getshell。赛后把源码down下来结合大佬wp复现了一下。代码审计考察面比较广,也比较适合新手练手,涉及到这个题目主要是php mvc框架、session机制以及反序列化的问题。菜鸡初学代码审计,写的比较详细,不当之处请指正
0×02 简单分析
题目要求是Getshell,源代码典型的MVC架构,一个典型的Web MVC流程:
Controller截获用户发出的请求;
Controller调用Model完成状态的读写操作;
Controller把数据传递给View;
View渲染最终结果并呈献给用户。
(涉及到php MVC开发请参考这篇文章,对于相关代码审计的理解及结构也有很大帮助–>http://www.cnblogs.com/Steven-shi/p/5914175.html)
所给代码主要有这么几个文件(夹):
config.php (配置文件)
index.php (入口文件)
view (视图类文件夹)
model (模型类文件夹)
controller控制器文件夹里的BaseController.php和mainController.php
这两个文件是主要文件,主要是对其他文件定义的类进行调用,包括跳转动作,登录会话处理,注册上传等等。
include文件夹两个文件MySessionHandler.php和Session.php两个文件,主要处理session会话处理。
lib文件夹里core.php(共用框架入口文件)。这个文件被index包含,个人感觉类似主文件,涉及到输入参数的过滤处理和关键函数的定义以及关键类的定义(MVC类)
0×03 core.php文件分析
首先是63~66行对输入参数的处理:
escape($_REQUEST);
escape($_POST);
escape($_GET);
escape($_SERVER);
//escape函数在153行定义
function escape(&$arg) {
if(is_array($arg)) {
foreach ($arg as &$value) {
escape($value);
}
} else {
$arg = str_replace(["'", '\\', '(', ')'], ["‘", '\\\\', '(', ')'], $arg);
}
}
可以看出 escape 对输入参数进行了黑名单替换。括号被过滤的话基本上注入比较难了。
涉及到MVC框架的实现,代码定义了三个类:Controller
(控制器)、Model
(模型)、View
(视图),function url
进行了路由设置。
最重要的是在78~79行进行了 控制器类的实例化
$controller_obj = new $controller_name(); //实例化mainController类
$controller_obj->$action_name(); //执行的动作actionindex
controller_name()是在45和67行定义的。系统首先执行的mainController类中的actionIndex方法。
0×04 Controller控制器分析
上面mainController类在 mainController.php 文件中定义,下面分析两个控制器文件。
mainController继承了BaseController:BaseControlller主要是初始化了session,下面是代码
class BaseController extends Controller{
public $layout = "layout.html";
function init(){
ini_set('session.save_handler', 'user');
$handler = new MySessionHandler();
session_set_save_handler($handler, true);
session_start();
header("Content-type: text/html; charset=utf-8");
}
//..
主要文件是mainController。mainController是对BaseCrotroller的继承,定义了登录(actionLogin)、注册(actionRegister)、上传(actionUploader)、信息(actionMessage)、提交(actionPOST)等方法。
首先看actionIndex函数:
function actionIndex(){
if(isset($_SESSION["data"])){
//...
}else{
$this->jump("/main/login");
return ;
}
}
首先进行的判定就是是否存在$_SESSION['data']
,如果没有就跳到登录界面。登陆请求的url对应控制器里的actionLogin动作。如果没有账号的会进行actionRegister动作。
下面代码是注册:
function actionRegister(){
if($_POST){
$username = arg('username');
$password = arg('password');
if(empty($username)||empty($password)){
echo "<script>alert('Username or password is error.')</script>";
}else{
$password = md5($password);
$user = New User();
$res = $user->query("SELECT * FROM `{$user->table_name}` WHERE `username` ='{$username}'");
if(!empty($res)){
echo "<script>alert('Username is registered!.')</script>";
}else{
$res = $user->create([
"username"=>$username,
"password"=>$password,
"picture"=>"/img/pic.jpg"]);
if(!$res) echo "<script>alert('something error. register fiaied!')</script>";
else $this->jump("/main/login");
}
}
}
}
可以看出其对输入参数首先进行了arg函数的处理,我们找一下arg函数的位置,发现在core.php文件第163行(escape函数后面)
function arg($name, $default = null, $trim = false) {
if (isset($_REQUEST[$name])) {
$arg = $_REQUEST[$name];
} elseif (isset($_SERVER[$name])) {
$arg = $_SERVER[$name];
} else {
$arg = $default;
}
if($trim) {
$arg = trim($arg);
}
return $arg;
}
对参数执行了 trim
去空格操作。
再看一下登录函数:
function actionLogin(){
if($_POST){
$username = arg('username');
$password = arg('password');
$ip = arg('REMOTE_ADDR');
$userAgent = arg('HTTP_USER_AGENT');
if (empty($username) || empty($password)) {
echo "<script>alert('Username or password is empty.')</script>";
}else{
$user = New User();
$password = md5($password);
$res = $user->query("SELECT * FROM `$user->table_name` where `username`='{$username}' AND `password`='{$password}'");
if(empty($res) || $res[0]['password']!==$password){
echo "<script>alert('Username or password is error.')</script>";
}else{
$session = new Session($res[0]["id"],time(),$ip,$userAgent);
$_SESSION['data'] = serialize($session);
$_SESSION['username'] = $username;
$this->jump("/main/index");
}
}
}
}
审计可知如果登录成功会实例一个session类,而这个类在include文件夹 session.php 中定义。网站SESSION主要有两个参数,一个是 data
,一个是 username
, data 是上面实例化后的Session序列化的结果。
除此之外还可以文件上传:
public function actionUpload(){
//...
$fileName = $_FILES['upfile']['name'];
$fileExt = isset(pathinfo($fileName)['extension'])?pathinfo($fileName)['extension']:"png";
$fileExt = addslashes($fileExt);
$filename = $this->randomStr().'.'.$fileExt;
$realFileName = APP_DIR.DS."img".DS."upload".DS.$filename;
if(move_uploaded_file($_FILES['upfile']['tmp_name'],$realFileName)){
$user = New User();
$webFileName = DS."img".DS."upload".DS.$filename;
$res = $user->execute("UPDATE `{$user->table_name}` set `picture`='{$webFileName}' where `id`='{$userId}'");
if($res){
echo '<script>alert("Upload file success!")</script>';
}else{
echo '<script>alert("Upload file error!")</script>';
}
$this->jump("/main/index");
return;
}else{
echo '<script>alert("Upload file Error!")</script>';
$this->jump("/main/index");
return ;
}
}
该函数主要对上传文件进行了重名名,对文件类型没有要求,可以上传php文件。但是在存储文件的文件夹里.htaccess
限制了执行(php_flag engine off)
0×05 文件包含
涉及到php文件包含的漏洞,可以全局搜索include。
这里有四个地方出现include
,后面两个涉及到view 视图操作,基本没有利用价值。前面两个是一个自动加载类函数,比较可疑,该函数位于core.php 50行左右:
spl_autoload_register('inner_autoload');
function inner_autoload($class){
GLOBAL $__module,$__custom;
$class = str_replace("\\","/",$class);
foreach(array('model','include','controller'.(empty($__module)?'':DS.$__module),$__custom) as $dir){
$file = APP_DIR.DS.$dir.DS.$class.'.php';
if(file_exists($file)){
include $file;
return;
}
}
}
sql_autoload_register
于php5中__autoload
函数的作用是一样的,当实例化一个未定义的类时,就会触发此函数,其目的是避免书写过多的引用文件,使整个系统更加灵活。
前面有文件上传的接口,而且可以上传php文件,加入我们上传一个shell,如果在这里可以成功包含的话,Shell就可以执行了。这里的自动加载类函数会加载以未命名类的类名的文件,也就是说我们要把我们上传的文件生成的随机文件名记录下来,然后作为类想办法加载到这个函数里。
0×06 session机制
按照前面的攻击思路,首先就要想办法触发inner_autoload函数,并把文件名作为参数传进去。也就是说要实例化一个类。涉及到类的实例化,全局搜索关键词‘new’, 一处是core.php 78行左右:
$controller_obj = new $controller_name();
经过分析 $controller_name() = $__controller.'Controller';
后缀必须有Controller,无法利用。
继续看发现一处对session的实例化操作,随之还进行了序列化:
$session = new Session($res[0]["id"],time(),$ip,$userAgent);
$_SESSION['data'] = serialize($session);
有序列化就有反序列化,如果我们可以控制session的内容,使之变成一个我们自己定义的序列化后的类,那么反序列化之后就会对这个类进行实例化,进而触发自动加载类函数,完成文件包含。
下面我们对session的生成进行详细分析:
对于session生成,我根据代码简单做了一个结构图:
首先是core.php将main/index登录请求交给mainController类控制器中的actionIndex方法,在这之前继承BaseController类,该类重写了 SessionHandler 函数,使得 session 可以存储到数据库中。进而调用 session_start() 开启session会话机制。该函数会自动调用MySessionHandler中的open()和read()函数,进行数据库的连接和session的读取。不存在 session的话跳转到 actionLogin 类方法生成session。然后通过序列化触发write()方法将之写入数据库。
下面是session类
class Session{
private $ip ;
private $userAgent;
private $userId;
private $loginTime ;
public static $timeFormat = "H:i:s";
function __construct($userId,$loginTime,$ip="0.0.0.0",$userAgent=""){
$this->userId = $userId;
$this->ip = $ip;
$this->loginTime = $loginTime;
$this->userAgent = $userAgent;
}
public function getUserInfo(){
return array(intval($this->userId),date(self::$timeFormat,$this->loginTime));
}
public function isAccountSec($ip="0.0.0.0",$userAgent=""){
return ($this->ip === $ip && $this->userAgent === $userAgent);
}
static function getTime($timestamp){
return date(self::$timeFormat,$timestamp);
}
}
Session由四部分组成,ip、useragent、userID、logintime。
拿到了$_SESSION['data']
,回到actionIndex函数。对于data,它会对其合法性进行判定:
$session = unserialize($_SESSION["data"],["allowed_classes" => ["Session"]]);
//第二个参数是反序列化的过滤机制,防止注入,转换所有对象到 __PHP_Incomplete_Class对象,除了session
$ip = arg("REMOTE_ADDR");
$userAgent = arg("HTTP_USER_AGENT");
$this->now = $session::getTime(time());
if($session->isAccountSec($ip,$userAgent)){
$userinfo = $session->getUserInfo();
$this->username = $_SESSION['username'];
$this->loginTime = $userinfo[1];
$userId = $userinfo[0];
$user = new User();
$res = $user->query("SELECT picture FROM `{$user->table_name}` where `id`='{$userId}'");
if(!empty($res)){
$this->picSrc = $res[0]['picture'];
}else{
$this->picSrc = "/img/pic.jpg";
}
}else{
echo "<script>alert('your cookie my be stealed by hacker!');</script>";
session_destroy();
$this->jump("/main/login");
}
这里为了方便测试,我将生成session和验证session的代码脱出来单独测试,这是生成的$_SESSION['data']和反序列化之后的结果:
我们再看一下session的存储,这里它通过MySessionHandler类和session_set_save_handler
重写了SessionHandlerInterface
,使session能够存储在数据库里。session_start()函数会自动调用open和read函数去数据库检索session。
session的读取和写入有可能造成session伪造,下面是相关代码(MySessionHandler.php)
public function read($session_id){
$res = $this->dbsession->query("SELECT * FROM `{$this->dbsession->table_name}` where `sessionid` = '{$session_id}'");
if(empty($res)){
return false;
}else{
return (string)@$res[0]['data'];
}
}
public function write($session_id,$data){
$time = time();
$res = $this->dbsession->query("SELECT * FROM `{$this->dbsession->table_name}` where `sessionid` = '{$session_id}' ");
if($res){
$this->dbsession->execute("UPDATE `{$this->dbsession->table_name}` SET `data` = '{$data}',`lastvisit` = '{$time}' where `sessionid` = '{$session_id}'");
}else{
$res = $this->dbsession->create(
["data"=>$data,
"sessionid"=>$session_id,
"lastvisit"=>$time]);
}
return true;
}
write函数是将session的数据写到相应的位置去。当操作$_SESSION来序列化数据的时候该函数被触发。对于write()更深的理解在下面这个图里:
也就是说session数据会更新。所以存到数据库后的session不只是 session['data'] , 还有session['username'],比如下面这样(存在不可打印字符):
data|s:288:"O:7:"Session":4:{s:11:" Session ip";s:9:"127.0.0.1";s:18:" Session userAgent";s:128:"Mozilla/5.0 ?Macintosh; Intel Mac OS X 10_14_0? AppleWebKit/537.36 ?KHTML, like Gecko? Chrome/69.0.3497.92 Safari/537.36";s:15:" Session userId";s:1:"1";s:18:" Session loginTime";i:1543310132;}";username|s:4:"test";
好了,我们搞清楚了session的生成、存储及构成方式,那么有没有办法伪造session成我们想要的内容呢。这里有两个思路,一个是session是存在数据库里的,是否可以通过注入进行修改呢。第二个思路是在在写入之前或读取之后利用代码漏洞进行修改。
0×07 任意session伪造
这里主要是以第一个思路为主(据说是预期解法),我在这里复现一下。这里存在一个任意session伪造漏洞,我从源代码里将关键函数和类拷出来并进行做了一个压缩版方便调试和审计,其主要逻辑就是session的生成过程及存储过程。下面我们将整个流程演示一遍:
按照wonderkun大佬的思路是这样的,
1.上传一个 php shell 文件,然后记录下其文件名,例如: 28mlzz380bs8e4sr6e98xzqxbdx2lj9m.php
2.再注册一个账户,账户名为: ;data|s:40:”s:32:”28mlzz380bs8e4sr6e98xzqxbdx2lj9m”;
3.用上面的账号登录,设置 User-Agent为16个反斜杠,根据自己的情况调整。
4.然后访问 http://127.0.0.1:8888/main/index?s=img/upload/ 就getshell了。
简单来说,就是伪造成这样:
下面是响应:
再次访问,getshell(为了简化我把shell直接放到了同目录下)
那么问题来了,为什么这个session中useragent插入16个反斜杠后会将;username|s:52:"
覆盖掉呢???
首先先明确两个问题:
下面是我请求的url:
http://127.0.0.1/ctftest/XUA-web/hardphp.php?username=leeswi\\\\
生成结果:
这里问题就发生了,我们可以看出来我们输入的username是leeswi\\\\
经过escape过滤之后变成了leeswi\\\\\\\\,并且将其序列化
但是由于写入数据库的时候反斜杠自动转义,username又成了leeswi\\\\
但是前面还是s:14,这在反序列化时就会出错,因为后面只剩10个字符而不是14个了。
如上图,我们再次访问,session会从数据库读出来并反序列化,反序列化出错decode fail进而触发destroy函数进行销毁。
另一个是session覆盖的问题:当数据库里的session有两个同名时,后面的会覆盖前面的。比如
data|s:5:"guest";data|s:5:"admin";
读取session的时候取的是admin,但前提是前面的guest可以正常反序列化。
回到题目,我们观察一下我们成功的payload:
data|s:191:"O:7:"Session":4:{s:11:"Sessionip";s:9:"127.0.0.1";s:18:"SessionuserAgent";s:32:"\\\\\\\\\\\\\\\\";s:15:"SessionuserId";s:3:"111";s:18:"SessionloginTime";i:1545704669;}";username|s:52:";data|s:40:"s:32:"28mlzz380bs8e4sr6e98xzqxbdx2lj9m";";
这里其实隐藏了一个反序列化(可能是session_start导致的,对这个函数还是搞得不太清楚),data这个数据其实是经过了两次序列化,一次是代码actionLogin里,另一次是隐含的序列化。这从username其实就可以看出来一点端倪。因为username我们是没有序列化的,但是存进数据库的却是序列化后的数据。
同样反序列化也是这样。对data要反序列化两次。而实现成功覆盖的前提是其后两个参数都可以反序列化成功,这里对于data来说只要第一次反序列化成功就可以。
这样我们就可以覆盖后面的参数了,由于反斜杠转义问题,导致存入数据库的数据比序列化时的字符要少,所以对于最外面一层的序列化来说,只要构造出符合字符数量要求和格式的字符串就可以反序列化成功。
(注意不可打印字符)
参考文章:https://xz.aliyun.com/t/3453 https://xz.aliyun.com/t/3428
缩水测试版代码:https://pan.baidu.com/s/19cgc8J4c-n4p7eq8yTDAMg
*本文作者:leeswi,转载请注明来自FreeBuf.COM