欢迎回到这间屋子:CitrixNetScaler预认证RCE(疑似CVE-2026-8452)"

admin 2026-10-18 04:37:34 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image

欢迎回到这间屋子:Citrix NetScaler 预认证 RCE(疑似 CVE-2026-8452)

幻泉之洲

2026年8月17日 10:04
北京

在小说阅读器读本章

去阅读

watchTowr Labs 披露了一个影响 Citrix NetScaler ADC/Gateway 的预认证远程代码执行漏洞。漏洞藏在 SAML 签名的规范化过程中——攻击者发送超大 PrefixList,堆溢出后获得 write-what-where 原语,最终劫持 nxppe 的控制流。文章详细拆解了从崩溃到 Rip 控制、绕过 pitboss 监控、最后提权到 root 的完整利用链。

突然之间,你坐在一个房间里。环顾四周——你被新公司的其他新人包围着。没错,周一早上,入职培训。

流程你都熟——典型的企业腔调:\"请不要做坏事,否则我们可能不得不解雇你。\"不过你心里清楚接下来是什么。那是你每次入职最喜欢的环节。

来了!密码策要求。你正了正领带,因为你敢打赌,这家机构的 SSLVPN 凭据不会成为突破口。这次不会。绝对不会。你对自己发过誓,这次一定要用特殊符号。

等等——他们刚刚说的是 NetScaler?

世界在你周围凝固。你怎么又回到了这片地狱?你慌了一下——跟每天晚上折磨你的噩梦相比,特殊符号算什么?

你意识到一个事实:没人在乎你的密码有没有特殊符号。你早就已经输了。

欢迎回到 watchTowr Labs 的博客文章。

Citrix NetScaler 是什么,为什么他们的网关是第一个 C 项目?

Citrix NetScaler(正式改过名,又改回来了,只有企业网络供应商能玩出这种操作)是一系列应用交付控制器和 VPN 网关设备,几乎遍布全球每大型企业网络。NetScaler 处理负载均衡、SSL 卸载、认证和远程访问——而 NetScaler Gateway 则充当数千个组织远程访问基础设施的前门。

距离上一篇公开记录的 NetScaler RCE 分析(bishopfox.com/blog/analysis-exploitation-cve-2023-3519)已经过去三年了。

ChatGPT 告诉我们,今天我们要改变这个现状。

背景设定

为了支撑今天的分析,我们分析并利用了一台配置为使用 SAML 的 NetScaler 13.1 设备。

注意:我们刻意省略了利用开发过程中使用的具体 build 号。你不需要它。

基于我们的测试,今天讨论的漏洞在 NetScaler 设备配置为 SAML 服务提供方(SP)或身份提供方(IdP)时即可触达。Citrix 列出了以下受影响版本:

  • NetScaler ADC 和 NetScaler Gateway 14.1 在 14.1-72.61 之前
  • NetScaler ADC 和 NetScaler Gateway 13.1 在 13.1-63.18 之前

我们今天分析的是什么?

事情从这里开始变得有点混乱、恼人,或者神秘——你选哪个词取决于你对誓言的忠诚度。

虽然我们很想告诉你我们肯定在分析 CVE-2026-8452,但 Citrix 一贯的操作(在我们看来)让我们无法确认。不过我们相信这就是 CVE-2026-8452,因为它的描述是\"内存溢出\"漏洞。

Citrix 没有将各个 CVE 与公告中致谢的研究人员一一对应,但被致谢的研究人员之一包括来自 JPMorgan Chase XOR 团队的 Michael Tucker(其他人包括我们自己,我们可以排除自己的漏洞)。

为我们的毫无根据的理论加把火:这个漏洞足够有趣、足够复杂,完全有可能是 Mythos 辅助研究的产物——JPMorgan 非常公开地拥有这个模型的使用权。

我们有证据吗?

完全没有。

猜测有趣吗?

永远有趣。

我们有更好的事情要做吗?

反正,我们继续。

怎么开始的

按照我们通常的分析流程,尤其是面对塞进单次修复里的多个已修补漏洞时,我们不会从特定目标入手。相反,我们问:\"什么变了?\"

具体来说,nsppe 二进制——NetScaler 的包处理引擎——显示出了大量变化,还附赠了一堆被剥离的符号。

面对让 Diaphora 去 diff 39,637 个函数的前景,我们尖叫了一声——没人开心。

经过魔法、痛苦的劳动,以及一点点对\"Secure by Designers\"的不信任,我们最终有 174 个被修改的函数需要审查。

在这些变更里,我们发现了 SAML 认证功能中的一堆变化。其中有项变更特别有意思:一个未认证、可远程触达的内存破坏。

