调试内存损坏是谁把 "2 "写进我的堆栈的?

TAUTVYDAS ŽILYS / UNITY TECHNOLOGIESContributor
Apr 25, 2016|16 Min
调试内存损坏是谁把 "2 "写进我的堆栈的?
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。
大家好,我叫 Tautvydas,是 Unity 的软件开发人员,在 Windows 团队工作。我想和大家分享一个调试难以捉摸的内存损坏错误的故事。

几周前,我们收到一位客户的错误报告,说他们的游戏在使用 IL2CPP 脚本 Backend 时崩溃了。QA 核实了错误,并将其分配给我进行修复。这个项目相当大(尽管远不是最大的);在我的机器上花了 40 分钟才完成。错误报告上的说明说"玩 5-10 分钟游戏,直到游戏崩溃"。果然,按照说明操作后,我观察到了崩溃。我启动了 WinDbg,准备把它固定下来。不幸的是,堆栈跟踪是假的:

0:049> k
# Child-SP           RetAddr           Call Site
00 00000022`e25feb10 00000000`00000010 0x00007ffa`00000102

0:050> u 0x00007ffa`00000102 L10
00007ffa`00000102 ??         ???
                          ^ Memory access error in 'u 0x00007ffa`00000102 l10'

显然,它试图执行一个无效的内存地址。虽然堆栈跟踪已损坏,但我希望只是整个堆栈的一部分损坏,如果查看堆栈指针寄存器之后的内存内容,应该可以重建堆栈。当然,这让我知道下一步该去哪里找:

