Fichiers d'hibernation Windows 11 dans Volatility 3 : offsets par build, en-tête des sets, décodeur Huffman, et 24 fichiers sur six builds

Catégorie : Volatility


Résumé

Sur un hiberfil.sys de Windows 11 24H2, windows.hibernation.Dump annonçait un succès et ne convertissait que la section de boot, 0,5 Go sur 5,9. L'image paraissait valide, la liste des processus se réduisait à System, et on en tirait des conclusions fausses. Trois causes, trouvées en deux temps. D'abord sur le fichier réel : l'offset du compteur de pages noyau dépend du build exact, et l'en-tête des sets de compression a changé de format (le bit 29 n'appartient plus à la taille). Puis, en générant 24 fichiers sur six builds Windows 11 pour en être sûr : le décodeur LZ77+Huffman recevait toujours 65 536 octets, et un set plus court par fichier faisait planter même les builds censés marcher. Le billet donne la méthode (dont une première campagne ratée), le tableau par build tel qu'il est publié dans la branche, et ce qui a été fusionné chez l'auteur de la couche le 16 octobre.

Suivi upstream : volatility3#1036 (la PR de la couche, ouverte ; mon commentaire du 26/09), forensicxlab#1 (fusionnée le 16/10 dans feature/hibernation-layer), forensicxlab#2 (lint, ouverte).


Environnement

Fichier d'étude : un hiberfil.sys de Windows 11 24H2 (build 26100.9445) venant d'un cas réel, non partageable ; aucune donnée n'en sort, les outils n'affichent que des compteurs. Six VM Windows 11 installées depuis les ISO officielles : 21H2 (22000.318), 22H2 (22621.382), 23H2 (22621.2428), 24H2 (26100.1742), 25H2 (26100.6584), 26H1 (28000.1340), plus un Windows 10 22H2. Hôte Xen 4.21.0, DRAKVUF pour l'injection. Volatility : branche feature/hibernation-layer de forensicxlab sur 603b53f8. Le format du fichier et le vocabulaire sont dans la présentation, section 4.

1. Le constat

windows.hibernation.Dump --version 0 sur le fichier réel finit sans erreur. windows.info répond. Mais il n'y a presque pas de mémoire noyau : seule la section de boot est convertie. Le pire des résultats, parce que rien ne dit que c'est faux.

2. L'offset du compteur dépend du build

Le « reversing » porte sur le fichier, pas sur le code de Windows. Je me suis placé dans la structure _PO_HIBER_PERF de l'en-tête pour trouver quel compteur borne la section noyau.

  1. KernelPagesProcessed est lu à 0x230, ce qui est juste de Windows 10 1703 à Windows 11 23H2. Dans ce fichier la valeur y est 0, donc la section noyau est sautée.
  2. FirstKernelRestorePage (0x70) n'a pas bougé. On suit la chaîne des sets de compression depuis cette page, jusqu'à ce qu'elle se termine d'elle-même.
  3. La valeur à 0x250 (1 573 026 pages) tombe exactement sur la fin de la chaîne, sans en-tête invalide en route.
  4. Trois relations la recoupent : NumPagesForLoader (0x58) est un compteur, le nombre de pages de la chaîne noyau en est un autre, et la longueur en octets de la section noyau aussi.
  5. Pour savoir si c'est propre à ce fichier, j'ai fabriqué des fichiers Fast Startup sur des VM (shutdown /s /hybrid) :
Fichier pages boot pages noyau longueur noyau (octets)
Windows 10 22H2 (pareil en hibernation complète) 0x228 0x230 0x240
Windows 11 24H2, build 26100.1742 (ISO officielle) 0x230 0x238 0x248
Windows 11 24H2, build 26100.9445 (le fichier réel) 0x248 0x250 0x260

Ce que ça implique : un zéro n'a rien à voir avec Fast Startup, puisque le Windows 10 remplit bien 0x230 ; l'offset dépend du build exact, pas de la version, donc un 0x250 fixe ne marche que sur certains builds ; la disposition 24H2 de Vergilius (0x238) correspond au 26100.1742 mais pas au fichier réel. Une erreur à signaler : mon premier audit affirmait, d'après Vergilius, que 0x250 n'était pas KernelPagesProcessed. Les tests ont montré que l'affirmation d'origine était juste en pratique, et j'ai réécrit la description.

3. L'en-tête des sets a changé

L'en-tête fait 4 octets : nombre de pages sur les bits 0 à 7, taille sur les bits 8 à 29 (22 bits), algorithme sur 30 et 31. Sur 24H2, le bit 29 ne fait plus partie de la taille (taille = bits 8 à 28), et le bit 30 indique LZ77+Huffman, sinon LZ77 simple. Exemple : l'en-tête 0x6010C302 (16 pages) était lu comme 2 101 443 octets, impossible puisqu'un set fait 65 536 octets au plus ; la chaîne cassait après 2 292 sets, soit environ 36 700 pages sur 1 573 026. Avec la bonne règle, la taille est 4 291 et la chaîne continue.

Les 98 315 sets de la section noyau du fichier réel : LZ77 simple 85 879 ; Huffman avec le bit 29 à 0, 6 950 ; Huffman avec le bit 29 à 1, 1 411 ; non compressés 4 075 (dont 3 060 avec le bit 30). La section de boot compte 7 885 LZ77 et 842 non compressés, sans bit 29, 30 ni 31. Les bits 25 à 28 et 31 ne sont jamais utilisés. Un set non compressé se reconnaît à taille == pages × 4096, donc son bit d'algorithme ne sert pas. Et le Huffman exige la taille de sortie exacte : sur ce fichier, les 8 361 sets Huffman font tous 16 pages, avec 0 échec de décompression. Cette dernière phrase est vraie pour ce fichier et fausse en général, c'est la section 6.

Avant de publier, j'ai refait le patch en v2 sans toucher à l'original : tests en anglais, plus aucun hash ni chemin propre au cas, 15 tests unitaires synthétiques (test_hib_layout.py), un script hib_check.py qui décode les sets sous les règles 23H2 et 24H2, et compare_outputs.py sur les vraies images, en lecture seule : le boot est identique page pour page, la section noyau est vide dans l'original contre 1 388 660 pages avec le patch (124 182 + 1 388 660 = 1 512 842), aucune page ne diffère hors des sections, et l'original en v0 et le patché en v0 sont identiques partout. Commentaire posté sur #1036 le 26 septembre, avec un --version 4 et le script.

4. La campagne : pourquoi, et la première qui n'a jamais tourné

Trois points, dont un non partageable, ne suffisent pas pour une PR. Il fallait savoir si 0x238 est la règle sur 26100 et au-delà, et si une autodétection accepte ou refuse correctement chaque fichier.

La première campagne. Un script .cmd déposé dans le dossier Startup de l'invité via ntfs-3g, démarrage, attente de l'arrêt hybride. J'ai obtenu des fichiers, je les ai analysés, et trois rounds plus tard je me suis aperçu que leur SystemTime (celui que windows.hibernation.Info lit dans l'en-tête) était au 26 ou 27 septembre, la date d'installation. Le script ne s'était jamais exécuté. Windows recréait le fichier du snapshot sur les mêmes clusters, et je comparais des copies du même fichier. Rounds invalidés, archivés sous _invalid-startup-script-method/. D'où la règle de la seconde : un fichier n'est compté que si son SystemTime est dans la fenêtre du run (horloge invité en UTC moins deux heures) et si son sha256 diffère de tous les précédents.

