Technical Article

Waarom Excel uw versleutelde werkboek weigert: ECB en RC4

U schrijft een werkboek, versleutelt het met een wachtwoord, geeft het bestand aan een collega, en de collega opent het in Excel. Excel vraagt om het wachtwoord. De collega typt het in en Excel accepteert het. Tot zover lijkt de versleuteling correct. Vervolgens toont Excel een dialoogvenster dat meldt dat het bestand corrupt is en niet kan worden geopend, of het opent een werkblad met onleesbare cellen. Het wachtwoord was correct. Het bestand is toch kapot. Dit is de meest verwarrende foutmodus in Office-versleuteling, omdat het deel dat bevestigt dat het wachtwoord klopt en het deel dat uw gegevens bevat door twee verschillende bewerkingen worden beschermd. Het correct invoeren van de ene biedt geen enkele garantie voor de andere.

Beide hier beschreven bugs vertoonden exact dit patroon. In beide gevallen slaagde de verificatie, maar de inhoud niet, waardoor u gaat zoeken naar een wachtwoord- of sleutelafleidingsfout die er niet is. De werkelijke fout lag verderop, in de manier waarop de bytes van het pakket werden getransformeerd. De twee fouten zijn onafhankelijk (de ene bevindt zich in het AES-pad en de andere in het RC4-pad), maar ze delen hetzelfde diagnoseprobleem. Het is daarom nuttig om te zien waarom een half-correct resultaat het lastigst te interpreteren is.

Waarom een correct wachtwoord niets bewijst over de inhoud

Het formaat dat de moderne versleutelde XLSX gebruikt is ECMA-376 Standard Encryption, en dit slaat twee versleutelde zaken naast elkaar op. De ene is de EncryptionVerifier: een klein blok dat een willekeurige waarde en de hash van die waarde bevat, versleuteld met de sleutel die uit het wachtwoord is afgeleid. De andere is het EncryptedPackage: de volledige zip-container van het werkboek, versleuteld met dezelfde sleutel. De verificatie bestaat zodat een lezer een wachtwoord kan bevestigen voordat hij bytes van de inhoud gaat verwerken. Ontsleutel de verificatie, hash de willekeurige waarde, vergelijk deze met de opgeslagen hash, en als ze overeenkomen is het wachtwoord correct.

De valkuil is dat de verificatie en het pakket door afzonderlijke aanroepen over verschillende buffers worden versleuteld. Een correct afgeleide sleutel zal de verificatie altijd correct ontsleutelen, ongeacht wat er daarna met het pakket gebeurt. Dus als uw sleutelafleiding klopt maar uw pakkettransformatie fout is, bevestigt Excel het wachtwoord via de verificatie en faalt vervolgens op de inhoud. Het symptoom luidt "correct wachtwoord, beschadigd bestand", wat het onderzoek naar het wachtwoordpad leidt, terwijl dat juist het enige deel was dat correct functioneerde. Dezelfde scheiding geldt voor het legacy RC4-geval: de verificatiehash wordt eerst gecontroleerd, en een inhoud die uit de pas loopt laat die controle ongemoeid.

Bug één: AES in ECB, niet CBC

[MS-OFFCRYPTO] §2.3.4.15 specificeert dat Standard Encryption het pakket versleutelt met AES in Electronic Codebook (ECB) modus. Elk 16-byte blok van het opgevulde pakket wordt onafhankelijk versleuteld met dezelfde sleutel. Er is geen koppeling (chaining) tussen blokken en er is geen initialisatievector. Dit is naar moderne maatstaven een ongebruikelijke keuze (ECB wordt normaal gesproken vermeden), maar bij interoperabiliteit is het niet aan ons om de specificatie in twijfel te trekken. Excel ontsleutelt het pakket als ECB, dus een schrijver moet het als ECB versleutelen, anders stemmen ze niet overeen.

