文章总结: 本文分析QNAP平台多个漏洞链,包括预认证RCE、SQL注入、本地提权及容器逃逸,根因是平台默认不安全写法、插件各自为政、桥接层脆弱及容器边界失效。建议加强插件安全审查、统一鉴权与容器隔离。
综合评分: 85
文章分类: 漏洞分析,渗透测试,红队,容器安全,安全建设
同一套坏习惯:QNAP CGI 预认证 RCE 到容器逃逸(CVE-2026-34007/34008)
黑卷
黑卷
赛博安全攻防日记
2026年9月23日 18:05
上海
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
同一套坏习惯:QNAP CGI 预认证 RCE 到容器逃逸(CVE-2026-34007/34008)
2026-05-17 · Runic Labs · 约 17 分钟
背景
这套研究是去年围着 Pwn2Own 开的。作者没赶上赛期;更离谱的是,ZDI 后来也不收这批洞了。于是链条直接报到 QNAP:对方打了补丁、发了 CVE(CVE-2026-34007、CVE-2026-34008),赏金这边却直接失联。
上下文就这些。下文想讲的,是这些洞把 QNAP 平台的「坏习惯」暴露到什么程度。
开场:一看就眼熟
读过一阵子 QNAP CVE 的人,很快会看出规律。洞并不「花」:CGI 里的命令注入、插件接口的 SQL 注入、特权进程吃掉全局可写路径、本该隔离的容器挂载反而把边界拆掉。类别几乎不变,换的只是插件和版本。
这一轮披露背后,是两个固件版本、三个插件、四类问题:
| 插件 | 问题类型 | 根因 |
| — | — | — |
| Notes Station 3 | 预认证 RCE | 请求头拼进 shell 命令,再丢进 shell_exec |
| Notes Station 3 | 本地提权 | root 监控进程消费全局可写 crontab |
| Notes Station 3 | 容器逃逸 | 可写宿主家目录挂载,且无 userns 重映射 |
| QmailAgent | 预认证 SQL 注入 | 手写 escape(),对着未加引号的整型字段拼 SQL |
这不是运气差。平台把「不安全写法」做成了默认路径,「安全写法」反而要人手工选。文中锚点:qnap-nas-1、qnap-nas-2、qnap-qmail-sqli。
威胁模型
QTS 攻击面远不止一个 Web 口。外面是服务,中间是各插件运行时,再往后才是宿主侧守护进程。真正反复翻车的,是这些区域之间的边界,而不是某个单点实现。
这一轮的重灾区大多依赖插件。默认装机的 QTS,攻击面比下图那台「装得比较满」的 TS-453E 小得多。Notes Station 3、QmailAgent、QVPN、QuFTP 都是 App Center 另装,不是出厂捆绑。对应插件没装上,这一轮洞对那台 NAS 就不成立。
网络可达的外部面:
- QTS Web(
:80/:443/:8080/:8081):主管理入口,默认就在听。实测于出厂状态的 TS-453E + QTS 5.2.9。 - SSH(
:22)、SMB(:139/:445)、NFS(:2049):管理 shell 与文件共享,出厂就带,多半由管理员打开。 - 同一 Web 口下的插件路由:装了 Notes Station 3 就有
/ns/...,装了 QmailAgent 就有/qmail/...。 - 装了 QuFTP 还有 FTP(
:21)。 - 装了 QVPN 还有 VPN(UDP
:500/:4500的 IPSec、L2TP、WireGuard)。
平台试图守住的信任边界,以及它们怎么漏:
- 公网 → QTS Web。真正像样的鉴权几乎只靠
authLogin.cgi。插件可以把路由标成预认证直接绕开——而且它们确实这么干。 - QTS Web → 插件容器。内部 HTTP,校验很轻。不受信的请求头(含
X-Forwarded-For)原样透传,直达插件代码。 - 容器 → 宿主。IPC 走 localhost HTTP,状态靠 bind-mount 宿主路径,容器 root 直接映射宿主 root。
- 插件 ↔ 插件。共享库表、套接字、哨兵文件、依赖边。平台层根本不追踪这些信任关系。
图里标的 1 / 2 / 3,对应本轮三条边界泄漏(与 CVE 一一挂钩)。后面按条拆开讲。
请求如何变成一条 shell 命令
模式:预认证命令注入。处理函数拿请求字段拼 shell 命令,再进 shell_exec——很多时候只是因为要调内部服务,而平台给的 IPC 原语居然是「再 spawn 一次 curl」。
QTS 的 Web 层本质上是一堆对外提供 HTTP 的 ELF。/cgi-bin/ 下大量端点是编译好的 C 二进制,Web 服务器每个请求 execve() 一次。鉴权令牌、会话、查询参数走环境变量和 stdin;这些二进制再继续 shell out 去干实事。插件在上面叠自己的运行时(Notes Station 3 的 Laravel 容器、QmailAgent 的 Roundcube),但同一套坏习惯原样复现:没有框架级「此请求已认证」保证;从请求字段拼参数到处都是;默认写法是字符串拼接,不是参数化调用。
内部服务也不走 Unix socket 或共享内存,而是 localhost HTTP;插件要碰宿主服务,就 curl 一把。qnap-nas-1 因此变得极其无聊:Notes Station 3 某个 PHP 控制器要调宿主上的 authLogin.cgi,用字符串拼接拼 curl 命令行,把攻击者可控的 X-Forwarded-For 直接塞进 shell_exec。getClientIP() 还顺手返错了变量——那只是彩蛋。结构性问题在于:跨容器边界调服务,第一步就是拼一条 shell。每个插件只要自己长一个「迷你 HTTP 客户端」,就多一个注入汇点。
插件各自为政
模式:插件本地 SQL 注入、校验残缺、会话混乱。每个插件在自己的运行时里重写鉴权、转义、会话——写法略有不同,但一个比一个糙。
App Center 装插件,每个插件自带运行时。Notes Station 3 是 Docker 里的 Laravel 风格应用(QPKG 树里直接能看到 storage/framework/{sessions,views,cache} 这套签名)。QmailAgent 带着 Roundcube 1.1.2 和独立 MariaDB。QVPN 则是包一层 IPSec / L2TP / WireGuard 的原生助手。鉴权、输入校验、数据库访问,各玩各的。
摊得比三个 PHP 例子更大:并排的有 PHP、Go(Container Station 自带 go1.24.2)、捆绑的 Python 2.7(NS3、QmailAgent、MultimediaConsole、HD_Station 各自带解释器),以及 /home/httpd/cgi-bin/ 里的原生 C/ELF。它们没有共享安全中间件,按语言隔离、靠 App Center 打包,共同点只有——最后都会回头打电话给 QTS。于是每家都重新手搓同一卷胶带,而且搓得又差又不一样。
平台不强制统一安全层,落到实处就是:
- 预认证端点出现在意想不到的地方。Notes Station 3 的漏洞点挂在双因素邮件流程上——按设计就必须在登录前可达。插件的预认证面 = 作者标成「无需会话」的那批路由,实践里这批标记会漂移。
- 鉴权原语不统一。
NAS_SID、QTS_SSID、插件本地 cookie 并存。跨层搬一个会话(比如从插件session表捞NAS_SID回放给 QTS)是可信的横向跳板——取决于两边校验器当天心情。
开源外面包的那层胶带
模式:洞几乎总落在 QNAP 加的「桥接层」,而不是上游本身。
App Center 里很多东西并不是从零写的:开源项目 + QNAP 贴一层。QmailAgent = Roundcube;Notes Station 3 = Laravel 风格 PHP;别处还有 OpenVPN、WireGuard、MariaDB、nginx、busybox。开源本身没错,麻烦在「加进去」的那截。
插件要做上游从没设计过的事,缺口就用胶带填——两边安全模型根本对不上:
- 上游有自己的鉴权。Roundcube 会验会话;QNAP 还要它认 QTS 的
NAS_SID,于是用桥接表(qmailhub_qts、session.vars里的qmailhub_qts_nas_sid)在两个世界搬身份。桥不继承上游会话模型的保证,只存令牌,指望大家都同意「这条路由听谁的」。 - 上游有自己的 DB 约定。Roundcube 全程参数化查询;QNAP 加的
backup_restore(qnap-qmail-sqli)手搓db->escape(),再把整型拼进 WHERE——比学上游写法省事。洞在 QNAP 代码里,不在 Roundcube。 - 上游有自己的鉴权门。Roundcube 的 task 默认要登录;QNAP 加的
backup_restore任务可以预认证调用。谁加的路由谁把闸门关掉了,平台层也没有「你不许这么干」的检查。 - 上游在一边,QTS 在另一边。Notes Station 3 在容器里跑,QTS 鉴权助手在宿主上;桥是按插件手写、活在上游之外的——所以 qnap-nas-1 落在 QNAP 的 curl 垫片里,而不是 Laravel 里。
套路就是:拉一个还行的上游 → 砍掉不合身的部分 → 包一层必须两边活的胶水 → 标成「内部」→ 跳过审查。这层胶带是作者看过的每个插件里最弱的一环:没有先例可抄,又最急着上线。QNAP 把还不错的开源软件,改得更糟。
应用依赖应用
模式:沿插件依赖边横向扩散,平台却不追踪这些信任边。
App Center 新玩法让事情更糟:插件开始声明对其它插件的运行时依赖。/etc/config/qpkg.conf 里明写 Dependency = ...:Docker 包装应用依赖 container-station,媒体栈依赖 HD_Station,吃媒体索引的依赖 MultimediaConsole。
以前是一排独立烟囱,现在是有向图——而 QNAP 并不拥有这张图。安装器只把依赖当元数据。没有平台级的 API 版本兼容、传递鉴权契约、破坏性变更传播,也没有「依赖方卸载后要撤销信任」的机制。一个插件的洞会横向进到依赖它的应用;一边打了补丁,另一边的鉴权假设悄悄崩掉。卸载也不等于解除信任——因为压根没人在记账。
这是平台已经失控的一块。依赖图本身成了新攻击面,平台侧却没有人在建模。
「可它跑在容器里啊」
模式:可写宿主路径 bind-mount + 容器 root 直接等于宿主 root → 容器逃逸。容器并不是边界。
架构评审一旦尴尬,口头挡箭牌通常是:「可插件跑在容器里。」这句话一压就碎。
qnap-nas-2 是两步证明。先看容器内:一个长时间跑的 PHP 进程以 root 盯着哨兵文件;它消费的 crontab 路径属主是 www-data 且可写。任何能以 www-data 执行的能力(qnap-nas-1 的产物,或以后插件里别的 RCE)都能白嫖容器 root。这里的「容器」,说白了更像用了 mount --bind 的目录布局。
再看配置:没有 user-namespace 重映射。容器内 /proc/self/uid_map 读出来是 0 0 4294967295——完整 32 位 UID 恒等映射。于是容器 root = 宿主 root。bind-mount 也远不止家目录:整棵宿主 /share 以读写挂进容器,用户家目录(含 .ssh/authorized_keys)、所有 NAS 共享、所有 QPKG 安装目录一览无余。单插件里一个 www-data 立足点,三条命令就能换到 NAS 宿主的 admin SSH。容器只有在真正收紧范围时才算边界;QNAP 交付的是最宽挂载 + 最松权限。
混淆不是边界
模式:二进制保护、编译型语言插件、未文档化的内部 API——被当成「安全」。
架构还内化了另一种错觉:攻击者要多花力气逆向,这力气就算安全。不算。
QTS 靠混淆的具体姿势:
- 整棵鉴权树干
authLogin.cgi做了混淆。磁盘上.text加密(熵约 7.997 / 8.000,124 KB 里几乎看不到正经 x86_64 prologue),.init里的解密桩在main前把代码解到内存——所以静态反汇编看到的是垃圾。但保护停在接口层:动态符号表是明文,列出Get_Cookie_Value_By_Tag、Check_NAS_Administrator_Password、qnap_exec、qnap_popen、Password_Encode。够用来画 API 地图,也够知道插件会去摸哪些汇点。 - 编译型插件运行时是同一故事换语言。Go 二进制留着足够的函数名、类型信息和字符串表;PyInstaller 打包的 Python 更友好,一轮
pyinstxtractor就能抠出半可读.pyc。选编译语言只是给逆向加时税,不是安全边界。 - 内部 localhost 服务被当成「没文档所以安全」。它们有路由表、环境变量契约、IPC 形状——QNAP 不公开,但任意插件 RCE 都能摸到。挖 qnap-nas-1 的过程,顺便把 Notes Station 3 怎么跟
authLogin.cgi说话记了下来。所谓内部 API「秘密」,只对还没开始看的人成立。 - 插件内预认证面在内部话术里常常不当成公开攻击面。账号恢复、安全邮件流、备份恢复这些未认证路由,谁会读路由表谁就能打到。作者们仍爱管它们叫「内部」。
拆掉它
authLogin.cgi 的加密是这条链里最「用力」的保护,也最容易演示翻车。解包器就在二进制里。它是 ET_DYN(PIE / 共享对象),动态链接器正常加载时会跑 .init / .init_array,解密桩就在其中,main 之前就把 .text 原地解开。让二进制自己干完,再从内存读出来:
// 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;
}
在 QTS 上编译(Entware 有 gcc)或交叉编译到 x86_64-linux-gnu,然后:
./dump /home/httpd/cgi-bin/authLogin.cgi authLogin.text.bin
吐出来的就是内存里那份已解密 .text。按文件偏移拼回 ELF 副本(作者这版 .text 偏移 0xf0c0、大小 0x1f206),丢进 Binary Ninja,原先磁盘上的噪声立刻能反汇编。
若 dlopen() 抱怨缺 libuLinux_.so,就在设备上跑,或把 NAS 上的 /usr/lib/libuLinux_.so* 拷下来设 LD_LIBRARY_PATH。
不想写代码也可以用 gdb:挂上(或 gdb ./authLogin.cgi),在 _start / 入口(ELF 头 e_entry = 0x131b0 + PIE 基址)下断,单步过 .init,再从 /proc/<pid>/maps 把 .text 映射 dump 出来。结果一样,三步搞定。
这一轮每条 advisory,都落在 QNAP 刻意加难阅读的代码里——洞还在。混淆挡不住愿意花一周啃二进制的攻击者;挡得住的是自家审计、安全团队,以及本来能从干净源码里把洞标出来的外部研究员。攻击者付一次成本就能走到利用;防守方一辈子付成本,还什么都买不到。
怎样才算真改
招数并不玄乎:
- 参数化,别
escape()。插件数据层应强制占位符 / bind;escape()根本不该作为可调用 API 暴露给作者。 - 禁止字符串拼 shell。框架应提供
run([argv...])一类接口,拒绝给作者等价于shell_exec的东西。内部 IPC 该是有类型的 RPC,不是再调一次curl。 - 单一鉴权层。身份归平台管。插件要么拿到已认证主体,要么 401;想把路由标成预认证,必须显式声明并接受审计。
- 容器默认收紧。挂载默认只读,按路径论证后才给写;默认开 userns 重映射。任何插件都不该有理由看见
/share/CACHEDEV1_DATA/homes/。 - 预认证面要做外围盘点。每个插件都有一小撮预认证端点(找回账号、2FA 开通之类),该由平台建档,而不是个案个案撞出来。
这些都不新。只是 QTS 架构早于「这些该是默认」的年代,插件模型又把改道成本滚雪球了。
收束
做 QNAP 研究最窝火的,不是找不到洞——而是这季度挖到的洞,下季度几乎原样再出现一次,只是换了个团队、换了门语言。QTS 优化的是插件上线速度和「设备上能跑」;不是把单个失误的爆炸半径关进笼子。
老实说,在 QNAP 给自己的应用做出真正的框架之前,局面不会好转:鉴权、IPC、DB 访问、容器范围应由平台做一次,而不是每个作者用不同语言重造四轮。现在平台对 CVE 的回答,是 A 插件组补一个汇点,同时 B 插件组在别处长出同一个汇点。补一个洞比改「不断产洞的东西」便宜,于是那个东西继续留着。
还有一件值得记:作者开始在一些应用树里看到忘删的 CLAUDE.md——说明 AI 编码代理已经掺进 QNAP 插件开发。观察而已;但一个已经接受「每个作者手搓鉴权 / 转义 / IPC」的平台,现在还在接受代理写出同一套。周围代码要是在拼 shell_exec,代理也很乐意继续拼。
QNAP 收了 CVE,跳过了赏金。赏金相对修架构本来就便宜;更便宜的是一分不付,等下一个人开下一轮。作者会继续把链条公开写出来。
作者:Runic Labs
免责声明:
本人所有文章均为技术分享,均用于防御为目的的记录,请勿用于其他用途,否则后果自负。
更多 IoT / 车联网 / 机器人 / AI 安全资料在星球里,扫码进「车联网攻防日记」。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:赛博安全攻防日记 黑卷
黑卷《同一套坏习惯:QNAP CGI 预认证 RCE 到容器逃逸(CVE-2026-34007/34008)》