Resumen
RubyGems.org permite crear roles de clave de API basados en OIDC, de modo que, por ejemplo, un flujo de GitHub Actions pueda publicar gemas sin guardar una clave fija. Cada rol lleva una política de acceso que compara los claims del JWT recibido con unas condiciones. Una de esas condiciones usa el operador string_matches, en el que el propio usuario escribe una expresión regular.
El fallo estaba en cómo se evaluaba esa condición. El servidor construía la expresión regular directamente a partir del texto del usuario y la aplicaba al valor del claim, sin límite de tiempo ni ninguna comprobación de complejidad:
Regexp.new(value).match?(claim_value)
Aquí value es el patrón que define quien crea el rol, y claim_value sale del JWT. Ese valor también lo puede influir el atacante, porque algunos claims proceden de metadatos de GitHub Actions como el nombre de la rama. Con las dos piezas bajo su control, basta un patrón con backtracking catastrófico y una entrada pensada para él para que el motor de expresiones regulares de Ruby gaste muchísimo tiempo de CPU en una sola petición. Es un caso de denegación de servicio por expresión regular (ReDoS), clasificado en HackerOne como consumo de recursos no controlado.
Pasos de reproducción
- Crear en RubyGems.org un rol de clave de API OIDC cuya política de acceso use el operador
string_matchescon un patrón de backtracking exponencial. El ejemplo del reporte es:
^(a+)+$
- Hacer que el claim evaluado contenga un valor que obligue al motor a probar todas las combinaciones. El reporte propone un nombre de rama de GitHub Actions como este:
refs/heads/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
- Lanzar la petición que hace que RubyGems.org evalúe la política con ese JWT. El servidor ejecuta
Regexp.new(value).match?(claim_value), el motor entra en backtracking exponencial y el hilo que atiende la petición se queda ocupado durante mucho tiempo.
Un matiz técnico sobre el ejemplo: con el ancla ^, esa cadena concreta (que empieza por r) se descarta enseguida. Para que el backtracking se dispare, el patrón tiene que poder consumir el principio del valor, por ejemplo sin el ancla inicial o incluyendo el prefijo de la rama antes del grupo anidado. La idea del ataque es la misma.
Impacto
- Una sola petición manipulada deja bloqueado un hilo de Puma durante un periodo prolongado.
- RubyGems.org funciona con un grupo de hilos muy pequeño, así que unas pocas peticiones simultáneas bastan para agotar todos los workers y dejar el servicio caído total o parcialmente.
- Mientras dura, se ve afectada la publicación de gemas y cualquier pipeline de CI/CD que dependa de que RubyGems.org esté disponible.
Qué aprender de este caso
- Busca cualquier sitio donde una aplicación haga
Regexp.new(o el equivalente en otros lenguajes) con texto que define el usuario: reglas de políticas, filtros guardados, condiciones de webhooks. Si el patrón y la cadena evaluada dependen los dos del atacante, el ReDoS está casi garantizado. - Los claims de un JWT de OIDC no son un dato neutro: campos como la rama (
refs/heads/...) los elige quien lanza el workflow. Trátalos como entrada del atacante al pensar en el coste de procesarlos. - Si una política necesita patrones, ofrece comparaciones más limitadas (igualdad, prefijo, comodines simples) o evalúa la expresión con un tiempo máximo (
Regexp.timeouten Ruby 3.2+) y rechaza al guardarlas las que tengan cuantificadores anidados como(a+)+. - En servidores con pocos hilos, como un Puma con un pool pequeño, cualquier operación de CPU sin límite dentro de una petición es una denegación de servicio en potencia: unos pocos casos simultáneos bastan para tumbarlo.