Tester DRAKVUF sur six builds Windows 11, et les correctifs qui en sont sortis

Catégorie : DRAKVUF


Résumé

Fin septembre j'ai passé DRAKVUF sur six builds Windows 11, de 21H2 à 26H1 Insider, avec le même scénario à chaque fois, et posté le résultat dans l'issue drakvuf#1883. Verdict : aucune incompatibilité propre à Windows 11. Tout ce que j'ai cru être un bug était autre chose : un plugin que je n'avais pas instancié, des redémarrages Windows Update, un repli KDBG lent mais déterministe. La première partie donne le protocole, les faux positifs et les pièges d'outillage. La seconde raconte les correctifs sortis de cette période et des campagnes LibVMI qui ont suivi : --userhook-target-process (#1876), six traps nuls déréférencés, les return hooks vérifiés dans libhook (#1879, #1880), repl (#1877), une macro de debug jamais compilée (#1878) et dkommon sur 24H2 (#1884).

Suivi upstream : issue #1883 (ouverte le 28/09, réponse de Tamas le 30/09) ; PR #1876, #1877, #1878, #1879 fusionnées le 26/09, #1880 le 27/09 ; issue #1884 ouverte ; traps nuls en commit local.


Environnement

Hôte habituel (Xeon E5-2683 v4, 32 Go, Debian 13, Xen 4.21.0). VM : altp2m=1, viridian=0, cpuid="host,hypervisor=0", xen_platform_pci=0, SMBIOS Dell OptiPlex 7090, disque IDE, carte e1000, bridge isolé sans sortie. Six builds : 21H2 (22000), 22H2 (22621), 23H2 (22631), 24H2 (26100.1742), 25H2 (26200), 26H1 (28000). TPM et Secure Boot contournés à l'installation (LabConfig). Un profil JSON par build (guestfish, pdbguid.py, pdbconv.py : 40 007 symboles et 1653 types pour un build). Snapshot after-install-cold pour chaque VM. Pour les correctifs de la seconde partie : Windows 10 22H2 (4 vCPU, 8 Go), Debian 12, et jusqu'à 13 invités de Windows 7 à Server 2019.

Partie 1 : six builds Windows 11

1. Pourquoi Windows 11 est à part

Deux raisons. Le KVA shadowing, renforcé par VBS et HVCI activés par défaut : si le vCPU que LibVMI choisit exécute du code utilisateur à l'instant de l'attache, la traduction par son CR3 échoue et LibVMI scanne la RAM pendant des minutes (c'est libvmi#1123). Et le nombre de builds, chacun avec son profil.

2. Le protocole

  1. Prérequis. Le profil de ce build précis. Le snapshot propre. Une seule VM allumée.
  2. Fumée. -a procmon -t 30. On attend une centaine de processus. Si 0 événement, ne pas conclure : lire d'abord xl dmesg et xl-<vm>.log.
  3. Blocage au démarrage. Reconnaître le domaine en pause (p dans xl list), Ctrl+C inopérant, kill -9. Répéter 10 à 15 attaches pour mesurer la fréquence. -k <kpgd> comme filet.
  4. Injection. --write-file seul d'abord, puis createproc, avant de combiner avec des plugins.
  5. Plugins. Un par un, puis groupés. Chaque plugin a son déclencheur et il faut le connaître. Pour fileextractor, c'est FILE_DELETE_ON_CLOSE, obtenu avec un .NET FileStream depuis PowerShell, passé en -EncodedCommand (Base64 d'UTF-16LE) pour survivre à la chaîne shell, SSH, injecteur, cmd, PowerShell, qui mange les guillemets.
  6. Contrôle Windows 10. Même scénario sur un invité connu, pour savoir si le problème est Windows 11 ou moi.
  7. Robustesse. Jamais de xl destroy répétés. Lire le code du plugin avant d'appeler « 0 événement » un bug.
  8. Rapport. What, Test environment, Method, Results, Log excerpts, une colonne par build.

3. Ce que la campagne a donné