0:049> dps @rsp L200
...............
00000022`e25febd8  00007ffa`b1fdc65c ucrtbased!heap_alloc_dbg+0x1c [d:\th\minkernel\crts\ucrt\src\appcrt\heap\debug_heap.cpp @ 447]
00000022`e25febe0  00000000`00000004
00000022`e25febe8  00000022`00000001
00000022`e25febf0  00000022`00000000
00000022`e25febf8  00000000`00000000
00000022`e25fec00  00000022`e25fec30
00000022`e25fec08  00007ffa`99b3d3ab UnityPlayer!std::_Vector_alloc<std::_Vec_base_types<il2cpp::os::PollRequest,std::allocator<il2cpp::os::PollRequest> > >::_Get_data+0x2b [ c:\program files (x86)\microsoft visual studio 14.0\vc\include\vector @ 642]
00000022`e25fec10  00000022`e25ff458
00000022`e25fec18  cccccccc`cccccccc
00000022`e25fec20  cccccccc`cccccccc
00000022`e25fec28  00007ffa`b1fdf54c ucrtbased!_calloc_dbg+0x6c [d:\th\minkernel\crts\ucrt\src\appcrt\heap\debug_heap.cpp @ 511]
00000022`e25fec30  00000000`00000010
00000022`e25fec38  00007ffa`00000001
...............
00000022`e25fec58  00000000`00000010
00000022`e25fec60  00000022`e25feca0
00000022`e25fec68  00007ffa`b1fdb69e ucrtbased!calloc+0x2e [d:\th\minkernel\crts\ucrt\src\appcrt\heap\calloc.cpp @ 25]
00000022`e25fec70  00000000`00000001
00000022`e25fec78  00000000`00000010
00000022`e25fec80  cccccccc`00000001
00000022`e25fec88  00000000`00000000
00000022`e25fec90  00000022`00000000
00000022`e25fec98  cccccccc`cccccccc
00000022`e25feca0  00000022`e25ff3f0
00000022`e25feca8  00007ffa`99b3b646 UnityPlayer!il2cpp::os::SocketImpl::Poll+0x66 [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\win32\socketimpl.cpp @ 1429]
00000022`e25fecb0  00000000`00000001
00000022`e25fecb8  00000000`00000010
...............
00000022`e25ff3f0  00000022`e25ff420
00000022`e25ff3f8  00007ffa`99c1caf4 UnityPlayer!il2cpp::os::Socket::Poll+0x44 [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\socket.cpp @ 324]
00000022`e25ff400  00000022`e25ff458
00000022`e25ff408  cccccccc`ffffffff
00000022`e25ff410  00000022`e25ff5b4
00000022`e25ff418  00000022`e25ff594
00000022`e25ff420  00000022`e25ff7e0
00000022`e25ff428  00007ffa`99b585f8 UnityPlayer!il2cpp::vm::SocketPollingThread::RunLoop+0x268 [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\vm\threadpool.cpp @ 452]
00000022`e25ff430  00000022`e25ff458
00000022`e25ff438  00000000`ffffffff
...............
00000022`e25ff7d8  00000022`e25ff6b8
00000022`e25ff7e0  00000022`e25ff870
00000022`e25ff7e8  00007ffa`99b58d2c UnityPlayer!il2cpp::vm::SocketPollingThreadEntryPoint+0xec [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\vm\threadpool.cpp @ 524]
00000022`e25ff7f0  00007ffa`9da83610 UnityPlayer!il2cpp::vm::g_SocketPollingThread
00000022`e25ff7f8  00007ffa`99b57700 UnityPlayer!il2cpp::vm::FreeThreadHandle [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\vm\threadpool.cpp @ 488]
00000022`e25ff800  00000000`0000106c
00000022`e25ff808  cccccccc`cccccccc
00000022`e25ff810  00007ffa`9da83610 UnityPlayer!il2cpp::vm::g_SocketPollingThread
00000022`e25ff818  000001c4`1705f5c0
00000022`e25ff820  cccccccc`0000106c
...............
00000022`e25ff860  00005eaa`e9a6af86
00000022`e25ff868  cccccccc`cccccccc
00000022`e25ff870  00000022`e25ff8d0
00000022`e25ff878  00007ffa`99c63b52 UnityPlayer!il2cpp::os::Thread::RunWrapper+0xd2 [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\thread.cpp @ 106]
00000022`e25ff880  00007ffa`9da83610 UnityPlayer!il2cpp::vm::g_SocketPollingThread
00000022`e25ff888  00000000`00000018
00000022`e25ff890  cccccccc`cccccccc
...............
00000022`e25ff8a8  000001c4`15508c90
00000022`e25ff8b0  cccccccc`00000002
00000022`e25ff8b8  00007ffa`99b58c40 UnityPlayer!il2cpp::vm::SocketPollingThreadEntryPoint [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\vm\threadpool.cpp @ 494]
00000022`e25ff8c0  00007ffa`9da83610 UnityPlayer!il2cpp::vm::g_SocketPollingThread
00000022`e25ff8c8  000001c4`155a5890
00000022`e25ff8d0  00000022`e25ff920
00000022`e25ff8d8  00007ffa`99c19a14 UnityPlayer!il2cpp::os::ThreadStartWrapper+0x54 [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\win32\threadimpl.cpp @ 31]
00000022`e25ff8e0  000001c4`155a5890
...............
00000022`e25ff900  cccccccc`cccccccc
00000022`e25ff908  00007ffa`99c63a80 UnityPlayer!il2cpp::os::Thread::RunWrapper [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\thread.cpp @ 80]
00000022`e25ff910  000001c4`155a5890
...............
00000022`e25ff940  000001c4`1e0801b0
00000022`e25ff948  00007ffa`e6858102 KERNEL32!BaseThreadInitThunk+0x22
00000022`e25ff950  000001c4`1e0801b0
00000022`e25ff958  00000000`00000000
00000022`e25ff960  00000000`00000000
00000022`e25ff968  00000000`00000000
00000022`e25ff970  00007ffa`99c199c0 UnityPlayer!il2cpp::os::ThreadStartWrapper [ c:\users\tautvydas\builds\bin2\il2cppoutputproject\il2cpp\libil2cpp\os\win32\threadimpl.cpp @ 26]
00000022`e25ff978  00007ffa`e926c5b4 ntdll!RtlUserThreadStart+0x34
00000022`e25ff980  00007ffa`e68580e0 KERNEL32!BaseThreadInitThunk

以下是粗略重建的堆栈跟踪:

00000022`e25febd8  00007ffa`b1fdc65c ucrtbased!heap_alloc_dbg+0x1c [...\appcrt\heap\debug_heap.cpp @ 447]
00000022`e25fec28  00007ffa`b1fdf54c ucrtbased!_calloc_dbg+0x6c [...\appcrt\heap\debug_heap.cpp @ 511]
00000022`e25fec68 00007ffa`b1fdb69e  ucrtbased!calloc+0x2e [...\appcrt\heap\calloc.cpp @ 25]
00000022`e25feca8  00007ffa`99b3b646 UnityPlayer!il2cpp::os::SocketImpl::Poll+0x66 [...\libil2cpp\os\win32\socketimpl.cpp @ 1429]
00000022`e25ff3f8  00007ffa`99c1caf4 UnityPlayer!il2cpp::os::Socket::Poll+0x44 [...\libil2cpp\os\socket.cpp @ 324]
00000022`e25ff428  00007ffa`99b585f8 UnityPlayer!il2cpp::vm::SocketPollingThread::RunLoop+0x268 [...\libil2cpp\vm\threadpool.cpp @ 452]
00000022`e25ff7e8  00007ffa`99b58d2c UnityPlayer!il2cpp::vm::SocketPollingThreadEntryPoint+0xec [...\libil2cpp\vm\threadpool.cpp @ 524]
00000022`e25ff878  00007ffa`99c63b52 UnityPlayer!il2cpp::os::Thread::RunWrapper+0xd2 [...\libil2cpp\os\thread.cpp @ 106]
00000022`e25ff8d8  00007ffa`99c19a14 UnityPlayer!il2cpp::os::ThreadStartWrapper+0x54 [...\libil2cpp\os\win32\threadimpl.cpp @ 31]
00000022`e25ff948  00007ffa`e6858102 KERNEL32!BaseThreadInitThunk+0x22
00000022`e25ff978  00007ffa`e926c5b4 ntdll!RtlUserThreadStart+0x34

好了,现在我知道是哪个线程崩溃了:是 IL2CPP 运行时套接字轮询线程。它的职责是告诉其他线程,它们的套接字何时可以发送或接收数据。具体过程是这样的:其他线程会将套接字轮询请求放入一个先进先出队列,然后套接字轮询线程会将这些请求逐个去掉,调用select()函数,当 select() 返回结果时,它就会将原始请求中的回调队列到线程池中。

因此,有人在严重破坏堆栈。为了缩小搜索范围,我决定将 "堆栈哨兵 "放在该主题中的大多数堆栈框架上。我的堆栈哨兵是这样定义的:

堆栈哨兵

构建后,它将在缓冲区中填入 "0xDD"。析构时,它会检查这些值是否没有变化。这个方法非常奏效:游戏不再崩溃了!而不是断言:

有人写道 2

有人摸了我哨兵的私处--而且肯定不是朋友。我又运行了几次,结果都一样:每次都是先将值 "2 "写入缓冲区。看着记忆视图,我发现眼前的一切似曾相识:

记忆视图

这些值与我们在第一个损坏的堆栈跟踪中看到的值完全相同。我意识到,之前导致崩溃的原因也是破坏堆栈哨兵的原因。起初,我以为这是某种缓冲区溢出,有人在写入超出其 Localization 变量边界的内容。因此,我开始更积极地放置堆栈哨兵:几乎在线程进行的每次函数调用之前都放置。但是,损坏似乎是随机发生的,我无法用这种方法找到导致损坏的原因。

我就知道,当我的一个哨兵处于监控范围内时,记忆总是会被破坏。我需要以某种方式当场抓住腐蚀它的东西。我想让堆栈哨兵内存在堆栈哨兵生命周期内只能读取:我会在构造函数中调用VirtualProtect()来标记页面为只读,然后在析构函数中再次调用来使其可写:

受保护的哨兵

令我惊讶的是,它仍然在损坏!调试日志中的信息是

内存在 0xd046ffeea8 处损坏。损坏时是只读的。
CrashingGame.exe 已触发断点。

这对我来说是个警示。在内存为只读时,或者就在我将其设置为只读之前,有人破坏了内存。由于没有出现违规访问,我认为是后者,因此修改了代码,以检查在设置魔法值后内存内容是否发生变化:

设置后立即检查

我的理论被证实了:

内存在 0x79b3bfea78 处损坏。
CrashingGame.exe 已触发断点。

此时我在想"嗯,一定是另一条线破坏了我的堆栈。必须如此。对不对?对不对?" 。我所知道的唯一调查方法就是使用数据(内存)断点来捕捉罪犯。不幸的是,在 x86 系统中,一次只能监控四个内存位置,这意味着我最多只能监控 32 字节,而损坏的区域是 16 KB。我需要想出在哪里设置断点。我开始观察腐败模式。起初,它们似乎是随机的,但这只是由于ASLR 的性质造成的假象:每次重新启动游戏时,它都会将堆栈放置在随机的内存地址上,因此损坏的位置自然不同。不过,当我意识到这一点后,我就不再在每次内存损坏时重启游戏了,而是继续执行。这让我发现,在给定的调试会话中,损坏的内存地址总是不变的。换句话说,一旦它被破坏过一次,只要我不终止程序,它就会一直在完全相同的内存地址被破坏:

内存在 0x90445febd8 处损坏。
CrashingGame.exe 已触发断点。
内存在 0x90445febd8 处损坏。
CrashingGame.exe 已触发断点。

我在该内存地址上设置了一个数据断点,每当我将其设置为 0xDD 的神奇值时,它就会不断断开。我想,这得花上一段时间,但 Visual Studio 实际上允许我在该断点上设置一个条件:只有当该内存地址的值为 2 时才断开:

条件数据断点

一分钟后,这个断点终于出现了。我是在调试了 3 天之后才发现这一点的。这将是我的胜利。我宣布:"我终于把你压制住了!"。我是这么乐观地想的:

在分配任务时被破坏

我难以置信地看着调试器,脑子里的问题比答案还多:“What?这怎么可能?我是不是疯了?" 。我决定看看拆卸情况:

任务分解时损坏

果然,它正在修改该内存位置。但写入的是 0xDD,而不是 0x02!查看内存窗口后发现,整个区域都已损坏:

内存

就在我准备把头往墙上撞的时候,我给同事打了个电话,让他看看我是否漏掉了什么明显的东西。我们一起查看了调试代码,没有发现任何可能导致这种怪异现象的地方。然后,我退后一步,试着想象是什么原因导致调试器断开,以为代码将值设置为 "2"。我想出了以下一连串的假设事件:

1. mov byte ptr [rax],0DDh 修改内存位置,CPU 中断执行,让调试器检查程序状态
2.记忆被某些东西破坏
3.调试器检查内存地址,发现里面有 "2",并认为这就是发生变化的地方。

那么......当程序被调试器冻结时,什么会改变内存内容?据我所知,有两种可能:要么是其他进程在做,要么是操作系统内核在做。要调查这两种情况,传统的调试器都不起作用。进入内核调试区域

令人惊讶的是,在 Windows 上设置内核调试非常简单。您需要两台机器:调试器运行的机器和您要调试的机器。在要调试的机器上打开命令提示符,然后键入以下内容:

启用内核调试器

Host IP 是运行调试器的机器的 IP 地址。它将使用指定的端口连接调试器。它可以介于 49152 和 65535 之间。按下第二条命令的回车键后,它会告诉你一个密匙(图片中截断的部分),这个密匙在连接调试器时充当密码。完成这些步骤后,重新启动。

在另一台计算机上打开 WinDbg,点击文件 -> 内核调试,然后输入端口和密钥。

连接内核调试器

如果一切顺利,按下 Debug -> Break 键即可中断执行。如果成功了,"脱机 "计算机就会冻结。输入 "g "继续执行。

我启动了游戏,等待它崩溃一次,这样我就能找出内存损坏的 Addressables:

内存在 0x49d05fedd8 处损坏。
CrashingGame.exe 已触发断点。

好了,既然知道了设置数据断点的 Addressables,我就得配置内核调试器来真正设置断点了:

kd> !process 0 0
PROCESS ffffe00167228080
    SessionId: 1  Cid: 26b8    Peb: 49cceca000  ParentCid: 03d8
    DirBase: 1ae5e3000  ObjectTable: ffffc00186220d80  HandleCount: 
    Image: CrashingGame.exe

kd> .process /i ffffe00167228080
You need to continue execution (press 'g' ) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
kd> g
Break instruction exception - code 80000003 (first chance)
nt!DbgBreakPointWithStatus:
fffff801`7534beb0 cc              int     3
kd> .process
Implicit process is now ffffe001`66e9e080
kd> .reload /f
kd> ba w 1 0x00000049D05FEDD8 ".if (@@c++(*(char*)0x00000049D05FEDD8 == 2)) { k } .else { gc }"

过了一段时间,断点真的出现了...

 # Child-SP          RetAddr           Call Site
00 ffffd000`23c1e980 fffff801`7527dc64 nt!IopCompleteRequest+0xef
01 ffffd000`23c1ea70 fffff801`75349953 nt!KiDeliverApc+0x134
02 ffffd000`23c1eb00 00007ffd`7e08b4bd nt!KiApcInterrupt+0xc3
03 00000049`d05fad50 cccccccc`cccccccc UnityPlayer!StackSentinel::StackSentinel+0x4d [...\libil2cpp\utils\memory.cpp @ 21]

