Artykuł techniczny

Obfuskacja XOR w XLS HotXLS: wyprowadzanie klucza i XorRor

HotXLS zapisuje zaciemniane XOR-em skoroszyty Excel 5.0/95 (BIFF5), które Excel 16 otwiera tylko wtedy, gdy trzy szczegóły zgadzają się ze [MS-OFFCRYPTO] co do joty: klucz w FILEPASS musi być CreateXorKey_Method1(password), 16-bajtowa tablica XOR musi powstawać przez XorRor (obrót w prawo o jeden bit), a każdy bajt musi używać XorArrayIndex = (offset w strumieniu + długość rekordu) mod 16. HotXLS poprawił indeks w v2.384.47, a klucz i obrót w v2.384.54. Wcześniej każdy chroniony hasłem plik BIFF5 otwierał się w HotXLS bez zarzutu i wykładał się w Excelu

To ostatnie zdanie to cała historia w miniaturze. Czytnik i pisarz dzielący to samo złe wyobrażenie zgadzają się ze sobą perfekcyjnie, więc testy rundy zapis–odczyt świecą na zielono, podczas gdy jedyny liczący się konsument mówi nie. Excel 16 powiedział nie dwa razy, dwoma różnymi komunikatami, a każdy komunikat wskazywał inną warstwę schematu. Ten artykuł przechodzi przez te warstwy w kolejności, w jakiej sprawdza je Excel, ze szczegółami na poziomie bajtów, po które sięgniesz niezależnie od tego, czy wołasz HotXLS, czy piszesz własny czytnik BIFF

Co tak naprawdę przechowuje zaciemnienie XOR w BIFF?

Zaciemnienie XOR w BIFF zapisuje w pliku tylko dwa 16-bitowe słowa, a wszystko inne jest przeliczane z hasła. Rekord FILEPASS ($002F) siedzi zaraz po BOF globals skoroszytu, a w pliku BIFF5 jego ciało ma dokładnie 4 bajty: klucz XOR, po nim weryfikator hasła. Nie ma soli, identyfikatora algorytmu ani zaszyfrowanego bloba weryfikatora, jakie niosą schematy RC4 i AES

Z tych dwóch słów czytnik odtwarza trzy rzeczy:

  • Weryfikator — 16-bitowy hash bajtów hasła XOR-owany z $CE4B. Porównanie go z przechowanym słowem to sprawdzenie hasła, i to jedyne
  • Klucz XOR — 16-bitowa wartość z CreateXorKey_Method1 w [MS-OFFCRYPTO] §2.3.7.2, napędzana dwiema stałymi tablicami (InitialCode, 15 słów, i XorMatrix, 105 słów)
  • Tablica XOR — 16 bajtów złożonych z bajtów hasła dopełnionych stałym 16-bajtowym padem, każdy XOR-owany z młodszym bajtem klucza (pozycje parzyste) albo starszym bajtem klucza (pozycje nieparzyste), a potem obrócony w prawo o jeden bit

Nagłówki rekordów pozostają w czystym tekście, podobnie jak garść całych rekordów, których schemat nie obejmuje — wśród nich BOF, FILEPASS i INTERFACEHDR. Ciało każdego innego rekordu jest przekształcane bajt po bajcie: obrót w lewo o 5 bitów, potem XOR z jednym wpisem 16-bajtowej tablicy. Odszyfrowanie, które [MS-OFFCRYPTO] §2.3.7.3 opisuje jako DecryptData_Method1, to obraz lustrzany: najpierw XOR, potem obrót w prawo o 5

W HotXLS nie dotykasz żadnej z tych rzeczy wprost. Ustawiasz hasło, wybierasz format, a SaveAs emituje FILEPASS i przekształca strumień:

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 wspiera wyłącznie zaciemnienie XOR; xletAuto wybrałoby je tak samo
  Wb.EncryptionType := xletXor;
  // Trzymaj się ASCII i maksymalnie 15 znaków (patrz niżej)
  Wb.EncryptionPassword := 'secret';

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

Dlaczego Excel twierdzi, że hasło jest złe, skoro weryfikator się zgadza?

Excel odrzuca hasło, bo nie ufa przechowanemu kluczowi: Excel wyprowadza klucz z wpisanego hasła przez CreateXorKey_Method1 i porównuje go ze słowem klucza w FILEPASS, więc plik z jakimkolwiek innym kluczem oblewa sprawdzenie hasła, nawet gdy weryfikator jest poprawny. Specyfikacja opisuje klucz jako wynik wyprowadzony z hasła, a nie wolny parametr, i Excel 16 egzekwuje właśnie takie odczytanie

Pisarz HotXLS sprzed v2.384.54 wypełniał słowo klucza dwoma losowymi bajtami. Na papierze wygląda to niewinnie, bo weryfikator jest udokumentowanym sprawdzeniem hasła, a tablica powstaje z tego klucza, który plik deklaruje. Sam HotXLS czytał takie pliki bez tarapatów, bo jego czytnik brał klucz z FILEPASS jako dany. Excel 16, dostawszy ten sam plik i poprawne hasło, odpowiadał, że hasło nie jest poprawne. Od v2.384.54 klucz jest wyprowadzany, więc FILEPASS dla hasła secret zawsze trzyma klucz $014D i weryfikator $DAA7 — wartości zweryfikowane krzyżowo względem niezależnej implementacji specyfikacji

Diagram HotXLS rekordu FILEPASS zaciemnienia XOR w BIFF5, który Excel 16 sprawdza przy otwarciu: cztery czyste bajty ciała trzymają klucz XOR i weryfikator hasła, Excel wyprowadza klucz z wpisanego hasła przez CreateXorKey_Method1 i odrzuca losowy klucz błędem hasła nawet wtedy, gdy weryfikator się zgadza; HotXLS zapisuje wyprowadzony klucz 014D dla secret
ciało FILEPASS to tylko dwa słowa, ale Excel wyprowadza klucz z twojego hasła od nowa i porównuje; klucz wypełniony losowo oblewa sprawdzenie nawet przy poprawnym weryfikatorze, dlatego HotXLS wyprowadza go od v2.384.54

Sama wyprowadzka jest krótka, gdy obie tablice są już na miejscu. Przejdź hasło od tyłu, siedem razy spójrz na bit 6 każdego bajtu, przesuwając go w lewo, i za każdym ustawionym bitem XOR-uj jeden wpis XorMatrix. Poniższy szkic poglądowy odtwarza algorytm ze specyfikacji i zgadza się z implementacją HotXLS; nie jest to API HotXLS:

// Poglądowy szkic [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// i CreateXorArray_Method1 (tylko ilustracja, nie API HotXLS)
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;                       // klucz widzi tylko 15 bajtów
  if Len = 0 then
    Exit;
  Result := InitialCode[Len - 1];
  Element := $68;                    // ostatni wpis XorMatrix
  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));   // obrót w prawo o jeden 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;

Dla secret daje to tablicę 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF — wygodna stała testowa, gdy testujesz własny czytnik

Diagram HotXLS budowy tablicy XOR dla zaciemnienia BIFF5: szesnaście bajtów zasianych hasłem i stałym padem jest XOR-owanych z młodszym bajtem klucza na pozycjach parzystych i starszym bajtem na pozycjach nieparzystych, a potem obracanych w prawo o jeden bit przez XorRor, co daje stałą testową 1F 32 17 B9 dla hasła secret
bajty tablicy pochodzą z hasła, pada i dwóch bajtów klucza, a na końcu jest jeden obrót; obrót w lewo o 2 działał tylko przy przekształceniu bajtów w odwrotnej kolejności, a Excel trzyma się kolejności ze specyfikacji

Dlaczego poprawny klucz wciąż daje uszkodzony plik?

Poprawny klucz wciąż daje uszkodzony plik, gdy tablica XOR jest obracana w złą stronę: [MS-OFFCRYPTO] definiuje krok tablicy jako XorRor, obrót w prawo o jeden bit, a tablica obrócona w lewo o dwa odszyfrowuje ciało każdego rekordu na szum. Naprawa klucza przepchnęła Excela 16 za monit o hasło prosto w inny błąd — komunikat, że plik ma problem i nie da się go otworzyć

Stary kod HotXLS obracał każdy bajt tablicy w lewo o 2 bity — wariant krążący w kilku implementacjach BIFF. Ponieważ HotXLS używał tego samego obrotu po obu stronach, jego własny czytnik nigdy się nie zorientował. Excel 16 nie zapisuje już plików Excel 5.0/95, a przy zapisie BIFF8 nie oferuje XOR, więc nie było natywnego pliku Excela do porównania. Dowody musiały przyjść z drugiej strony: zapisz jeden czystotekstowy strumień BIFF5, zakoduj go ponownie na osiem sposobów i każ Excelowi 16 otworzyć każdy wariant. Osiem wariantów krzyżowało trzy niezależne wybory:

WybórOpcja AOpcja B
Obrót tablicyXorRor (obrót w prawo o 1)Obrót w lewo o 2
Indeks tablicy(offset + długość rekordu) mod 16offset mod 16
Kolejność przekształcenia bajtuObrót w lewo o 5, potem XORXOR, potem obrót w lewo o 5

Excel 16 otworzył dokładnie dwa z ośmiu: XorRor z kolejnością obrót-potem-XOR i indeksem z długością rekordu oraz jeden wariant, który tylko wygląda inaczej. Obrót-w-lewo-o-2 z kolejnością XOR-potem-obrót i tym samym indeksem to ta sama funkcja w przebraniu. Obrót jest rozdzielny względem XOR, więc rol5(p xor rol2(b)) równa się rol5(p) xor rol7(b), a na wartości 8-bitowej obrót w lewo o 7 to obrót w prawo o 1. Krótko: rol5 ∘ rol2 = ror1, dlatego tablica z obrotem w lewo o 2 wygląda wiarygodnie w izolacji — jest poprawna tylko w parze z odwrotną kolejnością przekształcenia. W parze z kolejnością ze specyfikacji psuje każdy przekształcony bajt

Ten sam eksperyment rozstrzygnął drugie pytanie. Warianty, które wyrzuciły długość rekordu z indeksu, wszystkie poległy, co potwierdziło regułę indeksu przyjętą przez HotXLS jedno wydanie wcześniej na podstawie samego tekstu specyfikacji

Jak liczy się XorArrayIndex dla każdego bajtu?

XorArrayIndex dla bajtu to jego offset w strumieniu skoroszytu plus długość całych danych rekordu, do którego należy, mod 16. Indeks startuje więc od wartości zależnej od rekordu przy każdym rekordzie i rośnie o jeden na bajt w jego obrębie. Pseudokod specyfikacji nazywa wejścia FileOffset i Data.Length, co łatwo błędnie odczytać jako sam offset początku rekordu — i dokładnie ta błędna lektura siedziała w HotXLS do v2.384.47

O tym, czy twoje indeksy ułożą się z Excela, decydują trzy szczegóły:

  • 4-bajtowy nagłówek rekordu nie jest nigdy przekształcany, ale i tak zajmuje pozycje w strumieniu, więc pierwszy bajt ciała rekordu siedzi na offset nagłówka + 4
  • składnik długości to pełna długość danych rekordu, a nie liczba bajtów faktycznie przekształconych
  • BOUNDSHEET jest częściowo czysty: jego pierwsze 4 bajty, lbPlyPos, offset w strumieniu BOF arkusza, pozostają czytelne, żeby parser mógł zlokalizować arkusze. Te 4 bajty są pomijane przez przekształcenie, ale wliczają się i do offsetu, i do długości rekordu
Diagram HotXLS reguły XorArrayIndex dla zaciemnienia XOR w BIFF5: każdy bajt ciała używa offsetu w strumieniu plus pełnej długości danych rekordu modulo 16, czysty nagłówek o długości czterech bajtów i prefiks BOUNDSHEET lbPlyPos wliczają się do offsetu, a ignorowanie składnika długości rekordu było wadą, którą HotXLS naprawił w v2.384.47
indeks tablicy restartuje raz na rekord, a nie raz na strumień: nagłówek i każdy czysty prefiks zajmują pozycje, długość danych rekordu zasila modulo, a obie połowy starego HotXLS zgadzały się co do złego wzoru

