天才クールスレンダー美少女になりたい

チラシの表(なぜなら私はチラシの表にも印刷の上からメモを書くため)

SECCON CTF 2023 Finals 敗北記録

12月23〜24日、卒論執筆の気晴らしとしてSECCON CTF 2023 Finalsに参加してきました。

予選で活躍していた私より強いwebプレイヤーの皆様が誰も本選に出ないとのことで、未熟な身ではありますが不肖わたくしが出ました。web誰もできないのはマズくない? ということで。まあ結果的には私を外して誰か別の人(cryptoかrevかpwnの人)を入れた方が良かったのですが……。私の貢献が0であった以上、この操作を行っても点数が減ることはありえない。以上証明終了。

CTFの少人数チーム戦で自分以外のみんなが正の貢献をしているのに私だけ0で無という状況はTSG LIVE! のライブCTFで正直慣れたというか、いやこれは絶対に良くない慣れなんですが、もう何も思わなくなってしまいました。ほとんど精進せず雰囲気でJOIに出て本選で0点を取ってまあそんなもんだよなと勝手に納得していた中高生の頃から一切変わってない……*1

それはそれとして問題を解けないこと自体は悔しいので今後も精進します。来年……は予選突破できるか、突破したとしても本選に参加するか分かんないですけど。web見れる人が複数人いれば私の部分的な気付きが正の点数に繋がることもあるのですが、私だけで解ききる力は全然ないという問題が……

writeup

0点なので解法はありません。

私が手をつけた問題のうち大半が全部Arkさん作問なので、具体的なソースコードはこっちを見てもらえれば。Plain BlogはSatoooonさんですが。

github.com

作問者writeupもちょうど公開されていました。

blog.arkark.dev

[web] babywaf

babyもbeginnerもwarmupも信じられないこの世界で。

// プロキシ側
app.register(require("@fastify/http-proxy"), {
  upstream: "http://backend:3000",
  preValidation: async (req, reply) => {
    // WAF???
    console.log(req.body)
    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;
    },
  },
});
// バックエンド側
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) => {
  console.log(req.body)
  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);

バックエンドの前にプロキシが挟まっていて、前段はfastifyで後段はexpressでした。プロキシ側でtypeof req.body'object'以外のときにJSON.parseしているのが変だったので、たとえば配列とか突っ込んで.toStringで変なことにならないかな……と思ったのですが、普通にtypeof []'object'なのでJSON.parseに行けませんでした。

prototype pollutionも検討したけどプロパティ名を指定できるとこないし、{"__proto__": {"givemeflag":true}}{"constructor": {"prototype":{"givemeflag":true}}}も、Content-Typeapplication/jsonにしてもtext/plainにしてもダメでした。

で、expressとfastifyの挙動の差を利用するためexpressで使ってるbody-parserのドキュメントを見たところ、こっちはデフォルトで圧縮されたリクエストボディを受け付けるっぽいです。勝ったのでは? と思いリクエストボディをgzipにして送ってみましたが、前段のfastify側でFST_ERR_CTP_INVALID_CONTENT_LENGTHなるエラーが出て終わりました。これは後で分かったのですが、リクエストボディがASCII範囲じゃないと(正確にはUTF-8としてvalidじゃないと?)ダメらしいです。

アフターパーティーで「ASCII範囲だけでdeflateを作って送ったらフラグを得られた」という話を聞いたのですが、肝心の「ASCII範囲だけでdeflateさせる方法」をググっても発見できず検証できていません。そろそろ圧縮アルゴリズムに対する理解を深めて特殊なdeflate/gzipデータを作れるようになっておいた方がいい気がします。この間のzer0pts CTF*2で出たzlib oracleとかの例もありますし。

keymoon.hatenablog.com

gzipがダメならSHIFT_JISとか……と思ったけど普通にfastifyが理解してしまうので失敗。

追記:ASCIIだけでdeflateするツール、他の人のwriteupに書いていました。

github.com

blog.tyage.net

想定解はBOMだったらしいです。うーん存在を完全に忘れてた。

[web] cgi-2023

何回か読んで題意が全く掴めなかったので後回しにしました。クローラ付きの作品だから何かクライアント側の脆弱性だろうとは思ったんですが、XSSはなさそうだし……

CGIのこと全然分からなくてheader injectionが可能ということすら気付かなかった。

[web] DOMLeakify

問題見た瞬間「Firefox問!?!?!?」って絶叫しました。いや叫んでないけど。というわけでdangling markupかなあと思ったんですが普通にinnerHTMLで設定するので後ろを巻き込めないし、そもそもDOMPurifyがちゃんとdangling markupを除去してくれるので無意味ですね。

ちなみにこのためだけにDOMPurify結果確認ツールを作りました。petite-vue(Vueの超小型バージョン)とAlpine.jsのどっちで作るか一瞬迷ったんですが、普段Vue書いてて勝手が分かってるのでとりあえずpetite-vueで。こんな書き捨てツールでいちいちイベントハンドラ書いて云々とかダルすぎるので、それを完全にスキップしてくれるv-modelの偉さを思い知らされました。Reactじゃこの手軽さは出ない。

github.com

