CVE-2026-35211

CVSS 6.5 · Medium Patched
Type
Vulnerability research
Published
Reading time
6 min read
Author
rootzero
Lire en français
Lire en français →

CVE-2026-35211: Painless script injection in OpenCTI through the GraphQL script filter operator

Any OpenCTI user with the KNOWLEDGE capability could send raw Painless scripts to Elasticsearch through a public filter operator and exhaust the cluster.

For your decision

Impact
A filter can make Elasticsearch execute a Painless script and consume excessive resources.
Conditions
An OpenCTI account with KNOWLEDGE capability is required. Actual unavailability was not measured in our test.
Recommended action
Install OpenCTI 7.260401.0 or later and leave the script operator disabled unless explicitly needed.

Advisory facts

Product
OpenCTI
Package
npm · opencti-graphql
Affected versions
< 7.260401.0
Fixed in
7.260401.0
CVSS
6.5 Medium
Weakness
CWE-94, CWE-863
Access needed
Account required
Advisory published
Credit
rootzero

Investigation summary

The GraphQL API of OpenCTI exposed a script value in its FilterOperator enum, and the query builder copied the filter value into an Elasticsearch script query without any check. Any authenticated user with the KNOWLEDGE capability could make the search cluster execute arbitrary Painless code on every candidate document, enough to exhaust its CPU and degrade the platform for all users. Fixed in 7.260401.0, where the operator is refused unless an explicit setting enables it.

The GraphQL operator that executes a script

We started with OpenCTI’s GraphQL schema. Among filter operators describing comparisons, script stood out: it named an execution mechanism. We followed its value through the Elasticsearch filter builder and found that a user allowed to query objects could supply Painless code. The question was not whether a script escaped Elasticsearch’s sandbox, but whether a user-controlled filter should be able to create one at all.

We wrote a GraphQL request carrying a harmless Painless expression, then read the filter builder to see where it would be inserted. The report also proposed an expensive loop as an availability test; it does not retain a measured outage or a response from a live cluster. We therefore framed the impact as the ability to submit costly scripts, without claiming sandbox escape or operating-system execution. Our private report went in on 24 March 2026. OpenCTI integrated the initial fix on 31 March, released 7.260401.0 on 1 April and published CVE-2026-35211 in July. Further grammar hardening followed in August; it should not be confused with the initial closure of the script path.

A query filter with a script operator

OpenCTI stores its threat-intelligence objects in Elasticsearch or OpenSearch and exposes list queries through GraphQL. Those queries accept filter trees, so the allowed operators determine what a reader can make the search engine execute.

Filtering is central to that API. Most list queries, such as stixCoreObjects, accept a filters argument of type FilterGroup, a tree of Filter objects that each carry a key, a list of values and an operator. The backend translates this tree into an Elasticsearch query in buildLocalMustFilter(). Because the platform is meant to be queried by many analysts and integrations, the GraphQL schema is effectively the contract that decides what a user can ask the search cluster to do.

From the GraphQL enum to Painless

The GraphQL schema of the vulnerable versions shows an unexpected member in the FilterOperator enum, between the string operators and the null checks:

enum FilterOperator {
  eq  not_eq  lt  lte  gt  gte  match  wildcard  contains  not_contains
  ends_with  not_ends_with  starts_with  not_starts_with
  script
  nil  not_nil  search  within  only_eq_to  not_only_eq_to
}

Every other operator describes a comparison. script describes an execution mechanism, which raises the question of what the query builder does with its value. Following it into buildLocalMustFilter() answers it.

Root cause

In opencti-platform/opencti-graphql/src/database/engine.ts (7.260326.0, around line 2541), the branch for this operator reads:

} else if (operator === 'script') {
  valuesFiltering.push({
    script: {
      script: values[i].toString(),
    },
  });
}

The user’s value becomes the source of an Elasticsearch script query, using the short form where a string stands for the script source in the default language, Painless. Nothing checks the value, and nothing restricts the operator to internal callers: the only legitimate use in the code base was internal, in report.js, where the backend counts objects referenced by a single report with doc['…keyword'].length == 1.

Painless is the scripting language of Elasticsearch (and of OpenSearch). In a script query, the script runs in filter context: it is evaluated against each candidate document, reads that document’s doc values through doc['field'], and must return a boolean that decides whether the document matches. Painless runs in a sandbox, with an allowlist of classes and methods and no access to files, processes or the network, so the injection does not lead to code execution on the host. It still gives the caller a general-purpose language running inside the search cluster, with loops and arithmetic, evaluated once per document. Two weaknesses combine here: generation of code from user input (CWE-94), and an authorisation model that lets a read capability control script execution in the data tier (CWE-863).

Exploitation

Any list query that takes a FilterGroup can carry the operator. With the benign script used in OpenCTI’s own unit tests, a request looks like this:

POST /graphql HTTP/1.1
Authorization: Bearer <token of a user with KNOWLEDGE>
Content-Type: application/json

{"query": "query { stixCoreObjects(first: 1, filters: {mode: and, filterGroups: [], filters: [{key: [\"name\"], operator: script, values: [\"doc.containsKey('name.keyword')\"]}]}) { edges { node { id } } } }"}

The backend then adds the following clause to the Elasticsearch query, next to the access restrictions it computes for the user:

{ "script": { "script": "doc.containsKey('name.keyword')" } }