Fumée, injection, procmon, filetracer, regmon, syscalls, apimon, rpcmon, codemon : OK sur les six builds. Le blocage de libvmi#1123 ne s'est jamais produit (environ 15 attaches sur 21H2, 3 sur chacun des autres, avant et après le correctif). hidevm : 0 événement, mais c'est un vrai bug, traité dans son billet.

4. Deux faux positifs

fileextractor : 0 événement sur six builds. Je n'avais pas passé le flag -D <dossier>. Sans lui, le plugin n'est pas instancié du tout : gdb le prouve, même le constructeur ne break pas. Une fois corrigé, extraction de test2.tmp (Size 9) et plus de cent extractions venues de l'activité normale de Windows.

Un segfault 3 s après xl create. Code retour 139, gdb pointe un pointeur nul dans find_kdbg_address_fast (kdbg.c:767). C'est mon script qui attachait trop tôt, mais un pointeur nul reste un vrai manque de robustesse, noté dans l'issue.

5. Deux non-bugs

Des redémarrages spontanés sur 21H2, 23H2 et 26H1, reason code 1, très probablement Windows Update. Pendant le redémarrage, DRAKVUF sort une rafale de xc_altp2m_change_gfn rc -1 et « Critical error in removing int3 », le même schéma que drakvuf#1860. Réponse de Tamas le 30 septembre : DRAKVUF devrait détecter le redémarrage de l'invité et s'arrêter proprement, en lisant la même source que xl, pas par de la logique VMI. Je ne l'ai pas traité.

Un repli KDBG lent. Sur 24H2, 25H2 et 26H1, un démarrage sur 3 à 10 donne une attache à environ 150 s. Ce n'est pas un blocage et c'est déterministe par démarrage : domid 74, 3 attaches lentes sur 3 ; domid 75, 3 rapides sur 3. Mais avec -t 20, le timeout est consommé par l'init, DRAKVUF sort rc 0 et 0 événement. La cause est dans le billet LibVMI.

Une dernière découverte : trois injections createproc successives dans le même explorer.exe le font planter (bureau noir). Une injection par démarrage.

6. Les pièges d'outillage

  • drakvuf sans -a active tous les plugins.
  • Un binaire de build incrémental invalide toute bissection.
  • Trois lignes de diff suffisent à déplacer un débordement de pile sous LTO -O3 : le symptôme bouge, la cause reste.
  • ASan et stdbuf sont incompatibles.
  • pkill -f se tue lui-même quand le motif est dans sa propre ligne de commande.
  • Pas d'arithmétique bash sur des adresses 64 bits.
  • Un seul DRAKVUF par LibVMI : libvmi#1074 fait passer page_info_t de 128 à 136 octets sans changer la version 0.15.0, et le mélange donne un canari de pile qui saute au hasard.
  • « Plugin dkommon startup failed! » sort avant la boucle principale sur 24H2 et plus (section 12). -x dkommon en attendant.
  • Un premier xl create sur un build neuf peut se figer (deux fois sur 1709, une fois sur 26H1) : la colonne Time(s) de xl list ne bouge pas. destroy puis create.
  • vncdo capture est fiable ; le screendump QMP sort toujours noir sur cet hôte.
  • drakvuf ... | head : le SIGPIPE tue DRAKVUF sans drakvuf_close(), le domaine reste en pause avec le ring vm_event actif, et xl unpause ne suffit pas.
  • -i <pid> avec un PID périmé donne un InitFail sans plus d'explication.

Partie 2 : les correctifs

