무엇을 찾고 계신가요?
Engine & platform

메모리 손상 디버깅: 누가 내 스택에 '2'를 썼어?!

TAUTVYDAS ŽILYS / UNITY TECHNOLOGIESContributor
Apr 25, 2016|16 분
메모리 손상 디버깅: 누가 내 스택에 '2'를 썼어?!
이 웹페이지는 이해를 돕기 위해 기계 번역으로 제공됩니다. 기계 번역으로 제공되는 콘텐츠에 대한 정확도나 신뢰도는 보장되지 않습니다. 번역된 콘텐츠의 정확도에 관해 의문이 있는 경우 웹페이지의 공식 영어 원문을 참고해 주시기 바랍니다.
안녕하세요, 저는 Windows 팀에서 근무하는 유니티의 소프트웨어 개발자인 Tautvydas입니다. 파악하기 어려운 메모리 손상 버그를 디버깅한 사례를 공유하고자 합니다.

몇 주 전에 IL2CPP 스크립팅 백엔드를 사용할 때 게임이 충돌한다는 고객으로부터 버그 신고를 받았습니다. 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 런타임 소켓 폴링 스레드였습니다. 이 스레드는 소켓이 데이터를 보내거나 받을 준비가 되면 다른 스레드에 알리는 역할을 합니다. 소켓 폴링 요청이 다른 스레드에 의해 들어오는 FIFO 큐가 있고, 소켓 폴링 스레드가 이러한 요청을 하나씩 큐에 넣은 다음 select() 함수를 호출하고 select()가 결과를 반환하면 원래 요청에 있던 콜백을 스레드 풀에 큐에 넣는 식입니다.

누군가 스택을 심하게 손상시키고 있다는 뜻입니다. 검색 범위를 좁히기 위해 해당 스레드의 대부분의 스택 프레임에 '스택 센티널'을 넣기로 결정했습니다. 스택 센티널을 정의한 방법은 다음과 같습니다:

스택 센티널

버퍼가 생성되면 "0xDD"로 버퍼를 채웁니다. 파괴되면 해당 값이 변경되지 않았는지 확인합니다. 게임 충돌이 더 이상 발생하지 않는 놀라운 효과가 있었습니다! 대신 주장하고 있었습니다:

누군가 2

누군가 제 센티널의 사생활을 만지고 있었고, 그 사람은 분명히 친구가 아니었습니다. 이 작업을 몇 번 더 실행해 본 결과 결과는 동일했습니다. 매번 "2" 값이 버퍼에 먼저 기록되었습니다. 메모리 보기를 살펴보니 익숙한 장면이 눈에 들어왔습니다:

메모리 보기

이는 처음 손상된 스택 추적에서 보았던 값과 완전히 동일한 값입니다. 앞서 충돌을 일으킨 원인이 무엇이든 스택 센티널을 손상시킨 원인이라는 것을 깨달았습니다. 처음에는 일종의 버퍼 오버플로라고 생각했는데, 누군가 로컬 변수 범위를 벗어나서 글을 쓰고 있었습니다. 그래서 저는 스레드가 호출하는 거의 모든 함수 앞에 스택 센티널을 훨씬 더 적극적으로 배치하기 시작했습니다. 그러나 손상이 무작위로 발생하는 것 같았고, 이 방법으로는 손상의 원인을 찾을 수 없었습니다.

센티널 중 한 명이 범위 내에 있는 동안에는 항상 메모리가 손상된다는 것을 알고 있었습니다. 저는 어떻게든 그것을 타락시키는 것을 직접 잡아야 했습니다. 스택 센티널 메모리를 스택 센티널 수명 기간 동안만 읽도록 만드는 방법을 생각해냈습니다: 생성자에서 VirtualProtect()를 호출하여 페이지를 읽기 전용으로 표시하고 소멸자에서 다시 호출하여 쓰기 가능 상태로 만듭니다:

보호된 센티널

놀랍게도 여전히 손상되고 있었습니다! 그리고 디버그 로그의 메시지는 다음과 같습니다:

0xd046ffeea8에서 메모리가 손상되었습니다. 손상되었을 때는 읽기 전용이었습니다.
CrashingGame.exe가 중단점을 트리거했습니다.

이것은 저에게 적신호였습니다. 메모리가 읽기 전용일 때 또는 읽기 전용으로 설정하기 직전에 누군가가 메모리를 손상시켰습니다. 액세스 위반이 발생하지 않았기 때문에 후자라고 가정하고 매직 값을 설정한 직후 메모리 내용이 변경되는지 확인하도록 코드를 변경했습니다:

