core-jmp core-jmpdeath of core jump

The QNAP Pattern: Four Bugs and the Architecture That Keeps Producing Them

Runic Labs dissects a QNAP QTS disclosure cycle — four bugs across three plugins (Notes Station 3, QmailAgent, QVPN) — and shows why the platform's architecture keeps producing the same command-injection, SQL-injection, and container-escape classes. CVE-2026-34007, CVE-2026-34008.

oxfemale July 16, 2026 18 min read 24 reads
Export PDF
The QNAP Pattern: Four Bugs and the Architecture That Keeps Producing Them
Original text: “The QNAP Pattern”Runic Labs (May 17, 2026). Code and architecture diagrams below are reproduced with attribution.

Executive Summary

Runic Labs disassembled a full disclosure cycle against QNAP’s QTS operating system—four bugs across three App Center plugins (Notes Station 3, QmailAgent, QVPN) spanning two firmware releases—and found that the individual vulnerabilities matter far less than the pattern behind them. Pre-auth command injection, plugin SQL injection, world-writable paths consumed by root, and container mounts that erase their own boundary are not unlucky one-offs. They recur every release because the platform consistently makes the unsafe option the default and the safe option a manual opt-in.

This write-up walks the architecture through the lens of those bugs: how a request field becomes a shell command, why each plugin reinvents authentication and escaping in its own runtime, how the “duct tape” bridging upstream open-source projects into QTS is where the bugs actually live, why the container is not the boundary it is claimed to be, and why binary obfuscation buys QNAP nothing against a determined attacker while costing its own auditors everything. The CVEs from this cycle include CVE-2026-34007 and CVE-2026-34008.

Backstory

The research began around last year’s Pwn2Own. The chain didn’t land in time for the competition, and—unusually—ZDI was no longer interested in acquiring the bugs. So Runic Labs reported the chain to QNAP directly. QNAP patched, assigned CVEs (CVE-2026-34007, CVE-2026-34008), and then went quiet on the bounty. That backdrop aside, the rest of the post is about what the bugs revealed about the platform itself.

The Setup

Anyone who has followed QNAP CVEs for a while recognises the shape immediately. The bugs are not exotic: command injection in CGI handlers, SQL injection in plugin endpoints, world-writable paths consumed by privileged processes, and container mounts that dissolve the boundary they are 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 concatenated into a shell command reaching shell_exec
Notes Station 3Local priv-escWorld-writable crontab consumed by a root monitor
Notes Station 3Container escapeWritable host home-dir mount, no userns remapping
QmailAgentPre-auth SQL injectionHand-rolled escape() against an unquoted integer field
Four bugs, one root cause pattern. Source: original article.

That distribution is not bad luck. The platform simply ships the unsafe option as the default and leaves the safe option as a manual opt-in. The disclosure anchors used throughout the original research are qnap-nas-1, qnap-nas-2, and qnap-qmail-sqli.

Threat Model

The QTS attack surface is wider than any single web port. There are front-line services, a plugin runtime behind each one, and host services behind those. What keeps failing is the boundaries between those zones—not anything inside them. Most of the vulnerable surface in this cycle is plugin-dependent: Notes Station 3, QmailAgent, QVPN, and QuFTP are App Center installs, not bundled software, so a given NAS is only exposed to this cycle’s bugs if the corresponding plugins are present. A stock QTS install ships a much smaller surface than the fairly loaded TS-453E depicted below.

  • External surface: QTS web (:80/:443/:8080/:8081) is the always-listening admin entry point. SSH (:22), SMB (:139/:445) and NFS (:2049) ship out of the box. Plugin endpoints ride the same web port (/ns/… for Notes Station 3, /qmail/… for QmailAgent). FTP (:21) arrives with QuFTP; VPN (IPSec/L2TP/WireGuard on UDP :500/:4500) with QVPN.
  • Internet → QTS web: the only real auth check is authLogin.cgi. Plugins can declare routes pre-auth—and they do.
  • QTS web → plugin container: internal HTTP with light validation; untrusted headers (including X-Forwarded-For) pass straight through to plugin code.
  • Container → host: localhost HTTP for IPC, bind-mounted host paths for state, and container root mapping directly to host root.
  • Plugin → plugin: shared databases, sockets, sentinel files and dependency-graph edges, with 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 QTS attack surface and where its trust boundaries leak. Source: original article.

