Euskal Encounter 34 · Solve It 6
Solve It 6 — The Last Crusade
- #ctf
- #ia
Indy está buscando el Santo Grial pero anda muy perdido. ¿Estás dispuesto a hincar el diente a esta aventura y ayudarle? El padre de Indiana le ha enviado el siguiente telegrama: EL PRIMER ARTEFACTO LO PUEDES ENCONTRAR CERCA DE LA FUENTE DE TUS FRUSTRACIONES. PRESTA ATENCIÓN Y LO ENCONTRARÁS. ESTAREMOS EN CONTACTO. NOTA: cambio de estilo
Cómo se resolvió
-
Diente azul es Bluetooth
La pregunta pinta esa palabra en azul, y el telegrama dice que el primer artefacto está por ahí cerca. Ahí acaba el teclado: escaneando BLE por el recinto aparecen beacons con nombres de La última cruzada.
-
Una gymkhana de beacons
Cada uno emite una URL corta que lleva a una imagen de estática de televisión con una frase impresa arriba, y la frase dice dónde está la siguiente. La quinta llega interferida, sin frase.
-
Comparten la base de ruido
Dos cualesquiera de las cinco coinciden byte a byte el 60% de las veces; el azar daría un 20%. Si el fondo es común, la información solo puede estar en lo que difiere.
-
Las cinco o ninguna
Combinar dos o cuatro sigue dando ruido medido, sin estructura. Hay que recortar antes la franja del rótulo —el ruido empieza en la fila 142— y hacer XOR de las cinco: la contraseña sale en letras enormes.
Contraseña h0LyGr4IL
La pregunta traía la palabra diente pintada de azul, y debajo un telegrama del padre de Indiana anunciando dónde estaba el primer artefacto. Diente azul: Bluetooth. Este nivel no se jugaba desde el teclado: había que levantarse de la silla y patearse el recinto de la party cazando beacons escondidos por el autor.
La caza era una gymkhana. Cada beacon emitía una URL corta; cada URL llevaba a una imagen de estática de televisión con una frase impresa en la parte superior; y esa frase te mandaba a la siguiente ubicación física. Venezia, Brunwald, Brody, Iskenderun — las paradas de La última cruzada — hasta un último beacon que llegaba interferido, como el castillo del grial en la película.
La contraseña no estaba en ninguna de las cinco imágenes: estaba repartida entre todas. Y ese reparto nos costó un hallazgo falso publicado y dos análisis cerrados en falso, porque combinar dos, tres o cuatro imágenes da exactamente ruido. Abajo va primero la parte física, que solo se podía jugar estando allí, y después el análisis de las imágenes, que se puede reproducir hoy tal cual.
La caza de beacons
Escaneando Bluetooth Low Energy en el recinto aparece una familia de dispositivos con una firma común: OUI A0:F2:62 (el fabricante del ESP32), ServiceData bajo el UUID 0x4242, y nombres de localizaciones de la película. El ServiceData es ASCII plano: una URL corta.
name=Venezia uuid=00004242-... ascii=tinyurl.com/4vt9d7ut
name=Brunwald uuid=00004242-... ascii=tinyurl.com/4zykhx5e
Cada URL lleva a un PNG de estática con un rótulo negro sobre blanco, y el rótulo es la pista que apunta a la siguiente zona del recinto:
| Imagen | Rótulo |
|---|---|
| 1ª | «Bizkaia quiere darse a conocer y ha robado el artefacto» |
| 2ª | «Indiana se ha olvidado el diario de su padre junto a un montón de componentes electrónicos» |
| 3ª | «El Doctor Brody ha llamado por teléfono y nos ha pedido que vayamos a rescatarle» |
| 4ª | «Hemos oído que los nazis quieren boicotear la charla de resolución del Hack-It/Solve-It» |
| 5ª | (sin rótulo: la imagen «jammeada», 400 píxeles de alto en vez de 542) |

