Teknisk artikel

HotXLS XLS XOR-obfuscation: Nøgledannelse og XorRor

HotXLS skriver Excel 5.0/95 (BIFF5)-workbooks med XOR-obfuscation, som Excel 16 kun åbner, når tre detaljer matcher [MS-OFFCRYPTO] præcist: FILEPASS-nøglen skal være CreateXorKey_Method1(password), det 16-byte store XOR-array skal bygges med XorRor (rotér én bit mod højre), og hver byte skal bruge XorArrayIndex = (stream-offset + record-længde) mod 16. HotXLS fik indekset rigtigt i v2.384.47 og nøglen og rotationen rigtigt i v2.384.54. Før det åbnede hver kodeordsbeskyttede BIFF5-fil, den producerede, fint i HotXLS og fejlede i Excel

Den sidste sætning er hele historien i miniature. En læser og en skriver, der deler den samme forkerte idé, er fuldkommen enige med hinanden, så round-trip-tests forbliver grønne, mens den eneste forbruger, der tæller, siger nej. Excel 16 sagde nej to gange, med to forskellige beskeder, og hver besked pegede på et forskelligt lag af skemaet. Denne artikel går gennem de lag i den rækkefølge, Excel tjekker dem, med detaljer på byteniveau, du kan bruge, uanset om du kalder HotXLS eller skriver din egen BIFF-læser

Hvad gemmer BIFF XOR-obfuscation egentlig?

BIFF XOR-obfuscation gemmer kun to 16-bit ord i filen, og alt andet beregnes ud fra adgangskoden. FILEPASS-recorden ($002F) sidder umiddelbart efter workbook globals BOF, og i en BIFF5-fil er dens body præcis 4 bytes: XOR-nøglen efterfulgt af password-verificatoren. Der er intet salt, ingen algoritme-identifikator og ingen krypteret verifier-blob af den slags, RC4- og AES-skemaerne bærer

Ud fra de to ord genopbygger en læser tre ting:

  • Verificatoren, en 16-bit hash af adgangskode-byterne XORet med $CE4B. At sammenligne den med det gemte ord er adgangskodetjekket, og det eneste
  • XOR-nøglen, en 16-bit værdi fra CreateXorKey_Method1 i [MS-OFFCRYPTO] §2.3.7.2, drevet af to konstanttabeller (InitialCode, 15 ord, og XorMatrix, 105 ord)
  • XOR-arrayet, 16 bytes bestående af adgangskode-byterne fyldt op med en fast 16-byte-pad, hver XORet med den lave nøglebyte (lige positioner) eller den høje nøglebyte (ulige positioner) og derefter roteret én bit mod højre

Record-headere forbliver i klartekst, og det gør en håndfuld hele records også, som skemaet fritager, blandt andet BOF, FILEPASS og INTERFACEHDR. Alle andre record-bodies transformeres byte for byte: rotér 5 bit mod venstre, og XOR derefter med én indgang af det 16-byte store array. Dekryptering, som [MS-OFFCRYPTO] §2.3.7.3 beskriver som DecryptData_Method1, er spejlbilledet: XOR først, og rotér derefter 5 mod højre

I HotXLS rører du intet af dette direkte. Sæt en adgangskode, vælg formatet, og SaveAs sender FILEPASS ud og transformerer streamen:

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 understøtter kun XOR-obfuscation; xletAuto ville vælge den samme
  Wb.EncryptionType := xletXor;
  // Hold den ASCII og på højst 15 tegn (se nedenfor)
  Wb.EncryptionPassword := 'secret';

  if Wb.SaveAs(FileName, xlExcel5) <> 1 then
    raise Exception.Create('BIFF5 save failed');
end;

Hvorfor siger Excel, at adgangskoden er forkert, når verificatoren matcher?

Excel afviser adgangskoden, fordi den ikke stoler på den gemte nøgle: Excel udleder nøglen fra den indtastede adgangskode med CreateXorKey_Method1 og sammenligner den med FILEPASS-nøgleordet, så en fil, hvis nøgle er noget som helst andet, fejler adgangskodetjekket, selv når verificatoren er korrekt. Specifikationen beskriver nøglen som et output af adgangskoden, ikke som en fri parameter, og Excel 16 håndhæver den læsning

HotXLS-skriveren før v2.384.54 fyldte nøgleordet med to tilfældige bytes. Det ser harmløst ud på papir, eftersom verificatoren er den dokumenterede adgangskodetjek, og arrayet bygges ud fra den nøgle, filen deklarerer. HotXLS selv læste de filer uden problemer, fordi dens læser tog nøglen fra FILEPASS som givet. Excel 16 fik samme fil og den rigtige adgangskode og svarede, at adgangskoden ikke var korrekt. Siden v2.384.54 udledes nøglen, så FILEPASS for adgangskoden secret altid indeholder nøglen $014D og verificatoren $DAA7, værdier krydstjekket mod en uafhængig implementering af specifikationen