How a Request Becomes a Shell Command

Pattern: pre-authenticated command injection. A handler assembles a shell command out of a request field and reaches shell_exec—often because the thing it is really trying to do is call another internal service, and “spawn curl” is the only IPC primitive on offer.

QTS’s web layer is essentially ELF binaries serving HTTP. Many /cgi-bin/ endpoints are compiled C binaries that the web server execve()s once per request; request context (auth tokens, session IDs, query parameters) arrives via environment variables and stdin, and those binaries shell out to other binaries to do the real work. Plugins stack their own runtimes on top—Notes Station 3’s Laravel container, QmailAgent’s Roundcube—but the same idiom appears inside plugin code. There is no framework-level guarantee that a request has been authenticated, argument construction from request fields is everywhere, and the default idiom is string concatenation rather than parameterised invocation.

Internal services also don’t talk over Unix sockets or shared memory—they talk over localhost HTTP, and a plugin needing a host service reaches it by shelling out to curl. That is what makes qnap-nas-1 trivial: a Notes Station 3 PHP controller wanted to call authLogin.cgi on the host, built the curl command line by concatenation, and let the attacker-controlled X-Forwarded-For value flow straight into shell_exec. A getClientIP() return-the-wrong-variable bug was a bonus; the structural problem is that crossing the container boundary is a shell command in the first place. Every plugin that grows its own little HTTP client grows a fresh 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
From an attacker header to shell_exec on the host, via a plugin’s curl shim. Source: original article.

Plugins Are Their Own Worlds

Pattern: plugin-local SQL injection, broken validation, and session confusion. Every App Center plugin ships its own runtime and re-implements auth, escaping and session handling independently—slightly differently, and slightly worse, than the last. Notes Station 3 is a Laravel app inside a Docker container; QmailAgent is a Roundcube 1.1.2 install with its own MariaDB; QVPN is native helpers wrapping IPSec, L2TP and WireGuard. The 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 under /home/httpd/cgi-bin/. None share a security middleware; the only thing they have in common is that they all eventually phone QTS—which is exactly where each one hand-rolls the same duct tape.

The platform does not enforce a common security layer across them. Concretely, that produces pre-auth endpoints in unexpected places—Notes Station 3’s vulnerable route is part of the two-factor email flow, which must be reachable before login, so a plugin’s pre-auth surface is simply whatever routes its author tagged “no session required,” and that drifts. It also produces inconsistent authentication primitives: NAS_SID, QTS_SSID and plugin-local session cookies all coexist, and crossing between two of those layers—say, lifting a NAS_SID from a plugin’s session table and replaying it against QTS—is a credible pivot depending on the moods of the two validators.

The Duct Tape Layer Around Open Source

Pattern: the bugs land in the QNAP-added bridge that ferries upstream code into QTS, never in upstream itself. Much of what ships in the App Center is not green-field—QmailAgent is Roundcube, Notes Station 3 is a Laravel-style app, and other plugins lean on OpenVPN, WireGuard, MariaDB, nginx and busybox. None of that is a problem by itself. The trouble is the layer QNAP bolts on to reconcile two security models that agree on nothing:

  • Upstream has its own auth. Roundcube validates its own session, but QNAP also wants the plugin to honour NAS_SID from QTS, so a bridge table (qmailhub_qts, qmailhub_qts_nas_sid) ferries identity between worlds without inheriting the upstream session model’s guarantees.
  • Upstream has its own DB conventions. Roundcube uses parameterised queries throughout; the QNAP-added backup_restore plugin in qnap-qmail-sqli hand-rolls db->escape() and concatenates an integer into a WHERE clause. The bug is in the QNAP code, not in Roundcube.
  • Upstream has its own auth gate. Roundcube task handlers are normally gated by a session; the QNAP-added backup_restore task is callable pre-auth because whoever added the route simply opted out, with no platform check to stop them.
  • Upstream is in one place, QTS in another. Notes Station 3 runs in a container while the QTS auth helper runs on the host, so the hand-coded per-plugin bridge lives outside upstream—which is why qnap-nas-1 ends up in QNAP’s curl shim rather than in Laravel.

