| blog

From a document upload to RCE in AnythingLLM


Uploading an ordinary .pptx file to AnythingLLM is enough to execute arbitrary commands on the server. The bug is not in AnythingLLM itself: it lives in a transitive dependency, the decompress npm package, and is tracked as CVE-2026-10732.

The dependency chain

AnythingLLM lets users upload documents (PDF, DOCX, PPTX, …) to be embedded and queried. To read Office files it uses officeparser, and officeparser (in its 4.x line) uses decompress to unzip the OOXML container, since a .pptx is just a ZIP archive.

AnythingLLM  ->  officeparser (4.x)  ->  decompress

Untrusted archive extraction plus a filesystem is a well-known source of symlink bugs. This is a variant of that class.

decompress is aware of the classic ZipSlip attacks. It ships a preventWritingThroughSymlink guard that, before writing a file, calls readlink on the target path and refuses to follow an existing symlink.

The weakness is when that check runs. Archive entries are extracted concurrently with Promise.all:

Promise.all(files.map(file => /* write each entry */))

Now consider an archive with two entries that share the same path:

  1. Entry A: a symlink exploit.txt -> /some/target/outside
  2. Entry B: a regular file exploit.txt with attacker-controlled content

Because the writes are interleaved as microtasks, the ordering is deterministic:

Promise B (file):    readlink("out/exploit.txt")  -> ENOENT -> null -> "safe"
Promise A (symlink): symlink("/some/target" -> "out/exploit.txt")  -> created
Promise B (file):    writeFile("out/exploit.txt", payload)  -> follows symlink

The readlink guard in Promise B resolves before Promise A has created the symlink, so it sees nothing and allows the write. The writeFile itself runs after the symlink exists, so the payload is written through it to an arbitrary location on disk.

The result is an arbitrary file write that bypasses preventWritingThroughSymlink entirely. The building block is small:

# symlink entry -> target, then a same-named regular file with the payload
info = zipfile.ZipInfo("exploit.txt")
info.external_attr = 0o120777 << 16   # S_IFLNK
zf.writestr(info, target_path.encode())   # entry A: the symlink
zf.writestr("exploit.txt", content)       # entry B: the content

From arbitrary file write to RCE in AnythingLLM

An arbitrary file write is not automatically code execution. You need a target file that the application later acts upon. AnythingLLM provides a convenient one.

AnythingLLM supports MCP (Model Context Protocol) servers and reads their definitions from:

/app/server/storage/plugins/anythingllm_mcp_servers.json

Each entry declares a command and args that AnythingLLM will spawn. The plan follows directly:

  1. Craft a valid-enough .pptx (correct [Content_Types].xml, _rels, a presentation part) so AnythingLLM accepts it as a real slide deck.
  2. Inside it, make ppt/slides/slide1.xml a symlink to the MCP config path, plus a second ppt/slides/slide1.xml regular entry whose content is a malicious MCP server definition.
  3. Upload the file as a normal document.

When officeparser extracts the archive via decompress, the malicious JSON is written into anythingllm_mcp_servers.json:

{
  "mcpServers": {
    "exploit": {
      "command": "/bin/bash",
      "args": ["-c", "id > /tmp/pwned"]
    }
  }
}

The next time AnythingLLM starts or reloads its MCP servers, it executes the command. Document upload leads to arbitrary file write, which leads to remote code execution.

When there is no convenient file to overwrite

Overwriting the MCP config works because AnythingLLM happens to read an attacker-controllable file and spawn a process from it. That is a lucky target. In a more hardened setup you may only have an arbitrary read/write primitive and no config or script that later gets executed.

That case is not a dead end. Sonar’s write-up Why code security matters even in hardened environments shows how to turn a raw arbitrary read/write in a Node.js process into code execution by corrupting internal libuv state: overwriting a uv_signal_s handle to pivot control flow into a ROP chain that calls execve. A working proof of concept for Node 24 is here: poc_node24.py. It is a good primitive to keep in mind whenever a file-write bug like this one lands you in a process without an obvious executable sink.

Details and PoC

The full technical write-up, the archive generator, and the AnythingLLM RCE proof of concept are documented in the CVE page: