Teknisk artikel

HotXLS XLS XOR-obfuskering: nyckelhärledning och XorRor

HotXLS skriver Excel 5.0/95-arbetsböcker (BIFF5) med XOR-obfuskering som Excel 16 bara öppnar när tre detaljer stämmer exakt med [MS-OFFCRYPTO]: FILEPASS-nyckeln måste vara CreateXorKey_Method1(password), 16-bytes-XOR-matrisen måste byggas med XorRor (rotera höger en bit), och varje byte måste använda XorArrayIndex = (strömoffset + postlängd) mod 16. HotXLS fick indexet rätt i v2.384.47 och nyckeln och rotationen rätt i v2.384.54. Innan dess öppnades varje lösenordsskyddad BIFF5-fil som den producerade felfritt i HotXLS och misslyckades i Excel

Den sista meningen är hela historien i miniatyr. En läsare och en skrivare som delar samma felaktiga idé håller med varandra perfekt, så rundturntesterna förblir gröna medan den enda konsument som spelar roll säger nej. Excel 16 sa nej två gånger, med två olika meddelanden, och varje meddelande pekade på ett annat lager i schemat. Den här artikeln går igenom de lagren i den ordning Excel kontrollerar dem, med detaljer på bytenivå som du kan använda oavsett om du anropar HotXLS eller skriver din egen BIFF-läsare

Vad lagrar BIFF XOR-obfuskering egentligen?

BIFF XOR-obfuskering lagrar bara två 16-bitarsord i filen, och allt annat räknas om från lösenordet. FILEPASS-posten ($002F) sitter direkt efter arbetsbokens globals-BOF, och i en BIFF5-fil är dess kropp exakt 4 byte: XOR-nyckeln följt av lösenordsverifieraren. Det finns inget salt, ingen algoritmidentifierare och ingen krypterad verifier-blob av det slag som RC4- och AES-scheman bär på

Från de två orden bygger en läsare upp tre saker:

  • En verifierare, en 16-bitars hash av lösenordsbytena XOR:ade med $CE4B. Att jämföra den med det lagrade ordet är lösenordskontrollen, och den enda
  • En XOR-nyckel, ett 16-bitarsvärde från CreateXorKey_Method1 i [MS-OFFCRYPTO] §2.3.7.2, driven av två konstanta tabeller (InitialCode, 15 ord, och XorMatrix, 105 ord)
  • En XOR-matris, 16 byte bestående av lösenordsbytena utfyllda med ett fast 16-byte-fyllnadsmönster, vart och ett XOR:at med lågt nyckelbyte (jämna positioner) eller högt nyckelbyte (udda positioner), därefter roterat höger en bit

Posthuvuden förblir i klartext, och det gör också en handfull hela poster som schemat undantar, bland dem BOF, FILEPASS och INTERFACEHDR. Varje annan postkropp transformeras byte för byte: rotera vänster 5 bitar, sedan XOR med en post i 16-byte-matrisen. Dekryptering, som [MS-OFFCRYPTO] §2.3.7.3 stavar ut som DecryptData_Method1, är spegelbilden: XOR först, sedan rotera höger 5

I HotXLS rör du aldrig något av detta direkt. Sätt ett lösenord, välj formatet, och SaveAs avger FILEPASS och transformerar strömmen:

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 stödjer bara XOR-obfuskering; xletAuto skulle välja det också
  Wb.EncryptionType := xletXor;
  // Håll det i ASCII och högst 15 tecken (se nedan)
  Wb.EncryptionPassword := 'secret';

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

Varför säger Excel att lösenordet är fel när verifieraren stämmer?

Excel avvisar lösenordet för att det inte litar på den lagrade nyckeln: Excel härleder nyckeln från det inskrivna lösenordet med CreateXorKey_Method1 och jämför den med FILEPASS-nyckelordet, så en fil vars nyckel är något annat faller på lösenordskontrollen även när verifieraren är korrekt. Specifikationen beskriver nyckeln som en utdata från lösenordet, inte som en fri parameter, och Excel 16 driver igenom den läsningen

HotXLS-skrivaren före v2.384.54 fyllde nyckelordet med två slumpmässiga byte. Det ser harmlöst ut på papperet, eftersom verifieraren är den dokumenterade lösenordskontrollen och matrisen byggs från vilken nyckel filen än deklarerar. HotXLS själv läste de filerna utan problem, för dess läsare tog nyckeln från FILEPASS som given. Excel 16, med samma fil och rätt lösenord, svarade att lösenordet inte var korrekt. Sedan v2.384.54 härleds nyckeln, så FILEPASS för lösenordet secret innehåller alltid nyckeln $014D och verifieraren $DAA7, värden korskontrollerade mot en oberoende implementation av specifikationen

