PAUL GUERRERO software engineer
← retos

Euskal Encounter 34 · Hack It 4

Hack It 4 — Embedded secret

Resuelto
  • #ctf
  • #ia

Cómo se resolvió

  1. Una propiedad CSS que no existe

    El reto no traía fichero, traía un servicio. En el HTML, una línea que el navegador ignora por inválida, port: 55432, y al lado un usuario, vecwarden, guiño a Vaultwarden.

  2. 65.908 contraseñas que son el pajar

    Eso es lo que hay dentro de la base: filas que parecen contraseñas, cada una con un vector de 128 dimensiones que la resume. Probarlas una a una contra un login es justo lo que el reto quiere.

  3. Filas borradas que seguían ahí

    El autor había capado una función del catálogo para dejar leer solo dos tablas de mantenimiento, vacías. Pero Postgres marca la fila muerta sin borrarla: en sus páginas físicas seguían unas dimensiones, 400×90, y una permutación de 0 a 399.

  4. Los vectores dibujaban el texto

    Proyectados sobre las dos direcciones en las que más varían y contados en una rejilla de 400×90, su densidad forma una imagen. Ordenas las columnas con la permutación, quitas el degradado del fondo y aparece.

Contraseña TheEagleSeesTheForest

Embedded secret, «un secreto incrustado». El título dice la verdad dos veces, y las dos cuentan. Hay un secreto incrustado a mano en la página del reto —una línea que no debería estar ahí—, y hay un secreto guardado dentro de unos embeddings, esos vectores con los que hoy se representa cualquier cosa para que una máquina la compare. La gracia del nivel es que las dos lecturas son ciertas a la vez.

Este reto no traía fichero que descargar. Traía un servicio escondido y una invitación a mirar dónde nadie mira: no en los datos que la base te sirve, sino en los que cree haber borrado. La cadena entera —de una propiedad CSS inválida a un texto dibujado con la densidad de 65.908 puntos— es de las más bonitas de la Euskal 34. Y el error que cometimos por el camino es, con diferencia, lo más instructivo que nos llevamos.

Lo contamos entero, con la solución primero y el tropiezo después, porque el tropiezo no fue del reto: fue nuestro, y de los que se repiten en cualquier sitio.

Antes de nada: esto hoy no se puede reproducir

Hay que decirlo al frente y sin adornos. La base de datos del reto está caída. El host ni siquiera quedó anotado. No guardamos ningún dump, ni la tabla de contraseñas, ni los vectores, ni la permutación, ni la imagen final que resolvió el nivel. Para repetir esto de verdad haría falta el PostgreSQL original —o una copia de esa tabla más la permutación—, y no existe ninguna de las dos cosas.

Lo que sigue es un «cómo se haría» honesto pero no ejecutable: el mecanismo, reconstruido a partir de nuestras notas, no una sesión que puedas volver a lanzar. Lo único firme que sostiene la contraseña es que fue la que aceptó el concurso, y que su nombre encaja con el mecanismo de una forma que no parece casual. Todo lo demás es memoria, y así lo marcamos.

La puerta: leer filas que alguien había borrado

El comienzo es un detalle fácil de pasar por alto. En el HTML del reto, dentro de un <dd>, había una propiedad CSS que no existe:

port: 55432;

Un navegador se la salta sin rechistar —no es CSS válido, no hace nada—. Para nosotros era una dirección: un puerto. Al lado, un nombre de usuario, vecwarden, guiño a Vaultwarden, un gestor de contraseñas. Sumando las dos pistas: hay un PostgreSQL escuchando en el 55432 y estas son sus credenciales.

Dentro, lo primero que aparece es el decoy. Una tabla candidates con 65.908 filas, cada una un string que parece contraseña (shadow, matrix, letmein, y basura tipo ojudekixaw) y su embedding de 128 dimensiones. La tentación es evidente: la aguja está entre las 65.908, hay que probarlas contra algún login. Es exactamente lo que el reto quiere que pienses. 65.908 agujas idénticas no son una aguja: son el pajar.

La puerta real estaba en el catálogo de la base, que es la parte que describe cómo está construida por dentro. El autor había redefinido una función del sistemaget_raw_page, de la extensión pageinspect— envolviéndola para que solo dejara leer dos tablas concretas, en un esquema maintenance, y saltara con permission denied para cualquier otra cosa. Un candado tan específico es una señal, no un muro: si alguien se molesta en dejar abiertas exactamente dos tablas, es porque quiere que las mires.

El detalle bonito es qué había en esas dos tablas: nada, aparentemente. Sus filas estaban borradas. Pero en PostgreSQL borrar una fila no la borra del disco: la marca como muerta y la deja donde estaba hasta que otra escritura la pise. La función redefinida daba acceso a las páginas físicas —los bloques en crudo— y ahí seguían las filas muertas, enteras, esperando. Parseando esos bytes a mano salieron dos cosas:

  • Una tabla meta con una sola fila muerta: (w=400, h=90). Unas dimensiones.
  • Una tabla idx_rebuild_log con 400 filas muertas, cada una un par de enteros. Los primeros iban de 0 a 399; los segundos eran una permutación exacta de esos mismos 0 a 399.

Los nombres de las columnas —legibles aunque el esquema estuviera capado— cerraban la lectura: meta(w, h) y un log de reconstrucción con «columna mostrada → columna original». Traducido: hay una imagen de 400×90 con las columnas barajadas, y esta lista de 400 pares es el mapa para desbarajarla.

De dónde salían los píxeles

