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:
| Plugin | Class of Bug | Underlying Cause |
|---|---|---|
| Notes Station 3 | Pre-auth RCE | Header into string-concat shell command into shell_exec |
| Notes Station 3 | Local priv-esc | World-writable crontab consumed by root monitor |
| Notes Station 3 | Container escape | Writable host home-dir mount, no userns remapping |
| QmailAgent | Pre-auth SQL injection | Hand-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):
- QTS web (
:80/:443/:8080/:8081): primary admin entry point, always listening. Confirmed on a stock TS-453E running QTS 5.2.9. - SSH (
:22), SMB (:139/:445), NFS (:2049): admin shell and file-sharing services that QNAP ships out of the box, typically enabled by the admin. - Plugin endpoints exposed through the same web port:
/ns/...when Notes Station 3 is installed,/qmail/...when QmailAgent is installed. - FTP (
:21) when QuFTP is installed. - VPN (UDP
:500/:4500IPSec, L2TP, WireGuard) when QVPN is installed.
Trust boundaries the platform tries to enforce, and where they leak:
- 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. - QTS web to plugin container. Internal HTTP, light validation. Untrusted headers (including
X-Forwarded-For) ferry through and hit plugin code untouched. - Container to host. Localhost HTTP for IPC, bind-mounted host paths for state, container root maps straight to host root.
- Plugin to plugin. Shared databases, sockets, sentinel files, dependency-graph edges. No platform-level tracking of any of it.
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.
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:
- Pre-auth endpoints in unexpected places. Notes Station 3’s vulnerable endpoint is part of the two-factor email flow, which by its nature has to be reachable before the user is logged in. So the pre-auth surface area of a plugin is whatever set of routes the plugin author tagged as “no session required,” and in practice that drifts.
- Inconsistent authentication primitives.
NAS_SID,QTS_SSID, and plugin-local session cookies all coexist. Crossing a boundary between two of those layers (for example, aNAS_SIDlifted from a plugin’ssessiontable and replayed against QTS) is a credible pivot, depending on the moods of the two session validators.
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:
- Upstream has its own auth. Roundcube knows how to validate a session. QNAP wants the plugin to also recognise
NAS_SIDfrom QTS, so a bridge table (qmailhub_qts,qmailhub_qts_nas_sidinsession.vars) ferries identity between the two worlds. The bridge doesn’t inherit the upstream session model’s guarantees. It just stores tokens and hopes everyone agrees on which one is authoritative for a given route. - Upstream has its own DB conventions. Roundcube uses parameterised queries throughout. The QNAP-added
backup_restoreplugin in qnap-qmail-sqli hand-rollsdb->escape()and concatenates an integer into a WHERE clause, because that was easier than learning the upstream patterns. The bug is in the QNAP code, not in Roundcube. - Upstream has its own auth gate. Roundcube task handlers are normally gated by an authenticated session. The QNAP-added
backup_restoretask is callable pre-auth. Whoever added the route just opted out of the gate, and there’s no platform-level check that says “no, you can’t do that.” - Upstream is in one place, QTS is in another. Notes Station 3 runs in a container. The QTS auth helper runs on the host. The bridge is hand-coded per plugin and lives outside upstream, which is why qnap-nas-1 ends up in QNAP’s curl shim instead of in Laravel.
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.
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:
authLogin.cgi, the trunk of all authentication on QTS, is obfuscated..textships encrypted (entropy 7.997 against a max of 8.000, with no recognisable x86_64 prologues across 124 KB), and a decryption stub in.initunpacks it in memory beforemainruns. That’s why a static disassembler shows garbage. But the protection stops at the interface. The dynamic symbol table is plaintext and listsGet_Cookie_Value_By_Tag,Check_NAS_Administrator_Password,qnap_exec,qnap_popen,Password_Encode. Enough to map the API and know what sinks the plugins are reaching for.- Compiled plugin runtimes are the same story in different languages. Go binaries keep enough function names, type info, and string tables to reconstruct intent. PyInstaller-bundled Python is even friendlier. The bytecode is one
pyinstxtractorrun away from a half-readable.pyc. Choosing a compiled language for a plugin is a time tax on a reverser, not a security boundary. - Internal localhost services are treated as undocumented and therefore safe. They have route tables, environment-variable contracts, and IPC shapes that QNAP doesn’t publish. They’re also reachable from any RCE in any plugin. The act of finding qnap-nas-1 incidentally documented how Notes Station 3 talks to
authLogin.cgifor us. The internal API surface is “secret” only to outsiders who haven’t looked yet. - Pre-auth surface inside plugins gets talked about internally as if it isn’t part of the public attack surface. The unauthenticated routes (account recovery, security-mail flows, backup-restore handlers) are reachable to anyone who reads the route table. Plugin authors keep referring to them as “internal.”
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.
- Parameterise, don’t escape. Every plugin’s data layer should require placeholders or bind values.
escape()shouldn’t be a callable API surface. It shouldn’t exist for plugin authors to reach for. - No string-concat shell. Plugin frameworks should expose a
run([argv...])-style API and refuse to give code authors ashell_execequivalent. Internal IPC should be a typed RPC, not acurlinvocation. - A single authentication layer. The platform owns identity. Plugins get an authenticated principal or a 401, and they don’t get to opt routes out without an explicit, audited declaration.
- Containers scoped by default. Read-only mounts unless write is justified per-path. User-namespace remapping on by default. No plugin should ever have a reason to see
/share/CACHEDEV1_DATA/homes/. - A periphery audit of pre-auth surface. Every plugin has a small set of pre-auth endpoints (account recovery, 2FA setup, that kind of thing). These should be inventoried by the platform, not discovered case-by-case.
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.
authLogin.cgipulls 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;lddon-device lists the full set). Off-device recipe:scp 'admin@<nas>:/usr/lib/libuLinux_*.so*' ./qnap-libs/thenLD_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 anLD_PRELOADshim or fall back to building and running the harness on-device. ↩︎