HotXLS-diagram över BIFF5 XOR FILEPASS-posten som Excel 16 kontrollerar vid öppning: kroppen på fyra klartextbyte rymmer XOR-nyckeln och lösenordsverifieraren, Excel härleder nyckeln från det inskrivna lösenordet med CreateXorKey_Method1 och avvisar en slumpmässig nyckel med ett lösenordsfel även när verifieraren stämmer; HotXLS lagrar den härledda nyckeln 014D för secret
FILEPASS-kroppen är bara två ord, men Excel härleder nyckeln på nytt från ditt lösenord och jämför; en slumpmässigt fylld nyckel faller på kontrollen även med en korrekt verifierare, vilket är därför HotXLS har härlett den sedan v2.384.54

Härledningen i sig är kort när de två tabellerna är på plats. Gå lösenordet baklänges, titta på bit 6 i varje byte sju gånger medan du skiftar den vänster, och XOR:a in en XorMatrix-post varje gång biten är satt. Följande är en principiell skiss som reproducerar specifikationens algoritm och stämmer med HotXLS-implementationen; det är inte ett HotXLS-API:

// Principiell skiss av [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// och CreateXorArray_Method1 (endast illustration, inte ett 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;                       // nyckeln ser bara 15 byte
  if Len = 0 then
    Exit;
  Result := InitialCode[Len - 1];
  Element := $68;                    // sista XorMatrix-posten
  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));   // rotera höger en bit
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;

För secret ger detta matrisen 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF, ett praktiskt fast testvärde om du testar din egen läsare

HotXLS-diagram över konstruktionen av XOR-matrisen för BIFF5-obfuskering: sexton byte startade från lösenordet och ett fast fyllnadsmönster XOR:as med lågt nyckelbyte på jämna positioner och högt på udda positioner, roteras sedan höger en bit med XorRor, vilket ger testvärdet 1F 32 17 B9 för lösenordet secret
Matrisens byte kommer från lösenordet, fyllnadsmönstret och de två nyckelbytena, en rotation på slutet; rotera vänster 2 fungerade bara när byte-transformationen kördes i omvänd ordning, och Excel följer specifikationens ordning

Varför ger rätt nyckel ändå en skadad fil?

En korrekt nyckel ger ändå en skadad fil när XOR-matrisen roteras åt fel håll: [MS-OFFCRYPTO] definierar matrissteget som XorRor, en rotation höger en bit, och en matris roterad vänster två dekrypterar varje postkropp till brus. Att fixa nyckeln flyttade Excel 16 förbi lösenordsfrågan och rakt in i ett annat fel, ett meddelande om att filen har ett problem och inte kan öppnas

Den gamla HotXLS-koden roterade varje matrisbyte vänster 2 bitar, en variant som cirkulerar i flera BIFF-implementationer. Eftersom HotXLS använde samma rotation på båda sidor märkte dess egen läsare aldrig något. Excel 16 kan inte längre spara Excel 5.0/95-filer, och det erbjuder inte XOR när BIFF8 sparas, så det fanns inget inhemskt Excel-exempel att diffa mot. Bevisen fick komma från andra hållet: skriv en BIFF5-ström i klartext, koda om den på åtta sätt, och låt Excel 16 öppna varje variant. De åtta varianterna korsade tre oberoende val:

ValAlternativ AAlternativ B
MatrisrotationXorRor (rotera höger 1)Rotera vänster 2
Matrisindex(offset + postlängd) mod 16offset mod 16
Ordning för byte-transformationRotera vänster 5, sedan XORXOR, sedan rotera vänster 5

Excel 16 öppnade exakt två av de åtta: XorRor med rotera-sedan-XOR och postlängdsindexet, samt en variant som bara ser annorlunda ut. Rotera-vänster-2 med XOR-sedan-rotera och samma index är samma funktion i förklädnad. Rotation distribuerar över XOR, så rol5(p xor rol2(b)) är lika med rol5(p) xor rol7(b), och på ett 8-bitarsvärde är en rotation vänster 7 en rotation höger 1. Kort sagt är rol5 ∘ rol2 = ror1, vilket är därför matrisen med rotera-vänster-2 ser rimlig ut i isolering: den är korrekt bara tillsammans med den omvända transformeringsordningen. Tillsammans med specifikationens ordning förstör den varje transformerad byte

