# 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(<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.

## 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("<?- " + formula + " ?>", ...)`.

## 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: `<?- ({}[____safe____property({toString:()=>"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.