好吧,这到底是怎么回事?哨兵正在愉快地设置它的魔法值,然后是硬件中断,接着调用某个完成例程,然后将 "2 "写入我的堆栈。哇好吧,不知什么原因,Windows 内核正在破坏我的内存。但为什么呢?

起初,我以为这一定是我们调用了某个 Windows API,并给它传递了无效参数。因此,我再次查看了所有套接字轮询线程代码,发现我们在那里调用的唯一系统调用是select()函数。我访问了 MSDN,花了一个小时重新阅读关于select() 的文档,并重新检查我们的操作是否正确。在我看来,用它做错的事情并不多,而且文档中也没有提到"如果你把这个参数传递给它,我们就会在你的堆栈中写入 2"。我们做的一切似乎都是对的。

在无计可施的情况下,我决定用调试器进入select()函数,对其进行反汇编,弄清楚它是如何工作的。我花了几个小时,但还是成功了。select()函数似乎是WSPSelect()的包装器,大致看起来是这样的:

auto completionEvent = TlsGetValue(MSAFD_SockTlsSlot);

/* setting up some state
*/

IO_STATUS_BLOCK statusBlock;
auto result = NtDeviceIoControlFile(networkDeviceHandle, completionEvent, nullptr, nullptr, &statusBlock, 0x12024,
    buffer, bufferLength, buffer, bufferLength);

if (result == STATUS_PENDING)
    WaitForSingleObjectEx(completionEvent, INFINITE, TRUE);

/* convert result and return it
...
*/

这里重要的部分是调用NtDeviceIoControlFile()、将 Localization 变量 statusBlock 作为 out 参数传递以及最后使用警报等待事件信号的事实。到目前为止一切顺利:它调用了一个内核函数,如果该函数无法立即完成请求,就会返回 STATUS_PENDING。在这种情况下,WSPSelect() 会等待事件被设置。NtDeviceIoControlFile() 完成后,会将结果写入 statusBlock 变量,然后设置事件。等待完成后,WSPSelect() 返回。

IO_STATUS_BLOCK 结构如下所示:

typedef struct _IO_STATUS_BLOCK
{
    union
    {
        NTSTATUS Status;
        PVOID    Pointer;
    };
    ULONG_PTR Information;
} IO_STATUS_BLOCK, *PIO_STATUS_BLOCK;

在 64 位上,该结构长度为 16 字节。我注意到这个结构似乎与我的内存损坏模式一致:前 4 个字节损坏(NTSTATUS 长度为 4 字节),然后跳过 4 个字节(PVOID 的填充/空格),最后再损坏 8 个字节。如果写入内存的确实是这个,那么前四个字节将包含结果状态。前 4 个损坏字节总是 0x00000102。而这恰好是......STATUS_TIMEOUT 的错误代码!如果 WSPSelect() 不等待 NtDeviceIOControlFile() 完成,那么这个理论就很有道理。但它确实做到了。

在弄清 select() 函数的工作原理后,我决定全面了解一下套接字轮询线程的工作原理。然后,它就像一吨重的砖头砸在我身上。

当另一个线程将一个套接字推送给套接字轮询线程处理时,套接字轮询线程会调用该函数的 select() 函数。由于 select() 是一个阻塞调用,当另一个套接字被推送到套接字轮询线程队列时,必须以某种方式中断 select() ,以便尽快处理新的套接字。如何中断 select() 功能?显然,我们使用QueueUserAPC()在 select() 被阻塞时执行异步存储过程......并因此产生了异常!这就展开了堆栈,让我们再执行一些代码,然后在未来的某个时间点,内核会完成工作,并将结果写入 statusBlock Localization 变量(此时该变量已不存在)。如果它碰巧击中了堆栈上的返回地址,我们就会崩溃。

解决方法非常简单:我们现在不再使用 QueueUserAPC(),而是创建一个回环套接字,在需要中断 select() 时向其发送一个字节。该路径在 POSIX 平台上使用已久,现在也用于 Windows 平台。Unity 5.3.4p1 中已修复此错误。

这是一种让你彻夜难眠的虫子。我花了 5 天时间才解决了这个问题,这可能是我需要研究和修复的最难的 bug 之一。朋友们,吸取教训如果在系统调用中,不要从异步过程中抛出异常!