7. --userhook-target-process (#1876)

L'issue #1828 demandait d'intégrer un patch de 2019 dont dépend SNIPER, un traceur d'API. En relisant le patch contre main 45c7b26, presque tout était déjà en amont (drakvuf_find_mmvad, get_user_stack32/64, get_wow_context, les return hooks, le chargement des DLL par page fault). Il manquait le filtre : ne hooker que le malware et ses enfants, pas les cent processus de Windows. Le plugin persmon de SNIPER n'a pas de licence, je n'ai repris aucun code.

Option répétable. La vérification est dans perform_hooking() : les autres processus ne sont pas hookés du tout, ce n'est pas un filtre sur la sortie. Comparaison insensible à la casse sur le nom de base (Windows donne le chemin NT complet). Ascendance par la liste de processus vivante (drakvuf_find_process, drakvuf_get_process_ppid, 32 niveaux), donc un parent lancé avant l'attache est reconnu. Six fichiers, 89 lignes. Le cache a eu une première version par PID seul, qui a hooké mscorsvw.exe par réutilisation de PID : cache par couple (PID, PPID).

Validation : apimon avec un seul hook (ntdll.dll,LdrLoadDll), -t 45, injection de cmd.exe /c ping -n 3 127.0.0.1 dans explorer.exe, un script qui reconstruit la chaîne d'ancêtres de chaque api_called. 35 runs, 0 violation, 0 échec. Sans l'option : 2428 à 4373 événements sur main. Cible explorer.exe : 54 à 616 événements, 4 à 5 processus, bimodal parce que dans les runs à 610 explorer.exe avait planté (WerFault.exe, enfant de la cible, correctement suivi) puis redémarré. Cible cmd.exe : 43 événements, 3 processus (cmd, conhost, ping). Nom inexistant : 0. Linux : sans effet, 53 000 à 58 000 lignes dans les trois variantes.

La deuxième PR, « hooker un processus déjà en cours », a été mesurée avant d'être écrite. Un patch jetable appelle drakvuf_request_userhook_on_running_process N fois sur un notepad.exe inactif pendant qu'un PowerShell fait 800 000 Thread.Sleep(0), 3 runs par N : N = 0, 52,8 à 60,4 s ; N = 1, 85,1 à plus de 100 s ; CPU de DRAKVUF de 50 à 90 s. Le trap est sur une adresse physique, donc il tire dans tout processus qui mappe la même page de ntdll : des centaines de milliers de hits venus du workload, aucun de notepad. Réponse de Tamas le 26 septembre : cible et descendants, pas de victimes déjà en cours, DRAKVUF part d'une ardoise propre. Sujet clos.

8. Six traps nuls (correctif local)

pluginex::register_trap() renvoie nullptr quand le trap ne peut pas être posé (Could not find EPROCESS struct for pid = 4, No DTB found for pid 4, Failed to add a trap for NtCreateUserProcess). Douze appelants vérifiaient, six non : quatre dans procmon/win.cpp, deux dans dkommon.cpp. get_trap_params(nullptr) suivi de set_result_call_params() écrit target_pid à this+8, d'où segfault at 8. Vu deux fois sur environ 270 attaches pendant la campagne de libvmi#1128, sur Windows 10 21H2 et 2004, backtrace avec this=0x0 dans open_process_hook_cb.

Correctif : if (!trap) return VMI_EVENT_RESPONSE_NONE; aux six endroits, 18 lignes, commit local ac96c35. La preuve a été le plus long : 360 attaches naturelles n'ont jamais reproduit le cas (avec les deux vCPU saturés en mode utilisateur, presque toute attache se dégrade, mais pas jusqu'au crash). Donc un patch jetable, DRK_FORCE_TRAP_FAIL=1, qui force l'échec : sur 9 invités (Windows 7 SP1 x64 et x86, 8.1, Windows 10 1507, 1809 x86, 2004, 22H2, Windows 11 23H2, Server 2019), non corrigé, rc 139 à la même instruction (0x2625c7) ; corrigé, rc 0 avec la nouvelle branche prise de 31 à 6 506 fois. dkommon avec un vrai sc create et sc delete injectés : même crash sans, OK avec. Non-régression : 13 invités, 104 runs procmon et 52 dkommon, 4 binaires, 0 échec. ASan, UBSan et LSan sur 4 invités, 5 runs chacun : 0. Vu à côté, non traité : 15 createReturnHook() qui déréférencent sans test (par lecture, pas reproduit), dkommon::srv_trap[2] non initialisé (crash dans fprintf en debug, 4 runs sur 5 sur 1803), un faux « Hidden Service » quand le trap retour de delete_service_cb échoue. Pas poussé : j'attendais la fin de la campagne LibVMI.

