Liquid publicó el domingo que unos 4.000 BTC habían salido de su cartera de federación. Eso es más o menos el 95% de lo que había dentro, algo así como 320 millones de dólares según la hora a la que lo valores, y se fue en poco más de veinte minutos.

Quien lo hizo escribió we are whitehats. contact us on chain en un OP_RETURN y se quedó esperando a que alguien de Blockstream se diera cuenta.

Blockstream, por su parte, habló de "supuestos hackers de sombrero blanco", que es el sintagma más cuidadosamente construido que ha publicado nadie este mes.

Casi todo el dinero ha vuelto. Ya llegaré a lo de "casi". Pero la recuperación no es lo interesante: lo interesante es que nadie robó nada que tú llamarías normalmente una credencial.

Quince claves, todas honradas

El peg de Liquid es una federación 11-de-15. Quince funcionarios, en su mayoría exchanges y mesas de trading, y para mover bitcoin real fuera de la reserva hacen falta once firmas.

Ese diseño tiene un modelo de amenazas y es de los buenos. Ningún custodio puede largarse solo con la reserva. Para forzar una retirada fraudulenta tendrías que comprometer once organizaciones independientes en once jurisdicciones distintas, que es difícil de verdad.

Nadie comprometió once organizaciones. Nadie comprometió una. Todos los funcionarios firmaron, todas las firmas verificaban, y el peg-out que firmaron era —para todo el software que había en la sala— una retirada normal y perfectamente respaldada contra L-BTC que existía.

Ese L-BTC no existía. El software decía que sí.

Esta es la parte que me tatuaría encima de unos cuantos diagramas de arquitectura. Un esquema de firma por umbral responde a ¿han estado de acuerdo suficientes partes de las correctas?. No responde a ¿era verdad aquello con lo que estuvieron de acuerdo?. Son dos preguntas distintas, y la segunda se contesta en otro sitio completamente distinto, en código que no tiene ninguna clave delante y que recibe una fracción de la atención en seguridad.

Todo el mundo señala el 11-de-15 cuando explica cómo está protegido Liquid. El 11-de-15 funcionó impecablemente y no sirvió absolutamente de nada.

La caché

Liquid usa Confidential Transactions, así que los importes van ocultos. Un importe oculto solo es seguro si puedes demostrar que es un número sensato, así que cada salida lleva un rangeproof: la prueba criptográfica de que la cantidad que no ves no es negativa y no desborda. Quítale el rangeproof y no queda nada impidiéndote sacar valor de la nada. Es, literalmente, lo único que separa a un atacante de unas monedas sin respaldo.

Los rangeproofs también son lentos de verificar. Así que Elements cachea el resultado, el mismo truco que la caché de firmas de Bitcoin Core. Hasheas las entradas que identifican la prueba, te apuntas que esta ya salió bien y te saltas la aritmética la segunda vez que la ves, porque la vas a ver al menos dos veces: una en la mempool y otra en el bloque.

Las reconstrucciones que circulan dicen que la clave de caché era un hash con sal sobre una concatenación en crudo, más o menos así:

clave = H(sal || prueba || compromiso || generador_de_activo || scriptPubKey)

Sin prefijos de longitud. Sin separadores. La prueba y el script son ambos de longitud variable y están en extremos opuestos del flujo, así que la frontera entre campos se puede deslizar: le quitas un byte al final de uno, se lo pegas al principio del siguiente, y tienes una tupla genuinamente distinta que se serializa a una cadena de bytes idéntica. Colisiona con algo que sí verificó antes y tu rangeproof inválido pasa de largo con el sí cacheado de otro.

Conviene marcarlo: Blockstream no ha sacado un post-mortem completo, así que trata la forma a nivel de bytes como la mejor reconstrucción disponible y no como dogma. La categoría no está en disputa —todo el mundo coincide en que es un fallo de la caché de verificación de rangeproofs—, pero la concatenación exacta es análisis de terceros.

Y si esa forma es la buena, aquí no está fallando criptografía exótica. Es el fallo de codificación ambigua de la página uno de cualquier documento de diseño de protocolos, ese en el que "alicia" + "bertacarlos" y "aliciab" + "ertacarlos" son los mismos bytes. Si hasheas una concatenación de campos de longitud variable sin longitudes, no has hasheado los campos. Has hasheado una cadena que varias particiones distintas pueden producir.

