Article technique

Pourquoi Excel rejette votre classeur chiffré : ECB et RC4

Vous créez un classeur, le chiffrez avec un mot de passe, transmettez le fichier à un collègue, et ce dernier l'ouvre dans Excel. Excel demande le mot de passe. Le collègue le saisit et Excel l'accepte. Jusqu'ici, le chiffrement semble correct. Puis Excel affiche une boîte de dialogue indiquant que le fichier est corrompu et ne peut pas être ouvert, ou bien s'ouvre sur une feuille remplie de cellules incompréhensibles. Le mot de passe était pourtant correct, mais le fichier reste illisible. Il s'agit du mode d'échec le plus déroutant du chiffrement Office, car la partie qui confirme le mot de passe et la partie qui contient vos données sont protégées par deux opérations différentes, et la réussite de la première ne garantit en rien celle de la seconde

Les deux bogues décrits ici présentaient précisément cette configuration. Dans chaque cas, le vérificateur passait avec succès mais pas le corps du fichier, ce qui oriente les recherches vers un bogue de mot de passe ou de dérivation de clé inexistant. La véritable faille se situait en aval, dans la manière dont les octets du package étaient transformés. Ces deux défauts sont indépendants — l'un se trouvant sur le chemin AES et l'autre sur le chemin RC4 — mais ils partagent un problème de diagnostic, c'est pourquoi il convient d'analyser en quoi un résultat à moitié correct est le plus difficile à interpréter

Pourquoi la validation du mot de passe ne prouve rien concernant le corps du fichier

Le format utilisé par les fichiers XLSX chiffrés modernes est le chiffrement standard ECMA-376 (Standard Encryption), et il stocke deux éléments chiffrés côte à côte. Le premier est l'EncryptionVerifier : un petit bloc contenant une valeur aléatoire et le hachage de cette valeur, chiffré à l'aide de la clé dérivée du mot de passe. Le second est l'EncryptedPackage : l'intégralité du conteneur ZIP du classeur, chiffrée avec cette même clé. Le vérificateur permet au lecteur de valider le mot de passe avant de décoder des mégaoctets de contenu. Décryptez le vérificateur, hachez la valeur aléatoire, comparez-la au hachage stocké, et si les deux concordent, le mot de passe est correct

Le piège réside dans le fait que le vérificateur et le package sont chiffrés par des appels distincts sur des tampons séparés. Une clé correctement dérivée décryptera correctement le vérificateur, quel que soit l'état ultérieur du package. Ainsi, si la dérivation de clé est correcte mais que la transformation du package est erronée, Excel valide le mot de passe via le vérificateur puis échoue sur le corps du fichier. Le symptôme se manifeste par « mot de passe correct, fichier corrompu », ce qui oriente l'investigation vers le traitement du mot de passe, la seule section qui fonctionnait pourtant correctement. Cette même séparation régit le cas du RC4 hérité : le hachage du vérificateur est contrôlé en premier, et un corps qui se décale n'affecte en rien cette validation initiale

Bogue un : AES en mode ECB, pas CBC

La spécification [MS-OFFCRYPTO] §2.3.4.15 stipule que le chiffrement standard (Standard Encryption) chiffre le package avec de l'AES en mode Electronic Codebook (ECB). Chaque bloc de 16 octets du package complété est chiffré indépendamment avec la même clé. Il n'y a pas de chaînage entre les blocs et aucun vecteur d'initialisation n'est utilisé. C'est un choix inhabituel selon les critères modernes, le mode ECB étant généralement évité, mais l'interopérabilité n'est pas le lieu pour remettre en question les spécifications. Excel déchiffre le package en ECB, un producteur doit donc l'y chiffrer sous peine de non-concordance

Le bogue venait du fait que le package était chiffré avec de l'AES en mode CBC en utilisant un vecteur d'initialisation entièrement nul. Voici pourquoi cela fonctionne presque, et pourquoi le « presque » est la pire situation possible. En mode CBC, le premier bloc de texte brut subit un XOR avec le vecteur d'initialisation avant le chiffrement. Lorsque l'IV est entièrement nul, ce XOR ne change rien, de sorte que le premier bloc de CBC avec IV nul produit exactement le même texte chiffré que le mode ECB. À partir du deuxième bloc, le mode CBC injecte le texte chiffré précédent dans le suivant, ce qui fait dévier chaque bloc suivant par rapport à l'ECB