La méthode qui marche. Restauration du snapshot, démarrage, 120 s, injection DRAKVUF (-m createproc dans explorer.exe) de cmd.exe /c powercfg /h on & shutdown /s /hybrid /t 5, attente de l'extinction (on_poweroff=destroy), montage NTFS en lecture seule, copie de hiberfil.sys. Puis, par fichier : hib_check.py --decompress (trois dispositions d'en-tête, comptage des bits, décompression de chaque set avec vérification de la taille), Dump avec la branche et la bonne version, un deuxième run pour vérifier que le sha256 de l'image est identique, l'original 603b53f8 en --version 0 et en --version 4, et la branche avec la mauvaise version, qui doit refuser. Rounds r0 le 13 octobre, r1 à r3 le 14, puis r4 pour remplacer trois générations ratées (VM repartie de son propre fichier, signature WAKE, injection en InitFail : 22H2 r1, 24H2 r2, 26H1 r3). Le 26H1 r0 est le fichier écrit à l'installation (SystemTime au 27 septembre), gardé comme vrai 26H1. 27 générations, 3 ratées, 24 fichiers valides. Les fichiers r2 et r3 ont été supprimés après contrôle, faute de place, d'où quelques statistiques « n/a » dans le tableau publié.

5. Le tableau par build

Tel qu'il est dans contrib/hibernation_checks/campaign_2026-10/SUMMARY.md de la branche fusionnée, résumé par build :