Złożone w całość, przekształcenie per rekord to kilka linii. I znów: to szkic reguły, nie coś, co musisz wołać:

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

// Zaciemnia ciało jednego rekordu w miejscu. BodyPos to offset w strumieniu
// Body[0], czyli offset nagłówka rekordu + 4. PlainPrefix to 4 dla
// BOUNDSHEET, pełna długość dla BOF / FILEPASS, 0 dla większości rekordów
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;

// Odczyt to obraz lustrzany: B := Body[I] xor Arr[...];
// potem obrót w prawo o 5, czyli Rol8(B, 3)

Przed v2.384.47 czytnik HotXLS liczył indeks tylko z pozycji w strumieniu, a pisarz używał czystego offsetu bajtowego. Obie połowy ignorowały długość rekordu, więc znowu zgadzały się ze sobą i z nikim więcej. Niezależnie napisany dekoder czytał wyjście z v2.384.47 poprawnie, a starsze wyjście jako śmieci, a test ośmiu wariantów w Excelu 16 potwierdził później regułę względem prawdziwego celu

Co z plikami XOR zapisanymi przez starsze wersje HotXLS?

HotXLS wciąż czyta własne pliki XOR sprzed v2.384.54, sprawdzając klucz w FILEPASS: gdy przechowany klucz równa się kluczowi wyprowadzonemu z hasła, czytnik buduje tablicę XorRor ze specyfikacji, a gdy się różni, traktuje plik jako starszy plik HotXLS i odbudowuje tablicę z obrotem w lewo o 2. Pliki zapisane przez Excela zawsze niosą klucz wyprowadzony, więc zawsze idą ścieżką specyfikacji

Ten test to heurystyka z dokładnie znaną stopą porażek. Stary plik, którego losowy klucz zrządził się z wyprowadzonym, zostałby czytany złą tablicą, a szansa na to wynosi 1 do 65 536. Fallback obejmuje tylko obrót tablicy; reguła indeksu nie jest przełączana, więc ratowane są pliki zapisane między v2.384.47 a v2.384.53. Jeśli trzymasz jeszcze pliki BIFF5 XOR z tego okna, otwórz je bieżącym HotXLS i zapisz ponownie, żeby dostać plik akceptowany przez Excela

Dwa szczegóły dotyczące hasła obowiązują każdy plik, stary czy nowy:

  • Długość. CreateXorKey_Method1 czyta tylko pierwsze 15 bajtów hasła — to limit ze specyfikacji. HotXLS stosuje ten limit do klucza, a weryfikator i tablicę trzyma przy swoich zwykłych regułach pełnej długości i 16 bajtów, konsekwentnie po obu stronach. Sam Excel odmawia haseł dłuższych niż 15 znaków dla tego formatu, więc traktuj 15 jako prawdziwe maksimum
  • Zestaw znaków. HotXLS konwertuje hasło na bajty przez systemową stronę kodową ANSI. Specyfikacja opisuje branie młodszego bajtu każdego znaku UTF-16, co dla ASCII się zgadza. Bez próbek Excela chronionych hasłami nie-ASCII nie ma punktu odniesienia dla reszty, więc trzymaj się haseł ASCII dla plików XOR

Po stronie czytania TXLSWorkbook.OnPassword pozwala dopytać o hasło, gdy Open trafi na rekord FILEPASS. Zdarzenie to TXLSPasswordEvent z var PassWord: WideString i var Retry: Boolean; ustaw Retry na True, żeby spróbować ponownie, maksymalnie trzy razy:

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: wymagane hasło, ale żadnego nie podano; -1005: złe hasło
  if Rc <> 1 then
    raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
  ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;

Jeśli hasło już znasz, Open(FileName, APassWord) omija zdarzenie całkowicie

Czy zaciemnienie XOR nadaje się do czegokolwiek?