Appliquons maintenant cela à la structure. La disposition du package place un préfixe de longueur sur 8 octets en petit-boutiste tout au début, de sorte que les parties du fichier qu'Excel vérifie en premier se trouvent dans les deux premiers blocs. Si le premier bloc concorde, les premières étapes de validation réussissent, tandis que tous les blocs suivants sont décryptés sous forme de bruit. La correction est évidente une fois le mode identifié : chiffrer chaque bloc de 16 octets en ECB et cesser le chaînage. Dans le moteur, XlsEncryptStdPackage parcourt le tampon complété par étapes de 16 octets et appelle AESEncryptECB128Block sur chacun, ce qui correspond à la même primitive déjà exploitée pour les blocs de vérification. Le code source comporte un commentaire au niveau de la boucle qui énonce clairement cette règle : le mode CBC avec un IV nul ne correspond à l'ECB que pour le premier bloc, le reste du package se décryptant en données corrompues rejetées par Excel

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    Book.Open('report.xlsx');
    // SaveAsEncrypted serializes the workbook, then runs the
    // ECMA-376 Standard Encryption pipeline: AES-128 ECB over the
    // package per [MS-OFFCRYPTO] 2.3.4.15. Returns 1 on success.
    if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
      raise Exception.Create('Encryption failed');
  finally
    Book.Free;
  end;
end;

Bogue deux : le renouvellement de clé RC4 se décale

Le chemin d'accès au format hérité .xls utilise le schéma RC4 CryptoAPI, et sa règle est d'une autre nature. La spécification [MS-OFFCRYPTO] §2.3.6 indique que le chiffrement est réinitialisé avec une nouvelle clé à chaque limite de bloc de 1024 octets. Le flux est divisé en blocs de 1024 octets, une nouvelle clé RC4 est dérivée pour les blocs 0, 1, 2, etc., et à l'intérieur de chaque bloc, le flux de clé est consommé en continu d'un octet à l'autre. Deux invariants doivent être respectés simultanément : renouveler la clé à chaque limite de bloc, et consommer le flux de clé sans interruption à l'intérieur d'un bloc. Le RC4 étant un chiffrement de flux, son flux de clé est une séquence ordonnée unique ; le n-ième octet extrait dépend du nombre d'octets extraits précédemment. Le déchiffrement consistant à appliquer le même XOR sur cette même séquence, le producteur et le consommateur doivent impérativement extraire les mêmes octets aux mêmes positions

C'est là toute la difficulté. Un chiffrement de flux ne propose aucune resynchronisation. Si vous perdez un seul octet du flux de clé, chaque octet suivant subira un XOR avec un octet du flux de clé erroné, et l'erreur ne se corrigera jamais d'elle-même ; elle se propage jusqu'à la fin du bloc et, une fois la position de lecture décalée, à chaque bloc suivant. C'est précisément ce que faisait ce bogue. Le compteur de blocs démarrait d'une valeur sentinelle égale à moins un, et la routine de saut supposait que le compteur correspondait déjà au bloc actif. À partir de cette sentinelle, elle réinitialisait la clé et consommait un bloc entier de 1024 octets de flux de clé qui n'aurait jamais dû être lu, rendant le décompte restant négatif. À partir de ce moment, le déchiffreur était décalé d'un bloc complet. Le vérificateur, contrôlé avant cela, passait toujours, de sorte que le mot de passe semblait correct alors que chaque cellule de données se décryptait en données corrompues

