Technisch artikel

HotXLS XLS XOR-obfuscatie: sleutelafleiding en XorRor

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_Method1 in [MS-OFFCRYPTO] §2.3.7.2, aangedreven door twee constante tabellen (InitialCode, 15 woorden, en XorMatrix, 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

HotXLS-diagram van het BIFF5 XOR-FILEPASS-record dat Excel 16 bij het openen controleert: de kale body van vier bytes bevat de XOR-sleutel en de password verifier, Excel leidt de sleutel af uit het getypte wachtwoord met CreateXorKey_Method1 en wijst een willekeurige sleutel af met een wachtwoordfout zelfs als de verifier matcht; HotXLS slaat de afgeleide sleutel 014D op voor secret
De FILEPASS-body is maar twee woorden, maar Excel leidt de sleutel opnieuw af uit uw wachtwoord en vergelijkt; een willekeurig gevulde sleutel zakt voor de controle zelfs met een correcte verifier, en daarom leidt HotXLS haar sinds v2.384.54 af

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

HotXLS-diagram van de constructie van de XOR-array voor BIFF5-obfuscatie: zestien bytes gevoed vanuit het wachtwoord en een vaste pad worden ge-XORd met de lage sleutelbyte op even posities en de hoge sleutelbyte op oneven posities, en daarna met XorRor één bit naar rechts geroteerd, wat de fixture 1F 32 17 B9 oplevert voor het wachtwoord secret
De array-bytes komen uit het wachtwoord, de pad en de twee sleutelbytes, één rotatie aan het eind; 2 naar links roteren werkte alleen wanneer de bytetransformatie in omgekeerde volgorde liep, en Excel volgt de volgorde van de specificatie

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:

KeuzeOptie AOptie B
Array-rotatieXorRor (1 naar rechts)2 naar links
Array-index(offset + recordlengte) mod 16offset mod 16
Volgorde van de bytetransformatie5 naar links, dan XORXOR, 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
HotXLS-diagram van de XorArrayIndex-regel voor BIFF5 XOR-obfuscatie: elke bodybyte gebruikt de stream-offset plus de volledige recorddatalengte modulo 16, de kale header van vier bytes en de BOUNDSHEET lbPlyPos-prefix tellen toch mee voor de offset, en het negeren van de recordlengteterm was het defect dat HotXLS in v2.384.47 heeft gefixt
De array-index begint één keer per record opnieuw, niet één keer per stream: de header en eventuele plain-prefix bezetten posities, de recorddatalengte voedt de modulo, en beide helften van oude HotXLS waren het over de foute formule eens

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_Method1 leest 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 voor xlExcel97 en XOR voor xlExcel5, precies wat Excel zelf per formaat schreef
  • xletXor is alleen geldig voor BIFF5; bij xlExcel97 gooit de opslag een exception in plaats van stilletjes terug te vallen
  • xletRC4 en xletRC4CryptoAPI zijn 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; voor secret is 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