400 × 90 = 36.000 píxeles. La única fuente de datos con volumen para llenar una imagen así eran los 65.908 embeddings. Un embedding es un punto en un espacio de muchas dimensiones —aquí 128—; para dibujar hay que bajarlo a dos. La forma estándar es quedarse con las dos direcciones en las que los datos más varían, que es lo que hace un PCA, y tirar el resto.

Y los números cuadraban a la primera. Sobre esas dos direcciones, los 65.908 puntos se extendían justo en un rango de unas 400 unidades a lo ancho y unas 90 de alto. El eje ancho eran las 400 columnas; el alto, las 90 filas. Cada píxel valía tanto como puntos cayeran en su celda: la densidad. Donde había texto, más puntos; donde no, el ruido de fondo.

El pipeline, entonces, era corto:

  1. proyectar los 65.908 puntos a 2D y contarlos en una rejilla de 400×90 → un mapa de densidad;
  2. aplanar el perfil vertical —restar la media de cada fila— para quitar un degradado que crecía hacia abajo y tapaba el dibujo, como hierba tapando una figura;
  3. desbarajar las columnas con la permutación recuperada;
  4. suavizar y mirar.

Esquema ilustrativo con dos paneles: a la izquierda, un mapa de densidad con las columnas barajadas donde no se distingue nada; a la derecha, el mismo mapa tras ordenar las columnas y aplanar el fondo, donde emerge la palabra HELLO. Reconstrucción con datos de juguete, no la imagen real del reto.

Figura ilustrativa (reconstrucción). No es la imagen original del reto —la base de datos ya no existe y no se conservó ningún dump ni la imagen generada—. Esquema hecho con datos de juguete para mostrar el mecanismo: una nube de puntos esconde texto que solo se lee al deshacer un barajado de columnas. La palabra, el tamaño y la permutación son inventados.

Suena limpio. Y lo era. El problema es que tardamos horas en creérnoslo.

El error: ocho imágenes, una abierta

Esta es la parte que hay que contar entera, sin dramatizar, porque es el error caro y se repite en cualquier reto que empiece con una imagen.

El mapa de densidad, mirado en crudo, parecía hierba creciendo desde abajo: un degradado temático, sin texto a la vista. Muy en el espíritu de un pajar. Probamos umbrales de densidad, ocupación, residuos, el bit de signo de los vectores, distintos ordenamientos… todo daba ruido. Medimos incluso que los puntos se repartían de forma prácticamente uniforme por el lienzo, sin imagen aparente en la densidad global.

Entonces vinieron los dos errores, encadenados.

Uno. Para acertar con la orientación —no sabíamos de qué lado iba cada eje ni en qué sentido desbarajar— generamos un set combinatorio de ocho variantes de la reconstrucción: dos signos para un eje, dos para el otro, dos direcciones de desbarajado. Ocho imágenes escritas a disco de una tirada. Y abrimos una sola. Salió ruido. Concluimos «no hay texto». El texto estaba nítido en otra de las siete, en una variante que nadie llegó a mirar. El pipeline era correcto desde el primer momento; lo que falló fue no abrir los ocho ficheros que ya teníamos generados.

Dos. Para rematar, apuntalamos el negativo con un test estadístico: medimos que la densidad global se comportaba como un degradado puro, sin estructura, y de ahí salió una frase escrita con todas las letras —matemáticamente imposible que haya texto ahí—. El fallo es sutil y vale la pena verlo: ese test medía la densidad global, promediada, y el texto era una modulación local, débil, que solo emergía en una orientación concreta. Un promedio que recorre toda la imagen borra una señal que vive en una esquina. Un test que no distingue «no hay señal» de «hay señal fuera del eje que estoy mirando» no demuestra una ausencia; como mucho demuestra que no la vio.

Lo que rompió el bloqueo no fue un método nuevo. Fue mirar una imagen que ya teníamos y no habíamos abierto.

Un decoy que además estaba prohibido

Merece una línea, sin dramatizar. Rebuscando en la base apareció un rol bootstrap que era superusuario y aceptaba login. La lectura tentadora: la aguja es su contraseña, escondida entre las 65.908, y se encuentra probándolas contra ese login. Lo descartamos por una razón que no es técnica: las bases del concurso prohíben la fuerza bruta de credenciales —el servidor registra cada intento y es baneable—. No es una vía legítima aunque funcione. Y no funcionaba: el mecanismo real, la imagen, no necesita ningún login. Si una pista te empuja a martillear un oráculo, para y relee las reglas antes que el teclado.

Lo que nos llevamos

Con la variante correcta, aplanada y suavizada, el texto se leía sin esfuerzo. La contraseña es «el águila ve el bosque»: quien mira desde arriba distingue la figura entre los árboles, que es exactamente lo que había que hacer con esa nube de puntos. La confirmación blanda de que era la buena y no una casualidad.

Cuatro cosas, por orden de utilidad:

  1. Un canal capado a propósito es el mecanismo, no el obstáculo. Una función redefinida, dos tablas en whitelist, filas borradas pero recuperables: el autor te está señalando la puerta. El pajar decorativo —las 65.908 contraseñas— nunca se protege con tanto cuidado.
  2. Lo que se «borra» en una base de datos casi nunca se va del disco. Una fila muerta sigue en su página hasta que algo la pisa. Es un clásico del forense, y aquí era la vía de entrada.
  3. Si generas varias variantes, míralas todas. El error que más caro pagamos, y el más tonto: teníamos la respuesta en un fichero sin abrir.
  4. Una negativa estadística sin potencia es solo una opinión con formato de teorema. Medir la señal global y no encontrarla no prueba que no exista una señal local.

Solución:

TheEagleSeesTheForest

El material — la base de datos del reto ya no existe, así que aquí no hay nada ejecutable que repetir: lo que queda es el writeup técnico completo, en euskal34/hack-it/4.