Kalmar CTF 2025 - RWX series
Table of Contents
0x00. Introduction
FROM ubuntu:latest
RUN apt-get update && \
apt-get install -y python3 python3-pip gcc
RUN pip3 install flask==3.1.0 --break-system-packages
WORKDIR /
COPY flag.txt /
RUN chmod 400 /flag.txt
COPY would.c /
RUN gcc -o would would.c && \
chmod 6111 would && \
rm would.c
WORKDIR /app
COPY app.py .
RUN useradd -m user
USER user
CMD ["python3", "app.py"]
Looking at the Dockerfile, it builds would.c and creates a would binary under the / directory. A web server running on Flask is launched, and we need to obtain the flag through it.
Concept
int
Looking at would.c, we can obtain the flag by executing the binary as /would you be so kind to provide me with a flag.
=
return
return , 400
=
=
return
return , 400
=
return , 400
=
return
return , 400
Looking at app.py, arbitrary read/write is possible with user privileges, and command execution has a length limit.
There are RWX-Bronze, RWX-Silver, RWX-Gold, and RWX-Diamond variants where the length progressively decreases or the environment changes.
0x01. RWX-Bronze
The first challenge, Bronze, has a 7-byte limit.
=
return , 400
=
return
return , 400
Using the /write endpoint, I saved the following content to /home/user/x:
Then execute this command through the /exec endpoint:
sh<~x
0x02. RWX-Silver
This time there’s a 5-byte limit.
=
return , 400
=
return
return , 400
Like Bronze, create /home/user/x and execute this command:
. ~/x
0x03. RWX-Gold
This time it’s 3 bytes, which I couldn’t solve during the competition.
=
return , 400
=
return
return , 400
I tried various approaches like . ~, ~/*, ~;* but all failed.
Later checking the writeup, I learned it required using GPG (GNU Privacy Guard), the GNU version of PGP. It’s a tool I’d never heard of, but surprisingly it comes pre-installed on Ubuntu.
To explain the background regarding PGP for the exploit scenario:
- PGP keys consist of multiple packets:
- Public key packet
- Secret key packet
- User ID packet
- Signature packet
- Photo ID packet
- …
gpg.conffile controls GPG’s behavior:list-options: Tag for setting options when executinggpg --list-keysshow-photos: Enable displaying photos attached to PGP keys when listing keys
photo-viewer: Tag specifying the program to use for displaying photos attached to PGP keyslist-keys: If this option is ingpg.conf, automatically displays key list when executinggpg
The explanation switches between GPG and PGP, but they’re implemented to be compatible, so it’s not incorrect.
Save the following content to /home/user/.gnupg/gpg.conf:
show-photos
/would you be so kind to provide me with a flag > /tmp/x
Then executing the gpg command displays the key list according to settings. During this process, since the binary for displaying photos is set to /would you be so kind to provide me with a flag > /tmp/x, the command executes when displaying photos.
Payload
=
= +
+= +
return
= +
+= +
return
= +
+= +
return
=
=
=
0x04. RWX-Diamond
The final challenge actually increases the limit to 4 bytes.
=
return , 400
=
return
return , 400
However, the Dockerfile has a change.
...
# RUN useradd -m user
RUN useradd user
USER user
CMD ["python3", "app.py"]
Since the -m option is missing during useradd, the /home/user directory isn’t created, preventing the previous approach.
I also couldn’t solve this during the competition. Checking the writeup revealed it could be solved with a race condition.
When executing commands consecutively like this, the PID increases by 3, allowing prediction of the next process’s PID.
The exploit scenario:
| write | exec |
|---|---|
| Create write process | |
| Create exec process | |
Write “/would you …” to STDIN of sh | |
Execute w|sh |
The reason for executing w|sh is that the length limit is 4 bytes and we need to give STDIN to the sh process, so we use the one-byte command w. While giving a non-existent command to STDIN might work, apparently existing commands have a higher success rate due to different time windows.
Since w|sh creates another process called sh, we need to increase the PID by 4 instead of 3.
The concept is simple, but the approach is impressive.
Payload
=
=
global
global
= .
= +
+= +
return
=
=
+= 4
=
=
break