{"repro_id":"REPRO-2026-00331","version":7,"title":"OpenCTI CVE-2026-39980 safeEjs destructuring fix bypass RCE","repro_type":"security","status":"published","severity":"critical","description":"Remediation-bypass variant of CVE-2026-39980 discovered by Pruva run 2fe08010-d3fc-4894-8499-e770cecf571c (vuln_variant stage), independently verified against the real OpenCTI 6.9.5 source tree and parser mechanics.","root_cause":"# RCA Report: CVE-2026-39980-BYPASS-DESTRUCTURE — OpenCTI 6.9.5 safeEjs quoted-key destructuring sandbox bypass (pre-auth root RCE)\n\n## Summary\n\nOpenCTI 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(<attacker JS>)()})()` 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.\n\n## Impact\n\n- **Package/component affected**: `opencti/platform` (backend `opencti-graphql`), file `src/utils/safeEjs.ts` (`processString` / `transformTemplate`).\n- **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.\n- **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).\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact**: code execution (root RCE, pre-auth when chained with CVE-2026-27960).\n- **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).\n- **Parity**: `full`.\n- **Not demonstrated**: nothing claimed was left undemonstrated. (Persistence/exfiltration beyond the marker command was not attempted and was not claimed.)\n\n## Root Cause\n\n`src/utils/safeEjs.ts` (tag `6.9.5` = commit `be4ab13c30d154adc3cfc49ba128b2039b93e348`, fix commit `d91c19e1e7` contained):\n\n```ts\nconst processString = () => {\n  const parentType = cursor.node.parent?.type.name;\n  if (parentType === 'Property') {        // object literals ONLY\n    processPropertyDefinitionOrName();    // forbiddenProperties denylist check\n  }\n};\n```\n\n1. 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`).\n2. 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.\n3. `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).\n\nSink chain: `POST /graphql` → `jsonMapperTest(configuration, file)` (`@auth(for: [CSVMAPPERS])`) → `jsonMapper-domain.ts::jsonMapperTest` → `parser/json-mapper.ts::jsonMappingExecution` → `extractComplexPathFromJson` → `safeRender(\"<?- \" + formula + \" ?>\", ...)`.\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; only needs Docker).\n2. 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:\n   - proves CVE-2026-27960 auth bypass remotely (`me` query as admin via Bearer admin internal_id),\n   - 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),\n   - 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,\n   - recreates the platform container (fresh process, distinct hostname) and repeats the exploit successfully.\n3. Expected evidence: marker files with `uid=0(root)` + per-run epoch token + container hostname; GraphQL 200 responses; negative-control rejection; exit code 0.\n4. `bundle/repro/negative_control.sh` runs a dedicated negative control on a **separate fresh process** (distinct marker name) and asserts rejection + marker absence.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full pass transcript (two consecutive passes, both exit 0).\n- `bundle/artifacts/http/me_response.json` — `{\"data\":{\"me\":{\"user_email\":\"admin@pruva.local\",\"name\":\"admin\"}}}` via Bearer UUID only (CVE-2026-27960).\n- `bundle/artifacts/http/negative_control_response.json` — rejection of the original payload; the error message even shows the injected guard: `<?- ({}[____safe____property({toString:()=>\"constructor\"})]...) ... Forbidden property access {\"propertyName\":\"constructor\"}`.\n- `bundle/artifacts/http/exploit_attempt{1,2}_response.json` — HTTP 200 `{\"data\":{\"jsonMapperTest\":{...}}}` for the destructuring payload (request bodies preserved in `*.operations.json`).\n- `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.\n- `bundle/repro/runtime_manifest.json` — endpoint/runtime evidence manifest with pinned target identity.\n- Environment: rootless Docker 27.5.1, x86_64 Linux, Node runtime bundled in the image.\n\n## Recommendations / Next Steps\n\n- 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.\n- Treat `constructor`-family access uniformly regardless of syntax surface (dot, bracket, destructuring, default values, rest patterns).\n- 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`.\n- 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.\n- Testing: add AST-level regression tests feeding `{\"constructor\":...}` destructuring patterns into the verifier, plus end-to-end `jsonMapperTest` exploit tests.\n\n## Additional Notes\n\n- 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.\n- 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.\n- 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.\n","cve_id":"CVE-2026-39980","cwe_id":"CWE-94","source_url":"http://100.83.35.40:3001/runs/2fe08010-d3fc-4894-8499-e770cecf571c","package":{"name":"opencti-platform/opencti","ecosystem":"github"},"reproduced_at":"2026-08-23T15:38:59.612348+00:00","duration_secs":6384.0,"tool_calls":244,"handoffs":2,"total_cost_usd":5.64207,"agent_costs":{"claim_matcher":0.022458,"judge":0.31991,"learning_policy":0.016039,"repro":3.70621,"support":0.034276,"vuln_variant":1.543177},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.022458},"judge":{"gpt-5.6-sol":0.31991},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.016039},"repro":{"accounts/fireworks/models/kimi-k3":3.70621},"support":{"accounts/fireworks/models/kimi-k3":0.034276},"vuln_variant":{"accounts/fireworks/models/kimi-k3":1.543177}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-23T15:39:00.233349+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":8977,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":14814,"category":"reproduction_script"},{"path":"bundle/artifacts/http/exploit_attempt2_response.json.operations.json","filename":"exploit_attempt2_response.json.operations.json","size":657,"category":"other"},{"path":"bundle/artifacts/http/negative_control_distinct_response.json","filename":"negative_control_distinct_response.json","size":544,"category":"other"},{"path":"bundle/logs/negative_control.log","filename":"negative_control.log","size":1290,"category":"log"},{"path":"bundle/logs/platform_attempt1.log","filename":"platform_attempt1.log","size":8161,"category":"log"},{"path":"bundle/logs/platform_attempt2.log","filename":"platform_attempt2.log","size":7715,"category":"log"},{"path":"bundle/repro/negative_control.sh","filename":"negative_control.sh","size":5813,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1559,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1134,"category":"other"}]}