为什么我们不允许栈是稀疏的,而不是强制它们连续?

The Old New Thing (Raymond Chen) 新闻

摘要

本文讨论了为什么栈在内存中被设计为连续而不是稀疏的,重点介绍了诸如Stack Clash之类的安全风险以及异常处理中的实现复杂性。

<p>当我讨论 <a title="Why don't we just make the entire stack out of guard pages?" href="https://devblogs.microsoft.com/oldnewthing/20260713-00/?p=112528">为什么我们不直接用保护页构建整个栈</a>时,<a href="https://devblogs.microsoft.com/oldnewthing/20260713-00/?p=112528&amp;commentid=144516#comment-144516">评论者BCS想知道</a>,“为什么要求栈使用连续映射的页面?如果只映射被触碰的页面会出什么问题?例如,对于一个想在栈上 alloca 512MB 但只读写几个页面的函数,这可能实际上是一件好事。”</p> <p>所以这个问题是在问为什么栈必须是连续的。为什么不让它稀疏,只让被触碰的页面出错?</p> <p>第一个问题是,栈检查代码必须包含一个对栈限制的显式检查,而不是仅仅一次向下移动一个页面。这个显式检查是为了避免安全漏洞,如果有人能够 alloca 一个如此大的缓冲区,以至于它完全超出了栈预留范围。如果你一次只移动一个页面,你最终会碰到标记栈结束的无访问页面。但如果你能跳过多个页面而不触碰它们,你可能会跳得离栈末尾太远,以至于落在其他地方并开始破坏那块内存,因为你把它当作栈来使用。在Linux圈子中,这个漏洞被昵称为“<a href="https://www.qualys.com/2017/06/19/stack-clash/stack-clash.txt">Stack Clash</a>”¹,更正式的名称是“<a href="https://lwn.net/Articles/725832/">stack guard-page hopping</a>”。²</p> <p>解决这个问题后,你还有另一个问题:如何报告在栈中间提交页面失败?</p> <pre>void dosomething() { void* buffer = NULL; __try { buffer = alloca(65536); } __except (GetExceptionCode() == STATUS_STACK_OVERFLOW) { if (!_resetstkoflw()) __fastfail(FAST_FAIL_FATAL_APP_EXIT); } if (buffer != NULL) { ⟦ 使用缓冲区 ⟧ } } </pre> <p>如果你允许稀疏栈,那么缓冲区内存实际上直到代码使用它时才会被提交。但代码使用缓冲区的点是在 alloca() 失败的异常处理程序之外。代码合理地假设,如果 alloca 成功,那么内存确实被分配了。</p> <p>我猜你可以通过提交内存而不使其呈现来解决这个问题。这意味着调用 VirtualAlloc 来扩展栈,而不是仅仅访问内存。这不仅会使栈扩展代码更复杂,特别是因为你必须保留所有可能被任何调用约定使用的寄存器,而且你还需要确保 VirtualAlloc 函数本身不会分配太多栈!</p> <p>现在,你仍然可以调整x86-32栈探测器以避免<a href="https://devblogs.microsoft.com/oldnewthing/20260311-00/?p=112134&amp;commentid=143917#comment-143917">pete.d</a>的问题,即一个大的栈帧被完全呈现,导致的页面换入产生明显的性能问题。x86-32探测器可以短路栈探测(<a title="Windows stack limit checking retrospective: arm64, also known as AArch64" href="https://devblogs.microsoft.com/oldnewthing/20260320-00/?p=112154">像MIPS和其他处理器在这个表格中列出的那样</a>),以便页面换入仅在栈实际扩展时发生。</p> <p>¹ 关于Stack Clash的额外阅读:</p> <ul> <li><a href="https://developers.redhat.com/blog/2017/09/25/stack-clash-mitigation-gcc-background">GCC中的Stack Clash缓解 — 背景</a></li> <li><a href="https://developers.redhat.com/blog/2019/04/30/stack-clash-mitigation-in-gcc-why-fstack-check-is-not-the-answer">GCC中的Stack Clash缓解:为什么 -fstack-check 不是解决方案</a></li> <li><a href="https://developers.redhat.com/blog/2020/05/22/stack-clash-mitigation-in-gcc-part-3">GCC中的Stack Clash缓解,第3部分</a></li> </ul> <p>² 一些系统通过创建一个在栈末尾之外的非常大的无访问区域来缓解栈保护页跳跃。然而,这不是一个修复,只是一个缓解措施。它只是让人们必须跳得更远才能越过无访问区域。如果你已经有这个漏洞,很可能是因为攻击者可以控制分配的大小,那样的话你并没有真正减慢他们多少;他们只需要在攻击负载中放一个更大的数字。</p> <p>其他系统通过(令人惊讶地)按顺序探测栈的每个页面来更彻底地解决这个问题。</p> <p>Stack Clash即使在gcc在2020年有解决方案后仍然是一个问题。<a href="https://app.opencve.io/cve/CVE-2026-77658">这是几天前的CVE-2026-77658</a>。</p> <p>本文<a href="https://devblogs.microsoft.com/oldnewthing/20260907-00/?p=112677">为什么我们不允许栈是稀疏的,而不是强制它们连续?</a>最初出现在<a href="https://devblogs.microsoft.com/oldnewthing">The Old New Thing</a>上。</p>
查看原文
查看缓存全文

缓存时间: 2026/09/08 23:52