Me cuesta que no me guste este bug, en el sentido concreto en el que te puede gustar un bug que le ha costado 47 millones a alguien. No hay ningún ataque ingenioso sobre retículos. Es concatenar cadenas.

La ironía sostiene el edificio

Sigue la cadena hacia atrás. Confidential Transactions es el argumento estrella de Liquid frente a bitcoin a secas. Los importes confidenciales exigen rangeproofs. Los rangeproofs son caros. La verificación cara justifica una caché. Y la caché es donde la verificación dejó de ocurrir.

La función de privacidad pagó el atajo que se saltó la comprobación que esa misma función de privacidad necesitaba.

Cinco días

Y aquí la parte que se me ha quedado más tiempo en la cabeza que el dinero.

El arreglo del fallo de la caché de rangeproofs se subió públicamente al repositorio de Elements el 1 de septiembre, con un título de commit que dice qué arregla. Ninguna release etiquetada lo incluía. Ningún nodo en producción lo tenía. El vaciado ocurrió el día 6.

La convención de Bitcoin Core para bugs críticos de consenso es el embargo: lo arreglas en privado, coordinas una release y divulgas cuando los nodos ya se han actualizado de verdad. Esa convención existe por algo, y no es por ceremonia. Un commit público sobre código de consenso es una divulgación con un mapa grapado. Le has contado a todo el que lea el repositorio dónde vive el bug, más o menos qué forma tiene y —esto es lo importante— que todavía no hay nada en producción protegido contra él.

No me voy a subir a ningún caballo, porque no sé cómo era el calendario interno y sobran motivos por los que una release se retrasa cinco días. Pero la práctica existe exactamente para el escenario en el que alguien lee tu repo más rápido de lo que tus usuarios actualizan, y "parche público el 1, vaciado el 6" no es una secuencia que yo pueda mirar y llamar casualidad.

La versión generalizable, para los que no vamos a operar una sidechain en la vida: tu commit forma parte de tu calendario de divulgación, lo quieras o no. La ventana entre mergear un arreglo de seguridad y publicarlo pertenece a quien esté mirando el repositorio. Si esa ventana se mide en días y el arreglo viene descrito en el asunto, has montado un embargo al que le has quitado el embargo.

Sobre esa palabra

Los nodos puente se parchearon en torno a las 01:09 UTC del día 7. Confirmado eso, volvieron unos 3.400 BTC.

Se quedaron 598,5 BTC. Llámalo 47 millones de dólares, alrededor del 15%, presentado como recompensa. Su propia recompensa, fijada por ellos, a posteriori, tarifada desde dentro de la cámara acorazada y con la alternativa de la otra parte siendo perderlo todo.

Un white hat reporta el fallo. Y si nos ponemos generosos y admitimos la versión dramática, un white hat se lleva los fondos, devuelve todos, y luego negocia una recompensa desde la posición de quien ya los ha devuelto todos. Elegirte la tarifa mientras sigues sujetando el dinero es otra actividad distinta, y su nombre honesto no es "recompensa".

Eso sí, crédito donde toca: se movieron rápido, devolvieron el 85% y esperaron al parche antes de soltar nada, en vez de tirar los fondos a un puente todavía roto. Comparado con el peor caso realista, esto le ha salido rematadamente bien a Liquid.

Pero white hat no es, y que el sector compre el marco le enseña algo poco útil al siguiente que encuentre un fallo en un rangeproof, porque la lección que hay ahora mismo encima de la mesa es 47 millones, ninguna denuncia y la prensa llamándote uno de los buenos.

Lo que yo me llevaría

Si tu sistema tiene en algún punto un control de aprobación multiparte, ve a buscar el código que decide qué están aprobando esas partes. El de validar, no el de firmar. Ahí es donde viven los ataques interesantes, y suele ser justo la mitad que nadie puso en el diagrama.

Luego haz un grep por los sitios donde hasheas una concatenación de cosas de longitud variable: tokens de sesión, firmas de webhooks, claves de caché, cualquier cosa con un || y un sha256 cerca. Prefija los campos con su longitud. Es un cambio de cinco minutos y es exactamente el bug de ahí arriba.

Y si tienes encima de la mesa un arreglo de seguridad que aún no ha salido: el repositorio es público, el mensaje de commit es un titular, y el reloj arrancó cuando hiciste push.