De bug was dat het pakket werd versleuteld met AES in CBC-modus met een initialisatievector die volledig uit nullen bestond. Dit werkt bijna, en "bijna" is in dit geval de slechtst denkbare uitkomst. In CBC wordt het eerste klaartekstblok vóór versleuteling ge-XORed met de IV. Wanneer de IV uit louter nullen bestaat heeft die XOR geen effect, waardoor het eerste blok van CBC-met-nul-IV exact dezelfde cijfertekst oplevert als ECB. Vanaf het tweede blok voedt CBC het vorige cijfertekstblok in het volgende, waardoor elk blok na het eerste afwijkt van ECB.

Wanneer u dit projecteert op de structuur: de pakketindeling plaatst een 8-byte little-endian lengte-prefix helemaal aan het begin. De delen van het bestand die Excel het vroegst controleert bevinden zich dus in het eerste of tweede blok. Een eerste blok dat toevallig overeenkomt zorgt ervoor dat de vroegste validatie slaagt, terwijl elk volgend blok tot ruis ontsleutelt. De oplossing is eenvoudig zodra de modus is geïdentificeerd: versleutel elk 16-byte blok met ECB en stop met koppelen. In de engine doorloopt XlsEncryptStdPackage de opgevulde buffer in stappen van 16 bytes en roept AESEncryptECB128Block op elk blok aan, wat hetzelfde basismechanisme is dat al voor de verificatieblokken werd gebruikt. De broncode bevat bij de lus een opmerking die deze regel duidelijk stelt: CBC met een nul-IV komt alleen voor het eerste blok overeen met ECB, waardoor de rest van het pakket tot ruis zou ontsleutelen en Excel het zou weigeren.

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;

Bug twee: de RC4-hersleuteling raakt uit de pas

Het legacy .xls-pad maakt gebruik van het RC4 CryptoAPI-schema, en de regels daarvoor zijn wezenlijk anders. [MS-OFFCRYPTO] §2.3.6 specificeert dat het cijfer opnieuw wordt gesleuteld (re-keyed) bij elke grens van een 1024-byte blok. De stream is verdeeld in blokken van 1024 bytes, een nieuwe RC4-sleutel wordt afgeleid voor bloknummer 0, 1, 2, enzovoort, en binnen elk blok wordt de sleutelstroom continu van byte naar byte geconsumeerd. Twee invarianten moeten samen standhouden: hersleutel op elke grens, en consumeer de sleutelstroom zonder onderbrekingen binnen een blok. RC4 is een stroomcijfer, dus de sleutelstroom is een enkele geordende reeks; de n-de byte die u trekt wordt bepaald door hoeveel bytes u daarvoor hebt getrokken. Ontsleuteling is dezelfde XOR tegen dezelfde reeks, wat betekent dat schrijver en lezer exact dezelfde bytes op exact dezelfde posities moeten trekken.

Dat is de hele moeilijkheid. Een stroomcijfer kent geen hersynchronisatie. Als u één byte van de sleutelstroom verspilt, wordt elke volgende byte ge-XORed met de verkeerde sleutelstroombyte, en deze fout herstelt zich nooit. De fout cascadeert naar het einde van het blok en, zodra de actieve positie onjuist is, naar elk blok daarna. De bug die hier optrad deed exact dat. De blokteller startte vanaf een sentinel-waarde van min één, en de skip-routine nam aan dat de teller al overeenkwam met het huidige blok. Startend vanaf die sentinel hersleutelde hij en verbruikte een heel blok van 1024 bytes aan sleutelstroom dat nooit geconsumeerd had mogen worden. Daarbij dreef hij het resterende aantal in het negatieve. Vanaf dat moment was de ontsleutelaar een heel blok uit fase. De verificatie, die vóór dit alles werd gecontroleerd, slaagde nog steeds, waardoor het wachtwoord correct leek terwijl elke gegevenscel als ruis tevoorschijn kwam.

