RC05: Creating and hosting a CTF challenge in two days

| recurse

I've played quite a few CTF challenges, but hadn't attempted to create and host one before. With the announcement of Impossible Stuff Day at RC last week 1, it seemed like a good opportunity to finally give it a shot.

This post covers details of the challenge and the underlying infrastructure. The source files are up on pjg11/quack-ctf-challenge if you'd like to try it out yourself!

The initial plan #

My aim was to host one (1) challenge, with interaction via ssh or nc, as a web-based challenge would take more time to set up.

For challenge ideas, I was inspired by Wordle Bash, a challenge I attempted while playing NahamCon CTF this year. I found it tricky to solve, but liked the game-like nature of the challenge.

The target audience were fellow Recursers - some with CTF experience but a big majority of people being possibly new to them. Having an easy challenge made sense here, allowing me to focus more on the setup.

Looking for existing CTF setups brings up mentions of open-source frameworks like CTFd. This seemed overkill for my case, so I decided to host the challenge from one (1) Docker container on a server - a DigitalOcean droplet with a 25GB SSD and 1GB RAM.

The challenge would be accessible over SSH, but on a different port than 22 as that is the droplet's SSH port.

The container acts as a sandboxed environment - the user is restricted to the container's filesystem and can't access the server's files 2.

A test Dockerfile #

When looking for Dockerfiles of other CTF challenges, I came across a video - BASH ALIAS CTF Challenge built w/ Docker by John Hammond, a cybersecurity content creator and also the author of Wordle Bash.

He used ubuntu-sshd as the base image. The image hadn't been updated for recent Ubuntu versions, so I took the code from one of the Dockerfiles and used that as my starting point.

FROM ubuntu:latest

RUN apt-get update -y && \
    apt-get install -y openssh-server && \
    apt-get clean -y

RUN useradd -m user -s /bin/bash && \
    echo "user:pass" | chpasswd && \
    mkdir /var/run/sshd
WORKDIR /home/user

CMD ["/usr/sbin/sshd", "-D"]

This creates a user apart from the root user, and runs the SSH server with the -D flag:

-D      When this option is specified, sshd will not detach and
        does not become a daemon.  This allows easy monitoring of

I tried a test run, and it worked! I also found out that Docker makes it very easy to forward ports from the Docker container to the host machine, something I wasn't sure how to do before.

root@ubuntu:~# docker build -t test .
[+] Building 32.4s (9/9) FINISHED
root@ubuntu:~# docker run -d -p 20000:22 test
root@ubuntu:~# ssh -p 20000 localhost

SSH by default logs into a shell session, however I wanted to run the challenge file at login instead. I couldn't find how to do so, which lead me to change the setup entirely, as I was short on time.

sshdxinetd #

I came across another CTF challenge - misfortune, also written by John Hammond. This challenge used xinetd instead of sshd to run the challenge, and users can access the challenge over nc or netcat.

I wanted to test this out with the Wordle Bash source code, so the following changes were made:

FROM ubuntu:latest

RUN apt-get update -y && \
    apt-get install -y gpg curl xinetd && \
    mkdir -p /etc/apt/keyrings && \
    curl -fsSL https://repo.charm.sh/apt/gpg.key \
    | gpg --dearmor -o /etc/apt/keyrings/charm.gpg && \
    echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" \
    | tee /etc/apt/sources.list.d/charm.list && \
    apt-get update -y && \
    apt-get install -y gum && \
    apt-get clean -y

The installation is a lot longer, as it includes the setup required for gum, used for the input fields in Wordle Bash.

RUN useradd -m user -s /bin/bash && \
    echo "user:pass" | chpasswd
COPY challenge.xinetd /etc/xinetd.d/challenge
COPY entrypoint.sh /entrypoint.sh

RUN chmod 551 entrypoint.sh && \
    chown -R root:root /home/user

WORKDIR /home/user

There are two additional files required for this to work, which I'll talk about shortly.

CMD ["/entrypoint.sh"]

The port number changes from 22 to 9999, and a shell script called entrypoint.sh is provided as the starting command, which runs the xinetd server.


/etc/init.d/xinetd start;
trap : TERM INT; sleep infinity & wait

Lastly, challenge.xinetd contains information provided to the server, like the type of server, port number and the script to be run.

service challenge
    disable     = no
    socket_type = stream
    protocol    = tcp
    wait        = no
    user        = root
    type        = UNLISTED
    port        = 9999
    bind        =

    server      = /home/user/wordle_bash.sh

    log_type       = FILE /var/log/challenge.log
    log_on_success = HOST PID
    log_on_failure = HOST

This wasn't successful, as the script wouldn't wait for input.

root@ubuntu:~# nc localhost 9999

  ║                                                  ║
  ║                                                  ║
  ║                   WORDLE DATE                    ║
  ║            Uncover the correct date!             ║
  ║                                                  ║
  ║                                                  ║