Un detalle técnico que ahorra una hora: bluetoothctl info cachea el último advertisement que vio del dispositivo, así que si el beacon alterna payloads, te devuelve siempre el mismo. Hay que capturar por frame, con un callback — nuestros scripts usan bleak en Python: escanean, filtran por el UUID 0x4242 y registran cada payload nuevo con su MAC.
Hasta aquí lo irrepetible. Las URLs cortas siguen redirigiendo hoy (lo hemos comprobado al escribir esto), pero el host de las imágenes ya no responde: las cinco copias que guardamos son la única fuente, y con ellas se reproduce todo lo que sigue.
El primer intento: un hallazgo que no lo era
Lo primero que probamos con las imágenes fue combinar dos quedándonos con lo que difiere entre ellas. Apareció texto y lo cantamos como hallazgo. Es el caso más limpio de verificación circular de toda la serie: los rótulos están impresos a la vista en cada PNG, y al combinar dos, la banda blanca —idéntica en ambas— se cancela y quedan las letras de las dos frases, superpuestas. El «texto revelado» ya estaba en la entrada. Solo que se veía distinto —blanco sobre negro en vez de negro sobre blanco— y eso bastó para que pareciera nuevo. La cura cabe en una línea: antes de afirmar que un tratamiento revela algo, abre el original.
Lo que sí se sostiene: una base de ruido compartida
Descartado el espejismo, quedaba un hecho medible. Dos imágenes de ruido independiente coinciden byte a byte con la probabilidad que dictan sus histogramas; para estas imágenes, eso es un 20,3%. Lo medido entre pares: 60,8%. Tres veces la tasa de azar: el campo de ruido no es independiente, hay un generador base compartido. (Ojo con la cifra de manual «1/256 ≈ 0,4%»: solo vale para bytes uniformes, y estos no lo son. La baseline se calcula con los datos.)
from PIL import Image
import numpy as np, itertools
names = ['artefacto2.png', 'artefacto3.png', 'artefacto4.png', 'artefacto5.png']
A = {n: np.array(Image.open(n)) for n in names}
for x, y in itertools.combinations(names, 2):
a, b = A[x].ravel(), A[y].ravel()
px = np.bincount(a, minlength=256) / a.size
py = np.bincount(b, minlength=256) / b.size
print(x, y, 'obs=%.3f esperado=%.3f' % ((a == b).mean(), (px * py).sum()))
# -> obs ~0.608 frente a ~0.203: base de ruido compartida
Y la imagen jammeada, de 400 píxeles, encaja con las demás a partir de la fila 142 — justo donde acaba la banda del rótulo en las de 542. Alineada ahí, coincide al 50% con las otras; en cualquier otro desplazamiento, al 20% del azar. Es el mismo lienzo de ruido, recortado.
El mecanismo: las cinco o ninguna
Esa base compartida no sirve para «revelar» los rótulos, que ya se ven. Es el mecanismo de otra cosa: el campo de ruido esconde la contraseña, y hacen falta dos condiciones a la vez.
Primera: las cinco imágenes juntas, incluida la jammeada. Los pares, recortada la banda del rótulo, dan ruido puro: la desviación por bloques 8×8 del bit bajo queda en el nulo binomial (0,063 medido frente a 0,0625 teórico). Con cuatro imágenes, cualquier combinación de cuatro, lo mismo: ≈0,063, sin estructura. El payload solo emerge en el acumulado de las cinco.
Segunda: recortar antes la franja del rótulo. El ruido empieza en la fila 142; si la banda impresa se queda dentro, el texto visible y el escalón blanco/ruido tapan el resultado.
Con las dos condiciones, diez líneas:
from PIL import Image
import numpy as np, functools
names = ['artefacto2.png','artefacto3.png','artefacto4.png','artefacto5.png','artefacto6.png']
ims = [np.array(Image.open(n).convert('L')) for n in names]
# el campo de ruido: fila 142 en adelante; la jammeada ya viene sin banda
crops = [(a[142:542,:1000] if a.shape[0] > 500 else a[0:400,:1000]).astype(np.uint8) for a in ims]
x5 = functools.reduce(lambda a, b: a ^ b, crops)
pc = np.unpackbits(x5.reshape(-1,1), axis=1).sum(axis=1).reshape(x5.shape) # bits a 1 por byte
Image.fromarray((pc * 36).astype('uint8')).save('solucion.png')

La contraseña sale en letras negras gigantes: caen donde el XOR se anula, sobre un fondo de ruido equilibrado (la media de bits a 1 por byte es 3,90 sobre un rango 0–7). Y no hace falta ser fino con el realce: la mediana de las cinco la enseña también, con más grano; el bit bajo a solas, nítida.

Con esto, la estadística de antes encaja: la base compartida (60,8% frente al 20,3% de azar) es justo lo que hace que la combinación cancele el fondo y deje en pie lo que el autor escribió encima. Los dos hechos que parecían pelearse eran el mismo diseño.
Un cabo suelto, declarado
Algunos payloads de los beacons llegaban con un carácter de más tras el código de la URL (D, R, f, T…). Intentamos leerlos como canal secundario, forzamos una palabra temática con ellos, y descartamos esa lectura con razón: era apofenia. Pero el enunciado termina con una nota del autor — «NOTA: cambio de estilo» — que no encaja con nada de lo que sí explicamos, y esos caracteres mezclan mayúsculas y minúsculas, que es lo que uno esperaría de una pista sobre «estilo»: la propia contraseña alterna cajas. Descartar una lectura no es descartar el canal. Queda abierto y lo dejamos escrito, en vez de convertir «no supimos leerlo» en «no era nada».
Lo que nos llevamos
Cerramos este frente en falso dos veces, en direcciones opuestas. Primero por exceso: un tratamiento que «descubría» texto ya impreso en la entrada, publicado sin abrir un solo original. Después por defecto: negativos bien instrumentados —pares sin estructura, jammeada sin nada dentro—, correctos los dos, que solo cubrían subconjuntos del problema. Un negativo solo cubre lo que probaste, y probar diez pares no es probar la combinación de cinco. Lo que cerró el reto fue la memoria de quien recordaba las dos condiciones exactas — todas las imágenes juntas, y sin la banda del texto — después de que dos análisis rigurosos dijeran que ahí no había nada.
La parte de beacons, montada con asistencia de IA, funcionó a la primera. La de imágenes es el caso de estudio de la serie: la regla útil no es «si comparten base de ruido, combínalas», sino «combínalas todas, y recorta antes lo que no sea ruido». La versión ingenua no habría resuelto este reto.
Solución:
h0LyGr4IL
El material — los scripts, las capturas y el writeup técnico completo de este nivel están en euskal34/solve-it/6.