9. Les return hooks vérifiés dans libhook (#1879, #1880)

Un ReturnHook est un breakpoint sur l'adresse de retour ; un autre thread, un autre processus ou un appel récursif peut le toucher. Chaque plugin devait appeler verifyResultCallParams() à la main, rien ne l'imposait, et libhooktest ne le faisait pas (issue #1584). ReturnHook::create() enregistre PID, TID et RSP, et le callback de trap vérifie avant d'appeler le plugin : deux fichiers, +6/-3.

Tests en alternant référence et nouveau, en build debug, sur Windows 10 22H2 et Debian 12 : filetracer 107, rpcmon 22, apimon 43 événements pour le processus injecté dans les 6 runs (un run de référence à 210, isolé) ; Linux, 27 événements fichiers et 34 lignes procmon dans 6 runs sur 6. Sur libhooktest, la part des retours appelés passe de 89 à 90 % à 83 à 84 %, cohérent : ce sont les retours d'autres appels, maintenant filtrés. ASan et UBSan à 0. Non couvert : fileextractor et hidevm (callbacks jamais déclenchés, et pour cause, voir la section 4), codemon en fumée.

1880 supprime ensuite les appels devenus morts dans 10 fichiers (+114/-153), hidevm dé-indenté d'environ 190 lignes, vérifié mécaniquement. Résultats identiques avant et après (rpcmon 22, apimon 43, Linux 27 et 34, procmon 160 dans 4 runs sur 4, codemon 6 643 lignes). Combiné avec libvmi#1123, sans interaction.

10. repl (#1877)

Trois problèmes en build meson. repl cherche libdrakvuf.py à côté de l'exécutable, dans librepl/, le layout autotools ; meson le génère dans src/librepl/. L'échec se produisait après l'attache, avec un exit(1) sans drakvuf_close() : domaine laissé en pause, et le run suivant disait vm_event is (or was) active. Et impossible d'arrêter le REPL proprement : un trap sur chaque écriture de CR3, exit() qui rouvre un prompt, Ctrl+C qui est une touche IPython en mode brut. Deux commits fusionnés. Un troisième, Ctrl+C sur ligne vide (InteractiveShellEmbed, repli sur IPython.embed()), est testé sur IPython 7.34 à 9.17, tmux, ssh -tt, Windows 10 et Debian 12 (arrêt en 0,1 à 0,3 s, domaine jamais laissé en pause), et pas publié.

11. Une macro qui n'a jamais compilé (#1878)

Trois blocs de vmi.c (_int3_cb, _debug_cb, _msr_cb) sous #ifdef DEBUG, alors que la macro s'appelle DRAKVUF_DEBUG depuis des années. Les traces INT3, debug et MSR n'étaient jamais compilées, même en build debug avec -v. Trois lignes. Avant : 0 ligne INT3 event ; après : 18 787 puis 2 863 lignes en 25 s. Cinq variantes de CI, 0 avertissement. Les traces Debug et MSR, je ne les ai jamais vues s'imprimer : aucun plugin ne les déclenche ici.

12. dkommon sur 24H2 et plus (#1884)

MiProcessLoaderEntry n'existe plus dans le ntoskrnl.exe de 24H2, et register_trap dessus est dans le bloc des traps critiques (throw -1). 10 runs sur 10 en échec, 3 sur 3 après le correctif, qui sort ce trap du bloc critique avec un message « MiProcessLoaderEntry not found, driver load/unload monitoring disabled ». Issue ouverte le 29 septembre, patch prêt, PR pas ouverte.

Ce que je n'ai PAS fait

  • La détection des redémarrages demandée par Tamas.
  • Le pointeur nul de kdbg.c:767 côté LibVMI.
  • Ouvrir les PR des traps nuls et de dkommon, et publier le troisième commit de repl.
  • APC, détournement de thread, hollowing : pas couverts par l'arbre de processus de #1876.

Sources

  • Issue #1883 ; PR #1876, #1877, #1878, #1879, #1880 ; issues #1584, #1828, #1884.
  • Le protocole, le journal des profils, les rapports de test par PR, les backtraces gdb. En local, fournis sur demande.
  • Les chiffres de ce billet ont été recontrôlés contre ces rapports.