We've selected a random date, and it's up to you to guess it!
Attempt 1:
Please select the year you think we've chosen:
Now, enter the month of your guess:
Finally, enter the day of your guess:
date: extra operand 'to'
Try 'date --help' for more information.
Invalid date! Your guess must be a valid date in the format YYYY-MM-DD.

To see if this was specific to this script, I tested the same setup with a much smaller script, which worked fine.


echo "What is your name?"
read name
echo Hello $name

This meant my plans for the challenge had to change, and I couldn't use gum for input as I initially hoped 3. At this point, Impossible Stuff Day was coming to an end, so I decided to work on the challenge aspect the next day.

Building QUACK #

Once the initial plan for the challenge failed, I started to think about it more seriously. I looked to challenges I'd played before, particularly these two:

I didn't have the source code for either challenge, but Prisoner seemed easier to recreate, so I went in that direction.

Prisoner is an example of the pyjail style of challenges, where one is provided access to a restricted Python environment, and you're required to escape the jail to access the flag. The core steps are as follows:

For my challenge, I added one extra step. I stored the flag in a hidden directory called .secret, which might add an extra step for those who tend to use ls without any flags to list files.

For the ASCII art, I went with this cute duck, because the challenge started off as a reference to Rubber Duck Debugging.

___( o)>
\ <_. )
 `---'   hjw

While the challenge eventually took a different turn in terms of the story, the duck stuck around. I named the challenge after it too - QUACK.

I found two ways to show the traceback + interactive shell:

  1. Embed an interactive shell within the Python script.
  2. Run the source code with python3 -i

I went with the second option first, making the source code itself very simple. I start off with a sleepy duck, and a while loop asking for input.

#!/usr/bin/env python3

___( -)>
\ <_. )
 `---'   hjw

while True:
    input("> ")

I was still using the xinetd setup, so I faced issues with interactivity once again.

root@ubuntu:~# nc localhost 9999
___( -)>
\ <_. )
 `---'   hjw

> quack
> hello?
> ^D
Traceback (most recent call last):
  File "/home/user/quack.py", line 10, in <module>
    input("> ")
EOFError: EOF when reading a line

It displays the traceback and shell prompt as expected, but exits before I could even type anything in the interactive shell... *sigh*

I'm not entirely sure why this happened, but my guess is nc sees Ctrl+D as a signal to close the connection. I couldn't find any solution for this, so I tried the first option I mentioned earlier, to embed a shell in the code. This recreates the output produced by python3 -i.

31#!/usr/bin/env python3

import readline
import code
from traceback import print_exc
from sys import exit

A sleepy duck is blocking the way to my files.
Can you wake it up (gently) and ask it for
flag.txt? Thanks!
___( -)>
\ <_. )
 `---'   hjw

    while True:
        input("> ")
except KeyboardInterrupt:
except EOFError:
___( O)>
\ <_. )
 `---'    hjw

The challenge now has a more cohesive story tying it together. The goal is to try and wake up a sleepy duck (gently), and access the flag. Ctrl+D or EOFError works, however Ctrl+C or KeyboardInterrupt would be considered too harsh, and the program exits.

print_exc() prints the traceback, and code.InteractiveConsole() provides the interactive shell.

Lastly, I created a flag file, which the users can read on entering the shell. It follows the format commonly found in CTFs - some text written within braces in 1337 5p34k to make it look all cool and fancy:

root@ubuntu:~# echo "CTF{th4nks_f0r_p14y1ng}" > flag.txt

I made the changes to the Dockerfile and other files, and once again, I faced the EOF when reading a line I saw earlier. I found myself at a dead end, so I gave up on this setup, and finally went back to using SSH.

xinetdsshd #

The only thing stopping me from using sshd earlier was not knowing a way to run a script at login, which I finally figured. With that sorted, I got rid of challenge.xinetd and entrypoint.sh, and made the final set of changes to the Dockerfile:

python3 got added to the installation, as the challenge file is a Python script. The base image changed from Ubuntu to Debian for a smaller image size.

FROM debian:latest

RUN apt-get update -y && \
    apt-get install -y python3 openssh-server && \
    apt-get clean -y

Alongside user creation, I created the hidden directory, copied required files to the container and set appropriate permissions.

RUN useradd -m user -s /bin/bash && \
    echo "user:pass" | chpasswd && \
    mkdir "/home/user/.secret"

COPY quack.py /home/user/quack.py
COPY flag.txt /home/user/.secret/flag.txt

WORKDIR /home/user

RUN chmod 444 /home/user/.secret/flag.txt && \
    chmod u+x /home/user/quack.py && \
    chown -R root:root /home/user

Lastly, the ForceCommand parameter is added to the config file before running the SSH server.

RUN echo "ForceCommand /home/user/quack.py" >> /etc/ssh/sshd_config && \
    mkdir /var/run/sshd

CMD ["/usr/sbin/sshd", "-D"]

Finishing touches #

As I was testing and running containers multiple times throughout the process, I created a script to automate it.

set -ex

docker build -t quack .
docker run --pids-limit 100 --read-only -d -p 9999:22 quack

This builds and runs the container in read-only mode, preventing the user from making changes to the flag or any other file. The number of processes running at once have also been limited, to prevent forkbombs. This wouldn't be much of an issue in my case, but I had it there just in case.

After multiple detours and changes, I tested the challenge setup, and it worked. 🥹

root@ubuntu:~# ssh -p 9999 user@localhost
user@localhost's password:

A sleepy duck is blocking the way to my files.
Can you wake it up (gently) and ask it for
flag.txt? Thanks!
___( -)>
\ <_. )
 `---'   hjw