Zaciemnienie XOR w BIFF to nie szyfrowanie i nie daje zdeterminowanemu czytelnikowi żadnej ochrony. Sprawdzenie hasła to 16-bitowy weryfikator, klucz ma 16 bitów, a 16-bajtowa tablica powtarza się w całym strumieniu, więc przewidywalne treści rekordów BIFF wystawiają bajty tablicy bez żadnego hasła. HotXLS zapisuje XOR tylko dlatego, że pliki Excel 5.0/95 nie mają innego wyjścia, a powód, by dziś takie pliki produkować, to legacy'owy konsument, który nie czyta niczego nowszego

Silnik klasyczny wybiera schemat przez TXLSWorkbook.EncryptionType, a kombinacja z formatem zapisu jest sprawdzana restrykcyjnie:

  • xletAuto (domyślny) zapisuje RC4 CryptoAPI dla xlExcel97 i XOR dla xlExcel5, zgodnie z tym, co sam Excel zapisywał dla każdego formatu
  • xletXor jest ważny tylko dla BIFF5; przy xlExcel97 zapis rzuca wyjątkiem zamiast po cichu schodzić na niższy schemat
  • xletRC4 i xletRC4CryptoAPI to wyłącznie BIFF8, a żądanie ich przy zapisie BIFF5 również rzuca wyjątkiem

RC4 też ma swoje lata, a szczegóły doprowadzenia go do współpracy opisuje artykuł o tym, dlaczego Excel odrzuca zaszyfrowany skoroszyt mimo poprawnego hasła. Jeśli odbiorca potrafi czytać XLSX, użyj zamiast tego silnika XLSX: TXLSXWorkbook.SaveAsEncryptedAgile zapisuje Agile Encryption (haszowanie hasła SHA-512 ze spin countem 100 000 iteracji i AES-256-CBC) — format, który domyślnie zapisują Excel 2010 i nowsze — a SaveAsEncrypted zapisuje starsze Standard Encryption z AES-128. O kompromisach między nimi piszemy w artykule o szyfrowaniu plików XLSX algorytmem AES w Delphi, a stronę czytania opisuje czytanie zaszyfrowanych Agile plików Excela w HotXLS

Ściąga: zaciemnienie XOR w BIFF akceptowane przez Excela 16

  • FILEPASS ($002F) idzie za BOF globals; w BIFF5 jego ciało to 4 bajty: klucz, potem weryfikator
  • Klucz = CreateXorKey_Method1(password) wg [MS-OFFCRYPTO] §2.3.7.2, nigdy losowy; dla secret to $014D
  • Tablica = bajty hasła + pad, XOR z młodszym bajtem klucza na pozycjach parzystych i starszym na nieparzystych, potem XorRor (obrót w prawo o 1)
  • Szyfrowanie bajtu: obrót w lewo o 5, potem XOR; odszyfrowanie: XOR, potem obrót w prawo o 5 (§2.3.7.3)
  • XorArrayIndex = (offset bajtu w strumieniu + długość danych rekordu) mod 16; nagłówki i czyste prefiksy wliczają się do offsetu
  • BOUNDSHEET trzyma pierwsze 4 bajty w czystości; BOF, FILEPASS i INTERFACEHDR pozostają w całości czyste
  • Hasła: ASCII, maksymalnie 15 znaków
  • HotXLS: indeks naprawiony w v2.384.47, klucz i XorRor w v2.384.54, starsze pliki XOR HotXLS wykrywane po niedopasowaniu klucza
  • Do prawdziwej ochrony użyj co najmniej BIFF8 RC4 CryptoAPI albo XLSX Agile Encryption

HotXLS obsługuje ochronę hasłem BIFF5 i BIFF8, XLSX Standard i Agile Encryption oraz callback hasła po stronie czytania z jednej biblioteki dla Delphi i C++Buildera, a opisane wyżej szczegóły współpracy masz załatwione za ciebie. Wydania, platformy i wersję próbną znajdziesz na stronie komponentu arkuszy kalkulacyjnych HotXLS dla Delphi