# 为何不允许堆栈稀疏分布,而是强制要求连续映射? - The Old New Thing 来源:https://devblogs.microsoft.com/oldnewthing/20260907-00?p=112677 当我讨论为何不使用全保护页来构建堆栈时(https://devblogs.microsoft.com/oldnewthing/20260713-00/?p=112528),评论者 BCS 提出疑问(https://devblogs.microsoft.com/oldnewthing/20260713-00/?p=112528&commentid=144516#comment-144516):“为什么要求堆栈必须使用连续映射的页面?如果仅映射被访问的页面会出什么问题?这实际上可能有益处,例如某个函数想在堆栈上使用 `alloca` 分配 512MB,但实际只读写少数几页。” 因此,问题在于:为何堆栈必须是连续的?为何不允许稀疏堆栈,仅在访问时按需调入页面? 首要问题在于堆栈检查代码需要显式校验堆栈边界,而不能仅逐页向下探测。这种显式检查是防止安全漏洞的必要措施——假设有人通过 `alloca` 分配超大缓冲区,可能直接越过整个堆栈保留区域的边界。如果仅逐页探测,最终会触及标记堆栈末尾的不可访问页面。但若允许跨页跳跃访问,可能越过堆栈末端太远,落在其他内存区域上并开始破坏该内存(因为程序正将其用作堆栈)。在 Linux 社区中,此漏洞被称为“堆栈冲突(Stack Clash)”¹,正式术语为“堆栈保护页跳越(stack guard-page hopping)”²。 修复此问题后,还需处理另一个难题:如何报告堆栈中间页面提交失败的情况? ```c void dosomething() { void* buffer = NULL; __try { buffer = alloca(65536); } __except (GetExceptionCode() == STATUS_STACK_OVERFLOW) { if (!_resetstkoflw()) __fastfail(FAST_FAIL_FATAL_APP_EXIT); } if (buffer != NULL) { ⟦ 使用缓冲区 ⟧ } } ``` 若允许稀疏堆栈,`buffer` 的内存实际上会在代码使用时才提交。但代码使用缓冲区的位置*在*失败 `alloca()` 的异常处理器*之外*。代码可以合理地假设:若 `alloca` 成功,则内存已分配完成。 或许可通过提交内存但不立即映射来修复此问题。这意味着需调用 `VirtualAlloc` 扩展堆栈,而不仅仅是访问内存。这不仅会使堆栈扩展逻辑更复杂(尤其需保留所有调用约定可能使用的寄存器³),还需确保 `VirtualAlloc` 函数自身不会消耗过多堆栈空间! 当然,仍可调整 x86-32 堆栈探测器以避免 pete.d⁴ 提及的问题——即大型堆栈帧被完全映射,导致页面调入引发明显性能下降。x86-32 探测器可短路堆栈探测(如 MIPS 及表格中其他处理器⁵),仅在堆栈实际扩展时才触发页面调入。 --- ¹ 关于堆栈冲突的延伸阅读: - GCC 中的堆栈冲突缓解措施——背景介绍(https://developers.redhat.com/blog/2017/09/25/stack-clash-mitigation-gcc-background) - GCC 中的堆栈冲突缓解:为何 -fstack-check 并非解决方案(https://developers.redhat.com/blog/2019/04/30/stack-clash-mitigation-in-gcc-why-fstack-check-is-not-the-answer) - GCC 中的堆栈冲突缓解,第三部分(https://developers.redhat.com/blog/2020/05/22/stack-clash-mitigation-in-gcc-part-3) ² 部分系统通过创建堆栈末尾之后的超大不可访问区域来缓解堆栈保护页跳越问题。但这仅是缓解措施而非根本修复——攻击者只需跳过更长距离以越过不可访问区域。若系统已存在此漏洞,通常意味着攻击者可控制分配大小,此时缓解措施收效甚微;他们只需在攻击载荷中填入更大数值即可。 其他系统则通过(意料之中地)依次探测堆栈每一页来更彻底地解决此问题。 尽管 GCC 已于 2020 年提供解决方案,堆栈冲突问题至今仍然存在。以下是几天前刚公布的 CVE-2026-77658(https://app.opencve.io/cve/CVE-2026-77658)案例。 --- ### 分类 ### 主题 ## 作者 Raymond Chen Raymond 参与 Windows 系统演化已逾 30 年。2003 年,他创立 The Old New Thing 博客,其受欢迎程度远超他最疯狂的想象——这一发展至今仍让他感到不可思议。该博客衍生出一本同名著作(Addison Wesley 2007 出版)。他偶尔会通过 Windows Dev Docs Twitter 账号分享一些毫无实用信息的故事。

相似文章

为什么不干脆把整个栈都做成防护页?

The Old New Thing (Raymond Chen)

本文解释了为什么将整个栈都做成防护页是有问题的,因为它可能导致无限制的内存分配和系统挂起。相反,固定数量的防护页更受青睐,以实现有界限的故障处理。

Windows堆栈限制检查回顾,后续

The Old New Thing (Raymond Chen)

Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。

用栈和队列揭示边界

Lobsters Hottest

一篇技术博文,解释了在树的遍历中,使用栈和队列相比于递归的优势,并附有Rust代码示例。

Multistack Concatenative Programming Languages

Lobsters Hottest

An exploration of multistack concatenative programming languages, discussing how auxiliary stacks and dynamic bindings like Factor's namespaces and PostScript's dictionaries can ease data stack management without sacrificing concatenative composition.