alpinejs.dev

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js"></script>
  </head>
  <body>
    <h1>test</h1>

    <script type="module">
      import { createApp } from "https://unpkg.com/petite-vue?module";

      createApp({
        text: "",
        get purified() {
          return DOMPurify.sanitize(this.text, {
            FORBID_TAGS: ["style"], // No CSS Injection
            FORBID_ATTR: ["loading"], // No lazy loading
          });
        },
      }).mount();
    </script>

    <div v-scope>
      <p>{{ purified }}</p>
      <textarea v-model="text"></textarea>
    </div>
  </body>
</html>

で、いろいろ試して無理そうということが分かりました。DOMPurifyの脆弱性を突く問題である可能性はさすがに低いので、何かの機能をオラクルとして使うXS-Leaksだろうな〜という結論に。でも思いつきそうにないのでスキップ。

[web] LemonMD

deno-gfmかsanitize-htmlのゼロデイ脆弱性……はさすがにないでしょう。パッと見た感じそれ以外に脆弱性がなさそうで諦めました。

そうか、hydrationに使うデータをDOM Clobberingで上書きしてデシリアライザのprototype pollutionを狙うのか……

gist.github.com

これNextとかNuxtとかAstroとかで似た脆弱性が発生しないかちょっと怖くなってきますね。特にpartial hydrationをやってるAstro。

[web] Plain Blog

babywafと並んでsolveがそこそこあったので、たぶん解ける問題なんでしょう。

# main.py
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'
    print(path)
    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)
# util.py
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)
    print(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)

パスを外部から指定できる構造なのでパストラバーサルを疑います。resolve_dotsの実装があからさまにバグっていて、スラッシュを何個続けてもLinuxでは1つとして解釈されることを考慮できていません。たとえば/?page=index/////../../flagにアクセスすると[[FLAG]]が返ってきます。フラグファイルを読み出せてるけどフィルタで消されちゃってますね。ここまでは私でも解けました。

ただし、ファイル名の最後に強制的に.txtが付いてしまうので、変な小細工が効きません。たとえば/proc/self/memが読めれば何か別の方法があったのかもしれませんが。ヌルバイトを間に挟んだらその後は無視されないかな〜とも思ったのですが、普通に怒られました。

よく読むと他にも変な処理がありました。普通に'hoge' in textでチェックすればいいのにいちいちcontains_wordでファイル読み込みをやり直してるのは変で、race condition/TOCTOUを利用してくださいと言わんばかりです。なので、textの取得からcontains_wordの実行までにpathが指すファイルの内容をすり替えられないか、symlinkを悪用できないか、あるいはそもそもPythonが動いてるプロセスのcwdをズラせないか、いろいろ考えてみたのですが無理でした。contains_wordの脆弱性を突けさえすれば、passwordを盗んでpremium側のエンドポイントでフラグをゲットできそうなのですが……(premiumじゃない方のエンドポイントはフラグのフィルタをバイパスしようがないです)

想定解がprocfsを利用しているかは分かりませんが、procfsを利用した解法はいくつか見ました。もっとprocfsに詳しくならないとダメみたいです。

[misc] whitespace.js

コードを1文字ずつバラして空白を間に挟み、それをJavaScriptとして実行する問題。任意文字列を作るのは余裕だし、基本的なオブジェクトはFunction含めjsfuckと同様の方法で作れるのですが、文字列をevalする方法が思いつきませんでした。

github.com

Function`hoge` ``Function(['hoge'])()と等価になるのでevalとして使えるのですが、ここで実行する文字列はリテラルでないといけない罠があります。Function` h o g e ` ``で実行できるならFunction噛ませずh o g eでいいだろ……となって完全に詰みました。Array.prototype.toStringを汚染して[' '].toString()でコードを返させる発想が一切出なかったのは猛省しています。

research.securitum.com

こういう面白い記事も発見したんですが、今回は別プロセスをspawnするわけではないので使えませんでした。

それにしてもwebのCTFerってほんと縛りJavaScript問好きですよね。アフターパーティーでArkさんが「TSG CTF 2023のFunctionlessにインスパイアされて作った」みたいなことを言っていた気がします。TSG CTF 2023はmiscのFunctionlessとwebのBrainfxxk Challengeの2問でJavaScript括弧なし縛りプレイが出題された謎のCTFでした。まあ後者は私が作ったんですけど*3。おかげでjsfuckにだいぶ詳しくなり、今回の問題でもこの知識はけっこう活きました。結局解けてないから意味ないですが……

ちなみに解きながらFunctionlessのwriteupはいろいろ見たけど結局whitespace.jsに使えそうなやつ全然なかったです。悲しい。

まとめ

精進します……

せっかくオンサイトなので参加記本体も書きたいんですが、卒論が本当にヤバいので後回しです。締切まで1週間もなくて正月帰省すら諦めました。もうロシア語も英語も読みたくない。

*1:同じことが化グラ/IChO選考でも発生した

*2:調べたら1年半以上前で全然「この間」じゃなかった……

*3:これは自慢ですがFunctionlessという名前を考えたのは私です。あとFunctionlessはテスターとして解いたのですがJavaScript力が弱すぎて解けませんでした。instanceofが使えるとこまでは思いついたんですが……