文章总结: watchTowrLabs披露CitrixNetScaler预认证RCE漏洞(疑似CVE-2026-8452)。漏洞位于SAML签名规范化过程,攻击者通过超大PrefixList触发固定缓冲区堆溢出,在splitPktInner中获得目标与来源均受控的memcpy原语,实现write-what-where并劫持nsppe控制流提权至root。建议企业尽快排查并升级受影响版本。 综合评分: 89 文章分类: 漏洞分析,二进制安全,渗透测试,WEB安全,代码审计
欢迎回到这间屋子: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
说一下 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/
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:幻泉之洲 《欢迎回到这间屋子:Citrix NetScaler 预认证 RCE(疑似 CVE-2026-8452)》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论