Ducts challenge write-up
Introduction
Ducts is my pwn challenge written for the m0leCon CTF Teaser 2025. Here is the official write-up with the intended solution.
Description
The attachment contains three files:
chal
: the challenge executable (ELF binary).Dockerfile
anddocker-compose.yml
: Docker setup used to create the execution environment. The docker image is built on Ubuntu 22.04.
The ELF is dynamically compiled and is not stripped, but has canary, NX and PIE. The libc.so.6
is required to solve the challenge. Extract it from the container with:
The challenge listens to a random port:
When a new connection is made, the process forks and a child handles the new connection:
Summing up there are [2+N] processes:
- A main process that awaits for new connections. When a new connection arrives, it starts a new frontend process.
- N frontend processes (
talk
function), that receives data from the user and sends it to the backend. - A backend process, that receives the data from the frontend and handles it.
IPC structure
The packages exchanged between processes are sent over a pipe. There are two different type of packages: messages and commands.
The frontend function
The talk
function reads text from a socket and creates a new message that is sent over the pipe to the backend. Note that it's not possible to send commands with this function.
Backend function
The backend function supports handling both messages and commands:
Let's see how messages are handled:
void handle_message(undefined4 param_1)
{
message *pmVar1;
message *message;
message = (message *)receive_message(param_1);
printf("Destroying message with len \'%d\' by %s\n",(ulong)(uint)message->message_len,
message->author);
fwrite(&message->message,1,(long)message->message_len,devnull);
pmVar1 = message;
if (first != (message *)NULL_MESSAGE) {
*(message **)((long)last + 8) = message;
pmVar1 = first;
}
first = pmVar1;
last = message;
return;
}
First, the message is written to /dev/null (that's why the frontend says “network black hole” 🙂) and then is inserted into a linked list.
What about the commands? Three different commands are defined: flush_messages, print_messages and redact_message.
void flush_messages(void)
{
first = NULL_MESSAGE;
last = NULL_MESSAGE;
return;
}
The command flush_messages empties the message list (without bothering to free the unused space 💀).
void print_messages(void)
{
message *message;
for (message = first; (message != (message *)NULL_MESSAGE && (message != (message *)0x0));
message = message->next_message) {
printf("Message %p is \'%s\' by %s. Next is %p\n",message,&message->message,message->author,
message->next_message);
}
return;
}
The command print_messages prints the content of every message in the list.
void redact_message(long param_1)
{
int N;
message *source_message;
message *curr_message;
N = 0;
source_message = (message *)NULL_MESSAGE;
curr_message = first;
while ((curr_message != (message *)NULL_MESSAGE && (source_message == (message *)NULL_MESSAGE))) {
if ((long)N == *(long *)(param_1 + 8)) {
source_message = curr_message;
}
N = N + 1;
curr_message = curr_message->next_message;
}
if (source_message != (message *)NULL_MESSAGE) {
source_message->message_len = 1;
source_message->message = *(char **)(param_1 + 0x10);
}
return;
}
The command redact_message takes the Nth message in the list, sets its length to 1, and replaces the first word of the message with a number.
Vulnerabilities
Race condition on the pipe
The whole challenge use only one pipe to exchange data between multiple frontend processes and the backend. What happens when multiple writes are done concurrently on a single pipe? The Linux manual states (https://man7.org/linux/man-pages/man7/pipe.7.html):
POSIX.1 says that writes of less than PIPE_BUF bytes must be
atomic: the output data is written to the pipe as a contiguous
sequence. Writes of more than PIPE_BUF bytes may be nonatomic:
the kernel may interleave the data with data written by other
processes. POSIX.1 requires PIPE_BUF to be at least 512 bytes.
So if multiple messages are written at the same time, with a size bigger than PIPE_BUF
, the content may be interlaced. We can exploit this race condition to forge custom messages and commands.
Next message overwrite
When the frontend sends a message to the backend, it sets the next_message
attribute to NULL_MESSAGE
. This is not an issue per se, but if we manage to forge a message the backend won't overwrite our custom next_message
attribute.
print_messages address leak
The function print_messages
prints also the pointer of next_message
, leaking the address of the NULL_MESSAGE
global variable.
Exploit
Let's wrap up and see how to get RCE. We have to:
- create a helper function that sends custom messages and commands using the race condition on the pipe.
- leak the text base address using the command
print_messages
. - leak the libc base address by reading the GOT section.
- overwrite the function
fwrite
withsystem
in the GOT section. - send a message with content /bin/bash to trigger
system("/bin/bash")
in the handle_message function. - run
cat flag.txt
🚩
Instead of using the system
function, it's also possible to use a one-gadget.
Creating the helper function
To trigger the race condition, we have to send large messages concurrently (30 threads) and see where the backend starts receiving invalid messages. As usual, we can use pwntools' cyclic
to identify the right offset.
A semaphore is used to ensure that all the bytes are sent to the server before triggering the write over the pipe. This exploit works even if some messages are corrupted because the backend ignores invalid bytes until it recognizes the byte that signals the beginning of a packet.
Helper function to forge messages and commands
Text section leak
Libc leak
GOT rewrite and profit
By replacing the fwrite
with system
, the message handler code
becomes
thus the message in the payload triggers the RCE.