La reclamación que se puede eliminar: una lección de canonicalización HMAC
Vincular un valor a una firma es necesario pero no suficiente. Si la forma en que serializa las reclamaciones antes de firmar es ambigua, un atacante puede remodelar una solicitud para que una reclamación vinculada desaparezca en silencio, y la firma sigue verificándose.
Una promesa firmada vale tanto como su codificación
Un patrón sólido y común para entregar un recurso —un archivo, una llamada a una API, una capacidad— es una concesión firmada: el servidor calcula un HMAC sobre lo que se concede más un conjunto de reclamaciones (una expiración, un límite de uso, un sujeto), y entrega al cliente una URL que lleva esas reclamaciones y la firma. El cliente puede presentar la concesión pero no puede falsificar una. El razonamiento en el que todos confían es simple: cada restricción forma parte de lo que se firmó, así que cambiar cualquiera de ellas rompe la firma.
Ese razonamiento tiene una dependencia oculta. Supone que la correspondencia entre “un conjunto de reclamaciones” y “los bytes que se firman” es inyectiva: que dos conjuntos de reclamaciones distintos nunca pueden producir la misma cadena firmada. Cuando esa suposición falla en silencio, la firma deja de significar lo que usted cree que significa, y una reclamación vinculada puede eliminarse sin romper nada.
La trampa: una forma canónica ambigua
Suponga que serializa las reclamaciones de la forma obvia —las ordena y las une como pares key=value con un ampersand— y luego calcula el HMAC del resultado:
exp=1799999999&jti=abc&max=1
Parece canónico: mismas reclamaciones, mismo orden, mismos bytes. Pero considere lo que ocurre cuando un valor puede contener justo los caracteres que usted usa como delimitadores. Estos dos conjuntos de reclamaciones distintos —uno con tres reclamaciones, otro con dos—
{ exp: 1799999999, jti: "abc", max: 1 }{ exp: 1799999999, jti: "abc&max=1" }
se serializan en la cadena idéntica exp=1799999999&jti=abc&max=1. Una misma firma es válida para ambos. La codificación no es inyectiva.
Por qué eso es explotable, no solo desprolijo
El peligro aparece en el momento de la verificación, porque el verificador reconstruye el conjunto de reclamaciones a partir de lo que el cliente envió. Tome un enlace de un solo uso emitido de forma legítima:
?exp=1799999999&jti=abc&max=1&sig=…
Un atacante que lo posea pliega max dentro del valor contiguo y elimina el parámetro max real:
?exp=1799999999&jti=abc%26max%3D1&sig=…
El verificador decodifica jti como abc&max=1, canonicaliza las dos reclamaciones que ahora ve y obtiene los mismos bytes sobre los que se calculó la firma, de modo que la comprobación HMAC pasa. Pero el conjunto de reclamaciones reconstruido no tiene ninguna reclamación max. Cualquier lógica condicionada a “¿hay un límite de uso?” se omite, y el enlace de un solo uso se vuelve ilimitado. La misma maniobra elimina un sujeto vinculado o un nivel de garantía: lo que usted creía que la firma protegía, no lo protege, porque la reclamación ya no está ahí para comprobarse. No se falsificó nada y no se filtró ninguna clave: el atacante solo explotó una serialización que no podía distinguir “un valor que contiene un delimitador” de “otra reclamación”.
La solución: hacer que la codificación sea inequívoca
La regla es breve: la función de reclamaciones a bytes firmados debe ser inyectiva. Cualquiera de estas opciones lo consigue:
- Codifique con porcentaje cada clave y cada valor antes de unirlos, de modo que un
&o un=literal dentro de un valor se convierta en%26/%3Dy nunca pueda hacerse pasar por un delimitador. - Anteponga la longitud a cada campo, de modo que los límites sean explícitos en lugar de inferirse a partir de un delimitador.
- Serialice un objeto estructurado (JSON canónico, CBOR) con una única codificación bien definida, y firme eso.
Las tres comparten la misma propiedad: dados los bytes firmados, exactamente un conjunto de reclamaciones los produce. Una vez que eso se cumple, la intuición original —“cambie cualquier reclamación y la firma se rompe”— por fin es cierta, porque una solicitud remodelada ahora produce bytes distintos y falla la comprobación. Un segundo hábito, de defensa en profundidad: tome sus decisiones de acceso a partir del mismo conjunto de reclamaciones sobre el que se verificó la firma, no a partir de una nueva lectura de la solicitud sin procesar. Si “¿tiene esta concesión un límite de uso?” se responde desde las reclamaciones validadas y no desde “¿hay un parámetro de consulta max?”, una codificación ambigua tiene una forma menos de perjudicarle.
Una familia de errores conocida
Esto no es exótico; es miembro de una familia bien conocida. El envolvimiento de firmas XML, los desajustes de canonicalización de JWT y JSON, la inyección SQL y de registros, el contrabando de solicitudes HTTP: todos tienen la misma forma: dos capas discrepan sobre dónde termina un campo y empieza el siguiente. Siempre que una decisión de seguridad dependa de analizar una estructura serializada, la pregunta no es “¿incluí el campo?” sino “¿puede alguien que solo controla los valores mover los límites entre campos?”
Principios que vale la pena conservar
- Vincular es necesario, no suficiente. Una reclamación solo está protegida si la codificación que la lleva a la firma es inequívoca.
- La canonicalización debe ser inyectiva. Entradas distintas nunca deben colisionar en los mismos bytes firmados. Codifique, anteponga la longitud o serialice un objeto estructurado; nunca una valores no confiables con el delimitador que podrían contener.
- Decida a partir de lo que verificó. Lea las reclamaciones que la firma cubrió, no la solicitud sin procesar, al tomar la decisión de acceso.
- Pregunte dónde están los límites. La mayoría de las vulnerabilidades de serialización son confusiones de límites entre dos capas. Si un valor puede contener su delimitador, su delimitador no es un límite.
Detectamos esto mientras endurecíamos nuestro propio módulo de código abierto File Gate antes de su primera versión candidata: una revisión de seguridad previa al lanzamiento encontró exactamente este patrón en el firmador de concesiones, y se corrigió —la forma canónica ahora codifica con porcentaje cada reclamación, con una prueba de regresión que demuestra que una reclamación plegada ya no se valida— antes de que se publicara la versión. La lección generaliza mucho más allá de ese módulo: cualquier sistema que firme un conjunto de reclamaciones y confíe en el resultado está apostando por su propia serialización. Haga que la codificación sea inyectiva, y será una apuesta que gana por construcción.