Samma experiment avgjorde en andra fråga. Varianterna som släppte postlängden ur indexet misslyckades alla, vilket bekräftade indexregeln HotXLS hade antagit en utgåva tidigare enbart med specifikationstexten som stöd

Hur beräknas XorArrayIndex för varje byte?

XorArrayIndex för en byte är dess offset i arbetsboksströmmen plus längden på hela den postdata den tillhör, mod 16. Indexet startar alltså om på ett postspecifikt värde för varje post och ökar med ett per byte inuti den. Specifikationens pseudokod namnger ingångarna FileOffset och Data.Length, vilket är lätt att missläsa som enbart postens startoffset, och det misset är exakt vad HotXLS levererade fram till v2.384.47

Tre detaljer avgör om dina index stämmer med Excel:

  • Postheadern på 4 byte transformeras aldrig, men den upptar ändå strömmens positioner, så en posts första kroppsbyte ligger på headerns offset + 4
  • Längdtermen är hela postdatalängden, inte antalet byte som faktiskt transformeras
  • BOUNDSHEET är delvis klartext: dess första 4 byte, lbPlyPos, strömoffseten för bladets BOF, förblir läsbara så att en parser kan hitta bladen. Dessa 4 byte hoppas över av transformationen men räknas ändå med i både offseten och postlängden
HotXLS-diagram över XorArrayIndex-regeln för BIFF5 XOR-obfuskering: varje kroppsbyte använder strömoffset plus hela postdatalängden modulo 16, den kryptofria headern på fyra byte och BOUNDSHEET:s lbPlyPos-prefix räknas ändå med i offseten, och att ignorera postlängdstermen var defekten HotXLS fixade i v2.384.47
Matrisindexet startar om en gång per post, inte en gång per ström: headern och eventuellt klartextprefix upptar positioner, postdatalängden matar modulo, och båda halvorna av gamla HotXLS höll med om den felaktiga formeln

Satt ihop är transformationen per post några rader. Även detta är en skiss av regeln, inte något du behöver anropa:

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

// Obfuskera en postkropp på plats. BodyPos är strömoffseten för
// Body[0], dvs. postheaderns offset + 4. PlainPrefix är 4 för
// BOUNDSHEET, hela längden för BOF / FILEPASS, 0 för de flesta poster
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 är spegelbilden: B := Body[I] xor Arr[...];
// sedan rotera höger 5, dvs. Rol8(B, 3)

Före v2.384.47 beräknade HotXLS-läsaren indexet enbart från strömpositionen och skrivaren använde den kryptofria byte-offseten. Båda ignorerade postlängden, så återigen höll de två halvorna med varandra och med ingen annan. En oberoende skriven dekoder läste v2.384.47-utdata korrekt och den äldre utdatan som skräp, och åtta-variant-testet mot Excel 16 bekräftade senare regeln mot det verkliga målet

Vad händer med XOR-filer skrivna av äldre HotXLS-versioner?

HotXLS fortsätter läsa sina egna XOR-filer från före v2.384.54 genom att kontrollera FILEPASS-nyckeln: när den lagrade nyckeln är lika med nyckeln härledd från lösenordet bygger läsaren specifikationens XorRor-matris, och när den skiljer sig behandlar läsaren filen som en äldre HotXLS-fil och bygger upp matrisen med rotera-vänster-2. Filer skrivna av Excel bär alltid den härledda nyckeln, så de tar alltid specifikationens väg

Testet är en heuristik med en exakt felfrekvens. En gammal fil vars slumpmässiga nyckel råkade vara lika den härledda skulle läsas med fel matris, och sannolikheten för det är 1 på 65 536. Fallbacken täcker bara matrisrotationen; indexregeln byts inte, så de filer den räddar är de som skrevs mellan v2.384.47 och v2.384.53. Har du fortfarande BIFF5 XOR-filer från det fönstret, öppna dem med aktuella HotXLS och spara dem igen för att få en fil Excel accepterar

Två lösenordsdetaljer gäller varje fil, gammal som ny:

  • Längd. CreateXorKey_Method1 läser bara de första 15 byte av lösenordet, vilket är specifikationens gräns. HotXLS tillämpar det taket på nyckeln och håller verifieraren och matrisen på sina vanliga regler för full längd och 16 byte, konsekvent på båda sidor. Excel själv vägrar lösenord längre än 15 tecken för det här formatet, så betrakta 15 som det verkliga maximet
  • Teckenuppsättning. HotXLS konverterar lösenordet till byte via systemets ANSI-kodsida. Specifikationen beskriver att man tar det låga bytet av varje UTF-16-tecken, vilket stämmer för ASCII. Utan Excel-exempel skyddade av icke-ASCII-lösenord finns ingen sanning att jämföra med för resten, så håll dig till ASCII-lösenord för XOR-filer

