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

archivo Seleccionar un writeup Abrir árbol
DiceCTF2026Quals/Another-Onion.md READ_ONLY

Another Onion

Datos del reto

Campo Valor
CTF DiceCTF 2026 Quals
Reto Another Onion
Categoría Reversing
Flag dice{5p33d_1s_a1s0_imp0rtant_1n_r3ver5e_3ngineer1ng}

Descripción del reto

Another Onion era un ELF que se iba reescribiendo a sí mismo por etapas. Cada stage leía 2 bytes de stdin, tocaba una zona mmap fija en 0x90000000, desencriptaba el siguiente bloque y saltaba a él. En la instancia real había 256 etapas, así que la clave completa terminaba teniendo 512 bytes.

Resolver las 256 etapas de golpe no era práctico. La idea era resolver únicamente el siguiente par de bytes en cada vuelta e ir avanzando así.


Reconocimiento

Lo primero fue mirar qué se repetía entre stages. En el binario aparecía este patrón.

4004be: call   read@plt
4004cf: call   read@plt
...
4005a0: movzx  eax,BYTE PTR [rip+...]
4005a7: xor    al,BYTE PTR [rip+...]

Cada stage consumía exactamente 2 bytes y dejaba preparado el siguiente. El detalle importante era que la ejecución dependía por completo del estado dejado por todas las etapas anteriores, memoria baja, región mmap, stack y frame actual.

No era conveniente intentar resolverlo todo de forma estática. Era mejor tratar cada etapa como un problema independiente y capturar un estado válido para resolver únicamente el siguiente par.


Análisis

La estrategia terminó siendo parar el proceso justo después del read de la etapa actual y usar esa foto del estado para probar offline los 0x0000..0xffff pares posibles.

El solver quedó resumido en este bucle.

for stage_idx in range(len(prefix) // 2, total_pairs):
    resume = trace_last_read(binary, bytes(prefix), layout)
    low, mmap, stack = snapshot_stage(binary, bytes(prefix), resume, layout, stem)
    meta = parse_stage_meta(entry, resume, expected_next, disasm, layout)
    pair = solve_pair(meta, low, mmap, stack, stack_qword0, layout, expect)
    prefix += pair

El brute force solo funcionaba si el snapshot se parecía lo suficiente a la ejecución real.

  1. Imagen ejecutable baja.
  2. Región mmap fija.
  3. Ventana exacta del stack.
  4. Metadatos suficientes para reconstruir rbp, rsp y la vuelta al stage.

Mirar solo tres bytes del siguiente bloque daba falsos positivos. Para validarlo se usó una firma enmascarada del prólogo del siguiente stage.

EXPECT_STAGE_HEX = "0f280d0000000048c7c0f0ffffff0f2815"
EXPECT_STAGE_MASK_HEX = ("ff" * 3) + ("00" * 4) + ("ff" * 10)

Los cuatro bytes comodín correspondían al desplazamiento RIP-relative, que iba cambiando entre stages aunque el patrón general siguiera siendo el mismo.


Resolución

El flujo completo terminó siendo este.

  1. Ejecutar el binario con el prefijo ya resuelto.
  2. Trazar la última vuelta de read que correspondía al stage actual.
  3. Volver a ejecutar y volcar memoria baja, mmap y stack justo en ese retorno.
  4. Desensamblar el stage capturado para extraer metadatos.
  5. Probar offline los 65536 pares posibles.
  6. Quedarse con el que desencriptaba correctamente el siguiente bloque.
  7. Repetir hasta recuperar la clave completa.

En la práctica, el replay tenía que parecerse mucho a la ejecución real. En las etapas avanzadas, cualquier cambio pequeño en el frame hacía fallar el solver, así que capturar el stack exacto fue lo que hizo que dejara de desviarse.

Primero lo probé contra una instancia que ya había expirado y recuperé este token.

28d5de4e7fde4f5d5a2ace2ecc17f59f

Después repetí el mismo procedimiento sobre una instancia viva y salió el token bueno.

9e8067aa95d9277bf2a47b90cd1dcd87

Una vez con eso, solo quedaba mandarlo al servicio para obtener la flag final. La resolución estable salía de trabajar etapa por etapa.


Flag

dice{5p33d_1s_a1s0_imp0rtant_1n_r3ver5e_3ngineer1ng}