Que recherchez-vous ?
Engine & platform

Débogage de la corruption de la mémoire : Qui a écrit "2" dans ma pile ?

TAUTVYDAS ŽILYS / UNITY TECHNOLOGIESContributor
Apr 25, 2016|16 Min
Débogage de la corruption de la mémoire : Qui a écrit "2" dans ma pile ?
Cette page a été traduite automatiquement pour faciliter votre expérience. Nous ne pouvons pas garantir l'exactitude ou la fiabilité du contenu traduit. Si vous avez des doutes quant à la qualité de cette traduction, reportez-vous à la version anglaise de la page web.
Bonjour, je m'appelle Tautvydas et je suis développeur de logiciels chez Unity, dans l'équipe Windows. J'aimerais vous faire part d'une anecdote concernant le débogage d'un bogue insaisissable de corruption de la mémoire.

Il y a plusieurs semaines, nous avons reçu un rapport de bogue d'un client qui nous disait que son jeu se plantait lorsqu'il utilisait le backend de script IL2CPP. L'assurance qualité a vérifié le bogue et me l'a confié pour que je le corrige. Le projet était assez important (bien que loin des plus grands) ; il a pris 40 minutes à construire sur ma machine. Les instructions sur le rapport de bogue indiquaient : "Jouez au jeu pendant 5 à 10 minutes jusqu'à ce qu'il s'arrête. Après avoir suivi les instructions, j'ai observé un crash. J'ai lancé WinDbg, prêt à tout mettre en œuvre. Malheureusement, la trace de la pile était fausse :

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'

Manifestement, il a essayé d'exécuter une adresse mémoire non valide. Bien que la trace de la pile ait été corrompue, j'espérais que seule une partie de la pile avait été corrompue et que je serais en mesure de la reconstruire si je regardais le contenu de la mémoire après le registre du pointeur de pile. Cela m'a certainement donné une idée de la direction à prendre :

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

Voici une reconstitution approximative du tracé de la pile :

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

D'accord, je savais maintenant quel thread se plantait : c'était le thread de polling de socket du runtime IL2CPP. Sa responsabilité est d'indiquer aux autres threads quand leurs sockets sont prêts à envoyer ou à recevoir des données. Cela se passe comme suit : il y a une file d'attente FIFO dans laquelle les demandes d'interrogation des sockets sont placées par d'autres threads, le thread d'interrogation des sockets met ces demandes en file d'attente une par une, appelle la fonction select() et lorsque select() renvoie un résultat, il met en file d'attente dans le pool de threads le callback qui se trouvait dans la demande originale.

Quelqu'un corrompt donc gravement la pile. Afin de limiter la recherche, j'ai décidé de mettre "sentinelles de pile" sur la plupart des cadres de pile dans ce fil. Voici comment ma sentinelle de pile a été définie :

Sentinelle de la pile

Lorsqu'il est construit, il remplit le tampon avec "0xDD". Lors de sa destruction, il vérifiera que ces valeurs n'ont pas changé. Cela a incroyablement bien fonctionné : le jeu ne se plantait plus ! Il s'agit plutôt d'une affirmation :

Quelqu'un a écrit 2

Quelqu'un avait touché les parties intimes de ma sentinelle - et ce n'était certainement pas un ami. J'ai répété l'opération plusieurs fois, et le résultat a été le même : à chaque fois, c'est la valeur "2" qui a été écrite en premier dans le tampon. En regardant la vue de la mémoire, j'ai remarqué que ce que je voyais m'était familier :

Vue de la mémoire

Ce sont exactement les mêmes valeurs que nous avons vues dans la toute première trace de pile corrompue. Je me suis rendu compte que ce qui avait causé le crash plus tôt était également responsable de la corruption de la sentinelle de la pile. Au début, j'ai pensé qu'il s'agissait d'une sorte de débordement de mémoire tampon et que quelqu'un écrivait en dehors des limites de sa variable locale. J'ai donc commencé à placer ces sentinelles de pile de manière beaucoup plus agressive : avant presque chaque appel de fonction effectué par le thread. Cependant, les corruptions semblaient se produire à des moments aléatoires, et je n'ai pas été en mesure de trouver la cause de ces corruptions en utilisant cette méthode.

