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_Method1w [MS-OFFCRYPTO] §2.3.7.2, napędzana dwiema stałymi tablicami (InitialCode, 15 słów, iXorMatrix, 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
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
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ór | Opcja A | Opcja B |
|---|---|---|
| Obrót tablicy | XorRor (obrót w prawo o 1) | Obrót w lewo o 2 |
| Indeks tablicy | (offset + długość rekordu) mod 16 | offset mod 16 |
| Kolejność przekształcenia bajtu | Obrót w lewo o 5, potem XOR | XOR, 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
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_Method1czyta 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 dlaxlExcel97i XOR dlaxlExcel5, zgodnie z tym, co sam Excel zapisywał dla każdego formatuxletXorjest ważny tylko dla BIFF5; przyxlExcel97zapis rzuca wyjątkiem zamiast po cichu schodzić na niższy schematxletRC4ixletRC4CryptoAPIto 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; dlasecretto$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