HotXLS-diagram over BIFF5 XOR FILEPASS-recorden, som Excel 16 tjekker ved åbning: den rene fire-byte body indeholder XOR-nøglen og password-verificatoren, Excel udleder nøglen fra den indtastede adgangskode med CreateXorKey_Method1 og afviser en tilfældig nøgle med en adgangskodefejl, selv når verificatoren matcher; HotXLS gemmer den udledte nøgle 014D for secret
FILEPASS-bodyen er kun to ord, men Excel udleder nøglen igen fra din adgangskode og sammenligner; en tilfældigt udfyldt nøgle fejler tjekket, selv med en korrekt verificator, og derfor har HotXLS udledt den siden v2.384.54

Selve udledningen er kort, når de to tabeller er på plads. Gennemløb adgangskoden baglæns, se på bit 6 af hver byte syv gange, mens den skiftes mod venstre, og XOR én XorMatrix-indgang ind, hver gang biten er sat. Det følgende er en principskitse, der gengiver specifikationens algoritme og matcher HotXLS-implementeringen; det er ikke et HotXLS-API:

// Principskitse over [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// og CreateXorArray_Method1 (kun illustration, ikke et 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;                       // nøglen ser kun 15 bytes
  if Len = 0 then
    Exit;
  Result := InitialCode[Len - 1];
  Element := $68;                    // sidste XorMatrix-indgang
  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));   // rotér én bit mod højre
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;

For secret giver det arrayet 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF, en praktisk fixture, hvis du tester din egen læser

HotXLS-diagram over konstruktionen af XOR-arrayet til BIFF5-obfuscation: seksten bytes, sået fra adgangskoden og en fast pad, XORes med den lave nøglebyte ved lige positioner og den høje nøglebyte ved ulige positioner og roteres derefter én bit mod højre med XorRor, hvilket giver fixturen 1F 32 17 B9 for adgangskoden secret
Array-byterne kommer fra adgangskoden, pad'en og de to nøglebytes, én rotation til sidst; rotér 2 mod venstre virkede kun, når byte-transformeringen kørte i omvendt rækkefølge, og Excel følger specifikationens rækkefølge

Hvorfor giver den rigtige nøgle stadig en beskadiget fil?

En korrekt nøgle giver stadig en beskadiget fil, når XOR-arrayet roteres den forkerte vej: [MS-OFFCRYPTO] definerer array-trinnet som XorRor, en rotation én bit mod højre, og et array roteret to mod venstre dekrypterer hver record-body til støj. Nøglefixet fik Excel 16 forbi adgangskodeprompten og direkte ind i en anden fejl, en melding om, at filen har et problem og ikke kan åbnes

Den gamle HotXLS-kode roterede hver array-byte 2 bit mod venstre, en form, der cirkulerer i adskillige BIFF-implementeringer. Fordi HotXLS brugte den samme rotation på begge sider, bemærkede dens egen læser det aldrig. Excel 16 kan ikke længere gemme Excel 5.0/95-filer, og den tilbyder ikke XOR ved gemning af BIFF8, så der var ingen nativ Excel-prøve at diff-e imod. Beviset måtte komme fra den anden retning: skriv én BIFF5-stream i klartekst, omkod den otte veje, og lad Excel 16 åbne hver variant. De otte varianter krydsede tre uafhængige valg:

ValgMulighed AMulighed B
Array-rotationXorRor (rotér 1 mod højre)Rotér 2 mod venstre
Array-indeks(offset + record-længde) mod 16offset mod 16
Byte-transformeringsrækkefølgeRotér 5 mod venstre, derefter XORXOR, derefter rotér 5 mod venstre

Excel 16 åbnede præcis to af de otte: XorRor med rotér-derefter-XOR og record-længde-indekset, og én variant, der kun ser anderledes ud. Rotér-2-mod-venstre med XOR-og-derefter-rotér og det samme indeks er den samme funktion i forklædning. Rotation distribuerer over XOR, så rol5(p xor rol2(b)) er lig med rol5(p) xor rol7(b), og på en 8-bit værdi er en rotation 7 mod venstre en rotation 1 mod højre. Kort sagt: rol5 ∘ rol2 = ror1, og det er derfor, rotér-2-mod-venstre-arrayet ser plausibelt ud i isolation: det er kun korrekt sammen med den omvendte transformeringsrækkefølge. Parret med specifikationens rækkefølge korrumperer det hver transformerede byte