Je savais que la mémoire était toujours corrompue lorsque l'une de mes sentinelles était dans le champ d'application. Je devais en quelque sorte prendre en flagrant délit la chose qui le corrompt. J'ai pensé que la mémoire de la sentinelle de la pile ne serait lue que pendant la durée de vie de la sentinelle de la pile : J'appellerais VirtualProtect() dans le constructeur pour marquer les pages en lecture seule, et je l'appellerais à nouveau dans le destructeur pour les rendre accessibles en écriture :

Sentinelle protégée

À ma grande surprise, il était toujours corrompu ! Et le message dans le journal de débogage était :

La mémoire a été corrompue à 0xd046ffeea8. Il était en lecture seule lorsqu'il a été corrompu.
CrashingGame.exe a déclenché un point d'arrêt.

Cela m'a mis la puce à l'oreille. Quelqu'un avait corrompu la mémoire soit pendant qu'elle était en lecture seule, soit juste avant que je la mette en lecture seule. Comme je n'ai constaté aucune violation d'accès, j'ai supposé qu'il s'agissait de la seconde hypothèse et j'ai donc modifié le code pour vérifier si le contenu de la mémoire changeait juste après la définition de mes valeurs magiques :

Vérification juste après la mise en place

Ma théorie a été vérifiée :

La mémoire a été corrompue à 0x79b3bfea78.
CrashingGame.exe a déclenché un point d'arrêt.

C'est à ce moment-là que j'ai réfléchi : "Il doit s'agir d'un autre fil qui corrompt ma pile. Il doit l'être. C'est vrai ? DROIT ?". La seule façon dont je savais comment procéder pour enquêter sur ce problème était d'utiliser des points d'arrêt pour les données (mémoire) afin d'attraper le contrevenant. Malheureusement, sur x86, vous ne pouvez surveiller que quatre emplacements de mémoire à la fois, ce qui signifie que je peux surveiller 32 octets au maximum, alors que la zone qui avait été corrompue était de 16 Ko. Je devais en quelque sorte déterminer où placer les points d'arrêt. J'ai commencé à observer des schémas de corruption. Au début, il semblait qu'ils étaient aléatoires, mais ce n'était qu'une illusion due à la nature de l'ASLR: chaque fois que je redémarrais le jeu, il plaçait la pile à une adresse mémoire aléatoire, de sorte que le lieu de la corruption différait naturellement. Cependant, lorsque j'ai compris cela, j'ai cessé de redémarrer le jeu à chaque fois que la mémoire était corrompue et j'ai simplement poursuivi l'exécution. J'ai ainsi découvert que l'adresse de la mémoire corrompue était toujours constante pour une session de débogage donnée. En d'autres termes, une fois qu'il a été corrompu une fois, il sera toujours corrompu exactement à la même adresse mémoire tant que je ne mettrai pas fin au programme :

La mémoire a été corrompue à 0x90445febd8.
CrashingGame.exe a déclenché un point d'arrêt.
La mémoire a été corrompue à 0x90445febd8.
CrashingGame.exe a déclenché un point d'arrêt.

J'ai placé un point d'arrêt de données sur cette adresse mémoire et j'ai observé qu'il continuait à s'interrompre chaque fois que je le plaçais à la valeur magique de 0xDD. Je me suis dit que cela allait prendre un certain temps, mais Visual Studio me permet en fait de définir une condition sur ce point d'arrêt : ne l'interrompre que si la valeur de cette adresse mémoire est 2 :

Point d'arrêt conditionnel des données

Une minute plus tard, ce point d'arrêt est enfin atteint. J'en suis arrivé là après trois jours de débogage. Cela allait être mon triomphe. "J'ai enfin réussi à vous mettre au tapis", ai-je proclamé. C'est du moins ce que je pensais avec optimisme :

Corrompu à l'affectation