De gecorrigeerde logica bevindt zich in TXLSDecrypterRC4. Zowel Skip als Decrypt delen één lus: hersleutel alleen wanneer de actieve positie een nieuw blok binnengaat, waarbij de blokindex de positie gedeeld door REKEY_BLOCK_SIZE (1024) is, consumeer vervolgens tot de rest van het huidige blok en niets meer. MakeKey wordt aangeroepen met de blokindex, nooit met een verouderde of sentinel-index, en de positie schuift op met het exacte aantal verwerkte bytes, zodat Skip en Decrypt in fase blijven met de schrijver. De les schuilt in de kleinste eenheid: een enkele verloren byte is geen kleine fout in een stroomcijfer, maar een totaal verlies van alles wat volgt.

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;

Interoperabiliteit met een bevroren specificatie vereist exacte overeenstemming

Beide bugs vallen terug op hetzelfde basisprincipe, en het is de moeite waard om dit expliciet te formuleren omdat het invloed heeft op uw ontwerpkeuzes. Wanneer de afnemer van uw uitvoer een vast extern programma is dat u niet kunt wijzigen, zijn de cijfermodus en de hersleutelingsfrequentie geen implementatiedetails die u naar eigen inzicht kunt optimaliseren of vereenvoudigen. Ze maken deel uit van het fysieke contract. Excel zal met ECB ontsleutelen en hersleutelen op 1024-byte grenzen, of die keuzes u nu bevallen of niet. Het is uw enige taak om bytes te produceren die onder die exacte procedure tot het origineel ontsleutelen. Een modus die moderner is, een IV die onschadelijk lijkt, een teller die start waar het natuurlijk aanvoelt: elk van deze is een fout zodra deze afwijkt van wat de lezer verwacht. Interoperabiliteit tegen een bevroren specificatie is niet bij benadering. Het is byte-exact of het is kapot.

Dit is ook de reden waarom de verificatie op zichzelf een slechte rooktest is. Het vertelt u dat de sleutelafleiding werkt, wat noodzakelijk is maar verre van voldoende. Een test die alleen een versleuteld bestand opent en bevestigt dat het wachtwoord wordt geaccepteerd zal succes rapporteren, terwijl de inhoud onleesbaar is. Een echte test ontsleutelt het pakket en vergelijkt de herstelde bytes met de oorspronkelijke invoer, of doorloopt een werkboek via versleutelen en ontsleutelen en leest cellen terug. De verificatie bewijst het wachtwoord; alleen de inhoud bewijst de versleuteling.

De ondersteunde manier om beveiligde werkboeken te lezen en te schrijven

De publieke interface is compact. Om een met een wachtwoord beveiligd modern werkboek te schrijven, vult of opent u een TXLSXWorkbook en roept u SaveAsEncrypted aan met een bestandsnaam en een wachtwoord. Dit serialiseert het werkboek en voert de Standard Encryption-pijplijn uit die door de eerste oplossing is gecorrigeerd. Het retourneert 1 bij succes. Om te lezen roept u CanReadEncrypted aan om te testen of een bestand een versleutelde Compound File-container is, en vertakt vervolgens: OpenEncrypted regelt het versleutelde pad en valt terug op Open voor gewone bestanden, en Open met een wachtwoord is rechtstreeks beschikbaar. De modusafhandeling en de hersleutelingslus die hierboven zijn beschreven bevinden zich onder deze aanroepen. U levert het wachtwoord en de bestandsnaam en de engine voldoet namens u aan de specificatie.

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;

De vorm van de beveiligde uitvoer, de EncryptionInfo-stream, de verificatieblokken en de pakketindeling worden behandeld in onze handleiding over AES-beveiligde XLSX-uitvoer. Voor de afzonderlijke vraag naar werkbladvormige vergrendeling en hoe beveiliging samenwerkt met pagina-instellingen en afdrukken, zie het artikel over beveiliging, pagina-instellingen en afdrukken. Beide bouwen voort op het versleutelingspad dat hier wordt beschreven en dat wordt geleverd als onderdeel van de HotXLS spreadsheet component voor Delphi en C++Builder, naast de lees-, schrijf- en render-API's die elders op deze blog worden behandeld.