설정 후 바로 확인

제 이론은 확인되었습니다:

0x79b3bfea78에서 메모리가 손상되었습니다.
CrashingGame.exe가 중단점을 트리거했습니다.

이 시점에서 저는 이런 생각을 했습니다: "스택을 손상시키는 다른 스레드 때문이겠군요. 반드시 그래야 합니다. 그렇죠? 맞죠?". 제가 이 문제를 조사할 수 있는 유일한 방법은 데이터(메모리) 중단점을 사용하여 범인을 잡는 것이었습니다. 안타깝게도 x86에서는 한 번에 4개의 메모리 위치만 모니터링할 수 있기 때문에 최대 32바이트까지만 모니터링할 수 있는데, 손상된 영역은 16KB였습니다. 어떻게든 중단점을 어디에 설정해야 할지 알아내야 했습니다. 저는 부패 패턴을 관찰하기 시작했습니다. 처음에는 무작위인 것처럼 보였지만 이는 ASLR의 특성상 게임을 다시 시작할 때마다 스택을 임의의 메모리 주소에 배치하기 때문에 자연스럽게 손상되는 위치가 달라지는 착시 현상일 뿐이었습니다. 하지만 이 사실을 깨달은 후에는 메모리가 손상될 때마다 게임을 다시 시작하는 것을 중단하고 실행을 계속했습니다. 그 결과 손상된 메모리 주소가 특정 디버깅 세션 동안 항상 일정하다는 사실을 발견했습니다. 즉, 한 번 손상되면 프로그램을 종료하지 않는 한 항상 똑같은 메모리 주소에서 손상됩니다:

0x90445febd8에서 메모리가 손상되었습니다.
CrashingGame.exe가 중단점을 트리거했습니다.
0x90445febd8에서 메모리가 손상되었습니다.
CrashingGame.exe가 중단점을 트리거했습니다.

해당 메모리 주소에 데이터 중단점을 설정하고 0xDD라는 마법 같은 값으로 설정할 때마다 계속 중단되는 것을 지켜보았습니다. 시간이 좀 걸릴 거라고 생각했지만, 실제로 Visual Studio를 사용하면 해당 중단점에 조건을 설정하여 해당 메모리 주소의 값이 2인 경우에만 중단되도록 할 수 있습니다:

조건부 데이터 중단점

1분 후, 드디어 이 중단점이 도달했습니다. 디버깅을 시작한 지 3일 만에 이 시점에 도달했습니다. 이것은 저의 승리가 될 것입니다. "드디어 내가 널 잡았다!"라고 선언했습니다. 아니면 그렇게 낙관적으로 생각했습니다:

할당 시 손상됨

디버거를 바라보는 제 머릿속에는 대답보다 질문이 더 많이 떠올랐고, 저는 믿기지 않는 눈으로 디버거를 바라보았습니다: "네? 어떻게 이런 일이 가능할까요? 내가 미쳤나요?". 저는 분해를 살펴보기로 했습니다:

할당 분해 시 손상됨

당연히 해당 메모리 위치를 수정하고 있었습니다. 하지만 0x02가 아니라 0xDD를 쓰고 있었습니다! 메모리 창을 살펴보니 이미 전체 영역이 손상된 상태였습니다:

랙스 메모리

벽에 머리를 박고 싶을 때 동료에게 전화를 걸어 제가 놓친 것이 없는지 살펴봐 달라고 부탁했습니다. 디버깅 코드를 함께 검토했지만 원격으로 이러한 이상 현상을 일으킬 수 있는 어떤 것도 찾을 수 없었습니다. 그런 다음 한 걸음 물러나서 코드가 값을 "2"로 설정했다고 생각하여 디버거가 중단되는 원인이 무엇인지 상상해 보았습니다. 저는 다음과 같은 가상의 사건 연쇄를 생각해냈습니다:

1. mov byte ptr [rax], 0DDh는 메모리 위치를 수정하고, CPU는 디버거가 프로그램 상태를 검사할 수 있도록 실행을 중단합니다.
2. 메모리가 무언가에 의해 손상됨
3. 디버거는 메모리 주소를 검사하여 내부에서 "2"를 발견하고 변경된 것으로 간주합니다.

그렇다면 프로그램이 디버거에 의해 정지된 상태에서 메모리 내용을 변경할 수 있는 것은 무엇일까요? 제가 알기로는 다른 프로세스가 수행하거나 OS 커널이 수행하는 두 가지 시나리오에서 가능합니다. 이 중 하나를 조사하려면 기존 디버거로는 작동하지 않습니다. 커널 디버깅 영역으로 들어갑니다.