J'ai regardé le débogueur avec incrédulité alors que mon esprit se remplissait de plus de questions que de réponses : "Qu'est-ce que c'est ? Comment cela est-il possible ? Suis-je en train de devenir fou ?". J'ai décidé d'examiner le démontage :

Corrompu lors du démontage de l'affectation

En effet, il modifiait cet emplacement de mémoire. Mais il écrivait 0xDD, et non 0x02 ! L'examen de la fenêtre de mémoire a révélé que toute la région était déjà corrompue :

mémoire rax

Alors que j'étais prêt à me taper la tête contre le mur, j'ai appelé mon collègue et lui ai demandé de vérifier si je n'avais pas manqué quelque chose d'évident. Nous avons examiné ensemble le code de débogage et nous n'avons rien trouvé qui puisse, même de loin, provoquer une telle bizarrerie. J'ai ensuite pris du recul et j'ai essayé d'imaginer ce qui pouvait causer l'interruption du débogueur en pensant que le code fixait la valeur à "2". J'ai imaginé la chaîne d'événements hypothétique suivante :

1. mov byte ptr [rax], 0DDh modifie l'emplacement mémoire, l'unité centrale interrompt l'exécution pour permettre au débogueur d'inspecter l'état du programme.
2. La mémoire est altérée par quelque chose
3. Le débogueur inspecte l'adresse mémoire, trouve "2" à l'intérieur et pense que c'est ce qui a changé.

Alors, qu'est-ce qui peut modifier le contenu de la mémoire pendant que le programme est gelé par un débogueur ? Pour autant que je sache, il y a deux cas de figure : soit c'est un autre processus qui le fait, soit c'est le noyau du système d'exploitation. Pour étudier l'un ou l'autre de ces cas, un débogueur conventionnel ne fonctionnera pas. Entrez dans la zone de débogage du noyau.

Il est surprenant de constater que la configuration du débogage du noyau est extrêmement simple sous Windows. Vous aurez besoin de deux machines : celle sur laquelle le débogueur fonctionnera et celle sur laquelle vous déboguerez. Ouvrez une invite de commande élevée sur la machine que vous allez déboguer, et tapez ceci :

Activer le débogueur du noyau

Host IP est l'adresse IP de la machine sur laquelle tourne le débogueur. Il utilisera le port spécifié pour la connexion au débogueur. Il peut être compris entre 49152 et 65535. Après avoir appuyé sur la touche "Entrée" de la deuxième commande, vous obtiendrez une clé secrète (tronquée dans l'image) qui vous servira de mot de passe lorsque vous connecterez le débogueur. Une fois ces étapes terminées, redémarrez l'ordinateur.

Sur l'autre ordinateur, ouvrez WinDbg, cliquez sur File -> Kernel Debug et entrez le port et la clé.

Attacher le débogueur du noyau

Si tout se passe bien, vous pourrez interrompre l'exécution en appuyant sur Debug -> Break. Si cela fonctionne, l'ordinateur "debugee" se fige. Saisissez "g" pour poursuivre l'exécution.

J'ai démarré le jeu et j'ai attendu qu'il se casse une fois pour pouvoir trouver l'adresse à laquelle la mémoire est corrompue :

La mémoire a été corrompue à 0x49d05fedd8.
CrashingGame.exe a déclenché un point d'arrêt.

Très bien, maintenant que je connaissais l'adresse où placer un point d'arrêt de données, je devais configurer mon débogueur de noyau pour qu'il le place effectivement :

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 }"

Au bout d'un certain temps, le point d'arrêt est effectivement atteint...

 # 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]

D'accord, qu'est-ce qui se passe ici ? La sentinelle règle joyeusement ses valeurs magiques, puis il y a une interruption matérielle, qui appelle alors une routine de finalisation, et qui écrit "2" dans ma pile. Wow. Ok, pour une raison ou une autre, le noyau de Windows corrompt ma mémoire. Mais pourquoi?

