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

archivo Seleccionar un writeup Abrir árbol
DiceCTF2026Quals/Garden.md READ_ONLY

Garden

Datos del reto

Campo Valor
CTF DiceCTF 2026 Quals
Reto Garden
Categoría Pwn
Flag dice{m1tig4ti0ns_nev3r_w0rk_y0u_escap3d_th3_w4ll3d_g4Rd3n}

Descripción del reto

Este reto giraba alrededor de una VM que manejaba arrays normales y objetos off-heap. El recolector de basura hacía un mark and compact sobre el heap principal y luego actualizaba las referencias. Hasta ahí todo normal.

El problema aparecía cuando durante el marcado entraba un root con valor 0. En vez de descartarlo, el GC lo trataba como si heap_base + 0 apuntara a un objeto válido y empezaba a compactar desde ahí.

Eso terminaba introduciendo un desplazamiento de 4 bytes sobre todos los objetos vivos. Como el origen y el destino de la compactación se solapaban, se podía sobrescribir la cabecera del siguiente objeto antes de que fuera copiado. Ahí estaba la primitive que se podía aprovechar.


Reconocimiento

Lo primero que hice fue ir a la parte del recolector para entender exactamente en qué momento se rompía el layout del heap.

for (size_t i = 0; i < num_roots; i++) {
    mark_reachable(roots[i], &ctx);
}

size_t new_heap_used = 128;
for (size_t i = 0; i < ctx.obj_count; i++) {
    arr_header_t *arr = AS_ARR_HEADER(ctx.objs[i].object);
    size_t total_size = sizeof(arr_header_t) + ctx.objs[i].size * sizeof(ref_t);
    memmove(heap_base + new_heap_used, arr, total_size);
    ctx.new_locations[i] = (ref_t)new_heap_used;
    new_heap_used += total_size;
}

Revisando la lógica se veía que mark_reachable() no filtraba ref == 0 antes de tratar esa referencia como objeto. Como los primeros 128 bytes del heap estaban reservados, el recolector acababa interpretando esa zona como una cabecera falsa de tamaño cero y empezaba a compactar con el offset mal calculado.

En resumen, el GC se desalineaba y eso permitía pisar la cabecera del siguiente objeto vivo. A partir de ahí se podía usar el bug para modificar objetos y controlar mejor el heap.


Análisis

Con esa primitive había dos formas de seguir.

  1. Reconvertir el objeto off-heap original en un objeto numérico de longitud 5 para poder editar su data pointer y su obj_size.
  2. Forjar nuevos objetos off-heap a partir de arrays numéricos normales usando una secuencia bridge -> attacker -> target antes de disparar un fake-root GC.

Al revisar el exploit, las constantes ya mostraban hacia dónde iba la resolución.

OFFHEAP_HDR = 0x00040005

LIBC_LEAK_OFF = 0x203B20
ENVIRON_DELTA = 0x20AD58 - LIBC_LEAK_OFF
SYSTEM_DELTA = 0x58750 - LIBC_LEAK_OFF
POP_RDI_DELTA = 0x10F78B - LIBC_LEAK_OFF
RET_DELTA = 0x2882F - LIBC_LEAK_OFF

Con eso en mente, los pasos de explotación quedaron así.

  1. Usar el objeto original una sola vez para ampliar obj_size.
  2. Leer un puntero estable a libc + 0x203b20 desde metadata liberada por el GC en los índices 380/381.
  3. Forjar un nuevo lector off-heap apuntando a __environ.
  4. Leer desde ahí la dirección real de la zona de entorno en el stack.
  5. Forjar un escritor off-heap hacia saved_rip = __environ_value - 0x130.
  6. Escribir una ROP chain mínima que terminara llamando a system("cat flag.txt").

Lo que más costó aquí no fue encontrar el objetivo final, sino mantener la explotación estable. Si se reutilizaba demasiado un objeto ya modificado, el estado del heap era difícil de predecir.


Resolución

La parte más importante era la construcción de objetos falsos, porque dependía mucho del orden en el que el GC compactaba los vivos. Por eso hacía falta una fase intermedia de GC "seguro" antes del fake-root GC. Si no se hacía eso, quedaban temporales muertos entre medias y los objetos recién forjados se movían de sitio.

Una vez resuelto ese detalle, la parte final del exploit ya consistía en escribir directamente la cadena de retorno y el string para system.

vm.write_offheap_from_reg_elem(OFFHEAP_STACK_REG, 0, LIBC_LO_REG, 0, RET_DELTA)
vm.write_offheap_from_reg_elem(OFFHEAP_STACK_REG, 2, LIBC_LO_REG, 0, POP_RDI_DELTA)
vm.write_offheap_from_offheap(OFFHEAP_STACK_REG, 4, OFFHEAP_ENV_REG, 0, -0xF0)
vm.write_offheap_from_reg_elem(OFFHEAP_STACK_REG, 6, LIBC_LO_REG, 0, SYSTEM_DELTA)

vm.write_offheap_const(OFFHEAP_STACK_REG, 16, 0x20746163)
vm.write_offheap_const(OFFHEAP_STACK_REG, 17, 0x67616C66)
vm.write_offheap_const(OFFHEAP_STACK_REG, 18, 0x7478742E)

Esos últimos valores no tienen mucho misterio. Son "cat ", "flag" y ".txt" en little endian.

En local, el exploit terminaba imprimiendo esto.

dice{dummy_flag}

Y contra remoto, resolviendo también el PoW del servicio, devolvía ya la flag real.

dice{m1tig4ti0ns_nev3r_w0rk_y0u_escap3d_th3_w4ll3d_g4Rd3n}

La versión estable salió cuando dejé de reutilizar los mismos objetos una y otra vez y empecé a forjar lectores y escritores nuevos para cada fase importante. Al final fue más estable separar bien qué objeto se usaba en cada paso.


Flag

dice{m1tig4ti0ns_nev3r_w0rk_y0u_escap3d_th3_w4ll3d_g4Rd3n}