# REPRO-2026-00331: OpenCTI CVE-2026-39980 safeEjs destructuring fix bypass RCE ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-94 (Improper Control of Generation of Code ('Code Injection')) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00331 CVE: CVE-2026-39980 ## Package Name: opencti-platform/opencti Ecosystem: github Affected: Unknown Fixed: Unknown ## Root Cause # RCA Report: CVE-2026-39980-BYPASS-DESTRUCTURE — OpenCTI 6.9.5 safeEjs quoted-key destructuring sandbox bypass (pre-auth root RCE) ## Summary OpenCTI 6.9.5 shipped a fix for CVE-2026-39980 (commit `d91c19e1e7`, "[backend] Safe ejs with verifier") that rewrites `safeRender` in `src/utils/safeEjs.ts` with an `@lezer/javascript` AST verifier plus a runtime property guard (`____safe____property`). The fix contains a static-analysis gap: quoted object keys are checked against the `forbiddenProperties` denylist only when their AST parent is an object-literal `Property` node. Quoted keys in **destructuring patterns** are `PatternProperty` children and are never inspected, and because destructuring performs a real `[[Get]]` without bracket tokens, the runtime guard (which is injected only around `[` `]`) is never applied either. The formula `(()=>{const{"constructor":F}=Array;return F()()})()` therefore retrieves `Function` from the allowed `Array` global and executes arbitrary JavaScript inside the OpenCTI Node.js process. Delivered through the `jsonMapperTest` GraphQL mutation (JsonMapper variable formula) and chained with the still-unpatched CVE-2026-27960 Bearer-UUID auth bypass, this yields **unauthenticated remote code execution as root** on a fully patched-for-CVE-2026-39980 OpenCTI 6.9.5. ## Impact - **Package/component affected**: `opencti/platform` (backend `opencti-graphql`), file `src/utils/safeEjs.ts` (`processString` / `transformTemplate`). - **Affected versions**: 6.9.5 (the CVE-2026-39980 "fixed" release). The auth bypass used for pre-auth reachability (CVE-2026-27960) is live on 6.9.5 and was only fixed in 6.9.13; even without it, any authenticated user with the `CSVMAPPERS` capability (or access to notifier template testing, which shares the `transformTemplate` core) can trigger the same sandbox escape. - **Risk level and consequences**: Critical. Unauthenticated remote code execution as `uid=0(root)` inside the platform container — full platform compromise (all threat-intel data, credentials, connected systems). ## Impact Parity - **Disclosed/claimed maximum impact**: code execution (root RCE, pre-auth when chained with CVE-2026-27960). - **Reproduced impact from this run**: full remote code execution as `uid=0(root)` inside two fresh `opencti/platform:6.9.5` containers per pass, via the real `POST /graphql` `jsonMapperTest` endpoint, preceded by remote proof of the CVE-2026-27960 auth bypass (`me` query returns the admin identity when only `Authorization: Bearer 88ec0c6a-13ce-5e39-b486-354fe4a7084f` is supplied). - **Parity**: `full`. - **Not demonstrated**: nothing claimed was left undemonstrated. (Persistence/exfiltration beyond the marker command was not attempted and was not claimed.) ## Root Cause `src/utils/safeEjs.ts` (tag `6.9.5` = commit `be4ab13c30d154adc3cfc49ba128b2039b93e348`, fix commit `d91c19e1e7` contained): ```ts const processString = () => { const parentType = cursor.node.parent?.type.name; if (parentType === 'Property') { // object literals ONLY processPropertyDefinitionOrName(); // forbiddenProperties denylist check } }; ``` 1. In `@lezer/javascript`, a quoted key in an object literal (`x={"constructor":1}`) is a `String` node whose parent is `Property` → denylist-checked. The same quoted key in a destructuring pattern (`const {"constructor":F}=Array`) is a `String` node whose parent is `PatternProperty` → **never checked** (verified locally with `@lezer/javascript`: parent `PatternProperty` vs `Property`). 2. The runtime guard `____safe____property(...)` is injected only by `processBracketLeft`/`processBracketRight` around `[`/`]` tokens (`isPropertyNameInBracket` covers `MemberExpression`, `Property`, `PatternProperty` — but destructuring has **no bracket tokens**), so no runtime coercion/denylist happens either. 3. `Array` is an explicitly allowed global (`authorizeGlobals`), so `Array` → destructure `"constructor"` → `Function` → `F("return process.getBuiltinModule('child_process').execSync(...)")()` executes OS commands as the platform process user (root in the official image). Sink chain: `POST /graphql` → `jsonMapperTest(configuration, file)` (`@auth(for: [CSVMAPPERS])`) → `jsonMapper-domain.ts::jsonMapperTest` → `parser/json-mapper.ts::jsonMappingExecution` → `extractComplexPathFromJson` → `safeRender("", ...)`. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; only needs Docker). 2. The script deploys the real stack — `elasticsearch:8.19.9`, `redis:8.4.0`, `rabbitmq:4.2.2-management`, `minio/minio:RELEASE.2025-06-13T11-33-47Z`, and `opencti/platform:6.9.5` (digest pinned and verified: `sha256:1f91ad32f1aadf283b5f369ff7b358da071679d4e2bec64030127306db8e73b0`) — waits for the real `/health` endpoint, then: - proves CVE-2026-27960 auth bypass remotely (`me` query as admin via Bearer admin internal_id), - sends the **negative control**: the original CVE-2026-39980 computed-key payload, which the 6.9.5 fix rejects with `VerifierIllegalAccessError: Forbidden property access {"propertyName":"constructor"}` (proving the fix was active), - sends the **destructuring exploit formula** as a JsonMapper variable formula through `jsonMapperTest` and verifies a unique per-run marker file inside the platform container containing the token, `uid=0(root)`, and the container hostname, - recreates the platform container (fresh process, distinct hostname) and repeats the exploit successfully. 3. Expected evidence: marker files with `uid=0(root)` + per-run epoch token + container hostname; GraphQL 200 responses; negative-control rejection; exit code 0. 4. `bundle/repro/negative_control.sh` runs a dedicated negative control on a **separate fresh process** (distinct marker name) and asserts rejection + marker absence. ## Evidence - `bundle/logs/reproduction_steps.log` — full pass transcript (two consecutive passes, both exit 0). - `bundle/artifacts/http/me_response.json` — `{"data":{"me":{"user_email":"admin@pruva.local","name":"admin"}}}` via Bearer UUID only (CVE-2026-27960). - `bundle/artifacts/http/negative_control_response.json` — rejection of the original payload; the error message even shows the injected guard: `"constructor"})]...) ... Forbidden property access {"propertyName":"constructor"}`. - `bundle/artifacts/http/exploit_attempt{1,2}_response.json` — HTTP 200 `{"data":{"jsonMapperTest":{...}}}` for the destructuring payload (request bodies preserved in `*.operations.json`). - `bundle/artifacts/markers/marker_attempt1.txt` (pass 2, container `f7cd127ef744`), `marker_attempt2.txt` (pass 2, fresh container `e8d8ea37e8dd`): per-run token + `uid=0(root) gid=0(root)...` + hostname. Pass 1 used containers `fc0ed8fda7f3` / `ee1736911ce1` — four distinct fresh processes total. - `bundle/repro/runtime_manifest.json` — endpoint/runtime evidence manifest with pinned target identity. - Environment: rootless Docker 27.5.1, x86_64 Linux, Node runtime bundled in the image. ## Recommendations / Next Steps - Denylist quoted destructuring keys: in `processString`, also handle `parentType === 'PatternProperty'` (and consider `PropertyDefinition`/assignment patterns), or better, switch from a denylist to an **allowlist** of permitted property names. - Treat `constructor`-family access uniformly regardless of syntax surface (dot, bracket, destructuring, default values, rest patterns). - Isolate formula/template evaluation from the main Node.js process (worker with restricted `process`/module access); note `safeEjs.client.ts` already has a worker path — the jsonMapper path uses the in-process `safeEjs.ts`. - Upgrade guidance: 6.9.5 is **not** sufficient remediation for CVE-2026-39980. CVE-2026-27960 (auth bypass) is fixed in 6.9.13; upgrading to ≥6.9.13 removes the pre-auth vector but the destructuring sandbox escape should be verified/fix-forwarded independently. - Testing: add AST-level regression tests feeding `{"constructor":...}` destructuring patterns into the verifier, plus end-to-end `jsonMapperTest` exploit tests. ## Additional Notes - Idempotency: the script tears down and recreates the full stack on every run (`docker rm -f` + fresh network), generates a fresh epoch token and fresh admin API token per run, and cleans up via `trap ... EXIT`. Verified idempotent: two consecutive runs both exited 0. - Limitations: the exploit requires the formula to avoid bracket property access on forbidden names and `this`/`import`; the demonstrated formula is minimal and stable. The Docker-based stack requires ~2 GB RAM for the ES heap; timings on this host: full pass ≈ 4–5 minutes. - The Unicode-escape vector hypothesized in public write-ups was ruled out by the discovering run (escaped identifiers throw `VerifierParsingError`); the residual bypass class is destructuring, confirmed here at runtime. ## Reproduction Details Reproduced: 2026-08-23T15:38:59.612Z Duration: 6384 seconds Tool calls: 244 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00331 pruva-verify CVE-2026-39980 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00331&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00331/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-39980 - Source: http://100.83.35.40:3001/runs/2fe08010-d3fc-4894-8499-e770cecf571c ## Artifacts - bundle/repro/rca_report.md (analysis, 8977 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 14814 bytes) - bundle/artifacts/http/exploit_attempt2_response.json.operations.json (other, 657 bytes) - bundle/artifacts/http/negative_control_distinct_response.json (other, 544 bytes) - bundle/logs/negative_control.log (log, 1290 bytes) - bundle/logs/platform_attempt1.log (log, 8161 bytes) - bundle/logs/platform_attempt2.log (log, 7715 bytes) - bundle/repro/negative_control.sh (other, 5813 bytes) - bundle/repro/runtime_manifest.json (other, 1559 bytes) - bundle/repro/validation_verdict.json (other, 1134 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00331 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00331/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00331 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev