调试指南:无效指令上的访问违规,第3集
摘要
本文详细描述了一个调试案例,其中员工因Windows系统中一次失败的代码注入尝试导致的访问违规而遭遇内存写入错误。
<p>一位客户报告说,他们的员工随机遇到“内存写入错误”。</p>
<p>这是他们对<a title="为什么访问违规错误消息将操作放在引号中,重制版" href="https://devblogs.microsoft.com/oldnewthing/20151106-00/?p=92012">该错误消息</a>的解读方式。</p>
<blockquote class="q"><p>在“XX”处的指令引用了内存“YY”。该内存无法被“写入”。</p></blockquote>
<p>好的,那么这里发生的是一个访问违规。</p>
<p>奇怪的是,这个访问违规发生在多个不相关的程序中,而不是全部集中在单个程序或程序系列中。因此,这里存在某种更广泛的问题,而不仅仅是一个有缺陷的程序。</p>
<p>打开其中一个崩溃转储后显示如下:</p>
<pre>eax=0013d354 ebx=0049a000 ecx=009e9a9f edx=03111160 esi=009e9aa0 edi=009e9aa0
eip=009e9aa7 esp=003cfda4 ebp=003cfdb0 iopl=0 nv up ei pl nz ac pe cy
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010217
WerFault!wmainCRTStartup+0x7:
009e9aa7 0000 add byte ptr [eax],al ds:002b:0013d354=??
0:000>
</pre>
<p>那个<code>add byte ptr [eax], al</code>应该立即让你知道我们正在执行无效代码:<a title="调试指南:无效指令上的访问违规,第2集" href="https://devblogs.microsoft.com/oldnewthing/20150313-00/?p=44473">这是尝试执行零值时得到的指令</a>。你可以在第二列中看到这些零值。</p>
<p>所有崩溃转储看起来都类似,只是进程名称不同。</p>
<p>让我们从函数开头反汇编,看看我们是如何到达这里的。</p>
<pre>0:000> u .-7
WerFault!wmainCRTStartup:
009e9aa0 90 nop
009e9aa1 49 dec ecx
009e9aa2 ba6011f102 mov edx,2F11160h
009e9aa7 0000 add byte ptr [eax],al ← died here
009e9aa9 0000 add byte ptr [eax],al
009e9aab 41 inc ecx
009e9aac ffe2 jmp edx
009e9aae cc int 3
</pre>
<p>这看起来不像是一个正确的函数开头。</p>
<p>我的意思是,一个线索是它以单字节<code>nop</code>开头,而不是<a title="为什么Windows函数都以无用的MOV EDI, EDI指令开头?" href="https://devblogs.microsoft.com/oldnewthing/20110921-00/?p=9583">x86-32代码中惯用的<code>mov edi, edi</code></a>。¹</p>
<p>当然,指令流中间还有一块四个<code>00</code>字节。</p>
<p>我认为有趣的是,如果你去掉那四个<code>00</code>字节,那么<code>dec ecx</code>和<code>inc ecx</code>会相互抵消,剩下的看起来像是一个跳板:它将一个绝对地址加载到<code>edx</code>中,然后跳转到该地址。</p>
<p>在我看来,这看起来像是一次失败的函数跳转尝试。下一步是试着弄清楚他们想做什么。</p>
<p>嗯,看起来他们想跳转到<code>0x02f11160</code>处的一个函数,所以让我们看看调试器能告诉我们关于那个地址的信息。</p>
<pre>0:000> !address 0x2f11160
Usage: <unknown>
Base address: 02f11000
End address: 02f12000
Region Size: 00001000 (4.000 kB)
State: 00001000 MEM_COMMIT
Protect: 00000020 PAGE_EXECUTE_READ
Type: 00020000 MEM_PRIVATE
Allocation Base: 02f10000
Allocation Protect: 00000004 PAGE_READWRITE
</pre>
<p>所以这是一个神秘的4KB可执行内存分配。</p>
<p>也许那个内存块中有一些有趣的字符串。</p>
<pre>0:000> !strings 02f11000 02f12000
02f11080 --------
02f11148 ----------------
02f11472 C:\Program Files\Common Files\Contoso\injcore.dll
02f11545 IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII
</pre>
<p>好吧,那么那个指向DLL的路径有点抓了个现行。我猜“inj”代表“注入”。但他们想做什么呢?</p>
<p>我想,“嗯,那四个额外的零字节正好在他们试图加载的常数之后,所以如果我将<code>mov edx</code>改为<code>mov rdx</code>,它将是64位常数的高半部分,然后这看起来就正常了。</p>
<p>现在,这是一个32位进程(由32位指令指针证明),所以没有<code>mov rdx</code>指令。该指令需要64位进程。</p>
<p>但是等等,如果他们搞错了,<i>认为</i>这是64位进程呢?</p>
<p>让我们将这些字节反汇编,就好像它们被注入到64位进程中一样。</p>
<p>我这样做是加载一个牺牲的64位调试会话,然后将我想要研究的字节修补进去。为了诗意的讽刺,我将加载64位<tt>WerFault.exe</tt>到调试器中作为转储文件。</p>
<pre>C:\> windbgx -z C:\Windows\System32\WerFault.exe
Executable search path is:
ModLoad: 00000001`40000000 00000001`400a1000 C:\Windows\System32\WerFault.exe
WerFault!wmainCRTStartup:
00000001`40002480 sub rsp,28h
0:000> eb . 90 49 ba 60 11 f1 02 00 00 00 00 41 ff e2 cc
0:000> u .
WerFault!wmainCRTStartup
00000001`40002480 nop
00000001`40002481 mov r10,2F11160h
00000001`4000248b jmp r10
00000001`4000248e int 3
</pre>
<p>好的,现在这更有道理了。这是一个64位跳板,它将一个绝对跳转目标加载到64位寄存器(<code>r10</code>)中,然后跳转到它。</p>
<p>他们将64位代码注入到了32位进程中!</p>
<p>这也解释了为什么崩溃是零星的:客户的员工大多数时候运行64位进程,但偶尔会有东西运行32位进程,那些就是崩溃的进程。</p>
<p>与客户进一步讨论后,我们了解到Contoso是他们使用的一个反恶意软件程序。<a title="产品支持技巧:我们不够聪明来调试问题,能帮帮我们吗?" href="https://devblogs.microsoft.com/oldnewthing/20241203-00/?p=110601">我们建议他们暂时禁用它</a>以确认它是问题的根源,但他们不想禁用反恶意软件。</p>
<p>好吧,所以我们建议他们与供应商联系,看看是否有可用的更新。他们不愿意在未通过内部验证的情况下更改反恶意软件。他们认为这是Windows问题,并要求Windows解决方案。</p>
<p>我们仍在努力说服客户重新评估他们的反恶意软件。²</p>
<p>¹ 即使这是64位进程,<a title="为什么Windows函数在x86-64上不以无用的MOV EDI,EDI指令开头?" href="https://devblogs.microsoft.com/oldnewthing/20221109-00/?p=107373">Windows组件也不会以单字节<code>nop</code>或任何其他单字节指令开头</a>。如果使用nop,它会使用双字节nop。</p>
<p>² 通过客户联络人与客户沟通的一个缺点:我的同事解释说,通过一个技术不足、不熟悉调试的客户联络人传达这种细节水平使我们处于劣势,因为联络人无法应对客户的反对意见。在这种情况下,我们可能必须让工程师直接与客户交谈。</p>
<p>文章<a href="https://devblogs.microsoft.com/oldnewthing/20260925-00/?p=112731/">调试指南:无效指令上的访问违规,第3集</a>首先出现在<a href="https://devblogs.microsoft.com/oldnewthing">The Old New Thing</a>上。</p>
查看缓存全文
缓存时间: 2026/09/26 14:40
# 调试指南:非法访问无意义指令,第 3 期——《The Old New Thing》
来源:https://devblogs.microsoft.com/oldnewthing/20260925-00/?p=112731/
某客户报告其员工随机遭遇“内存写入错误”。这是他们对错误消息的解读方式(https://devblogs.microsoft.com/oldnewthing/20151106-00/?p=92012):
> 位于“XX”的指令引用了内存地址“YY”。该内存无法被“写入”。
好的,这里发生的是访问冲突。奇怪的是,这个访问冲突出现在多个不相关的程序中,而非仅限于单个程序或同族程序。因此,这显然是一个更广泛的问题,而非某个有缺陷的程序所致。
打开其中一个崩溃转储文件,显示如下:
```
eax=0013d354 ebx=0049a000 ecx=009e9a9f edx=03111160 esi=009e9aa0 edi=009e9aa0
eip=009e9aa7 esp=003cfda4 ebp=003cfdb0 iopl=0 nv up ei pl nz ac pe cy
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010217
WerFault!wmainCRTStartup+0x7:
009e9aa7 0000 add byte ptr [eax],al ds:002b:0013d354=??
0:000>
```
这里的 `add byte ptr [eax], al` 应该立即让你意识到:我们正在执行的并非有效代码——这是执行全零字节(https://devblogs.microsoft.com/oldnewthing/20150313-00/?p=44473)时会出现的指令。你可以在第二列看到这些零字节。所有崩溃转储文件看起来都一样,只是进程名称不同。
让我们从函数起始位置反汇编,看看是如何到达这里的:
```
0:000> u .-7
WerFault!wmainCRTStartup:
009e9aa0 90 nop
009e9aa1 49 dec ecx
009e9aa2 ba6011f102 mov edx,2F11160h
009e9aa7 0000 add byte ptr [eax],al ← 在此崩溃
009e9aa9 0000 add byte ptr [eax],al
009e9aab 41 inc ecx
009e9aac ffe2 jmp edx
009e9aae cc int 3
```
这看起来不像一个函数的正确开头。一个线索是它以单字节的 `nop` 开头,而非 x86-32 代码(https://devblogs.microsoft.com/oldnewthing/20110921-00/?p=9583)惯用的 `mov edi, edi`。此外,指令流中间那四个 `00` 字节也很可疑。
我认为有趣的是:如果去掉那四个 `00` 字节,那么 `dec ecx` 和 `inc ecx` 相互抵消,剩下的看起来像是一个跳板代码:它将绝对地址加载到 `edx` 中,然后跳转过去。在我看来,这像是一次失败的函数跳板尝试。
下一步是尝试弄清楚他们原本想做什么。看起来他们试图跳转到地址 `0x02f11160` 处的函数,那么我们来看看调试器能告诉我们关于该地址的什么信息:
```
0:000> !address 0x2f11160
Usage: Image
Base address: 02f11000
End address: 02f12000
Region Size: 00001000 ( 4.000 kB)
State: 00001000 MEM_COMMIT
Protect: 00000020 PAGE_EXECUTE_READ
Type: 00020000 MEM_PRIVATE
Allocation Base: 02f10000
Allocation Protect: 00000004 PAGE_READWRITE
```
这是一个神秘的 4KB 可执行内存分配。也许这个内存块中有一些有趣的字符串:
```
0:000> !strings 02f11000 02f12000
02f11080 --------
02f11148 ----------------
02f11472 C:\Program Files\Common Files\Contoso\injcore.dll
02f11545 IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII
```
好吧,那个 DLL 路径基本坐实了他们的嫌疑。我猜 “inj” 代表 “注入”。但他们到底想干什么?我琢磨着:“嗯,那四个额外的零字节紧接在他们试图加载的常量之后。所以如果我把 `mov edx` 改成 `mov rdx`,它就成了 64 位常量的高半部分,那么一切就说得通了。”
等等,这是一个 32 位进程(由 32 位指令指针证明),所以不存在 `mov rdx` 指令——该指令需要 64 位进程。但假设他们搞错了,*以为* 这是一个 64 位进程呢?让我们把这些字节当作注入到 64 位进程中那样反汇编。我的做法是加载一个临时的 64 位调试会话,然后把我想研究的字节补丁进去。出于诗意的讽刺,我将 64 位 WerFault.exe 的转储文件加载到调试器中:
```
C:\> windbgx -z C:\Windows\System32\WerFault.exe
Executable search path is:
ModLoad: 00000001`40000000 00000001`400a1000 C:\Windows\System32\WerFault.exe
WerFault!wmainCRTStartup:
00000001`40002480 sub rsp,28h
0:000> eb . 90 49 ba 60 11 f1 02 00 00 00 00 41 ff e2 cc
0:000> u .
WerFault!wmainCRTStartup
00000001`40002480 nop
00000001`40002481 mov r10,2F11160h
00000001`4000248b jmp r10
00000001`4000248e int 3
```
现在这就合理多了。这是一个 64 位跳板代码,将绝对跳转目标加载到 64 位寄存器(`r10`)中,然后跳转过去。他们竟然将 64 位代码注入到了 32 位进程中!这也解释了为什么崩溃是间歇性的:客户的员工大部分时间运行 64 位进程,但偶尔会运行 32 位进程,而崩溃的正是这些进程。
经与客户进一步沟通,我们得知 Contoso 是他们使用的反恶意软件程序。我们建议他们临时禁用它(https://devblogs.microsoft.com/oldnewthing/20241203-00/?p=110601)以确认它是问题根源,但他们不愿意禁用反恶意软件。于是我们建议他们联系供应商查看是否有更新可用。他们不愿在未经内部验证的情况下更换反恶意软件。他们认为这是一个 Windows 问题,并要求 Windows 方面的解决方案。我们仍在努力说服客户,他们需要重新评估他们的反恶意软件。
### 分类
### 主题
## 作者 Raymond Chen
Raymond 参与 Windows 的演进已超过 30 年。2003 年,他创办了一个名为《The Old New Thing》的网站,该网站的受欢迎程度远超他最疯狂的想象,这一发展至今仍让他感到不安。该网站催生了一本书,巧合的是也叫《The Old New Thing》(Addison Wesley, 2007)。他偶尔会出现在 Windows Dev Docs 的 Twitter 账号上,讲述一些毫无有用信息的故事。
相似文章
调试器的谎言
作者讨论了在调试Nordic的nRF54L系列时遇到的意外内存值问题,解释了调试器与内部SoC组件的交互如何可能导致此类问题。
关于线程从已卸载的第三方DLL中执行的问题
微软Raymond Chen的一则技术调试故事,讲述了Windows资源管理器崩溃由从已卸载第三方DLL中执行的线程引起,展示了开发者如何调查此类问题。
一个未正式卸载却从内存中消失的DLL案例,第二部分
Ray Chen的一篇技术博文,探究一个内存损坏bug:单个字节0x01破坏HMODULE句柄,导致DLL被错误释放,进而在进程终止时崩溃。
x86仿真器团队曾遇到一段代码糟糕到他们在仿真过程中直接修复
一个关于Windows x86仿真器团队的故事:他们遇到一个程序,其初始化循环完全展开了64KB(65,536条指令),于是添加了特殊优化,将其替换为一个紧凑循环。
核心转储流行病学:修复一个18年的旧bug
OpenAI工程师详细描述了Rockset的C++数据基础设施中看似不可能的崩溃的诊断过程,揭示了一个Azure上的静默硬件损坏bug以及GNU libunwind中存在18年的竞态条件,最终通过崩溃数据的流行病学分析得以解决。