J'ai d'abord pensé qu'il s'agissait d'un appel à une API Windows et de la transmission d'arguments non valides. J'ai donc revu tout le code du thread d'interrogation des sockets et j'ai constaté que le seul appel système que nous appelions était la fonction select(). Je suis allé sur MSDN et j'ai passé une heure à relire la documentation sur select() et à revérifier si nous faisions tout correctement. Pour autant que je sache, il n'y avait pas grand-chose à faire de mal avec, et il n'y avait certainement pas d'endroit dans la documentation où il était dit "si vous lui passez ce paramètre, nous écrirons 2 dans votre pile". Nous avions l'impression de faire tout ce qu'il fallait.

À court d'idées, j'ai décidé d'entrer dans la fonction select() à l'aide d'un débogueur, de la désassembler et de comprendre comment elle fonctionne. Cela m'a pris quelques heures, mais j'y suis parvenu. Il semble que la fonction select() soit une enveloppe pour la fonction WSPSelect(), qui ressemble à peu près à ceci :

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
...
*/

La partie importante ici est l'appel à NtDeviceIoControlFile(), le fait qu'il passe sa variable locale statusBlock comme paramètre out, et enfin le fait qu'il attende que l'événement soit signalé à l'aide d'un alertable wait. Jusqu'à présent, tout va bien : elle appelle une fonction du noyau, qui renvoie STATUS_PENDING si elle ne peut pas exécuter la demande immédiatement. Dans ce cas, WSPSelect() attend que l'événement soit défini. Une fois que NtDeviceIoControlFile() a terminé, il écrit le résultat dans la variable statusBlock et définit ensuite l'événement. L'attente se termine et WSPSelect() revient.

La structure IO_STATUS_BLOCK se présente comme suit :

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

Sur 64 bits, cette structure a une longueur de 16 octets. J'ai remarqué que cette structure semble correspondre à mon modèle de corruption de la mémoire : les 4 premiers octets sont corrompus (NTSTATUS a une longueur de 4 octets), puis 4 octets sont sautés (padding/espace pour PVOID) et enfin 8 autres octets sont corrompus. Si c'est bien ce qui a été écrit dans ma mémoire, les quatre premiers octets contiendront l'état du résultat. Les 4 premiers octets de corruption étaient toujours 0x00000102. Et il se trouve que c'est le code d'erreur pour... STATUS_TIMEOUT! Ce serait une bonne théorie, si seulement WSPSelect() n'attendait pas que NtDeviceIOControlFile() se termine. Mais c'est ce qui s'est passé.

Après avoir compris le fonctionnement de la fonction select(), j'ai décidé d'examiner le fonctionnement global du fil d'attente de la socket. C'est alors que j'ai été frappé comme une tonne de briques.

Lorsqu'un autre thread pousse une socket à être traitée par le thread d'interrogation de la socket, ce dernier appelle select() sur cette fonction. Comme select() est un appel bloquant, lorsqu'une autre socket est ajoutée à la file d'attente du thread d'interrogation des sockets, il faut interrompre select() pour que la nouvelle socket soit traitée le plus rapidement possible. Comment fonctionne la fonction interrupt select() ? Apparemment, nous avons utilisé QueueUserAPC() pour exécuter une procédure asynchrone alors que select() était bloqué... et nous avons levé une exception ! Cela a déroulé la pile, nous a fait exécuter un peu plus de code, et à un moment donné dans le futur, le noyau a terminé le travail et a écrit le résultat dans la variable locale statusBlock (qui n'existait plus à ce moment-là). S'il arrivait qu'une adresse de retour soit touchée sur la pile, nous nous planterions.

La solution est assez simple : au lieu d'utiliser QueueUserAPC(), nous créons maintenant une socket loopback à laquelle nous envoyons un octet à chaque fois que nous devons interrompre select(). Ce chemin est utilisé depuis longtemps sur les plates-formes POSIX et l'est désormais également sous Windows. La correction de ce bug est disponible dans Unity 5.3.4p1.

Il s'agit d'un de ces insectes qui vous empêchent de dormir. Il m'a fallu 5 jours pour le résoudre, et c'est probablement l'un des bogues les plus difficiles que j'ai eu à examiner et à corriger. Leçon apprise, les amis : ne lancez pas d'exceptions à partir de procédures asynchrones si vous êtes à l'intérieur d'un appel système !