The advisory’s concern is availability. A script that performs a long computation before returning, for instance nested loops close to the iteration limit that Painless enforces per execution, costs CPU on every document the query visits, and the knowledge indices of an active OpenCTI instance hold large numbers of documents. Repeating such requests ties up the search threads of the cluster, which every user, connector and background manager of the platform depends on.

Impact

The GitHub advisory scores the flaw 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H). PR:L corresponds to the KNOWLEDGE capability, the base capability for accessing the knowledge base, which analyst roles normally include. A:H reflects that the target is the shared search cluster: when it saturates, the whole platform slows down or stops answering.

The advisory’s title also mentions data exfiltration, but its body and its vector only retain availability (C:N). The material available for this post does not show a confidentiality path, so it does not claim one. On the integrity side, a script query cannot modify documents; it only decides which documents match.

The fix

OpenCTI 7.260401.0, released on 2026-04-01, contains commit b134cce (#15284), titled “move script usage to internal_script and remove by default from API”. The script operator stays in the GraphQL enum, but the query builder now refuses it unless a configuration flag enables it:

// opencti-graphql/src/database/engine.ts (7.260401.0)
} else if (operator === 'script' || operator === 'internal_script') {
  if (operator === 'script' && !isEsScriptFilterEnabled()) {
    throw UnsupportedError('Filter script is not allowed', { filter: validFilter });
  } else {
    valuesFiltering.push({ script: { script: values[i].toString() } });
  }
}
// opencti-graphql/src/database/engine-config.ts (7.260401.0)
const ES_SCRIPT_FILTER_ENABLED: boolean = booleanConf('elasticsearch:unsecure_script_filter_enabled', false);

The internal use in report.js switches to a new internal_script operator, which is absent from the GraphQL enum. The breaking-changes page of the documentation records the removal and shows how to re-enable the operator “at your own risk” with ELASTICSEARCH__UNSECURE_SCRIPT_FILTER_ENABLED=true; its JSON example uses a key named script_filter_enabled, while the code reads elasticsearch:unsecure_script_filter_enabled. The same check is present in the 6.9.29 and 7.260309.0-lts.3 releases, although the advisory only lists versions before 7.260401.0.

The first fix still left internal_script accepted by buildLocalMustFilter() for any caller that could pass a filter without going through the GraphQL enum. A later commit, 1589eb3 (#17510, 2026-08-06, released in 7.260807.0), removes internal_script from the filter grammar, moves the report query to a separate internalScriptFilters option populated only by backend code, rejects any unknown operator, and adds a test that feeds a raw JSON filter with internal_script and expects it to fail. The same commit also stops interpolating the filter key into the Painless source of the only_eq_to operator, passing it through params.field instead, and restricts filter keys to [A-Za-z0-9_*-] segments. Those changes close the remaining paths from filter input to script source.

Detection and mitigation

  1. Upgrade OpenCTI to 7.260401.0 or later (or to 6.9.29 or 7.260309.0-lts.3 on those branches), preferably to 7.260807.0 or later for the follow-up hardening, and make sure ELASTICSEARCH__UNSECURE_SCRIPT_FILTER_ENABLED is not set to true.
  2. Search the OpenCTI API access logs, or a GraphQL-aware reverse proxy, for request bodies containing operator: script or "operator":"script"; legitimate clients of the public API have no reason to send it.
  3. On patched versions, alert on the “Filter script is not allowed” error in the backend logs, which identifies the accounts that still try.
  4. Enable the Elasticsearch slow log on the OpenCTI indices and look for queries containing a script clause with an unusual source, together with sustained CPU saturation of the search threads.
  5. Review which roles and connector accounts hold KNOWLEDGE, and keep the search cluster reachable only from the OpenCTI platform and its workers.

Timeline

Date Event
2026-03-24 Private report to the maintainers through a GitHub security advisory
2026-03-31 Fix merged (b134cce, #15284)
2026-04-01 OpenCTI 7.260401.0 released
2026-07-01 Advisory GHSA-qpp6-p693-rmm4 published with CVE-2026-35211
2026-08-06 Follow-up hardening of the filter grammar merged (1589eb3, #17510)
2026-08-07 OpenCTI 7.260807.0 released with the follow-up hardening

References

  1. Vendor advisory · CVE-2026-35211 github.com/OpenCTI-Platform/opencti/security/advisories/GHSA-qpp6-p693-rmm4
  2. NVD entry · CVE-2026-35211 nvd.nist.gov/vuln/detail/CVE-2026-35211
  3. CVE.org record · CVE-2026-35211 www.cve.org/CVERecord?id=CVE-2026-35211
  4. MITRE CWE-94 cwe.mitre.org/data/definitions/94.html
  5. MITRE CWE-863 cwe.mitre.org/data/definitions/863.html
  6. Project repository github.com/OpenCTI-Platform/opencti

Cite this advisory

rootzero. “CVE-2026-35211: Painless script injection in OpenCTI through the GraphQL script filter operator”. Vulnerability research, rootzero, 1 July 2026. https://rootzero.eu/en/research/cve/cve-2026-35211-opencti-painless-script-injection/

#Vulnerability research

Have your code audited before someone else does.

Your team needs to decide what to fix before a release or assess a critical dependency. We scope an engagement around that decision, with evidence and explained remediation priorities.