Use the tree to jump between collections without leaving the reader.

archive Select writeup Open tree
DiceCTF2026Quals/Message-Store.en.md READ_ONLY

Message Store

Challenge Data

Field Value
CTF DiceCTF 2026 Quals
Challenge Message Store
Category Pwn
Flag dice{w0w...ru5t_1snt_s4f3??}

Challenge Description

The binary was a 64-bit ELF written in Rust, with no PIE and Full RELRO. The menu looked pretty harmless. It only allowed saving a message, changing the color, printing the message, and exiting.

But behind that, two bugs fit together nicely. First there was an out-of-bounds index over a table of function pointers and, after that, a memcpy that ended up copying user-controlled data over the stack.


Reconnaissance

The first thing that stood out was that the real entry point was not the message, but the color. The local exploit already summarized it quite well with these constants.

BUFFER       = 0x2f9e38
GOT_MEMCPY   = 0x2f71c8
TABLE        = 0x2f08e8
MEMCPY_COLOR = (GOT_MEMCPY - TABLE) // 8

Since COLOR had no bounds check, MEMCPY_COLOR = 3356 could be used so that the indirect call that normally selected a color printer ended up jumping to memcpy@GOT.

That produced this effect.

memcpy(rsp + 0x18, BUFFER_global, 0x1000)

In other words, the program copied the global buffer, which was completely under user control, over the active stack. The really important offset was 0x60, because that was where the saved RIP ended up aligned.

Up to that point it looked like a fairly standard stack overflow. The twist came when I saw that print_message passed the buffer through from_utf8_lossy().


Analysis

That Rust detail was what prevented the challenge from being solved with a normal chain on the first try. If the payload contained invalid UTF-8 bytes, from_utf8_lossy() replaced them with EF BF BD, so the chain was corrupted before even reaching memcpy.

Because of that, the first stage of the exploit had to be completely valid UTF-8. In the script I ended up building it byte by byte and adding small fixers where needed.

buf[0x5F] = 0xC2
w8(0x60, POP_RDI)
w8(0x68, 1)
w8(0x70, POP_RDX_0A)
w8(0x78, GOT_LEAK_SIZE)

That 0xC2 made some conflicting bytes from the following gadget get interpreted as a valid UTF-8 continuation and avoided Rust applying the lossy conversion.

With that clear, the final exploitation became fairly organized in three stages.

  1. Stage 1 compatible with UTF-8 to leak GOT, read a second chain, and pivot.
  2. Raw stage 2 to dump bytes from syscall() in libc and load the third chain.
  3. Raw stage 3 to open, read, and write the flag using direct syscalls.

Solution

The first stage leaked read@libc and syscall@libc, and also prepared the second chain.

# write(1, GOT_READ, 0x38)
w8(0x80, POP_RSI)
w8(0x92, GOT_READ)
w8(0x9A, CALL_WRITE)

# read(0, PIVOT_ADDR, 0x400)
w8(0xDA, POP_RSI)
w8(0xEC, PIVOT_ADDR)
w8(0xF4, CALL_READ)

Once that phase was passed, there was no UTF-8 restriction anymore, so the exploit could work with raw bytes. The useful part there was requesting a dump of syscall() and locating the 0f 05 instruction at runtime.

sc_dump = io.recv(0x400, timeout=10)
sc_off = sc_dump.find(b"\x0f\x05")
syscall_ret = syscall_addr + sc_off

That also solved the difference between local and remote. Remotely, syscall() started with endbr64, so the offset of the useful syscall and ret gadget did not match my local libc.

The third stage was already quite direct. It was a sequence of pure syscalls to open flag.txt, read it, and write it to standard output.

# open("flag.txt", O_RDONLY)
w8(0x10, POP_RAX);  w8(0x18, 2)
w8(0x20, POP_RDI);  w8(0x28, flag_path_addr)
w8(0x30, POP_RSI);  w8(0x38, 0)
w8(0x40, syscall_ret)

# read(3, buf, 0x100)
# write(1, buf, 0x100)

The least flashy but most important part of the whole chain was always respecting the shift introduced by the only useful gadget for loading rdx.

pop rdx ; ret 0xa

That ret 0xa moved the stack 2 extra bytes every time it was used, so it was not enough to think in qword offsets. The payload had to be built at byte level.

Once that was adjusted, the chain became stable and the binary ended up reading flag.txt. The delicate part was not the main bug, but adapting to the extra layer Rust introduced by passing the buffer through from_utf8_lossy().


Flag

dice{w0w...ru5t_1snt_s4f3??}