The QNAP Pattern

Backstory

This research started around Pwn2Own last year. I didn’t make it in time. And stranger still, ZDI didn’t want these bugs anymore. So I reported the chain to QNAP directly. They patched, they assigned CVEs (CVE-2026-34007, CVE-2026-34008), and then they ghosted me on the bounty.

That’s the context. The rest of this post is what the bugs taught me about the platform.

The Setup

If you’ve been reading QNAP CVEs for any length of time you’ll spot a pattern pretty fast. The bugs aren’t exotic. Command injections in CGI handlers. SQL injections in plugin endpoints. World-writable paths consumed by privileged processes. Container mounts that erase the boundary they’re supposed to enforce. Same categories, every release, every plugin.

Four bugs in three plugins across two firmware versions sit behind this disclosure cycle:

PluginClass of BugUnderlying Cause
Notes Station 3Pre-auth RCEHeader into string-concat shell command into shell_exec
Notes Station 3Local priv-escWorld-writable crontab consumed by root monitor
Notes Station 3Container escapeWritable host home-dir mount, no userns remapping
QmailAgentPre-auth SQL injectionHand-rolled escape() against an unquoted integer field

That’s not bad luck. The platform just makes the unsafe option the default and the safe option a manual opt-in. Anchors throughout this post: qnap-nas-1, qnap-nas-2, qnap-qmail-sqli.

Threat Model

The QTS attack surface is wider than just one web port. You’ve got services out front, plugin runtimes behind each one, and host services behind those. The thing that keeps failing is the boundaries between those zones, not anything inside them.

Most of the bug-laden surface in this cycle is plugin-dependent. A default QTS install ships a much smaller attack surface than the diagram below, which is a fairly stocked TS-453E. Notes Station 3, QmailAgent, QVPN, and QuFTP are App Center installs, not bundled. A given NAS is only exposed to this cycle’s bugs if the corresponding plugins are installed.

External attack surface (reachable from the network):

Trust boundaries the platform tries to enforce, and where they leak:

  1. Internet to QTS web. The only real auth check is authLogin.cgi. Plugins can opt routes out by declaring them pre-auth, and they do.
  2. QTS web to plugin container. Internal HTTP, light validation. Untrusted headers (including X-Forwarded-For) ferry through and hit plugin code untouched.
  3. Container to host. Localhost HTTP for IPC, bind-mounted host paths for state, container root maps straight to host root.
  4. Plugin to plugin. Shared databases, sockets, sentinel files, dependency-graph edges. No platform-level tracking of any of it.
INTERNET · UNTRUSTEDANONYMOUS ATTACKEREXPOSED SURFACE · NETWORK REACHABLEQTS WEB:8080 / :443HTTP(s), plugin routesprimary entry pointSSH:22admin shellkey-basedSMB · NFS:139 :445 :2049file sharingFTP · QuFTP:21proftpd, plugin-addedVPN · QVPNudp :500 / :4500IPSec · L2TP · WGplugin-addedPLUGIN CONTAINERS · PER-RUNTIME SILOSNOTES STATION 3 · PHP/ns/api/v2/...send_security_mail (pre-auth)cron_monitor.php (root)www-data foothold → container rootQMAILAGENT · ROUNDCUBE/qmail/?_task=...backup_restore (pre-auth)hand-rolled db->escape()qmailhub_qts_nas_sid bridgeQVPN · NATIVEIPSec / L2TP / WGnative helpersauth bridge to QTSOTHER PLUGINSPHP / Go /Python / Ceach its own siloHOST · QTS BASE OSauthLogin.cgi · ELFtrunk of all authencrypted .text, reachable vialocalhost HTTP from any pluginsshd · admin shellauthorized_keys basedaccepts container-injectedkeys without distinctionFILESYSTEM · /share/entire shares tree bind-mounted rwincludes homes/.ssh/authorized_keyscontainer root = host root, no usernsTrust crossings between zones: localhost HTTP, bind mounts, sentinel files, shared DB users, plugindependency edges. None of them are gated by the platform; each plugin re-implements its own contract.123

The numbered callouts (1, 2, 3) are the three CVEs from this cycle, one per leaking boundary. The sections below walk through each one.

How a Request Becomes a Shell Command