> quack
> hello
> ^D
___( O)>
\ <_. )
 `---'    hjw

Traceback (most recent call last):
  File "/home/user/quack.py", line 20, in <module>
    input("> ")
>>> import os
>>> os.system("cat .secret/flag.txt")
>>> ^D
Connection to localhost closed.

After making sure it all works for the 100th time, I sent a message on our chat platform, in the style of a CTF challenge description:

easy | 100 points | misc

A sleepy duck is blocking the way to my files.
Can you wake it up (gently) and ask it for
flag.txt? Thanks!

The challenge is accessible at ssh -p 9999 user@<IP>, the password is pass.

Running the challenge #

For the 5 hours the challenge was live, I received a total of 6 flag submissions! (For context, this was posted this later in the day on a Friday without much prior notice, so not bad at all!)

The way to submit was to message me the flag directly. Not the most sophisticated setup, but it worked, and I got some positive feedback too:

The only thing I really found missing was some form of logging, which I made an attempt at while writing this post.

Logging #

I was looking to log two things - the number of challenge attempts and the number of solves. Nothing too fancy, just everything in one log file would also work.

Challenge attempts #

SSH logs to /var/auth/auth.log by default. However, that I wasn't seeing any log file when I ran my setup. At the time, I missed a crucial detail - my container was set to read-only, so the log file wasn't being created.

To solve this, I added the -e flag to the Dockerfile command, which prints the logs to stderr instead of a file:

CMD ["/usr/bin/sshd", "-e", "-D"]

Challenge solves #

I started thinking of a backup to reliably track submissions, in case someone solved it but forgot to send me a message. I figured logging the number of times the file was accessed could be a potential solution.

I came across a StackOverflow answer for the same, which used inotifywait to watch at the flag file, and performs commands when the file is opened. I looked up the man page and changed the syntax according to what I was looking for.

inotifywait -e open .secret/flag.txt -m --format "[%T] %w %e" --timefmt "%F %T"

The command watches for the flag file to open, and prints output in the following format when the file is accessed:

[2023-10-11 13:30:00] .secret/flag.txt OPEN

It would run in the background, as the SSH server is running in the foreground. Since there are multiple shell commands to be executed at build time, using an entrypoint script, like in the xinetd setup makes more sense. This would run the inotifywait command as a background process and sshd as a server.


inotifywait -e open .secret/flag.txt -m --format "[%T] %w %e" --timefmt "%F %T" &
/usr/sbin/sshd -D -e

The Dockerfile would also change accordingly.

COPY entrypoint.sh /entrypoint.sh
RUN chmod 551 /entrypoint.sh
CMD ["/entrypoint.sh"]

This doesn't account for multiple flag reads by the same user, but some manual filtering of the logs could solve the issue in my case.

Accessing logs #

The logs are accessible using the docker logs command:

root@ubuntu:~# docker logs <containerid>

During the challenge, appending the -f file to command shows log entries as they appear. Once the challenge is over and I've stopped the container, I can redirect the output of the command (without -f) to a file.

This would contain a combination of SSH logs and the file access logs, which I could filter out using tools like grep.

Conclusion #

Given the timeframe of two days, I'm honestly surprised I was even able to do this much. I'm glad to be a part of a community like RC, that encourages people to try things they otherwise wouldn't.

I hope you enjoyed reading this, and if you do try out the challenge, feel free to leave a comment with any feedback!


  1. It’s a day to work on something that is well beyond what you're currently capable of. The idea is to take the first step towards doing something seemingly impossible, and seeing how far you can get. It's been one of the highlights of my RC experience. 

  2. However, messing up the challenge files for someone else or escaping the container due to a vulnerability is a possibility. Solutions to this include nsjail, which spawns individual read-only instances whenever someone accesses a challenge. It seemed extra for my use case, but I found the concept very cool. 

  3. While writing this post, I happened to test Wordle Bash with the final SSH setup, and it works just fine 🤷🏻‍♀️ probably an issue specific to xinetd then.