HotXLS schrijft XOR-geobfusceerde Excel 5.0/95-workbooks (BIFF5) die Excel 16 alleen opent wanneer drie details exact matchen met [MS-OFFCRYPTO]: de FILEPASS-sleutel moet CreateXorKey_Method1(password) zijn, de XOR-array van 16 bytes moet met XorRor zijn opgebouwd (één bit naar rechts roteren), en elke byte moet XorArrayIndex = (stream-offset + recordlengte) mod 16 gebruiken. HotXLS had de index goed in v2.384.47 en de sleutel en rotatie goed in v2.384.54. Daarvoor opende elk met wachtwoord beveiligd BIFF5-bestand dat het produceerde prima in HotXLS en faalde het in Excel
Die laatste zin is het hele verhaal in miniatuur. Een reader en een writer die dezelfde foute aanname delen zijn het perfect met elkaar eens, dus round-trip-tests blijven groen terwijl de enige consument die ertoe doet nee zegt. Excel 16 zei twee keer nee, met twee verschillende meldingen, en elke melding wees naar een andere laag van het schema. Dit artikel loopt die lagen door in de volgorde waarin Excel ze controleert, met detail op byteniveau dat u kunt gebruiken of u nu HotXLS aanroept of uw eigen BIFF-reader schrijft
Wat slaat BIFF XOR-obfuscatie eigenlijk op?
BIFF XOR-obfuscatie slaat maar twee 16-bit woorden in het bestand op, en alles daaromheen wordt opnieuw berekend uit het wachtwoord. Het FILEPASS-record ($002F) staat direct achter de workbook globals BOF, en in een BIFF5-bestand is zijn body exact 4 bytes: de XOR-sleutel gevolgd door de password verifier. Er is geen salt, geen algoritme-identifier en geen versleutelde verifier-blob zoals RC4- en AES-schema's die meedragen
Uit die twee woorden bouwt een reader drie dingen terug:
- De verifier, een 16-bit hash van de wachtwoordbytes ge-XORd met
$CE4B. Haar vergelijken met het opgeslagen woord is de wachtwoordcontrole, en de enige - De XOR-sleutel, een 16-bit waarde uit
CreateXorKey_Method1in [MS-OFFCRYPTO] §2.3.7.2, aangedreven door twee constante tabellen (InitialCode, 15 woorden, enXorMatrix, 105 woorden) - De XOR-array, 16 bytes opgebouwd uit de wachtwoordbytes aangevuld met een vaste pad van 16 bytes, elk ge-XORd met de lage sleutelbyte (even posities) of de hoge sleutelbyte (oneven posities), en daarna één bit naar rechts geroteerd
Record-headers blijven in platte tekst, en net zo goed een handvol complete records die het schema vrijstelt, waaronder BOF, FILEPASS en INTERFACEHDR. Elke andere record-body wordt byte voor byte getransformeerd: 5 bits naar links roteren, daarna XOR met één entry uit de array van 16 bytes. Decryptie, die [MS-OFFCRYPTO] §2.3.7.3 uitschrijft als DecryptData_Method1, is het spiegelbeeld: eerst XOR, dan 5 naar rechts roteren
In HotXLS raakt u hiervan nooit iets direct aan. Zet een wachtwoord, kies het formaat, en SaveAs emitteert FILEPASS en transformeert de stream:
uses
SysUtils, lxHandle;
procedure SaveLegacyProtectedBook(const FileName: string);
var
Wb: IXLSWorkbook;
begin
Wb := TXLSWorkbook.Create;
Wb.Sheets.Add.Name := 'Ledger';
Wb.Sheets[1].Range['A1', 'A1'].Value := 'Account';
Wb.Sheets[1].Range['B1', 'B1'].Value := 1250.75;
// BIFF5 ondersteunt alleen XOR-obfuscatie; xletAuto zou hem ook kiezen
Wb.EncryptionType := xletXor;
// Houd hem ASCII en hooguit 15 tekens (zie hieronder)
Wb.EncryptionPassword := 'secret';
if Wb.SaveAs(FileName, xlExcel5) <> 1 then
raise Exception.Create('BIFF5 save failed');
end;
Waarom zegt Excel dat het wachtwoord verkeerd is terwijl de verifier matcht?
Excel wijst het wachtwoord af omdat hij de opgeslagen sleutel niet vertrouwt: Excel leidt de sleutel af uit het getypte wachtwoord met CreateXorKey_Method1 en vergelijkt haar met het FILEPASS-sleutelwoord, dus een bestand wiens sleutel iets anders is zakt voor de wachtwoordcontrole zelfs als de verifier klopt. De specificatie beschrijft de sleutel als een output van het wachtwoord, niet als een vrije parameter, en Excel 16 handhaaft die lezing
De HotXLS-writer vóór v2.384.54 vulde het sleutelwoord met twee willekeurige bytes. Dat lijkt ongevaarlijk op papier, want de verifier is de gedocumenteerde wachtwoordcontrole en de array wordt opgebouwd uit de sleutel die het bestand maar verkondigt. HotXLS zelf las die bestanden zonder problemen, want zijn reader nam de sleutel uit FILEPASS voor waar aan. Excel 16, hetzelfde bestand en het juiste wachtwoord krijgt, antwoordde dat het wachtwoord niet correct was. Sinds v2.384.54 wordt de sleutel afgeleid, dus FILEPASS voor het wachtwoord secret bevat altijd sleutel $014D en verifier $DAA7, waarden die zijn gecontroleerd tegen een onafhankelijke implementatie van de specificatie
De afleiding zelf is kort zodra de twee tabellen erstaan. Loop het wachtwoord achterstevoren door, kijk zeven keer naar bit 6 van elke byte terwijl u hem naar links schuift, en XOR telkens wanneer de bit staat één XorMatrix-entry erin. Het volgende is een principeschets die het algoritme uit de specificatie reproduceert en matcht met de HotXLS-implementatie; het is geen HotXLS-API:
// Principeschets van [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// en CreateXorArray_Method1 (alleen ter illustratie, geen HotXLS-API)
type
TXorArray = array [0..15] of Byte;
function DemoCreateXorKey(const Password: AnsiString): Word;
const
InitialCode: array [0..14] of Word = ($E1F0, $1D0F, $CC9C, $84C0, $110C,
$0E10, $F1CE, $313E, $1872, $E139, $D40F, $84F9, $280C, $A96A, $4EC3);
XorMatrix: array [0..104] of Word = (
$AEFC, $4DD9, $9BB2, $2745, $4E8A, $9D14, $2A09,
$7B61, $F6C2, $FDA5, $EB6B, $C6F7, $9DCF, $2BBF,
$4563, $8AC6, $05AD, $0B5A, $16B4, $2D68, $5AD0,
$0375, $06EA, $0DD4, $1BA8, $3750, $6EA0, $DD40,
$D849, $A0B3, $5147, $A28E, $553D, $AA7A, $44D5,
$6F45, $DE8A, $AD35, $4A4B, $9496, $390D, $721A,
$EB23, $C667, $9CEF, $29FF, $53FE, $A7FC, $5FD9,
$47D3, $8FA6, $0F6D, $1EDA, $3DB4, $7B68, $F6D0,
$B861, $60E3, $C1C6, $93AD, $377B, $6EF6, $DDEC,
$45A0, $8B40, $06A1, $0D42, $1A84, $3508, $6A10,
$AA51, $4483, $8906, $022D, $045A, $08B4, $1168,
$76B4, $ED68, $CAF1, $85C3, $1BA7, $374E, $6E9C,
$3730, $6E60, $DCC0, $A9A1, $4363, $86C6, $1DAD,
$3331, $6662, $CCC4, $89A9, $0373, $06E6, $0DCC,
$1021, $2042, $4084, $8108, $1231, $2462, $48C4);
var
Len, I, Bit, Element: Integer;
Ch: Byte;
begin
Result := 0;
Len := Length(Password);
if Len > 15 then
Len := 15; // de sleutel ziet maar 15 bytes
if Len = 0 then
Exit;
Result := InitialCode[Len - 1];
Element := $68; // laatste XorMatrix-entry
for I := Len downto 1 do
begin
Ch := Ord(Password[I]);
for Bit := 1 to 7 do
begin
if (Ch and $40) <> 0 then
Result := Result xor XorMatrix[Element];
Ch := Byte(Ch shl 1);
Dec(Element);
end;
end;
end;
function XorRor(B, KeyByte: Byte): Byte;
begin
B := B xor KeyByte;
Result := Byte((B shr 1) or (B shl 7)); // één bit naar rechts roteren
end;
procedure DemoCreateXorArray(const Password: AnsiString; out Arr: TXorArray);
const
PadArray: TXorArray = ($BB, $FF, $FF, $BA, $FF, $FF, $B9, $80,
$00, $BE, $0F, $00, $BF, $0F, $00, $00);
var
Key: Word;
Len, I: Integer;
begin
Key := DemoCreateXorKey(Password);
Len := Length(Password);
if Len > 16 then
Len := 16;
for I := 0 to Len - 1 do
Arr[I] := Ord(Password[I + 1]);
for I := Len to 15 do
Arr[I] := PadArray[I - Len];
for I := 0 to 15 do
if Odd(I) then
Arr[I] := XorRor(Arr[I], Byte(Key shr 8))
else
Arr[I] := XorRor(Arr[I], Byte(Key and $FF));
end;
Voor secret levert dit de array 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF op, een handige fixture als u uw eigen reader test
Waarom levert de juiste sleutel toch een beschadigd bestand op?
Een correcte sleutel levert nog steeds een beschadigd bestand op wanneer de XOR-array de verkeerde kant op wordt geroteerd: [MS-OFFCRYPTO] definieert de arraystap als XorRor, een rotatie van één bit naar rechts, en een array die twee naar links is geroteerd ontsleutelt elke record-body tot ruis. De sleutel fixen bracht Excel 16 voorbij de wachtwoordvraag en meteen in een andere fout, de melding dat het bestand een probleem heeft en niet kan worden geopend
De oude HotXLS-code roteerde elke arraybyte 2 bits naar links, een vorm die in verschillende BIFF-implementaties circuleert. Omdat HotXLS aan beide kanten dezelfde rotatie gebruikte, viel het in zijn eigen reader nooit op. Excel 16 kan Excel 5.0/95-bestanden niet meer opslaan, en bij het opslaan van BIFF8 biedt hij geen XOR aan, dus er was geen native Excel-sample om tegen te diffen. Het bewijs moest uit de andere richting komen: schrijf één BIFF5-stream in platte tekst, codeer hem op acht manieren opnieuw, en laat Excel 16 elke variant openen. De acht varianten doorkruisten drie onafhankelijke keuzes:
| Keuze | Optie A | Optie B |
|---|---|---|
| Array-rotatie | XorRor (1 naar rechts) | 2 naar links |
| Array-index | (offset + recordlengte) mod 16 | offset mod 16 |
| Volgorde van de bytetransformatie | 5 naar links, dan XOR | XOR, dan 5 naar links |
Excel 16 opende er precies twee van de acht: XorRor met eerst-roteren-dan-XOR en de recordlengte-index, en één variant die er alleen anders uitziet. 2-naar-links met eerst-XOR-dan-roteren en dezelfde index is dezelfde functie in vermomming. Rotatie distribueert over XOR, dus rol5(p xor rol2(b)) is gelijk aan rol5(p) xor rol7(b), en op een 8-bit waarde is 7 naar links roteren hetzelfde als 1 naar rechts. Kortom, rol5 ∘ rol2 = ror1, en daarom ziet de 2-naar-links-array er op zichzelf genomen plausibel uit: hij is alleen correct samen met de omgekeerde transformatievolgorde. Gecombineerd met de volgorde van de specificatie corrumpeert hij elke getransformeerde byte
Hetzelfde experiment beslechtte een tweede vraag. De varianten die de recordlengte uit de index lieten vielen allemaal af, wat de indexregel bevestigde die HotXLS een release eerder uitsluitend op de tekst van de specificatie had aangenomen
Hoe wordt XorArrayIndex per byte berekend?
XorArrayIndex voor een byte is zijn offset in de workbook-stream plus de lengte van de volledige recorddata waartoe hij behoort, mod 16. De index begint daardoor bij elke record opnieuw vanaf een recordafhankelijke waarde en loopt per byte binnen de record met één op. De pseudocode in de specificatie noemt de inputs FileOffset en Data.Length, wat makkelijk verkeerd te lezen is als uitsluitend de start-offset van de record, en precies die foute lezing verscheepte HotXLS tot v2.384.47
Drie details bepalen of uw indices uitlijnen met die van Excel:
- De record-header van 4 bytes wordt nooit getransformeerd, maar hij bezet wél stream-posities, dus de eerste bodybyte van een record zit op header-offset + 4
- De lengteterm is de volledige recorddatalengte, niet het aantal bytes dat werkelijk is getransformeerd
- BOUNDSHEET is gedeeltelijk plain: zijn eerste 4 bytes,
lbPlyPos, de stream-offset van de sheet-BOF, blijven leesbaar zodat een parser werkbladen kan vinden. Die 4 bytes worden door de transformatie overgeslagen maar tellen toch mee voor zowel de offset als de recordlengte
Samengevat is de transformatie per record een paar regels. Ook dit is een schets van de regel, niet iets dat u hoeft aan te roepen:
function Rol8(B: Byte; N: Integer): Byte;
begin
Result := Byte((B shl N) or (B shr (8 - N)));
end;
// Obfusceer één record-body in place. BodyPos is de stream-offset van
// Body[0], dus de record-header-offset + 4. PlainPrefix is 4 voor
// BOUNDSHEET, de volledige lengte voor BOF / FILEPASS, 0 voor de meeste records
procedure DemoObfuscateRecord(var Body: array of Byte; RecordLength: Word;
PlainPrefix: Integer; BodyPos: LongWord; const Arr: TXorArray);
var
I: Integer;
begin
for I := PlainPrefix to RecordLength - 1 do
Body[I] := Rol8(Body[I], 5) xor
Arr[(BodyPos + LongWord(I) + RecordLength) mod 16];
end;
// Lezen is het spiegelbeeld: B := Body[I] xor Arr[...];
// daarna 5 naar rechts roteren, dus Rol8(B, 3)
Vóór v2.384.47 berekende de HotXLS-reader de index uitsluitend uit de streampositie en gebruikte de writer de kale byte-offset. Beide negeerden de recordlengte, dus weer waren de twee helften het met elkaar eens en met niemand anders. Een onafhankelijk geschreven decoder las de output van v2.384.47 correct en de oudere output als rommel, en de acht-varianten-test in Excel 16 bevestigde de regel later tegen de echte doelstelling
Wat gebeurt er met XOR-bestanden van oudere HotXLS-versies?
HotXLS blijft zijn eigen XOR-bestanden van vóór v2.384.54 lezen door de FILEPASS-sleutel te controleren: is de opgeslagen sleutel gelijk aan de sleutel die uit het wachtwoord is afgeleid, dan bouwt de reader de XorRor-array uit de specificatie, en wijkt hij af, dan behandelt de reader het bestand als een ouder HotXLS-bestand en bouwt hij de 2-naar-links-array opnieuw. Bestanden van Excel dragen altijd de afgeleide sleutel, dus zij nemen altijd het pad van de specificatie
De test is een heuristiek met een precieze faalkans. Een oud bestand wiens willekeurige sleutel toevallig gelijk was aan de afgeleide sleutel zou met de verkeerde array worden gelezen, en de kans daarop is 1 op 65.536. De fallback dekt alleen de array-rotatie; de indexregel wordt niet omgezet, dus de bestanden die hij redt zijn de geschreven tussen v2.384.47 en v2.384.53. Heeft u nog BIFF5-XOR-bestanden uit dat venster, open ze dan met de huidige HotXLS en sla ze opnieuw op voor een bestand dat Excel accepteert
Twee wachtwoorddetails gelden voor elk bestand, oud of nieuw:
- Lengte.
CreateXorKey_Method1leest alleen de eerste 15 wachtwoordbytes, de limiet uit de specificatie. HotXLS past die cap op de sleutel toe en houdt de verifier en de array aan hun gebruikelijke regels voor volledige lengte en 16 bytes, consequent aan beide kanten. Excel zelf weigert wachtwoorden langer dan 15 tekens voor dit formaat, dus behandel 15 als het echte maximum - Tekenset. HotXLS zet het wachtwoord om naar bytes via de systeem-ANSI-codepagina. De specificatie beschrijft het nemen van de lage byte van elk UTF-16-teken, wat voor ASCII overeenkomt. Zonder Excel-samples die door niet-ASCII-wachtwoorden zijn beveiligd is er geen ground truth voor de rest, dus houd het bij ASCII-wachtwoorden voor XOR-bestanden
Aan de leeskant laat TXLSWorkbook.OnPassword u om een wachtwoord vragen zodra Open een FILEPASS-record tegenkomt. Het event is een TXLSPasswordEvent met een var PassWord: WideString en een var Retry: Boolean; zet Retry op True om het opnieuw te proberen, tot drie keer toe:
procedure TImportForm.WorkbookPassword(Sender: TObject;
var PassWord: WideString; var Retry: Boolean);
var
S: string;
begin
S := '';
Retry := InputQuery('Protected workbook', 'Password:', S);
PassWord := S;
end;
procedure TImportForm.ImportLegacyFile(const FileName: string);
var
Wb: IXLSWorkbook;
Rc: Integer;
begin
Wb := TXLSWorkbook.Create;
Wb.OnPassword := WorkbookPassword;
Rc := Wb.Open(FileName);
// -1003: wachtwoord vereist maar geen opgegeven; -1005: verkeerd wachtwoord
if Rc <> 1 then
raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;
Weet u het wachtwoord al, dan slaat Open(FileName, APassWord) het event helemaal over
Is XOR-obfuscatie ergens veilig genoeg voor?
BIFF XOR-obfuscatie is geen versleuteling en beschermt niets tegen een gemotiveerde lezer. De wachtwoordcontrole is een 16-bit verifier, de sleutel is 16 bits, en de array van 16 bytes herhaalt zich over de hele stream, dus voorspelbare BIFF-recordinhoud legt array-bytes bloot zonder enig wachtwoord. HotXLS schrijft XOR alleen omdat Excel 5.0/95-bestanden geen andere optie hebben, en de reden om zulke bestanden vandaag te produceren is een legacy-consument die niets nieuwers kan lezen
De klassieke engine kiest het schema via TXLSWorkbook.EncryptionType, en de combinatie met het opslagformaat wordt streng gecontroleerd:
xletAuto(de default) schrijft RC4 CryptoAPI voorxlExcel97en XOR voorxlExcel5, precies wat Excel zelf per formaat schreefxletXoris alleen geldig voor BIFF5; bijxlExcel97gooit de opslag een exception in plaats van stilletjes terug te vallenxletRC4enxletRC4CryptoAPIzijn alleen voor BIFF8, en ze vragen bij een BIFF5-opslag gooit ook een exception
RC4 is ook gedateerd, en de details om hem interoperabel te maken staan bij waarom Excel een versleuteld workbook afwijst met een correct wachtwoord. Kan de ontvanger XLSX lezen, gebruik dan de XLSX-engine: TXLSXWorkbook.SaveAsEncryptedAgile schrijft Agile Encryption (SHA-512 wachtwoordhashing met een spin count van 100.000 iteraties en AES-256-CBC), het formaat dat Excel 2010 en later by default schrijven, terwijl SaveAsEncrypted de oudere AES-128 Standard Encryption schrijft. De afwegingen tussen beide staan bij XLSX-bestanden versleutelen met AES in Delphi, en de leeskant is behandeld bij Agile-versleutelde Excel-bestanden lezen met HotXLS
Snelnaslag: BIFF XOR-obfuscatie die Excel 16 accepteert
- FILEPASS (
$002F) volgt de globals BOF; in BIFF5 is zijn body 4 bytes: sleutel, dan verifier - Sleutel =
CreateXorKey_Method1(password)volgens [MS-OFFCRYPTO] §2.3.7.2, nooit willekeurig; voorsecretis het$014D - Array = wachtwoordbytes + pad, XOR met de lage sleutelbyte op even posities en de hoge op oneven posities, daarna XorRor (1 naar rechts)
- Een byte versleutelen: 5 naar links, dan XOR; ontsleutelen: XOR, dan 5 naar rechts (§2.3.7.3)
- XorArrayIndex = (byte-stream-offset + recorddatalengte) mod 16; headers en plain-prefixes tellen mee voor de offset
- BOUNDSHEET houdt zijn eerste 4 bytes plain; BOF, FILEPASS en INTERFACEHDR blijven volledig plain
- Wachtwoorden: ASCII, hooguit 15 tekens
- HotXLS: index gefixt in v2.384.47, sleutel en XorRor gefixt in v2.384.54, oudere HotXLS-XOR-bestanden herkend aan een sleutelmismatch
- Voor echte bescherming gebruikt u minstens BIFF8 RC4 CryptoAPI, of XLSX Agile Encryption
HotXLS behandelt BIFF5- en BIFF8-wachtwoordbeveiliging, XLSX Standard en Agile Encryption en de password-callback aan de leeskant vanuit één Delphi- en C++Builder-library, met de interop-details hierboven voor u geregeld. Zie de HotXLS Delphi spreadsheet component voor edities, platforms en een proefdownload