Pattern: Pre-authenticated command injection. A handler builds a shell command from a request field and reaches shell_exec, often because what it’s trying to do is talk to another internal service and “spawn curl” is the only IPC primitive on offer.

QTS’s web layer is basically ELF binaries serving HTTP. A lot of the endpoints under /cgi-bin/ are compiled C binaries that the web server execve()s once per request. Request context (auth tokens, session identifiers, query parameters) comes in via environment variables and stdin, and these binaries shell out to other binaries to do the actual work. Plugins layer their own runtimes on top (Notes Station 3’s Laravel container, QmailAgent’s Roundcube), but the same pattern shows up inside plugin code too. There’s no framework-level “this request has been authenticated” guarantee. Argument construction from request fields is everywhere. The default idiom is string concatenation, not parameterised invocation.

Internal services don’t talk to each other over Unix sockets or shared memory either. They talk over localhost HTTP, and plugins that need to reach a host service do it by shelling out to curl. That choice is what makes qnap-nas-1 trivial. A PHP controller in Notes Station 3 wanted to call authLogin.cgi on the host, built the curl command line by string concatenation, and let the attacker-controlled X-Forwarded-For value go straight into shell_exec. getClientIP() also had a return-the-wrong-variable bug, but honestly that was a bonus. The structural problem is that calling another service across the container boundary is a shell command in the first place. Every plugin that grows its own little HTTP client grows a new injection sink with it.

ATTACKERHTTP POST + headersX-Forwarded-For: 127.0.0.1'; CMD; echo 'POST /ns/api/v2/user/send_security_mailQTS WEB · :8080routes to plugin containerNOTES STATION 3 · PHP CONTAINERsend_security_mail(Request $r)└─ container_send_security_mail()SINK · shell_exec()$cmd = "/usr/bin/curl -H 'X-Forwarded-For:". $this->getClientIP() . "'" . ...;shell_exec($cmd);getClientIP() returns the raw attacker-controlled XFFcurl HTTP :nas_ip (crosses container → host)authLogin.cgi · ELF · HOSTvalidates NAS_SID / credsreachable from any plugin via loopback HTTP

Plugins Are Their Own Worlds

Pattern: Plugin-local SQL injection, broken validation, and session confusion. Each plugin re-implements auth, escaping, and session handling in its own runtime, slightly differently and slightly worse than the last.

QTS lets you install plugins from the App Center, and every plugin ships with its own runtime. Notes Station 3 ships a Laravel app (the storage/framework/{sessions,views,cache} signature is right there in the QPKG tree) inside a Docker container. QmailAgent ships a Roundcube 1.1.2 install with its own MariaDB instance. QVPN ships native helpers wrapping IPSec, L2TP, and WireGuard. Each one has its own way of doing auth, input validation, and database access.

And the sprawl is wider than just three PHP examples. Runtimes side-by-side include PHP, Go (Container Station ships a go1.24.2 binary), bundled Python 2.7 (NS3, QmailAgent, MultimediaConsole, and HD_Station each carry their own interpreter), and native C/ELF helpers in /home/httpd/cgi-bin/. None of them share a security middleware. They’re siloed by language and packaged by App Center, and the only thing they have in common is that they all eventually phone QTS. Which is where each one hand-rolls the same duct tape, badly and differently.

The platform doesn’t enforce a common security layer across them. Concretely, that means:

The Duct Tape Layer Around Open Source

Pattern: Bugs land in the QNAP-added bridge that ferries upstream code into QTS, never in upstream itself.

A lot of what ships in the App Center isn’t actually green-field. It’s an open-source project with a QNAP layer bolted on top. QmailAgent is Roundcube. Notes Station 3 is a Laravel-style PHP app of the kind that has dozens of permissive-licensed cousins. Other plugins lean on OpenVPN, WireGuard, MariaDB, nginx, busybox. None of that is bad on its own. The trouble is what gets added.

QNAP plugins have to do things the upstream project never planned for, and what fills the gap is a layer of duct tape between two security models that don’t agree on anything:

So the pattern is: pull in a reasonable upstream project, lop off the parts that don’t fit, wrap them in a layer that has to live in both worlds, mark that layer “internal,” and skip review. The duct tape is the weakest part of every plugin we’ve looked at, because it has no prior art to borrow from and the most pressure to ship. QNAP takes good open-source software and makes it worse.

