CVE-2026-39980: Verified Reproduction
CVE-2026-39980: OpenCTI CVE-2026-39980 safeEjs destructuring fix bypass RCE
CVE-2026-39980 is verified against opencti-platform/opencti · github. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00331.
What Is CVE-2026-39980?
CVE-2026-39980 is a critical-severity RCE vulnerability affecting opencti-platform/opencti. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00331).
CVE-2026-39980 Severity
CVE-2026-39980 is rated critical severity.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
How to Reproduce CVE-2026-39980
pruva-verify REPRO-2026-00331 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00331/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-39980
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
JsonMapper variable formula in jsonMapperTest GraphQL mutation: (()=>{const{"constructor":F}=Array;return F(<attacker JS>)()})() - quoted PatternProperty key bypasses safeEjs denylist
- POST /graphql jsonMapperTest multipart mutation
- jsonMapper-domain.ts
- parser/json-mapper.ts extractComplexPathFromJson
- safeEjs.ts safeRender
- Function constructor
- child_process.execSync (root); pre-auth via CVE-2026-27960 Bearer admin internal_id
How the agent worked
Root Cause and Exploit Chain for CVE-2026-39980
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.
- Package/component affected:
opencti/platform(backendopencti-graphql), filesrc/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
CSVMAPPERScapability (or access to notifier template testing, which shares thetransformTemplatecore) 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 freshopencti/platform:6.9.5containers per pass, via the realPOST /graphqljsonMapperTestendpoint, preceded by remote proof of the CVE-2026-27960 auth bypass (mequery returns the admin identity when onlyAuthorization: Bearer 88ec0c6a-13ce-5e39-b486-354fe4a7084fis 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):
const processString = () => {
const parentType = cursor.node.parent?.type.name;
if (parentType === 'Property') { // object literals ONLY
processPropertyDefinitionOrName(); // forbiddenProperties denylist check
}
};
- In
@lezer/javascript, a quoted key in an object literal (x={"constructor":1}) is aStringnode whose parent isProperty→ denylist-checked. The same quoted key in a destructuring pattern (const {"constructor":F}=Array) is aStringnode whose parent isPatternProperty→ never checked (verified locally with@lezer/javascript: parentPatternPropertyvsProperty). - The runtime guard
____safe____property(...)is injected only byprocessBracketLeft/processBracketRightaround[/]tokens (isPropertyNameInBracketcoversMemberExpression,Property,PatternProperty— but destructuring has no bracket tokens), so no runtime coercion/denylist happens either. Arrayis an explicitly allowed global (authorizeGlobals), soArray→ 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
bundle/repro/reproduction_steps.sh(self-contained; only needs Docker).- 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, andopencti/platform:6.9.5(digest pinned and verified:sha256:1f91ad32f1aadf283b5f369ff7b358da071679d4e2bec64030127306db8e73b0) — waits for the real/healthendpoint, then:- proves CVE-2026-27960 auth bypass remotely (
mequery 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
jsonMapperTestand 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.
- proves CVE-2026-27960 auth bypass remotely (
- Expected evidence: marker files with
uid=0(root)+ per-run epoch token + container hostname; GraphQL 200 responses; negative-control rejection; exit code 0. bundle/repro/negative_control.shruns 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, containerf7cd127ef744),marker_attempt2.txt(pass 2, fresh containere8d8ea37e8dd): per-run token +uid=0(root) gid=0(root)...+ hostname. Pass 1 used containersfc0ed8fda7f3/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 handleparentType === 'PatternProperty'(and considerPropertyDefinition/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); notesafeEjs.client.tsalready has a worker path — the jsonMapper path uses the in-processsafeEjs.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-endjsonMapperTestexploit 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 viatrap ... 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.
CVE-2026-39980 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
python3 -c "
import json
d=json.load(open('bundle/learning/exploit_knowledge_preflight.json'))
for r in d['results']:
rec=r['record']
print('ID:',rec.get('record_id') or rec.get('id'))
body=rec.get('body',{})
print(' type:',body.get('record_type'),'title:',rec.get('title'))
print(' summary:',(rec.get('summary') or '')[:300])
print('---')
"EXPLOIT-KNOWLEDGE PREFLIGHT REVIEW REQUIRED: `run_shell` is blocked until this agent calls `review_exploit_knowledge_preflight` with snapshot_sha256 `c44b863005fab787c1702a1b818e1039319cdf49b2a1e64cf714fc44d9d481f9` and exactly one disposition for each candidate ID [2bbf1277-7e0a-40ad-bbc7-dcc2cd1b07a1, 51e25bd6-5f60-45cd-9422-4eb98bcb8cd6, 9c599f34-064b-4487-839a-83d04c6a0d5f, becefd37-8130-4f46-bf8e-e16efa26d139, e5629ac2-8476-4835-8575-b74310392f84, f2d5c8c1-6e07-4040-945f-5eba4aac3963]. Hist...
Unknown error
trueEXPLOIT-KNOWLEDGE PREFLIGHT REVIEW REQUIRED: `run_shell` is blocked until this agent calls `review_exploit_knowledge_preflight` with snapshot_sha256 `c44b863005fab787c1702a1b818e1039319cdf49b2a1e64cf714fc44d9d481f9` and exactly one disposition for each candidate ID [2bbf1277-7e0a-40ad-bbc7-dcc2cd1b07a1, 51e25bd6-5f60-45cd-9422-4eb98bcb8cd6, 9c599f34-064b-4487-839a-83d04c6a0d5f, becefd37-8130-4f46-bf8e-e16efa26d139, e5629ac2-8476-4835-8575-b74310392f84, f2d5c8c1-6e07-4040-945f-5eba4aac3963]. Hist...
Unknown error
Unknown error
Artifacts and Evidence for CVE-2026-39980
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-39980
FAQ: CVE-2026-39980
Is CVE-2026-39980 exploitable?
How severe is CVE-2026-39980?
What type of vulnerability is CVE-2026-39980?
How can I reproduce CVE-2026-39980?
Is the CVE-2026-39980 reproduction verified?
References for CVE-2026-39980
Authoritative sources for CVE-2026-39980 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.