这个特定变化引起了我们的注意——63.18 补丁在这些拷贝周围添加了显式的大小检查,这恰好是我们最终定位漏洞的入口。

在签名规范化过程中,早期版本的 NetScaler 会把攻击者可控的数据——来自 SAML 消息里的 ds:SignedInfo 元素——复制到一个固定大小的全局缓冲区,并且没有检查它是否真的放得下。

发送一个超大的 SignedInfo 元素,拷贝就会越过缓冲区末尾,破坏相邻的包引擎状态,最终让 nsppe(根包引擎)崩溃。

例如:

[..SNIP..]
  v73 = (int)v191 + 46LL;
           if ( v73 >= 0x1001 )
           {
             if ( (\((\_BYTE \)off\_2E26480 + 12682) & 1) != 0 || (\((\_BYTE \)off\_2E26480 + 655736) & 8) == 0 )
               goto LABEL\_92;
             sub\_142E640((unsigned int)v195, 25, 603, 3, 0, 0, 0);
             v48 = sub\_208CB90(
                     LODWORD(v195[0]) + 56,
                     1156,
                     (unsigned int)\"SAML signature validation failed: SignedInfo size (%u bytes) is too large (inline ns)\",
                     v73,
                     v93,
                     v94,
                     v164);
             v50 = v195[0];
             \(\_WORD \)(v195[0] + 12LL) = v48;
             v51 = v48;
             if ( v48

当有人向 NetScaler 发送一条经过签名的 SAML 消息时,NetScaler(毕竟是一台安全设备)需要核验签名是否真实。但它并不签名整条消息。相反,它只签名内部的一个小模块,叫做 SignedInfo。

把 SignedInfo 想象成一张小\"收据\",上面写着:这是我要签名的东西,这是签名方法。实际的签名就是在收据上计算的。所以 SignedInfo 才是真正重要的部分;核验了它,就等于核验了签名。

一条签名消息看起来像这样:


 
   ...
 

 ...

不过你首先得知道一件事:\"规范化\"(canonicalization)。

同一个 XML 可以写成许多略有不同的形式——多余的空格、属性顺序不同、换行符等等——但表达的仍旧是同一个意思。这对签名是个问题,因为签名是对准确字节的数学运算:改动一个空格,逻辑上数学就崩了,尽管没有重要内容变化。

所以在哈希 SignedInfo 之前,双方会先把它重写成一个标准的、整洁的形式。这个清理步骤就叫规范化(通常缩写为 c14n)。发送方和 NetScaler 执行相同的清理,因此他们最终哈希到同样的字节。

这个清理步骤就是 bug 所在。为了整理 SignedInfo,NetScaler 必须在一个内存缓冲区中构建清理后的文本,而那个缓冲区大小是固定的。

SignedInfo 里面有什么?

SignedInfo 始终有三个部分:


 
   ...
 
   ...
 
   ...

  • CanonicalizationMethod 指定使用哪条清理规则。它有一个 Algorithm 属性(命名方法的 URL)。当使用某特定方法(\"独占\"清理)时,它可以包含一个子元素:PrefixList——就是发送方选择的一串单词。
  • SignatureMethod 说明使用的签名算法(例如 RSA with SHA-256)。只有一个 Algorithm 属性。
  • Reference 指向被签名的真实对象(在 SAML 中通常是 assertion)。它有一个 URI 属性和几个子元素:一组 Transforms(更多处理步骤,也可以包含 PrefixList)、一个 DigestMethod(用哪个哈希)和一个 DigestValue(哈希本身)。

请记住,规范化必须把文本复制到固定大小的缓冲区。所以如果你在问\"SignedInfo 的哪部分攻击者能做得特别大?\",答案是……

所以我们……就……炸了……

我们一边乱试一边 fuzz 所有能找到的属性,发现它们大多要么有自己的检查,要么被限制在很小的常数值集合里。

但有个属性表现不大一样——PrefixList。

毫不意外(毕竟是 NetScaler),事情开始变得有趣。

逆向了一下,发现 PrefixList 几乎可以容纳任意字符串。唯一的限制是每个空格分隔的值必须唯一。

所以我们不再使用 AAAA AAAA AAAA AAAA,而是用 N0000 N0001 N0002 N0003 这样的唯一标记:

试着构造这样的东西:

[..SNIP..]
prefix\_list\_overflow = \" \".join(f\"N{i}\" for i in range(2000)) # -> \"N0 N1 N2 N3 ... N1999\"
inclusive\_ns = f'\" PrefixList=\"{prefix\_list\_overflow }\"/>'
[..SNIP..]

解码后:

Boom——又到了 NetScaler\"计划外的预期功能\"时间:

Program received signal SIGBUS, Bus error.
0x000000001c6dea0 in ??()

理解\"计划外的预期功能\"

由于缺少符号,一开始我们根本不知道崩在哪里。恢复了几个符号并加载进 GDB 会话之后,事情逐渐变得清晰起来。

再次触发崩溃给了我们:

Program received signal SIGBUS, Bus error.
0x000000001c6dea0 in ns\_memcpy\_avx ()

查看崩溃指令,$rax 寄存器即将被解引用,但因为它的值无效,我们得到的是 SIGBUS 错误:

(gdb) x/i $rip
=> 0x1c6dea0 :   vmovdqu YMMWORD PTR [rax], ymm0
(gdb) p/x $rax
$1 = 0x4d4d4d4d4d4d43d3

说一下 SIGBUS,因为我们一开始被它搞懵了。在 FreeBSD 上,这类无效内存访问可能产生 SIGBUS 而不是你在 Linux 上期望的 SIGSEGV。

但重点在这里:那些 0x4d 字节是我们自己的。我们刻意在溢出部分填满 M 字符(0x4d)作为显著标记。在 memcpy 用到的指针里看到 0x4d4d4d4d4d4d4d4d,说明我们的数据已经侵入了分配器元数据。

你可能也注意到低字节是 0x43d3 而不是 0x4d4d。那不是损坏,而是某些算术操作的结局,我们稍后会讲。

从初始崩溃状态和 backtrace 看,崩溃并非发生在溢出点本身。相反,它发生在稍后,当 NetScaler 从 freelist 检索一个 chunk,而那个 chunk 里包含我们已经破坏的元数据:

(gdb) bt

0  ns\_memcpy\_avx ()

1  0x00000000015d128b in splitPktInner ()

2  0x00000000015d2a40 in splitPkt ()

3  ...              in nstcp\_send\_nsb ()

4  ...              in the aaad send path

因为我们在规范化期间砸坏了元数据,程序安然继续运行。直到后来同一块 chunk 从 freelist 被取回,NetScaler 才尝试使用被破坏的元数据并踉跄跌倒。

换句话讲,ns\_memcpy\_avx 里的崩溃离实际溢出位置远得很。盯着崩溃本身看,没多少收获。

于是开始了一场打地鼠游戏。我们在 backtrace 沿路的函数上设置断点,逐步往回排查。最终找到了 splitPktInner。反编译函数、设断点、再次触发溢出之后,我们发现了有用的东西。

我们控制了传入 rdi 的值:

Program received signal SIGBUS, Bus error.
0x00000000015d128b in splitPktInner ()
(gdb) p/x $rdi
$1 = 0x4d4d4d4d4d4d43d3     # dest = poisoned nsb+0x50, mangled by an internal subtraction

splitPktInner 函数在 0x15D128B 左右调用了 memcpy。在调用前检查参数揭示了一些有趣的东西:

// a3 是我们通过溢出正在破坏其 header 的新分配 chunk
// a1 是指向保存规范化数据的 chunk 的指针
memcpy(\(a3 + 0x50),           // DEST  = poisoned data pointer
\
(a1 + 0x50),           // SRC   = our canon
\(a1 + 0xE0) - \(a1 + 0x50));  // LEN = packet length

那 0x43d3 从哪来的?

我们之前说 0x43d3 是数学而不是破坏,现在就来算这笔数学。我们在 a3+0x50 种下了一串 M 字节,但 splitPktInner 并不直接使用原始值。它在交给 memcpy 之前会先按包长度调整它:

dest = \*(a3+0x50) - pktlen

我们的包是 0x97A(2426)字节,所以:

0x4D4D4D4D4D4D4D4D      our planted M tag at a3+0x50 (\"MMMMMMMM\")
-         0x0000097A      minus the packet length


0x4D4D4D4D4D4D43D3      the dest ns\_memcpy\_avx faulted on

所以目标不仅仅是\"接近我们数据的某个地方\"。它是一个我们完全控制的值,减去一个我们也控制的长度。如果我们想让拷贝落在某个地址 X 上,就调整溢出长度,让减法正好把它放到 X 上。在玩任何花样之前,我们就已经拥有了一个目标地址完全受控的 memcpy。

好,但来源是什么?

目标是好,但 memcpy 需要复制点什么。有一阵子我们以为来源是一些我们无法控制的内部帧头。我们错了,这浪费了不少时间。来源是 \*(a1+0x50),而 a1 正是保存我们规范化 PrefixList 的 chunk。所以来源也是我们的。

我们在 splitPktInner 断点处检查了 a1+0x50,在拷贝执行之前确认了这一点:


参考资料

[1] https://labs.watchtowr.com/youre-back-in-the-room-citrix-netscaler-pre-auth-rce-cve-2026-8452/


评论:0   参与:  0