놀랍게도 Windows에서는 커널 디버깅을 설정하는 것이 매우 쉽습니다. 디버거를 실행할 컴퓨터와 디버깅할 컴퓨터, 두 대의 컴퓨터가 필요합니다. 디버깅할 컴퓨터에서 관리자 권한 명령 프롬프트를 열고 다음과 같이 입력합니다:

커널 디버거 사용

호스트 IP는 디버거가 실행 중인 컴퓨터의 IP 주소입니다. 디버거 연결에 지정된 포트를 사용합니다. 49152에서 65535 사이의 숫자가 될 수 있습니다. 두 번째 명령에서 Enter 키를 누르면 디버거를 연결할 때 비밀번호 역할을 하는 비밀 키(사진에서 잘린 부분)를 알려줍니다. 이 단계를 완료한 후 재부팅합니다.

다른 컴퓨터에서 WinDbg를 열고 파일 -> 커널 디버그를 클릭한 다음 포트와 키를 입력합니다.

커널 디버거 연결하기

모든 것이 순조롭게 진행되면 디버그 -> 중단을 눌러 실행을 중단할 수 있습니다. 이 방법이 작동하면 '디버거' 컴퓨터가 멈춥니다. 실행을 계속하려면 "g"를 입력합니다.

게임을 시작하고 메모리가 손상되는 주소를 알아내기 위해 게임이 한 번 중단될 때까지 기다렸습니다:

0x49d05fedd8에서 메모리가 손상되었습니다.
CrashingGame.exe가 중단점을 트리거했습니다.

이제 데이터 중단점을 설정할 주소를 알았으니 실제로 설정할 수 있도록 커널 디버거를 구성해야 했습니다:

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() 호출, 로컬 변수 statusBlock을 아웃 파라미터로 전달한다는 사실, 마지막으로 알림 가능 대기를 사용하여 이벤트 신호를 기다린다는 사실입니다. 요청을 즉시 완료할 수 없는 경우 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바이트에는 결과 상태가 포함될 것입니다. 처음 4개의 손상 바이트는 항상 0x00000102였습니다. 그리고 이것이 바로... STATUS_TIMEOUT의 오류 코드입니다! WSPSelect()가 NtDeviceIOControlFile()이 완료될 때까지 기다리지 않는다면 그럴듯한 이론이 될 수 있습니다. 하지만 실제로 그랬죠.

select() 함수가 어떻게 작동하는지 파악한 후, 소켓 폴링 스레드가 어떻게 작동하는지 큰 그림을 그려보기로 했습니다. 그러다 벽돌 한 장이 날아드는 것 같았습니다.

다른 스레드가 소켓 폴링 스레드가 처리할 소켓을 푸시하면 소켓 폴링 스레드는 해당 함수에서 select()를 호출합니다. select()는 블로킹 호출이므로 다른 소켓이 소켓 폴링 스레드 큐로 푸시되면 어떻게든 select()를 중단시켜 새 소켓이 최대한 빨리 처리되도록 해야 합니다. 선택() 함수는 어떻게 인터럽트하나요? 분명히, 우리는 select()가 차단된 상태에서 비동기 프로시저를 실행하기 위해 QueueUserAPC()를 사용했고, 거기서 예외를 던졌습니다! 스택을 풀고 코드를 더 실행한 다음 미래의 어느 시점에 커널이 작업을 완료하고 그 결과를 statusBlock 로컬 변수(그 시점에는 더 이상 존재하지 않음)에 씁니다. 스택에서 반환 주소에 부딪히면 충돌이 발생했습니다.

수정은 매우 간단했습니다. 이제 QueueUserAPC()를 사용하는 대신 select()를 중단해야 할 때마다 바이트를 전송하는 루프백 소켓을 생성합니다. 이 경로는 POSIX 플랫폼에서 꽤 오랫동안 사용되어 왔으며 이제 Windows에서도 사용됩니다. 이 버그에 대한 수정 사항은 Unity 5.3.4p1에서 제공되었습니다.

밤잠을 설치게 하는 버그 중 하나입니다. 이 문제를 해결하는 데 5일이 걸렸고, 아마도 제가 조사하고 수정해야 했던 버그 중 가장 어려웠던 버그 중 하나일 것입니다. 교훈을 얻으세요, 여러분: 시스템 호출 내부에 있는 경우 비동기 프로시저에서 예외를 던지지 마세요!