Det samme eksperiment afgjorde et andet spørgsmål. De varianter, der droppede record-længden fra indekset, fejlede alle, hvilket bekræftede indeksreglen, HotXLS havde vedtaget en udgivelse tidligere alene på specifikationsteksten

Hvordan beregnes XorArrayIndex for hver byte?

XorArrayIndex for en byte er dens offset i workbook-streamen plus længden af den samlede record-data, den hører til, mod 16. Indekset starter derfor ved en record-afhængig værdi for hver record og tæller én op pr. byte indeni. Specifikationens pseudokode navngiver inputtene FileOffset og Data.Length, hvilket er let at mislæse som kun record-start-offsettet, og den fejerlæsning er præcis, hvad HotXLS shippede indtil v2.384.47

Tre detaljer afgør, om dine indekser stemmer med Excel:

  • Record-headeren på 4 bytes transformeres aldrig, men den optager stadig stream-positioner, så en records første body-byte ligger ved header-offset + 4
  • Længdeleddet er hele record-datalængden, ikke antallet af bytes, der faktisk transformeres
  • BOUNDSHEET er delvist klartekst: dens første 4 bytes, lbPlyPos, stream-offsettet for sheet-BOF'en, forbliver læsbare, så en parser kan finde arkene. De 4 bytes springes over af transformeringen, men tælles stadig med i både offsettet og record-længden
HotXLS-diagram over XorArrayIndex-reglen for BIFF5 XOR-obfuscation: hver body-byte bruger stream-offsettet plus hele record-datalængden modulo 16, den rene fire-byte header og BOUNDSHEET-lbPlyPos-præfikset tælles stadig med i offsettet, og at ignorere record-længde-leddet var defekten, HotXLS rettede i v2.384.47
Array-indekset starter forfra én gang pr. record, ikke én gang pr. stream: headeren og ethvert klartekst-præfiks optager positioner, record-datalængden går ind i moduloen, og begge halvdele af den gamle HotXLS var enige om den forkerte formel

Sat sammen er transformeringen pr. record et par linjer. Igen: dette er en skitse af reglen, ikke noget, du behøver at kalde:

function Rol8(B: Byte; N: Integer): Byte;
begin
  Result := Byte((B shl N) or (B shr (8 - N)));
end;

// Obfuskér én record-body in place. BodyPos er stream-offsettet for
// Body[0], altså record-header-offset + 4. PlainPrefix er 4 for
// BOUNDSHEET, hele længden for BOF / FILEPASS, 0 for de fleste 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;

// Læsning er spejlbilledet: B := Body[I] xor Arr[...];
// og rotér derefter 5 mod højre, altså Rol8(B, 3)

Før v2.384.47 beregnede HotXLS-læseren indekset ud fra stream-positionen alene, og skriveren brugte det rene byte-offset. Begge ignorerede record-længden, så igen var de to halvdele enige med hinanden og med ingen andre. En uafhængigt skrevet dekoder læste v2.384.47-outputtet korrekt og det ældre output som skrald, og otte-variant-testen mod Excel 16 bekræftede senere reglen over for det rigtige mål

Hvad sker der med XOR-filer skrevet af ældre HotXLS-versioner?

HotXLS læser fortsat sine egne XOR-filer fra før v2.384.54 ved at tjekke FILEPASS-nøglen: når den gemte nøgle er lig den fra adgangskoden udledte nøgle, bygger læseren specifikationens XorRor-array, og når den afviger, behandler læseren filen som en ældre HotXLS-fil og genopbygger rotér-2-mod-venstre-arrayet. Excel-skrevne filer bærer altid den udledte nøgle, så de følger altid specifikationsvejen

Tjekket er en heuristik med en præcis fejlrate. En gammel fil, hvis tilfældige nøgle tilfældigvis lignede den udledte nøgle, ville blive læst med det forkerte array, og chancen for det er 1 ud af 65.536. Fallbacken dækker kun array-rotationen; indeksreglen skiftes ikke, så de filer, den redder, er dem skrevet mellem v2.384.47 og v2.384.53. Har du stadig BIFF5-XOR-filer fra det vindue, så åbn dem med den aktuelle HotXLS og gem dem igen for at få en fil, Excel accepterer