På lässidan låter TXLSWorkbook.OnPassword dig fråga efter ett lösenord när Open stöter på en FILEPASS-post. Eventet är en TXLSPasswordEvent med en var PassWord: WideString och en var Retry: Boolean; sätt Retry till True för att försöka igen, upp till tre försök:

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: lösenord krävs men inget angivet; -1005: fel lösenord
  if Rc <> 1 then
    raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
  ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;

Känner du redan lösenordet hoppar Open(FileName, APassWord) över eventet helt

Är XOR-obfuskering säker nog för något alls?

BIFF XOR-obfuskering är inte kryptering och skyddar inget mot en motiverad läsare. Lösenordskontrollen är en 16-bitars verifierare, nyckeln är 16 bitar, och 16-byte-matrisen upprepas genom hela strömmen, så förutsägbara BIFF-postinnehåll avslöjar matrisens byte utan något lösenord alls. HotXLS skriver XOR bara för att Excel 5.0/95-filer inte har något annat alternativ, och skälet att producera sådana filer idag är en äldre konsument som inte kan läsa något nyare

Den klassiska motorn väljer schema via TXLSWorkbook.EncryptionType, och kombinationen med sparaformatet kontrolleras strikt:

  • xletAuto (standard) skriver RC4 CryptoAPI för xlExcel97 och XOR för xlExcel5, i linje med vad Excel själv skrev för varje format
  • xletXor är giltigt bara för BIFF5; med xlExcel97 kastar sparandet ett undantag i stället för att tyst falla tillbaka
  • xletRC4 och xletRC4CryptoAPI är bara för BIFF8, och att begära dem vid ett BIFF5-sparande kastar också ett undantag

RC4 är daterat också, och detaljerna för att få det att samverka tas upp i varför Excel avvisar en krypterad arbetsbok med ett korrekt lösenord. Kan mottagaren läsa XLSX, använd XLSX-motorn i stället: TXLSXWorkbook.SaveAsEncryptedAgile skriver Agile Encryption (SHA-512-lösenordshashning med en spin count på 100 000 iterationer och AES-256-CBC), formatet Excel 2010 och senare skriver som standard, medan SaveAsEncrypted skriver den äldre AES-128 Standard Encryption. Avvägningarna mellan dem finns i kryptering av XLSX-filer med AES i Delphi, och lässidan tas upp i läsning av Agile-krypterade Excel-filer med HotXLS

Snabbreferens: BIFF XOR-obfuskering som Excel 16 accepterar

  • FILEPASS ($002F) följer globals-BOF; i BIFF5 är dess kropp 4 byte: nyckeln, sedan verifieraren
  • Nyckel = CreateXorKey_Method1(password) enligt [MS-OFFCRYPTO] §2.3.7.2, aldrig slumpmässig; för secret är den $014D
  • Matris = lösenordsbyte + fyllnadsmönster, XOR med lågt nyckelbyte på jämna positioner och högt på udda, sedan XorRor (rotera höger 1)
  • Kryptera en byte: rotera vänster 5, sedan XOR; dekryptera: XOR, sedan rotera höger 5 (§2.3.7.3)
  • XorArrayIndex = (bytes strömoffset + postdatalängd) mod 16; huvuden och klartextprefix räknas med i offseten
  • BOUNDSHEET håller sina första 4 byte i klartext; BOF, FILEPASS och INTERFACEHDR förblir helt i klartext
  • Lösenord: ASCII, högst 15 tecken
  • HotXLS: index fixat i v2.384.47, nyckel och XorRor fixat i v2.384.54, äldre HotXLS XOR-filer upptäcks via nyckelmismatch
  • För verkligt skydd använd BIFF8 RC4 CryptoAPI som minimum, eller XLSX Agile Encryption

HotXLS hanterar BIFF5- och BIFF8-lösenordsskydd, XLSX Standard och Agile Encryption och lässidans lösenordsåteranrop från ett enda Delphi- och C++Builder-bibliotek, med interop-detaljerna ovan åtgärdade åt dig. Se HotXLS Delphi spreadsheet component för utgåvor, plattformar och en nedladdningsbar testversion