一个未正式卸载却从内存中消失的DLL案例,第二部分
摘要
Ray Chen的一篇技术博文,探究一个内存损坏bug:单个字节0x01破坏HMODULE句柄,导致DLL被错误释放,进而在进程终止时崩溃。
<p>上次,<a title="一个未正式卸载却从内存中消失的DLL案例" href="https://devblogs.microsoft.com/oldnewthing/20260625-00/?p=112467">我们看了由于DLL在背后被从内存中移除而导致的崩溃</a>,当有人试图调用那个大家都以为还在但其实已不存在的DLL时就会崩溃。</p>
<p>我的一个同事正在调查来自同一进程的其他崩溃,发现大部分其他崩溃也是同样的形式:“数据结构因有人向其中写入了单个字节<tt>01</tt>而损坏。”这个消息让我的调查豁然开朗。</p>
<p>我们之前看到,<a title="我的HMODULE的最低位被设置是什么意思?" href="https://devblogs.microsoft.com/oldnewthing/20260619-00/?p=112447">数据文件模块句柄的<code>HMODULE</code>的最低位被设置</a>。因此,如果这些散乱的<tt>01</tt>字节恰好覆盖了现有<code>HMODULE</code>句柄的最低位字节,就会将其变成一个(假的)数据文件模块句柄。然后,在进程销毁期间,某个组件尽职尽责地清理它们加载的DLL(例如,因为它们存储在RAII类型如<code>wil::<wbr />unique_<wbr />hmodule</code>中),代码会将这个假的数据文件模块句柄传递给<code>FreeLibrary</code>。<code>FreeLibrary</code>函数看到最低位被设置,就会说:“哦,这一定是一个通过<code>LOAD_<wbr />LIBRARY_<wbr />AS_<wbr />DATAFILE</code>加载的模块的句柄”,因此它将作为数据文件释放。</p>
<p>释放数据文件模块意味着撤销加载该模块作为数据文件时所采取的步骤:从内存中取消映射DLL。特别地,以数据文件方式加载模块并不会将DLL添加到作为代码加载的DLL列表中;因此,卸载数据文件模块并不会将其从该列表中移除。就DLL列表而言,该DLL仍在内存中。</p>
<p>一位比特错误导致代码说谎,并试图释放一个不对应于<code>LoadLibrary</code>调用的模块句柄,从而引发了大规模的混乱。</p>
<p>“DLL从内存中取消映射”的崩溃只是“有人在不应写入的地方写入<tt>01</tt>字节”bug的另一种表现。这个原始bug的<a title="Microspeak: 桶bug、桶喷溅、bug喷溅和故障转移" href="https://devblogs.microsoft.com/oldnewthing/20200121-00/?p=103351">桶喷溅</a>范围比我们最初想象的要大。</p>
<p>好消息是,所有崩溃都归结为一个单一的bug。坏消息是,你现在必须调试这一个内存损坏bug。</p>
<p>不幸的是,在撰写本文时,第三方程序中的根本内存损坏bug尚未确定。我们不知道它是来自操作系统组件还是程序本身。不过,它似乎只发生在一个进程中,并且在该进程中跨多个模块喷溅,这表明是该程序本身的问题,或者该特定进程使用系统的方式有些特殊。</p>
<p>如果你看原始堆栈跟踪,会发现问题发生在进程终止时。这可能是问题潜伏了这么久的原因:退出时的崩溃通常会被忽略,因为不会导致最终用户功能损失。用户本来就已经用完程序了。无论是干净退出还是崩溃退出,对用户来说影响不大。</p>
<p>抱歉。并非所有故事都有美满结局。</p>
<p>本文<a href="https://devblogs.microsoft.com/oldnewthing/20260626-00/?p=112472">一个未正式卸载却从内存中消失的DLL案例,第二部分</a>首发于<a href="https://devblogs.microsoft.com/oldnewthing">The Old New Thing</a>。</p>
查看缓存全文
缓存时间: 2026/06/27 05:12
# 内存中不存在的DLL,尽管它没有被正式卸载——第二部分 - 旧事新说
来源:https://devblogs.microsoft.com/oldnewthing/20260626-00?p=112472
上次,我们探讨了因 DLL 在众人背后被悄然从内存中移除(https://devblogs.microsoft.com/oldnewthing/20260625-00/?p=112467)而导致的崩溃问题——当有人试图调用这个每个人以为还在、实则已消失的 DLL 时,就会发生崩溃。
我的一位同事正在查看此进程产生的其他崩溃,他发现其中大部分崩溃也属于“数据结构被损坏,因为有人向其中写入了一个单字节 `01`”的类型。这一信息让我的调查豁然开朗。
我们之前谈到过(https://devblogs.microsoft.com/oldnewthing/20260619-00/?p=112447),`HMODULE` 的最低位被设置为数据文件模块句柄的标志。因此,如果这些四处乱窜的 `01` 字节恰好覆盖了某个现有 `HMODULE` 句柄的最低位字节,就会将其变成一个(伪造的)数据文件模块句柄。接着,在进程销毁过程中,某组件会尽职尽责地清理它加载的 DLL 并释放它们(例如,因为它们被存储在像 `wil::unique_hmodule` 这样的 RAII 类型中),代码会将这个(伪造的)数据文件模块句柄传递给 `FreeLibrary`。`FreeLibrary` 函数检测到最低位被设置,就会认为:“哦,这一定是通过 `LOAD_LIBRARY_AS_DATAFILE` 加载的模块的句柄”,于是将其作为数据文件释放。
释放一个数据文件模块意味着撤销加载数据文件时采取的操作:将 DLL 从内存中取消映射。特别地,以数据文件方式加载模块并不会将该 DLL 添加到作为代码加载的 DLL 列表中;因此,卸载一个数据文件模块并不会将其从该列表中移除。就 DLL 列表而言,该 DLL 仍然在内存中。
一个比特的错误导致代码做出错误判断,试图释放一个并非对应 `LoadLibrary` 调用的模块句柄,从而引发了大混乱。
“DLL 从内存中取消映射”的崩溃,只是“有人把 `01` 字节写到了不该写的地方”这个 bug 的另一种表现形式。原本的这个 bug 的“桶式喷洒”(https://devblogs.microsoft.com/oldnewthing/20200121-00/?p=103351)范围比我们最初想象的要大得多。
好消息是,所有这些崩溃都归结为了一个单一的 bug。坏消息是,你现在需要调试这个内存损坏 bug。
遗憾的是,截至本文撰写时,该第三方程序中根本的内存损坏 bug 尚未被确认。我们不知道它是来自操作系统组件还是程序本身。尽管它似乎只发生在一个进程中,并波及多个模块,这暗示问题可能出在那个程序,或者这个特定进程使用系统的方式有些特殊。
如果你看看原始的调用栈,你会发现问题发生在进程终止时。这可能就是问题潜伏这么久的原因:退出时的崩溃往往不被注意,因为终端用户的功能没有损失。用户反正已经用完了程序。无论它是正常退出还是崩溃退出,对用户影响都不大。
抱歉,并非所有故事都有美好的结局。
### 分类
### 主题
## 作者
Raymond Chen
Raymond 参与 Windows 的发展已有 30 多年。2003 年,他创办了一个名为“The Old New Thing”的网站,其受欢迎程度远远超出了他最疯狂的想象,这一发展至今仍让他感到毛骨悚然。该网站催生了一本书,巧合的是书名也叫做《The Old New Thing》(Addison Wesley 2007)。他偶尔会出现在 Windows 开发文档的 Twitter 账号上,讲述一些毫无实际信息的故事。
相似文章
一个DLL未正式卸载却从内存消失的案例,第一部分
本文调查了一个崩溃转储文件:进程关闭期间,shell32.dll中发生栈溢出,导致重复的异常处理循环。分析追踪了递归异常处理,并确定了涉及DLL卸载的根本原因。
关于线程从已卸载的第三方DLL中执行的问题
微软Raymond Chen的一则技术调试故事,讲述了Windows资源管理器崩溃由从已卸载第三方DLL中执行的线程引起,展示了开发者如何调查此类问题。
我们如何确定 CcNamespace.dll 是一组过早卸载的 DLL 中的主谋?
Raymond Chen 解释了他是如何通过分析调试器的已卸载 DLL 列表,并注意命名模式和顺序,从而确定 CcNamespace.dll 是一串过早卸载的 DLL 中的关键 DLL。
推测故障控制面板扩展如何截断了它面前的值
Raymond Chen 推测故障控制面板扩展如何因将64位指针截断为32位而导致崩溃,可能是由于64位移植过程中代码更新不完整。
关于滥用Windows窗口类额外字节的兼容性说明
Raymond Chen 讨论了一个历史性的 Windows 兼容性问题,其中一些 16 位程序滥用窗口类额外字节来存储私有数据,以及微软如何在保持向后兼容性的同时,对 32 位和 64 位程序堵住了这个漏洞。