To adgangskodedetaljer gælder for hver fil, gammel som ny:

  • Længde. CreateXorKey_Method1 læser kun de første 15 adgangskode-bytes, hvilket er specifikationens grænse. HotXLS anvender det loft på nøglen og holder verificatoren og arrayet på deres sædvanlige regler for fuld længde og 16 bytes, konsekvent på begge sider. Excel selv nægter adgangskoder længere end 15 tegn til dette format, så behandl 15 som det reelle maksimum
  • Tegnsæt. HotXLS konverterer adgangskoden til bytes gennem systemets ANSI-codepage. Specifikationen beskriver at tage den lave byte af hvert UTF-16-tegn, hvilket stemmer for ASCII. Uden Excel-prøver beskyttet med ikke-ASCII-adgangskoder er der ingen ground truth for resten, så hold dig til ASCII-adgangskoder til XOR-filer

På læsesiden lader TXLSWorkbook.OnPassword dig bede om en adgangskode, når Open støder på en FILEPASS-record. Eventet er en TXLSPasswordEvent med en var PassWord: WideString og en var Retry: Boolean; sæt Retry til True for at prøve igen, op til tre forsøg:

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: adgangskode påkrævet, men ingen angivet; -1005: forkert adgangskode
  if Rc <> 1 then
    raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
  ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;

Kender du allerede adgangskoden, springer Open(FileName, APassWord) eventet helt over

Er XOR-obfuscation sikker nok til noget?

BIFF XOR-obfuscation er ikke kryptering og beskytter intet mod en motiveret læser. Adgangskodetjekket er en 16-bit verificator, nøglen er 16 bit, og det 16-byte store array gentages gennem hele streamen, så forudsigelige BIFF-record-indhold afslører array-bytes uden nogen adgangskode overhovedet. HotXLS skriver kun XOR, fordi Excel 5.0/95-filer ikke har andre muligheder, og grunden til at producere sådanne filer i dag er en legacy-forbruger, der ikke kan læse noget nyere

Den klassiske motor vælger skemaet gennem TXLSWorkbook.EncryptionType, og kombinationen med gemmeformatet tjekkes strengt:

  • xletAuto (default) skriver RC4 CryptoAPI til xlExcel97 og XOR til xlExcel5, i overensstemmelse med, hvad Excel selv skrev for hvert format
  • xletXor er kun gyldig til BIFF5; med xlExcel97 udløser gemningen en exception i stedet for lydløst at falde tilbage
  • xletRC4 og xletRC4CryptoAPI er kun BIFF8, og at bede om dem ved en BIFF5-gemning udløser også en exception

RC4 er også dateret, og detaljerne om at få den til at interoperere er dækket i hvorfor Excel afviser en krypteret workbook med en korrekt adgangskode. Kan modtageren læse XLSX, så brug XLSX-motoren i stedet: TXLSXWorkbook.SaveAsEncryptedAgile skriver Agile Encryption (SHA-512-adgangskodehashing med en spin count på 100.000 iterationer og AES-256-CBC), det format, Excel 2010 og senere skriver som default, mens SaveAsEncrypted skriver den ældre AES-128 Standard Encryption. Afvejningerne mellem de to står i kryptering af XLSX-filer med AES i Delphi, og læsesiden er dækket i læsning af Agile-krypterede Excel-filer med HotXLS

Hurtig reference: BIFF XOR-obfuscation, som Excel 16 accepterer

  • FILEPASS ($002F) følger globals BOF; i BIFF5 er dens body 4 bytes: nøglen, derefter verificatoren
  • Nøgle = CreateXorKey_Method1(password) iht. [MS-OFFCRYPTO] §2.3.7.2, aldrig tilfældig; for secret er den $014D
  • Array = adgangskode-bytes + pad, XOR den lave nøglebyte ved lige positioner og den høje nøglebyte ved ulige positioner, derefter XorRor (rotér 1 mod højre)
  • Kryptér en byte: rotér 5 mod venstre, derefter XOR; dekryptér: XOR, derefter rotér 5 mod højre (§2.3.7.3)
  • XorArrayIndex = (byteens stream-offset + record-datalængde) mod 16; headere og klartekst-præfikser tælles med i offsettet
  • BOUNDSHEET holder sine første 4 bytes i klartekst; BOF, FILEPASS og INTERFACEHDR forbliver helt i klartekst
  • Adgangskoder: ASCII, højst 15 tegn
  • HotXLS: indeks rettet i v2.384.47, nøgle og XorRor rettet i v2.384.54, ældre HotXLS-XOR-filer genkendt ved nøgle-mismatch
  • Til reel beskyttelse: brug mindst BIFF8 RC4 CryptoAPI, eller XLSX Agile Encryption

HotXLS håndterer BIFF5- og BIFF8-adgangskodebeskyttelse, XLSX Standard og Agile Encryption og read-side-adgangskode-callback fra ét Delphi- og C++Builder-bibliotek, med interoperabilitetsdetaljerne ovenfor håndteret for dig. Se HotXLS Delphi spreadsheet-komponenten for udgaver, platforme og en trial-download