La logique corrigée se trouve dans TXLSDecrypterRC4. Les fonctions Skip et Decrypt partagent la même boucle : renouveler la clé uniquement lorsque la position de lecture passe à un nouveau bloc, l'index de bloc étant calculé en divisant la position par REKEY_BLOCK_SIZE (1024), puis consommer uniquement jusqu'à la fin du bloc en cours. La fonction MakeKey est appelée avec l'index de bloc correct, jamais avec un index obsolète ou une sentinelle, et la position avance du nombre exact d'octets traités de sorte que Skip et Decrypt restent alignés avec le producteur. La leçon à retenir est claire : un seul octet perdu dans un chiffrement de flux ne constitue pas une erreur mineure, mais entraîne la perte complète de tout le contenu en aval

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    // CanReadEncrypted checks the Compound File (OLE2) signature so
    // you can branch before attempting a normal Open. OpenEncrypted
    // routes plain files to Open and handles the encrypted container.
    if Book.CanReadEncrypted('legacy.xls') then
      Book.OpenEncrypted('legacy.xls', 'S3cret!')
    else
      Book.Open('legacy.xls');
    // read cells here
  finally
    Book.Free;
  end;
end;

L'interopérabilité avec une spécification figée exige une correspondance à l'octet près

Ces deux bogues découlent du même principe fondamental, qu'il convient de formuler clairement car il redéfinit la façon d'envisager les choix de conception. Lorsque le consommateur de votre sortie est un programme externe fixe que vous ne pouvez pas modifier, le mode de chiffrement et la cadence de renouvellement des clés ne sont pas des détails d'implémentation que vous êtes libre d'optimiser ou de simplifier. Ils font partie du contrat d'échange. Excel déchiffrera en ECB et renouvellera la clé aux limites de 1024 octets, que ces choix vous conviennent ou non, et votre seule tâche consiste à générer des octets qui se décryptent fidèlement selon cette procédure exacte. Un mode plus moderne, un IV apparemment inoffensif, un compteur démarrant à une valeur arbitraire : chacun de ces choix devient un défaut dès qu'il dévie de ce que le lecteur attend. L'interopérabilité avec une spécification figée ne tolère aucune approximation. Elle est exacte à l'octet près ou elle échoue

C'est aussi pourquoi le vérificateur ne suffit pas à valider le chiffrement. Il confirme le bon fonctionnement de la dérivation de clé, ce qui est nécessaire mais loin d'être suffisant. Un test se contentant d'ouvrir un fichier chiffré et de confirmer le mot de passe affichera un succès alors que le corps reste illisible. Un test complet décrypte le package et compare les octets récupérés à l'entrée d'origine, ou effectue un aller-retour complet de chiffrement/déchiffrement pour relire les cellules. Le vérificateur valide le mot de passe ; seul le corps valide le chiffrement

La méthode prise en charge pour lire et écrire des classeurs protégés

La surface d'API publique est réduite. Pour écrire un classeur moderne protégé par mot de passe, alimentez ou ouvrez un TXLSXWorkbook et appelez SaveAsEncrypted avec un nom de fichier et un mot de passe ; la fonction sérialise le classeur et exécute le processus Standard Encryption corrigé par notre première modification, en renvoyant 1 en cas de succès. Pour lire, appelez CanReadEncrypted afin de vérifier si le fichier est un conteneur Compound File chiffré, puis effectuez le branchement : OpenEncrypted gère le chemin chiffré et bascule vers Open pour les fichiers bruts, et Open accepte directement un mot de passe. La gestion du mode et la boucle de renouvellement de clé décrites ci-dessus s'exécutent de façon transparente sous ces appels ; vous fournissez simplement le mot de passe et le nom du fichier, et le moteur se charge de respecter la spécification

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    Book.Open('quarterly.xlsx');
    Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
    // Reopen on the consumer side
    Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
  finally
    Book.Free;
  end;
end;

La structure de la sortie protégée, le flux EncryptionInfo, les blocs de vérification et la disposition du package sont détaillés dans notre présentation de la génération de fichiers XLSX protégés par AES. Pour la question distincte du verrouillage des feuilles et de l'impression, consultez l'article sur la protection, la mise en page et l'impression. Ces deux aspects reposent sur le chemin de chiffrement décrit ici, intégré dans le composant HotXLS spreadsheet component pour Delphi et C++Builder, aux côtés des API de lecture, d'écriture et de rendu présentées sur ce blog