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 week1, 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 files2.
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
EXPOSE 22
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
sshd.
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
858a1c9b2658715ebc074f8c6de189ebf5fb0b3005d02a5466159a63bae1f75e
root@ubuntu:~# ssh -p 20000 localhost
user@858a1c9b2658:~$
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.
sshd
→ xinetd
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.
EXPOSE 9999
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.
#!/bin/sh
/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 = 0.0.0.0
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.
#!/bin/sh
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 hoped3. 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:
- Prisoner from NahamCon CTF 2022
- Compressor from Cyber Apocalypse 2021
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:
- Start off with an input prompt that doesn't do anything when typing into it. The challenge can be assisted with ASCII art, to create a story.
- Pressing Ctrl+D prints a traceback and displays a Python interactive shell.
- In this shell, one can execute system commands and read the flag.
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:
- Embed an interactive shell within the Python script.
- 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
print("""
__
___( -)>
\ <_. )
`---' 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
>>>
root@ubuntu:~#
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
.
#!/usr/bin/env python3
import readline
import code
from traceback import print_exc
from sys import exit
print("""
A sleepy duck is blocking the way to my files.
Can you wake it up (gently) and ask it for
flag.txt? Thanks!
__
___( -)>
\ <_. )
`---' hjw
""")
try:
while True:
input("> ")
except KeyboardInterrupt:
exit()
except EOFError:
print("""
__
___( O)>
\ <_. )
`---' hjw
""")
print_exc()
code.InteractiveConsole(globals()).interact(banner="",exitmsg="")
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.
xinetd
→ sshd
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
EXPOSE 22
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.
#!/bin/bash
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.
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:
QUACK 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 ispass
.
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:
- What a lovely capture the flag!
- Fun challenge, thank you!
- I really love what you made here! Thanks again!
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.
#!/bin/bash
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!
Notes
-
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. ↩
-
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. ↩ -
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. ↩