Usa el árbol para saltar entre colecciones sin salir del lector.

archivo Seleccionar un writeup Abrir árbol
DiceCTF2026Quals/Message-Store.md READ_ONLY

Message Store

Datos del reto

Campo Valor
CTF DiceCTF 2026 Quals
Reto Message Store
Categoría Pwn
Flag dice{w0w...ru5t_1snt_s4f3??}

Descripción del reto

El binario era un ELF de 64 bits escrito en Rust, sin PIE y con Full RELRO. El menú parecía simple. Solo dejaba guardar mensaje, cambiar color, imprimir mensaje y salir.

Pero por detrás había dos bugs que juntos permitían controlar la ejecución. Primero había un índice fuera de rango sobre una tabla de punteros a función y, después, un memcpy que acababa copiando datos controlados por el usuario encima del stack.


Reconocimiento

Lo primero que llamaba la atención era que el punto de entrada real no estaba en el mensaje, sino en el color. El exploit local lo resumía con estas constantes.

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

Como COLOR no tenía comprobación de límites, se podía usar MEMCPY_COLOR = 3356 para que la llamada indirecta que normalmente seleccionaba un color printer terminara saltando a memcpy@GOT.

Eso llevaba a este efecto.

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

El programa copiaba el buffer global, que estaba completamente bajo control del usuario, sobre la pila activa. El offset importante era 0x60, porque ahí acababa alineado el RIP salvado.

Hasta ese punto parecía un stack overflow normal. El problema apareció cuando vi que print_message pasaba el buffer por from_utf8_lossy().


Análisis

Ese detalle de Rust era el que hacía que el reto no se resolviera con una chain normal desde el primer intento. Si el payload llevaba bytes inválidos en UTF-8, from_utf8_lossy() los sustituía por EF BF BD, así que la cadena quedaba corrupta antes incluso de llegar a memcpy.

Por eso la primera etapa del exploit tenía que ser completamente válida en UTF-8. En el script terminé armándola byte a byte y metiendo pequeños fixers donde hacía falta.

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

Ese 0xC2 servía para que algunos bytes conflictivos del gadget siguiente se interpretaran como continuación válida de UTF-8 y así evitar que Rust hiciera la conversión lossy.

Con eso claro, la explotación final quedó dividida en tres etapas.

  1. Stage 1 compatible con UTF-8 para filtrar GOT, leer una segunda chain y pivotar.
  2. Stage 2 en raw para volcar bytes de syscall() desde libc y cargar la tercera chain.
  3. Stage 3 en raw para hacer open, read y write de la flag usando syscalls directas.

Resolución

La primera etapa filtraba read@libc y syscall@libc, y además dejaba preparada la segunda 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)

Una vez pasada esa fase, ya no había restricción UTF-8, así que se podía trabajar en crudo. En ese punto se pedía un dump de syscall() y se localizaba en tiempo real la instrucción 0f 05.

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

Ese detalle resolvía además la diferencia entre local y remoto. En remoto, syscall() empezaba con endbr64, así que el offset del gadget con syscall y ret no coincidía con el de mi libc local.

La tercera etapa era una secuencia de syscalls para abrir flag.txt, leerla y escribirla por salida estándar.

# 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)

Un detalle importante de toda la cadena fue respetar siempre el desfase que metía el único gadget que servía para cargar rdx.

pop rdx ; ret 0xa

Ese ret 0xa desplazaba la pila 2 bytes extra cada vez que se usaba, así que no bastaba con pensar en offsets de qword. Había que construir el payload a nivel de bytes.

Una vez ajustado eso, la chain quedó estable y el binario terminó leyendo flag.txt. La parte más importante no estaba en el bug principal, sino en adaptarse a la capa extra que metía Rust al pasar el buffer por from_utf8_lossy().


Flag

dice{w0w...ru5t_1snt_s4f3??}