# 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](https://github.com/volatilityfoundation/volatility3/pull/1036) (la PR de la couche, ouverte ; mon commentaire du 26/09), [forensicxlab#1](https://github.com/forensicxlab/volatility3/pull/1) (fusionnée le 16/10 dans `feature/hibernation-layer`), [forensicxlab#2](https://github.com/forensicxlab/volatility3/pull/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](index.html?w=volatility-presentation.md), 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](https://github.com/forensicxlab/volatility3/pull/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](https://github.com/forensicxlab/volatility3/pull/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

- [volatility3#1036](https://github.com/volatilityfoundation/volatility3/pull/1036) et ses commentaires des 26 septembre et 16 octobre ; [forensicxlab#1](https://github.com/forensicxlab/volatility3/pull/1) et [forensicxlab#2](https://github.com/forensicxlab/volatility3/pull/2).
- [`contrib/hibernation_checks/campaign_2026-10/`](https://github.com/forensicxlab/volatility3/tree/feature/hibernation-layer/contrib/hibernation_checks) dans la branche (`SUMMARY.md`, `campaign.log`, sorties par build et par round).
- Les chiffres de ce billet ont été recontrôlés contre le `SUMMARY.md` publié et les sorties locales (`compare_outputs.py`, `hib_check.py`, `TEST_*.txt`).