Build Noyau KernelPagesProcessed Fichiers Pages noyau Sets Huffman de moins de 16 pages Dump, pages non nulles
21H2 22000.318 0x230 4 295 105 à 352 225 1 par fichier 306 883 à 362 847
22H2 22621.382 0x230 4 286 984 à 298 470 1 par fichier 298 042 à 308 402
23H2 22621.2428 0x230 4 267 919 à 302 894 1 par fichier 278 116 à 313 341
24H2 26100.1742 0x238 4 301 546 à 314 207 1 par fichier 313 942 à 327 167
25H2 26100.6584 0x238 4 318 642 à 346 065 0 334 667 à 363 514
26H1 28000.1340 0x238 4 391 575 à 429 581 1 par fichier généré, 0 sur celui d'installation 407 227 à 444 349

À quoi s'ajoutent le Windows 10 22H2 du lab (Fast Startup et S4 complet, 222 203 et 291 139 pages noyau, 0x230) et le fichier réel (0x250).

La carte est donc : 0x230 jusqu'à 23H2, 0x238 sur 26100.1742, 26100.6584 et 28000.1340, 0x250 sur une révision 26100 plus récente, vue une seule fois. Les règles de bits sont les mêmes sur les six builds. Ce qui change, c'est la répartition : 21H2 à 24H2 sont presque tout en Huffman (24H2 r0 : 17 sets LZ77 pour 19 327 Huffman), 25H2 et le 26H1 d'installation presque tout en LZ77 simple (25H2 r0 : 20 748 LZ77 pour 435 Huffman). Je note le fait, je ne l'explique pas. Les douze doubles runs de Dump sont identiques au sha256 près. L'original en --version 0 plante sur 21H2 à 23H2 (ValueError: EOF Error) et refuse 24H2 et plus (« could not be converted ») ; en --version 4, et la branche avec une mauvaise version, refusent partout. Jamais d'image tronquée.

6. Le troisième bug : un set Huffman plus court que 16 pages

Sur tous les fichiers 21H2, 22H2 et 23H2 de la campagne, le code d'origine en --version 0, la version censée marcher, plante avec ValueError: EOF Error, code retour 1, aucune image. Cause : _uncompress appelait lz77_huffman_decompress_py(data, 65536) quelle que soit la taille réelle du set, et un set Huffman de moins de 16 pages échoue. Chaque fichier de 21H2 à 24H2 en contient exactement un, en fin de section noyau ; 25H2 et le 26H1 d'installation n'en ont pas, les 26H1 générés en ont un. Le fichier réel n'en avait pas non plus, d'où la phrase de la section 3.

Correctif : la couche mémorise la taille décompressée de chaque set (_uncompressed_size, pages × 4096) et la passe au décodeur. Rien ne change pour les sets de 16 pages, et ça s'applique à toutes les versions. Preuve : 19 tests unitaires (16 sur les outils, 3 sur la couche avec des fichiers synthétiques), passés cinq fois ; en remettant 65 536 en dur, les 3 tests de couche échouent avec six lignes EOF Error.

7. Ce qui a été fusionné

PR forensicxlab#1, ouverte le 14 octobre vers feature/hibernation-layer, fusionnée le 16 par k1nd0ne, l'auteur de la couche, 2 commits, sans demande de changement : - --version 4 pour Windows 11 24H2 et plus, versions 0 à 3 inchangées ; - l'autodétection : KPP_OFFSETS_24H2 = (0x238, 0x250), premier offset dont la valeur non nulle est égale à la longueur de la chaîne des sets noyau (_kernel_chain_has_pages, _find_kernel_pages_processed), sinon LayerException avec un message explicite. Un fichier inconnu est refusé, jamais tronqué en silence. Un troisième offset inconnu ne serait pas trouvé, c'est la limite ; - la taille réelle passée au décodeur ; - les scripts et la campagne sous contrib/hibernation_checks/.

La PR amont #1036 porte donc ces commits. Son job lint-and-format (ruff 0.17.1) a ensuite échoué sur un import codecs inutilisé dans layers/codecs/__init__.py, préexistant : correctif d'une ligne, fichier identique à develop, dans forensicxlab#2, en attente.

8. Ce qui reste incertain

  • L'hibernation complète (S4) sur Windows 11 : pas testée, seulement sur Windows 10 22H2.
  • x86, et les révisions 26100 autres que .1742, .6584 et .9445. Le 0x250 n'a été vu qu'une fois.
  • Le sens du bit 29, et celui des bits 29 et 30 sur les sets non compressés.
  • Je n'ai pas désassemblé le code d'hibernation de Windows. Tout vient de l'analyse des fichiers et de Vergilius, et je ne sais pas quel changement entre builds a déplacé le bloc.
  • Pas de réponse de k1nd0ne sur forensicxlab#2 à l'heure où j'écris.

Sources