Apps Depending on Apps

Pattern: Lateral compromise through plugin-to-plugin trust edges that the platform doesn’t track.

There’s a newer App Center pattern that’s making all of this worse. Plugins now declare runtime dependencies on other plugins. The QPKG manifest at /etc/config/qpkg.conf carries explicit Dependency = ... entries: container-station for Docker-packaged apps, HD_Station for the media stack, MultimediaConsole for apps that consume the media index.

What used to be a flat field of independent silos is now a directed graph, and QNAP doesn’t own that graph. The App Center installer just treats it as metadata. There’s no platform-level enforcement of API-version compatibility, transitive auth contracts, breaking-change propagation, or trust persistence after a dependent app is uninstalled. A vuln in one plugin moves laterally into its dependents. A patch in one quietly breaks an auth assumption in another. Uninstalling an app doesn’t de-trust the apps that depended on it, because nobody was tracking the trust edges to begin with.

This is the part QNAP has lost control of. The dependency graph is a new attack surface and nobody on the platform side is modelling it.

“It’s in a Container”

Pattern: Container escape through writable host-path bind mounts and a container root that maps directly to host root. The container is not a boundary.

Whenever the architecture review starts going badly, the fallback answer is usually “but the plugin runs in a container.” That answer collapses under any pressure at all.

qnap-nas-2 is a two-step proof. First, inside the container, a long-running PHP process runs as root and watches a sentinel file. The crontab path it consumes is owned and writable by www-data. Anything that can execute as www-data (any output of the qnap-nas-1 primitive, or any future RCE in the plugin) hands itself container root for free. The “container” here is really just a directory layout that happens to use mount --bind.

Second, that container is configured with no user-namespace remapping. /proc/self/uid_map inside the container reads 0 0 4294967295, which is the identity map over the full 32-bit UID space. So container root is host root. The bind mount is also way broader than just the home directories. The entire host /share tree is mounted read-write into the container, which means every user home (with its .ssh/authorized_keys), every NAS share, and every QPKG install is reachable. A www-data foothold in a single plugin becomes admin SSH on the NAS host in three commands. A container is a boundary only when it’s actually scoped, and QNAP ships these with the broadest possible mounts and the loosest possible permission model.

QNAP NAS HOST/share/CACHEDEV1_DATA/homes/admin/.ssh/authorized_keyswritable by anything with container rootNOTES STATION 3 · CONTAINERPID 165 root crond -fPID 166 root php /cron_monitor.phpPID N www-data php-fpmRCE FOOTHOLDstorage/crontabs/rootwww-data writablecron.update (sentinel)www-data writable/share (entire tree, rw)includes homes/, all NAS shares, all QPKGscontainer root = host rootno user-namespace remappingESCALATION1www-data writescrontab + sentinel2cron_monitor (root)installs crontab3container rootpayload runs as root4write.ssh/authorized_keyson host filesystem5ssh admin@nasfull host compromise

Obscurity Is Not a Boundary

Pattern: Binary protection, compiled-language plugins, and undocumented internal APIs treated as security.

The other thing the architecture has internalised is the idea that if attackers have to work to reverse the platform, that work counts as security. It doesn’t.

QTS leans on obscurity in a few specific ways:

Undoing it

The authLogin.cgi encryption is the most aggressive protection in the chain, and it’s also the easiest to show falling over. The unpacker ships with the binary. authLogin.cgi is built as ET_DYN (a PIE / shared object), so the dynamic linker runs its .init and .init_array constructors as part of normal load, and those constructors include the decryption stub that unpacks .text in place before main is ever called. Just let the binary do it, then read the result out of memory:

// dump_authlogin.c
// gcc -O0 -o dump dump_authlogin.c -ldl
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char **argv) {
    if (argc < 3) { fprintf(stderr, "usage: %s <authLogin.cgi> <out.bin>\n", argv[0]); return 1; }
    if (!dlopen(argv[1], RTLD_NOW | RTLD_LOCAL)) {
        fprintf(stderr, "dlopen: %s\n", dlerror());
        return 1;
    }

    FILE *m = fopen("/proc/self/maps", "r");
    char line[512];
    while (fgets(line, sizeof line, m)) {
        unsigned long lo, hi;
        char perms[8], path[256] = "";
        sscanf(line, "%lx-%lx %7s %*x %*s %*d %255s",
               &lo, &hi, perms, path);
        if (strstr(path, "authLogin.cgi") && perms[2] == 'x') {
            FILE *o = fopen(argv[2], "wb");
            fwrite((void*)lo, 1, hi - lo, o);
            fclose(o);
            fprintf(stderr, "dumped %lx-%lx (%lu bytes)\n",
                    lo, hi, hi - lo);
            break;
        }
    }
    fclose(m);
    return 0;
}

Compile on QTS (Entware ships gcc) or cross-compile to x86_64-linux-gnu, then run:

./dump /home/httpd/cgi-bin/authLogin.cgi authLogin.text.bin

What comes out is the decrypted .text segment exactly as it sits in memory. Splice it back into a copy of the original ELF at the .text file offset (0xf0c0, size 0x1f206 on the build we looked at) and load that into Binja. The function bodies that were random noise on disk now disassemble cleanly.

If dlopen() complains about missing libuLinux_*.so symbols, either run the harness on-device or point LD_LIBRARY_PATH at a copy of /usr/lib/libuLinux_*.so* from the NAS.1

If you’d rather not write code, you can do this under gdb too. Attach (or gdb ./authLogin.cgi), break on _start or the entry-point address (e_entry = 0x131b0 from the ELF header, plus the PIE load base), step past the .init calls, and dump the .text mapping out of /proc/<pid>/maps. Same result, three commands.

Every advisory in this cycle is in code that QNAP went out of its way to make harder to read. The bugs were still there. Obscurity bought QNAP nothing against an attacker willing to spend a week with the binaries, and plenty against its own auditors, its own security team, and any external researcher who would’ve flagged these bugs from clean source. Attackers pay the cost once on the way to an exploit. Defenders pay it forever and get nothing for it.

What Would Actually Change This

The fixes aren’t subtle.

None of this is novel. It’s just that the QTS architecture predates the era when any of this was the default, and the plugin model has compounded the cost of changing course.

Closing

The frustrating part of QNAP research isn’t finding bugs. It’s that the bugs you find this quarter are going to look almost exactly like the bugs you’ll find next quarter, in a plugin written by a different team in a different language. QTS optimises for plugin velocity and “it works on the device.” Not for confining the blast radius of any single mistake.

Honestly, none of this gets better until QNAP builds a real framework for its own apps. Something that handles auth, IPC, DB access, and container scoping once, so plugin authors aren’t reinventing all four every time in a different language. Right now the platform’s answer to any CVE is one plugin team patching one sink, while the next plugin team is busy growing the same sink somewhere else. Patching one bug is cheaper than fixing the thing that keeps producing them, so the thing that keeps producing them stays.

One other thing worth mentioning. We’re starting to find forgotten CLAUDE.md files lying around in some of these app trees, so AI coding agents are clearly in the loop on QNAP plugin code now. Just an observation, but a platform that already accepts hand-rolled auth, escape, and IPC from every plugin author is now accepting it from agents too. And agents will happily concatenate request fields into shell_exec if the surrounding code does.

QNAP took the CVEs and skipped the bounty. The bounty was always cheap compared to fixing the architecture. Cheaper still is paying nothing at all and waiting for someone else to do the next round. We’ll keep publishing the chains.


  1. authLogin.cgi pulls in a chain of QNAP-specific libraries (libuLinux_Storage, libuLinux_NAS, libuLinux_cgi, libuLinux_config, libuLinux_PDC, libuLinux_statistics, libuLinux_Util, libuLinux_nasauth, and others; ldd on-device lists the full set). Off-device recipe: scp 'admin@<nas>:/usr/lib/libuLinux_*.so*' ./qnap-libs/ then LD_LIBRARY_PATH=./qnap-libs ./dump ./authLogin.cgi ./out.bin. Some libs run constructors at load time that touch /etc/config/uLinux.conf, /proc/qnap, or device nodes under /dev/. If a constructor crashes on the host, either stub the missing entry points with an LD_PRELOAD shim or fall back to building and running the harness on-device. ↩︎