LibVMI sur Windows 11, Windows Server et Windows 32 bits : tout ce qu'il a fallu corriger autour du KDBG
Catégorie : LibVMI
Résumé
Au départ, un symptôme : DRAKVUF s'attache à une VM Windows 11 qui vient de démarrer et ne sort rien pendant 150 à 700 s. Ma première PR pour le corriger ne corrigeait rien, et le chemin pour comprendre pourquoi a traversé toute l'initialisation Windows de LibVMI. Ce que ça a donné, en cinq PR : un échec immédiat quand le guest n'a pas fini de démarrer (#1126), le CR3 d'un vCPU qui exécute du code noyau plutôt que celui du vCPU 0 (#1123), le décodage du KdDebuggerDataBlock encodé, sans symboles, avec la gestion des noyaux non contigus et d'un CR3 d'init qui devient périmé (#1127), puis le support des invités 32 bits, de Windows 7 et 8, et de Windows Server 2008 à 2025 (#1128). À la fin : 50 invités, 100 attaches, avec et sans profil, tout passe. Le billet garde aussi ce qui n'est pas réglé et les erreurs que j'ai faites en chemin.
Suivi upstream : #1123 (fusionnée le 27/09), #1124 (fusionnée le 30/09, mais inutile, voir section 2), #1126 (fusionnée le 02/10), #1127 (fusionnée le 10/10), #1128 (ouverte le 11/10), issue #1125.
Le vocabulaire (KDBG, KPCR, profil, traduction d'adresses, déroulé de l'initialisation) est dans la présentation de LibVMI. La liste de toutes les PR, avec leur état, est dans les contributions.
Environnement
Hôte : Xeon E5-2683 v4, 32 Go, Debian 13, Xen 4.21.0 (ept=ad=0), DRAKVUF main, LibVMI master. Invités : Windows 11 de 21H2 à 26H1 ; Windows 10 de 1507 à 22H2 en x64 et en x86 ; Windows 7 et 8 en x64 et x86 ; Windows Server 2008 SP2, 2008 R2, 2012, 2012 R2, 2016, 2019, 2022 et 2025. Une VM à la fois, disque remis sur un snapshot propre avant chaque démarrage. « Sans profil » veut dire que LibVMI reçoit les offsets EPROCESS du JSON de ce build, mais que le JSON ne sert pas à l'attache : tout ce que LibVMI sait du noyau, il doit le retrouver en mémoire.
1. Le symptôme
DRAKVUF s'attache à une VM Win11 qui vient de démarrer et ne sort rien pendant 150 à 700 s, puis plus rien du tout. Et quand ça marche, personne ne sait pourquoi ça a marché cette fois-là.
2. Ma première PR ne corrigeait rien
La PR #1124 ajoutait un scan de 2 Mo en 2 Mo de la mémoire physique pour retrouver le KDBG quand le contexte des vCPU est illisible. Les tests semblaient bons. En relisant mes propres logs, j'ai vu que le grep ne montrait que KernBase : le chemin passait par le JSON et mon code n'avait jamais tourné. Une autre série finissait en segfault (rc 139) avec 0 bloc trouvé. J'ai mis une bannière « do not merge » et corrigé mon commentaire. Elle a été fusionnée quand même. Leçon : pour prouver qu'un chemin de code a tourné, il faut une ligne de log dans la fonction. Un rc 0 ne prouve rien.
3. Sur Windows 11, le KDBG est encodé
Pour savoir où était vraiment le bloc, une petite sonde en C, attachée avec le profil JSON pour avoir les symboles, lit KdpDataBlockEncoded, KdDebuggerEnabled et le KdDebuggerDataBlock. Sur 6 builds Windows 11 sur 6 : KdpDataBlockEncoded = 1, KdDebuggerEnabled = 0, et le bloc est encodé. Le tag KDBG à +0x10 n'est que du bruit. Le noyau est toujours à l'adresse physique 0x100400000 (0x100800000 sur 26H1), alignée sur 2 Mo, donc le scan trouve bien ntoskrnl, mais le KDBG n'est pas dans .data et il est encodé de toute façon.
Conclusion : chercher la signature KDBG en clair ne peut pas marcher sur Windows 11. Chaque méthode init_from_kdbg n'est qu'une façon lente d'échouer, et accélérer le scan ne change rien.
4. Le vrai problème : on s'attache trop tôt
Un balayage du démarrage sur les 6 builds, avec des attaches à 10 s ou moins après xl create : si le contexte du vCPU est illisible, LibVMI abandonne le JSON et lance les scans KDBG (210 à 216 s, puis échec) ; sinon le chemin JSON appelle windows_find_cr3(), un scan exhaustif à la recherche du processus System (environ 160 s). À partir de 15 s, ça marche toujours, en 310 ms environ. Autre détail : DRAKVUF appelle xen_pause() avant init_vmi, donc la VM est gelée pendant une init qui ne peut jamais réussir.
Les délais fixes ne tiennent pas : winload active et désactive la pagination 30 à 48 fois (thunks BIOS INT 13h). J'ai donc écrit un échantillonneur qui lit CR0, EFER, IDTR et LSTAR du vCPU 0 toutes les 50 ms, déduit la base du noyau de LSTAR, lit PsInitialSystemProcess via le CR3, et met le domaine en pause dès que l'état voulu apparaît. Une sonde appelle alors vmi_init_complete() sur la VM figée, et je classe le résultat d'après le log de debug, pas d'après le rc. La carte du boot est la même sur les 6 builds : bootmgr (pagination désactivée) jusqu'à environ 10 s, winload de 10 à 14,5 s, LSTAR vers 14 s, System créé 0,35 à 0,46 s plus tard.
| Cas | État du guest | Ce que fait LibVMI | Résultat |
|---|---|---|---|
| A | pagination désactivée (bootmgr, thunks BIOS) | abandonne le JSON, init_from_kdbg scanne toute la RAM |
210 à 216 s puis échec |
| B | pagination activée, noyau pas encore démarré | kpcr_find3 tire une fausse base noyau de l'IVT du BIOS, puis windows_find_cr3 scanne toute la RAM une fois par registre, puis KDBG |
692 s puis échec |
| C | noyau lancé, PsInitialSystemProcess encore NULL (environ 0,4 s) |
get_kpgd_from_json_profile lit le DTB à la VA 0x28 (IVT du BIOS) et annonce un succès |
kpgd faux, 0xf000d43ef000d43e 12 fois sur 12 |
Contrôle : à System plus 500 ms, succès en 0,33 à 0,42 s sur les 6 builds.
Le correctif du cas A, #1126. Au début de init_core, si la pagination du guest est désactivée, on échoue tout de suite avec un message qui finit par « retry once the guest has booted ». Résultat : 327 à 418 ms au lieu de 200 s et plus, 6 builds sur 6. Sous DRAKVUF : attache à 5 s, échec en moins d'une seconde, la VM n'est jamais gelée, et la nouvelle attache à 26 s réussit. CI rejouée 10 sur 10, ASan 0 erreur, non-régression Linux 85 processus sur 85. Un détail qui compte : si on ne rappelle pas vmi_init_paging avant de réessayer, page_mode est périmé et on enchaîne 45 échecs rapides.
Les cas B et C sont traités dans une branche à part (section 11), validée mais pas proposée.
Un bug DRAKVUF trouvé au passage : si drakvuf_init_os() échoue, DRAKVUF ne le dit pas, il tourne jusqu'au timeout avec 0 événement et rc 0.
5. Le CR3 du vCPU 0 n'est pas toujours celui du noyau, #1123
C'est l'autre moitié du « gel » décrit dans drakvuf#1860. windows_init() prenait le CR3 du vCPU 0 comme table de pages d'init. Si ce vCPU exécute du code utilisateur à l'instant de l'attache, sa table ne mappe pas le noyau (KVA shadowing). La traduction échoue, LibVMI tombe dans get_kpgd_method2(), un scan de toute la RAM, et DRAKVUF avait mis le domaine en pause juste avant. Mesuré : vmi-process-list stock ne revient pas en 40 s ; borné à 900 s, il revient après 410 s avec 0 événement. Reproduit à la demande avec start /affinity 1 powershell -Command "while($true){}" dans l'invité, registres relus par xl pause et xen-hvmctx : RIP bas et CR3 de processus sur le vCPU 0, noyau sur les trois autres.
Correctif (get_vcpu_kernel_cr3(), +38/-1) : si le RIP du vCPU 0 n'est pas dans la moitié haute, prendre le CR3 d'un autre vCPU dont le RIP l'est. Sur Win10 22H2 (4 vCPU) : 0,3 s et 117 processus avec la boucle qui tourne ; 10 attaches procmon sur 10 en 3,3 à 3,4 s ; 40 attaches d'affilée, la plus lente à 2,4 s ; 3 redémarrages de l'hôte, 48 attaches, 0 gel, alors que 2 démarrages sur 3 gelaient avant. Limite : tous les vCPU en mode utilisateur, c'est le même scan qu'avant ; sur un invité à 2 vCPU (Win10 1809), 1 attache sur 40 a encore scanné 212 s. Tamas a répondu que les utilisateurs passent normalement le kpgd en ligne de commande (-k) : vrai, mais la valeur change à chaque démarrage, donc les deux sont complémentaires.
6. Décoder le KDBG sans symboles, #1127
Le décodeur
Chaque qword du bloc vaut bswap(ROL(q, r)) ^ C, avec r < 64 et C propre à chaque démarrage. Volatility dérive r et C de KiWaitNever et KiWaitAlways, donc avec des symboles. Ici on n'en a pas : C se déduit du KernBase du KdVersionBlock (DBGKD_GET_VERSION64, machine 0x8664), lu en clair dans .data. Un candidat est accepté si le tag vaut KDBG, la taille est entre 0x200 et 0x1000, et les pointeurs sont cohérents. Le chemin en clair reste, essayé en premier.
Pourquoi 1507, 1511 et 1607 échouaient : le noyau n'est pas contigu
Une première version du décodeur marchait sur 1703 à 22H2 et échouait sur 1507, 1511 et 1607 sans que je sache pourquoi. Sur 1607 le bloc est bien encodé et se décode (rotation 12, PsLoadedModuleList égal au JSON), dans .data aux RVA 0x2f0500 et 0x2f2cf8. Mais l'image du noyau n'est pas contiguë en physique : ses grandes pages ne se suivent pas, l'adresse du noyau n'est pas alignée sur 2 Mo, le KDBG est à PA de l'en-tête + RVA − 0x400000, et le répertoire d'export (.edata, RVA 0x731000) est illisible à PA + RVA. Tous les scans de LibVMI supposent un noyau contigu, donc ils ne reconnaissent jamais ntoskrnl. Sur 1703, c'est contigu (.data à 0x338000). Quatre petits outils pour le voir : secs.c (sections et RVA), kpe.c (PE par adresse physique), trace.sh (LibVMI instrumenté), kdbgdec.c (décodage sans symboles).
Correctif : find_kdbg_address_faster() change de stratégie si la page d'en-tête rate le test d'export mais que ses sections (.data plus INITKDBG ou PAGEKD) identifient ntoskrnl : retrouver l'adresse virtuelle qui mappe cette page (vmi_get_va_pages(cr3)) et lire .data par VA. Le corps de la boucle est sorti en helpers ; la CI de LibVMI mesure la complexité : 3389 après, 3440 sur master.
Le CR3 d'init devient périmé
La recherche dure des dizaines de secondes sur un invité vivant. Si le CR3 lu au départ appartenait à un processus qui se termine entre-temps, sa table de pages est libérée et réutilisée : j'ai vu la page remplie de texte UTF-16. Toutes les traductions suivantes échouent, « Failed to find kernel page directory » après 205 s. Correctif (windows_refresh_init_cr3()) : une fois le bloc trouvé, vérifier que kpgd mappe encore la base du noyau, sinon prendre le CR3 courant d'un vCPU qui le fait. Preuve par empoisonnement (un build jetable écrase le CR3 d'init par une page de données) : sans, INITFAIL à 201 et 212 s ; avec, succès en 37 et 39 s, message « init CR3 0x3000 is stale, using the CR3 of vCPU 0 ». Le cas s'est déclenché tout seul une fois, sur 1809 (0x55a00001 remplacé par 0x25c00002). Sur Win11 21H2 : 15 attaches sur 15 avec, 9 sur 10 sans.
Résultats
Windows 10 x64, sans profil. Stock : INITFAIL en 92 à 101 s sur les 14 builds, avec environ 131 000 lectures de pfn en échec par run. Avec #1127, 14 sur 14 :
| Build | Processus | Rotation | Durée |
|---|---|---|---|
| 1507 | 37 | 13 | 39 s |
| 1511 | 37 | 19 | 38 s |
| 1607 | 44 | 3 | 42 s |
| 1703 | 92 | 30 | 41 s |
| 1709 | 95 | 33 | 41 s |
| 1803 | 120 | 53 | 1 s |
| 1809 | 122 | 12 | 3 s |
| 1903 | 118 | 56 | 0 s |
| 1909 | 124 | 63 | 1 s |
| 2004 | 93 | 35 | 1 s |
| 20H2 | 89 | 1 | 0 s |
| 21H1 | 80 | 38 | 0 s |
| 21H2 | 87 | 19 | 0 s |
| 22H2 | 83 | 25 | 1 s |
Les 40 s sont les noyaux non contigus ou les attaches sans vCPU en mode noyau ; les 0 à 3 s, le chemin KPCR direct. Windows 11 sans profil, de 21H2 à 26H1 : 6 sur 6, 109 à 125 processus, même PsActiveProcessHead qu'avec le JSON. Répétabilité : 11 sur 11. ASan : 0 erreur, avec un rapport LeakSanitizer de 256 octets à chaque run (la table de configuration du programme de test, pas tracé). CI 10 sur 10. Fusionnée le 10 octobre.
7. Windows 32 bits : deux bugs, #1128
J'ai installé les 14 mêmes builds de Windows 10 en x86 (PAE). Surprise : sur x86 le bloc n'est pas encodé, KdpDataBlockEncoded vaut 0 partout, aussi sur 7, 8 et 8.1 x86. Le chemin en clair tourne, et 11 builds sur 14 passent sans profil sur master. Les trois autres sont, encore, 1507, 1511 et 1607.
Le noyau 32 bits n'est jamais reconnu. Ces trois builds trouvent le bloc mais aucun symbole derrière : noyau non contigu, et find_kernel_vcpu() comme kdbg_header_vas() ne reconnaissaient que des adresses 64 bits (bit 63 du RIP, préfixe 0xffff8...). find_kdbg_address_fast() dérivait alors une adresse de bloc sous la base du noyau (0x8108f870 pour une base à 0x81260000). Correctif : windows_is_kernel_address() décide selon le mode de pagination, au-dessus de 0x80000000 en PAE ou en legacy. Pareil pour Windows 8 x86, et parfois 8.1 x86, dont le placement du noyau varie d'un démarrage à l'autre.
Une base noyau fausse tirée de l'IDT. Avec profil, 8 builds x86 sur 14 échouaient (1803 à 22H2, sauf 20H2). Les PDB x86 n'ont pas KiInitialPCR et kpcr_find2() exige KiSystemCall64Shadow, donc on passe par kpcr_find3() : entrée 0 de l'IDT moins la RVA de KiTrap00. Si le vCPU 0 est en mode utilisateur, l'IDT pointe sur KiTrap00Shadow. La base sort décalée de 0x113404 sur 21H2 (KernBase VA=0x83b13404, même pas alignée), NtBuildNumber est lu au mauvais endroit, et ça finit par « LibVMI found physical kernel base address 0x3400000 that conflicts with provided value from config file ». 20H2 passait ou pas selon ce que faisait le vCPU 0. Correctif : essayer les RVA de KiDivideErrorFault et KiTrap00 et de leurs trampolines Shadow, garder la base alignée, en espace noyau, avec un en-tête PE.
Résultats x86 : sans profil de 11 sur 14 à 14 sur 14 (36 à 73 processus) ; avec profil de 6 sur 14 à 14 sur 14. Non-régression x64 (14 Windows 10 et 6 Windows 11, démarrage à froid) : sans profil 20 sur 20 ; avec profil 19 sur 20 à 90 s. Le manquant est 1709 x64, qui est encore sur le logo de démarrage à 90 s (pareil sur master, captures à l'appui) : le DTB de System lit zéro jusqu'à 110 s, puis 0x1aa000, et 3 attaches sur 3 réussissent à 150 s. Pas un bug.
8. Windows 7, 8 et Windows Server 2008 à 2025
Windows 7 (7600 et 7601, x86 et x64) : 25 processus partout, bloc en clair. C'est la première fois que le chemin en clair de #1127 tournait sur un vrai invité ; je ne l'avais que relu. Windows 8 9200 x86 : master trouve le bloc et 0 symbole, la branche en voit 27. 8 x64 : déjà encodé (rotation 12, puis 60 à un autre démarrage). 8.1 x86 : 28 processus avec la branche, master échoue au dernier démarrage. 8.1 x64 : encodé. Même PsActiveProcessHead avec et sans profil partout.
Premier commentaire de review : et Windows Server ? Huit installations plus tard, deux commits de plus.
Échantillonner les vCPU avant d'abandonner. Sur Server 2012 x64 à 150 s, les deux vCPU étaient en mode utilisateur. find_kdbg_address_faster() passait direct au scan physique par pas de 2 Mo, qui ne trouve qu'un noyau contigu et aligné ; 9200 ne l'est pas, donc « All KdDebuggerDataBlock search methods failed » après lecture de toute la RAM, sur master comme sur la branche d'avant. Les registres sont maintenant relus jusqu'à 50 fois à 10 ms. Preuve par un build jetable (KDBG_FORCE_USER_SAMPLES, pas dans la PR) : forcer 50 échantillons « utilisateur » reproduit l'échec (2 démarrages, 1,22 million de pfn lus) ; en forcer 5 attache au cinquième (34 processus). Sur 3 démarrages naturels, deux avaient les deux vCPU en mode utilisateur à l'instant de l'attache.
La version depuis l'en-tête PE. LibVMI devinait la version de Windows d'après la taille de l'en-tête du bloc. Or 0x360 est partagé par 8, 8.1 et Windows 10 1507 et 1511, et 24H2 comme Server 2025 ont déjà 0x3a0. Jusqu'à Windows 7 rien ne change ; à partir de 0x360, la version vient de l'en-tête PE du noyau (pe2version(), qui connaît maintenant 6.3), avec 0x368 (1607 à 1909) et 0x380 (2004 à 22H2) gardés en repli. Le message « Unsupported Windows version or incorrect configuration » disparaît.
Final sur les 8 Server (2008 SP2, 2008 R2, 2012, 2012 R2, 2016, 2019, 2022, 2025) : la branche passe 8 sur 8 avec et sans profil ; master échoue sur 2012 sans profil et dit « Unsupported » sur 2016 à 2025. 2008 SP2 a le bloc en clair et rapporte winver 6 sans profil contre 5 avec, déjà comme ça sur master. Notes d'installation : 2012 R2 a demandé trois essais, 2008 SP2 s'arrête sur une erreur OOBE mais le noyau est démarré, et 1803 x86 n'existe qu'en média danois sur ce que j'ai trouvé.
9. Le bilan
| Famille | Invités | Sans profil | Avec profil |
|---|---|---|---|
| Windows 10 x64, 1507 à 22H2 | 14 | 14 | 14 (1709 à 150 s) |
| Windows 10 x86, 1507 à 22H2 | 14 | 14 | 14 |
| Windows 11, 21H2 à 26H1 | 6 | 6 | 6 |
| Windows 7 et 8, x86 et x64 | 8 | 8 | 8 |
| Windows Server 2008 à 2025 | 8 | 8 | 8 |
50 invités, 100 attaches. CI 10 sur 10, complexité 3389 contre 3440.
| PR | Sujet | État |
|---|---|---|
| #1123 | CR3 d'un vCPU en mode noyau à l'init | fusionnée le 27/09 |
| #1124 | scan physique par pas de 2 Mo | fusionnée le 30/09, sans effet sur Windows 11 |
| #1126 | échec immédiat si la pagination est désactivée (cas A) | fusionnée le 02/10 |
| #1127 | décodeur KDBG, noyaux non contigus, CR3 d'init périmé | fusionnée le 10/10 |
| #1128 | 32 bits, base depuis l'IDT, échantillonnage des vCPU, version depuis le PE | ouverte le 11/10 |
10. Erreurs en chemin
-
1124, fusionnée alors qu'elle ne faisait rien. Un log dans la fonction aurait suffi à le voir.
- Les fixtures
xl saveétaient instables : une 26H1 était mal étiquetée (état PG=0) et j'ai supprimé une fixture LSTAR par erreur. - L'échec 20H2 1 sur 14 de la toute première série n'a pas de log ; CR3 périmé probable, pas prouvé.
- Au premier démarrage de 1809, les deux modes ont échoué ensemble ; j'ai mis ça sur le compte d'un invité pas prêt à 90 s sans le vérifier.
11. La branche des cas B et C, validée, pas proposée
Six changements : find_kpgd() extrait (complexité de windows_init() de 33 à 25) ; refus d'un PsInitialSystemProcess NULL et d'un DTB qui n'est pas une adresse physique plausible (cas C) ; MSR_LSTAR à zéro sur le vCPU 0, échec immédiat (cas B) ; pas de windows_find_cr3() pour une base non alignée ; PsInitialSystemProcess lisible et NULL, échec avant get_kpgd_method2() ; libération du profil JSON à l'échec pour pouvoir réessayer. Winload en mode long : 692 s avant, 0,35 s après. Cas C : faux succès avant, échec en 0,35 s après. 9 rejeux de fixtures et 30 attaches live sur 6 Windows 11 et 1809, tous classés comme attendu en 0,28 à 0,42 s. Un appelant qui réessaie toutes les secondes réussit après 16,7 s et 15 tentatives, contre jamais sur upstream. ASan 0 erreur, CI complète. J'attends que #1128 soit traitée avant de l'ouvrir.
12. Ce que je n'ai PAS fait
-
1128 n'est pas fusionnée à l'heure où j'écris.
- Les cas B et C ne sont pas en amont.
- Vista et XP : pas testés. Windows 11 et Server 2016 et plus se présentent comme
VMI_OS_WINDOWS_10, l'en-tête PE dit 10.0. - Aucun désassemblage de
ntoskrnl.exe: les RVA et les rotations viennent de la mémoire des VM et des PDB. vmi_init_complete()ne détruit pas l'instance à l'échec (fuite de 2968 octets, déjà là avant moi).
Sources
- PR #1123, #1124, #1126, #1127, #1128, issue #1125, et côté DRAKVUF #1860 et #1883.
- Preuves sur le fork :
test-evidence/early-boot-2026-09-30,test-evidence/win10-kdbg-2026-10-06,test-evidence/kdbg-final-2026-10-08,test-evidence/32bit-win7-8-2026-10-11, et la branchewindows-fail-fast-paging-disabled. - Les chiffres de ce billet ont été recontrôlés contre ces journaux.