The recurring recipe: pull in a reasonable upstream project, remove 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 examined, because it has no prior art to borrow from and the most pressure to ship.

Apps Depending on Apps

Pattern: lateral compromise through plugin-to-plugin trust edges the platform doesn’t track. A newer App Center pattern is making things 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 consuming the media index.

What used to be a flat field of independent silos is now a directed graph—and QNAP does not own that graph. The installer treats it as mere metadata. There is no platform-level enforcement of API-version compatibility, transitive auth contracts, breaking-change propagation, or trust persistence after a dependent app is uninstalled. A vulnerability 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. The dependency graph is a new attack surface that no one on the platform side is modelling.

“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 an architecture review starts going badly, the fallback answer is “but the plugin runs in a container”—and 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, while the crontab path it consumes is owned and writable by www-data. Anything that can execute as www-data—the output of the qnap-nas-1 primitive, or any future plugin RCE—hands itself container root for free. The “container” is really just a directory layout that happens to use mount --bind.

Second, the container has no user-namespace remapping: /proc/self/uid_map reads 0 0 4294967295, the identity map over the full 32-bit UID space, so container root is host root. The bind mount is also far broader than the home directories—the entire host /share tree is mounted read-write, exposing every user home (with its .ssh/authorized_keys), every NAS share, and every QPKG install. A www-data foothold in a single plugin becomes admin SSH on the host in three commands. A container is a boundary only when it is 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
A www-data foothold escalates to full host compromise in five steps. Source: original article.

Obscurity Is Not a Boundary

Pattern: binary protection, compiled-language plugins, and undocumented internal APIs treated as security. The architecture has internalised the idea that if attackers have to work to reverse the platform, that work counts as security. It does not. QTS leans on obscurity in a few specific ways:

  • authLogin.cgi, the trunk of all QTS authentication, is obfuscated: .text ships encrypted (entropy 7.997 out of 8.000, no recognisable x86_64 prologues across 124 KB) and a decryption stub in .init unpacks it in memory before main runs. But the protection stops at the interface—the dynamic symbol table is plaintext and lists Get_Cookie_Value_By_Tag, Check_NAS_Administrator_Password, qnap_exec, qnap_popen, Password_Encode—enough to map the API and know which sinks plugins reach for.
  • Compiled plugin runtimes are the same story in other languages. Go binaries keep enough function names, type info and strings to reconstruct intent; PyInstaller-bundled Python is one pyinstxtractor run from a half-readable .pyc. A compiled language is a time tax on a reverser, not a security boundary.
  • Internal localhost services are treated as undocumented and therefore “safe,” despite being reachable from any RCE in any plugin. Finding qnap-nas-1 incidentally documented how Notes Station 3 talks to authLogin.cgi.
  • Pre-auth surface inside plugins gets discussed internally as if it isn’t public. Account recovery, security-mail flows and backup-restore handlers are reachable to anyone who reads the route table, yet authors keep calling them “internal.”

Undoing it

The authLogin.cgi encryption is the most aggressive protection in the chain, and also the easiest to defeat—because 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 during normal load, and those constructors include the decryption stub that unpacks .text in place before main is ever called. Just let the binary do the work, 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 examined) and load that into Binary Ninja—the function bodies that were random noise on disk now disassemble cleanly. If dlopen() complains about missing libuLinux_*.so symbols, run the harness on-device or point LD_LIBRARY_PATH at a copy of the NAS’s /usr/lib/libuLinux_*.so*. If you’d rather not write code, the same result is three commands under gdb: break at the entry point (e_entry = 0x131b0 plus the PIE base), step past the .init calls, and dump the .text mapping from /proc/<pid>/maps.

