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.
- Imagen ejecutable baja.
- Región
mmapfija. - Ventana exacta del stack.
- Metadatos suficientes para reconstruir
rbp,rspy 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.
- Ejecutar el binario con el prefijo ya resuelto.
- Trazar la última vuelta de
readque correspondía al stage actual. - Volver a ejecutar y volcar memoria baja,
mmapy stack justo en ese retorno. - Desensamblar el stage capturado para extraer metadatos.
- Probar offline los 65536 pares posibles.
- Quedarse con el que desencriptaba correctamente el siguiente bloque.
- 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}