Every advisory in this cycle is in code QNAP went out of its way to make harder to read—and 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 have 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 are not subtle:

  • Parameterise, don’t escape. Every plugin’s data layer should require placeholders or bind values. escape() should not exist as a callable API for plugin authors to reach for.
  • No string-concat shell. Plugin frameworks should expose a run([argv…])-style API and refuse to provide a shell_exec equivalent. Internal IPC should be a typed RPC, not a curl invocation.
  • A single authentication layer. The platform owns identity; plugins receive an authenticated principal or a 401, and cannot opt routes out without an explicit, audited declaration.
  • Containers scoped by default. Read-only mounts unless write is justified per path, and user-namespace remapping on by default. No plugin should ever see /share/CACHEDEV1_DATA/homes/.
  • A periphery audit of pre-auth surface. Every plugin’s small set of pre-auth endpoints should be inventoried by the platform, not discovered case by case.

None of this is novel. The QTS architecture simply predates the era when any of it became the default, and the plugin model has compounded the cost of changing course.

Key Takeaways

  • QNAP’s recurring CVE classes—command injection, SQL injection, world-writable paths, container escapes—are symptoms of a platform that defaults to unsafe patterns, not isolated coding mistakes.
  • Cross-boundary IPC implemented as curl shell-outs turns every plugin’s internal HTTP client into a fresh command-injection sink; qnap-nas-1 puts attacker-controlled X-Forwarded-For straight into shell_exec.
  • Each plugin re-implements auth, escaping and sessions in its own runtime (PHP, Go, Python 2.7, native C), with no shared security middleware and inconsistent session primitives (NAS_SID, QTS_SSID, plugin cookies).
  • The vulnerabilities live in the QNAP-authored “duct tape” bridging upstream projects (Roundcube, Laravel) into QTS—not in the upstream code itself.
  • The plugin container is not a boundary: identity UID mapping (0 0 4294967295) plus a read-write /share bind mount turns a www-data foothold into host root and admin SSH in three commands.
  • Binary obfuscation of authLogin.cgi is trivially undone by letting its own .init constructors decrypt .text in memory—obscurity delays attackers once and blinds defenders permanently.
  • Plugin-to-plugin dependency edges in qpkg.conf form an untracked trust graph that enables lateral compromise the platform never models.

Defensive Recommendations

  • Minimise the plugin surface. Install only the App Center plugins you actually use—Notes Station 3, QmailAgent, QVPN and QuFTP each add network-reachable, pre-auth routes. Uninstall anything unused.
  • Keep the admin plane off the internet. Never expose QTS web (:8080/:443), SSH, SMB, NFS or plugin endpoints directly; place them behind a VPN or restricted management network.
  • Patch promptly and track advisories. Apply firmware and plugin updates as soon as they ship, and watch for CVE-2026-34007 / CVE-2026-34008 and related plugin advisories.
  • Harden SSH and homes. Enforce key-based SSH, audit .ssh/authorized_keys across all user homes, and monitor for unexpected key additions—the documented container escape targets exactly these files.
  • Monitor privileged cron and world-writable paths. Alert on writes to plugin crontab directories and sentinel files consumed by root monitors.
  • Segment the NAS. Put the appliance on an isolated VLAN with no lateral path to workstations or servers, limiting the blast radius of any single plugin RCE.
  • Treat plugin dependencies as trust edges. Review qpkg.conf dependencies and re-audit access assumptions whenever a depended-upon plugin is added, patched or removed.
  • Prefer parameterised, least-privilege deployments. Where configurable, disable unneeded pre-auth flows and restrict plugin database and file permissions.

Conclusion

The hard part of QNAP research isn’t finding bugs—it’s that this quarter’s bugs look almost identical to next quarter’s, 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. Nothing improves until QNAP builds a real framework that handles auth, IPC, database access and container scoping once, so plugin authors—and, increasingly, the AI coding agents now clearly in the loop on plugin code—aren’t reinventing all four, badly, every time. Patching one sink is cheaper than fixing the thing that keeps producing sinks, so the thing that keeps producing them stays.

Original text: “The QNAP Pattern” by Runic Labs.

oxfemale Vulnerability research, reverse engineering, and exploit development.