HotXLS scrie workbook-uri Excel 5.0/95 (BIFF5) obfuscate cu XOR pe care Excel 16 le deschide doar când trei detalii se potrivesc exact cu [MS-OFFCRYPTO]: cheia FILEPASS trebuie să fie CreateXorKey_Method1(password), array-ul XOR de 16 bytes trebuie construit cu XorRor (rotație la dreapta cu un bit), iar fiecare byte trebuie să folosească XorArrayIndex = (offset de stream + lungime de înregistrare) mod 16. HotXLS a nimerit index-ul în v2.384.47, iar cheia și rotația în v2.384.54. Înainte de asta, fiecare fișier BIFF5 protejat cu parolă pe care l-a produs se deschidea perfect în HotXLS și dădea greș în Excel
Propoziția din urmă e toată povestea în miniatură. Un reader și un writer care împărtășesc aceeași idee greșită sunt de acord perfect unul cu altul, deci testele de round-trip rămân verzi în timp ce singurul consumator care contează spune nu. Excel 16 a spus nu de două ori, cu două mesaje diferite, iar fiecare mesaj arăta spre un alt strat al schemei. Articolul ăsta trece prin stratele acelea în ordinea în care le verifică Excel, cu detalii la nivel de byte pe care le puteți folosi fie că apelați HotXLS, fie că vă scrieți propriul reader BIFF
Ce stochează de fapt obfuscare XOR BIFF?
Obfuscare XOR BIFF stochează în fișier doar două cuvinte de 16 biți, iar tot restul se recalculează din parolă. Înregistrarea FILEPASS ($002F) stă imediat după BOF-ul workbook globals, iar într-un fișier BIFF5 corpul ei e exact 4 bytes: cheia XOR urmată de verifier-ul parolei. Nu există salt, nici identificator de algoritm și nici blob de verifier criptat de felul celui pe care îl poartă schemele RC4 și AES
Din cele două cuvinte un reader reconstruiește trei lucruri:
- Verifier-ul, un hash de 16 biți al bytes-ului parolei XOR-at cu
$CE4B. Compararea lui cu cuvântul stocat e verificarea parolei, și singura - Cheia XOR, o valoare de 16 biți din
CreateXorKey_Method1în [MS-OFFCRYPTO] §2.3.7.2, acționată de două tabele de constante (InitialCode, 15 cuvinte, șiXorMatrix, 105 cuvinte) - Array-ul XOR, 16 bytes din bytes parolei umplute cu un pad fix de 16 bytes, fiecare XOR-at cu byte-ul de jos al cheii (poziții pare) sau cu cel de sus (poziții impare), apoi rotit la dreapta cu un bit
Antetele de înregistrare rămân în text clar, la fel și o mână de înregistrări întregi pe care schema le scutește, printre ele BOF, FILEPASS și INTERFACEHDR. Orice alt corp de înregistrare e transformat byte cu byte: rotație la stânga cu 5 biți, apoi XOR cu o intrare din array-ul de 16 bytes. Decriptarea, pe care [MS-OFFCRYPTO] §2.3.7.3 o scrie ca DecryptData_Method1, e imaginea în oglindă: întâi XOR, apoi rotație la dreapta cu 5
În HotXLS nu atingeți nimic din toate astea direct. Stabiliți o parolă, alegeți formatul, iar SaveAs emite FILEPASS și transformă stream-ul:
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 suportă doar obfuscare XOR; și xletAuto ar fi ales-o
Wb.EncryptionType := xletXor;
// Păstrați-l ASCII și de cel mult 15 caractere (vezi mai jos)
Wb.EncryptionPassword := 'secret';
if Wb.SaveAs(FileName, xlExcel5) <> 1 then
raise Exception.Create('BIFF5 save failed');
end;
De ce spune Excel că parola e greșită când verifier-ul se potrivește?
Excel respinge parola pentru că nu are încredere în cheia stocată: Excel derivă cheia din parola tastată cu CreateXorKey_Method1 și o compară cu cuvântul de cheie din FILEPASS, deci un fișier a cărui cheie e altceva pice verificarea parolei chiar și când verifier-ul e corect. Specificația descrie cheia ca un rezultat al parolei, nu ca un parametru liber, iar Excel 16 impune această citire
Writer-ul HotXLS de dinainte de v2.384.54 umplea cuvântul de cheie cu doi bytes aleatori. Pe hârtie pare inofensiv, fiindcă verifier-ul e verificarea de parolă documentată, iar array-ul se construiește din orice cheie declară fișierul. HotXLS însuși citea acele fișiere fără probleme, pentru că reader-ul lui lua cheia din FILEPASS ca atare. Excel 16, primind același fișier și parola corectă, răspundea că parola nu e corectă. Din v2.384.54 cheia e derivată, deci FILEPASS pentru parola secret conține întotdeauna cheia $014D și verifier-ul $DAA7, valori contra-verificate cu o implementare independentă a specificației
Derivarea în sine e scurtă odată ce cele două tabele sunt la locul lor. Parcurgeți parola invers, priviți bitul 6 al fiecărui byte de șapte ori în timp ce îl deplasați la stânga, iar de fiecare dată când bitul e setat faceți XOR cu o intrare XorMatrix. Ce urmează e o schiță de principiu care reproduce algoritmul din specificație și se potrivește cu implementarea HotXLS; nu e un API HotXLS:
// Schiță de principiu a [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// și CreateXorArray_Method1 (doar ilustrativ, nu un 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; // cheia vede doar 15 bytes
if Len = 0 then
Exit;
Result := InitialCode[Len - 1];
Element := $68; // ultima intrare 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)); // rotație la dreapta cu un 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;
Pentru secret asta produce array-ul 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF, un fixture la îndemână dacă vă testați propriul reader
De ce cheia corectă produce tot un fișier deteriorat?
O cheie corectă produce tot un fișier deteriorat când array-ul XOR e rotit în direcția greșită: [MS-OFFCRYPTO] definește pasul array-ului ca XorRor, o rotație la dreapta cu un bit, iar un array rotit la stânga cu doi decriptează fiecare corp de înregistrare în zgomot. Repararea cheii a mutat Excel 16 dincolo de prompt-ul de parolă și drept într-o altă eroare, o raportare că fișierul are o problemă și nu poate fi deschis
Codul HotXLS vechi rotea fiecare byte al array-ului la stânga cu 2 biți, o formă care circulă în mai multe implementări BIFF. Fiindcă HotXLS folosea aceeași rotație pe ambele părți, propriul lui reader nu observa niciodată. Excel 16 nu mai poate salva fișiere Excel 5.0/95 și nu oferă XOR la salvarea BIFF8, deci nu a existat niciun eșantion Excel nativ cu care să facem diff. Dovezile au trebuit să vină din cealaltă direcție: scrieți un singur stream BIFF5 în text clar, re-encodați-l în opt feluri și lăsați Excel 16 să deschidă fiecare variantă. Cele opt variante traversau trei alegeri independente:
| Alegere | Opțiunea A | Opțiunea B |
|---|---|---|
| Rotația array-ului | XorRor (rotație la dreapta 1) | Rotație la stânga 2 |
| Index-ul array-ului | (offset + record length) mod 16 | offset mod 16 |
| Ordinea transformării byte-ului | Rotație la stânga 5, apoi XOR | XOR, apoi rotație la stânga 5 |
Excel 16 a deschis exact două din cele opt: XorRor cu rotație-apoi-XOR și index-ul cu lungime de înregistrare, plus o variantă care doar pare diferită. Rotație-la-stânga-2 cu XOR-apoi-rotație și același index e aceeași funcție deghizată. Rotația se distribuie peste XOR, deci rol5(p xor rol2(b)) egală cu rol5(p) xor rol7(b), iar pe o valoare de 8 biți o rotație la stânga cu 7 e o rotație la dreapta cu 1. Pe scurt, rol5 ∘ rol2 = ror1, de aceea array-ul cu rotație la stânga 2 pare plauzibil în izolare: e corect doar împreună cu ordinea inversă de transformare. Împerecheat cu ordinea din specificație, corupe fiecare byte transformat
Același experiment a lămurit și o a doua întrebare. Variantele care aruncau lungimea de înregistrare din index au picat toate, ceea ce a confirmat regula de index pe care HotXLS o adoptase o versiune mai devreme pe baza textului specificației singur
Cum se calculează XorArrayIndex pentru fiecare byte?
XorArrayIndex pentru un byte e offset-ul lui în stream-ul workbook plus lungimea datelor întregii înregistrări căreia îi aparține, mod 16. Index-ul deci reia de la o valoare dependentă de înregistrare pentru fiecare înregistrare și crește cu unu per byte în interiorul ei. Pseudocodul din specificație numește intrările FileOffset și Data.Length, ceea ce e ușor de citit greșit ca offset-ul de start al înregistrării singur, iar citirea greșită aceea e exact ce a livrat HotXLS până la v2.384.47
Trei detalii decid dacă indicii voștri se potrivesc cu Excel:
- Antetul de înregistrare de 4 bytes nu e transformat niciodată, dar ocupă totuși poziții în stream, deci primul byte al corpului unei înregistrări stă la offset-ul antetului + 4
- Termenul de lungime e lungimea completă a datelor înregistrării, nu numărul de bytes efectiv transformați
- BOUNDSHEET e parțial clar: primii lui 4 bytes,
lbPlyPos, offset-ul de stream al BOF-ului sheet-ului, rămân lizibili ca un parser să poată localiza sheet-urile. Acesți 4 bytes sunt săriți de transformare dar contează totuși atât la offset, cât și la lungimea înregistrării
Puse cap la cap, transformarea per înregistrare e câteva linii. Din nou, e o schiță a regulii, nu ceva ce trebuie să apelați:
function Rol8(B: Byte; N: Integer): Byte;
begin
Result := Byte((B shl N) or (B shr (8 - N)));
end;
// Obfuscară un corp de înregistrare în loc. BodyPos e offset-ul de stream
// al lui Body[0], adică offset-ul antetului + 4. PlainPrefix e 4 pentru
// BOUNDSHEET, lungimea completă pentru BOF / FILEPASS, 0 pentru majoritate
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;
// Citirea e imaginea în oglindă: B := Body[I] xor Arr[...];
// apoi rotație la dreapta cu 5, adică Rol8(B, 3)
Înainte de v2.384.47, reader-ul HotXLS calcula index-ul doar din poziția în stream, iar writer-ul folosea offset-ul simplu al byte-ului. Ambele ignorau lungimea înregistrării, deci iar cele două jumătăți erau de acord una cu alta și cu nimeni altcineva. Un decoder scris independent citea corect output-ul v2.384.47 și pe cel mai vechi ca gunoi, iar testul cu opt variante în Excel 16 a confirmat mai târziu regula față de ținta reală
Ce se întâmplă cu fișierele XOR scrise de versiuni HotXLS mai vechi?
HotXLS continuă să citească propriile fișiere XOR de dinainte de v2.384.54 verificând cheia FILEPASS: când cheia stocată egalează cheia derivată din parolă, reader-ul construiește array-ul XorRor din specificație, iar când diferă, reader-ul tratează fișierul ca un fișier HotXLS mai vechi și reconstruiește array-ul cu rotație la stânga 2. Fișierele scrise de Excel poartă întotdeauna cheia derivată, deci urmează întotdeauna calea specificației
Testul e o euristică cu o rată de eșec precisă. Un fișier vechi a cărui cheie aleatoare s-ar fi întâmplat să egaleze cheia derivată ar fi citit cu array-ul greșit, iar șansa asta e 1 din 65.536. Fallback-ul acoperă doar rotația array-ului; regula de index nu e comutată, deci fișierele pe care le salvează sunt cele scrise între v2.384.47 și v2.384.53. Dacă mai dețineți fișiere BIFF5 XOR din fereastra aceea, deschideți-le cu HotXLS-ul curent și salvați-le din nou ca să obțineți un fișier pe care Excel îl acceptă
Două detalii despre parolă se aplică fiecărui fișier, vechi sau nou:
- Lungimea.
CreateXorKey_Method1citește doar primii 15 bytes ai parolei, ceea ce e limita din specificație. HotXLS aplică plafonul ăsta cheii și păstrează verifier-ul și array-ul pe regulile lor obișnuite de lungime completă și 16 bytes, consecvent pe ambele părți. Excel însuși refuză parolele mai lungi de 15 caractere pentru acest format, deci tratați 15 ca maximul real - Setul de caractere. HotXLS convertește parola în bytes prin pagina de cod ANSI a sistemului. Specificația descrie luarea byte-ului de jos al fiecărui caracter UTF-16, ceea ce coincide pentru ASCII. Fără eșantioane Excel protejate cu parole non-ASCII nu există adevăr de referință pentru restul, deci rămâneți la parole ASCII pentru fișierele XOR
Pe partea de citire, TXLSWorkbook.OnPassword vă lasă să cereți o parolă când Open întâlnește o înregistrare FILEPASS. Evenimentul e un TXLSPasswordEvent cu un var PassWord: WideString și un var Retry: Boolean; setați Retry pe True ca să reîncercați, până la trei reîncercări:
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: e cerută parolă dar nu s-a furnizat nicio parolă; -1005: parolă greșită
if Rc <> 1 then
raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;
Dacă deja știți parola, Open(FileName, APassWord) sare cu desăvârșire peste eveniment
E obfuscare XOR destul de sigură pentru ceva?
Obfuscare XOR BIFF nu e criptare și nu protejează nimic împotriva unui reader motivat. Verificarea parolei e un verifier de 16 biți, cheia e de 16 biți, iar array-ul de 16 bytes se repetă de-a lungul întregului stream, deci conținuturi de înregistrare BIFF previzibile expun bytes array-ului fără nicio parolă de loc. HotXLS scrie XOR doar pentru că fișierele Excel 5.0/95 n-au altă opțiune, iar motivul de a produce asemenea fișiere astăzi e un consumator legacy care nu poate citi nimic mai nou
Motorul clasic alege schema prin TXLSWorkbook.EncryptionType, iar combinația cu formatul de salvare e verificată strict:
xletAuto(implicit) scrie RC4 CryptoAPI pentruxlExcel97și XOR pentruxlExcel5, potrivit cu ce scria Excel însuși pentru fiecare formatxletXore valid doar pentru BIFF5; cuxlExcel97salvarea ridică o excepție în loc să cadă tăcut pe alternativăxletRC4șixletRC4CryptoAPIsunt doar BIFF8, iar cererea lor la o salvare BIFF5 ridică și ea o excepție
Și RC4 e datat, iar detaliile pentru interoperabilitatea lui sunt acoperite în de ce respinge Excel un workbook criptat cu o parolă corectă. Dacă destinatarul poate citi XLSX, folosiți în schimb motorul XLSX: TXLSXWorkbook.SaveAsEncryptedAgile scrie Agile Encryption (hash de parolă SHA-512 cu un spin count de 100.000 de iterații și AES-256-CBC), formatul pe care Excel 2010 și mai nou îl scriu implicit, în timp ce SaveAsEncrypted scrie mai vechiul Standard Encryption AES-128. Compromisurile dintre cele două sunt în criptarea fișierelor XLSX cu AES în Delphi, iar partea de citire e acoperită în citirea fișierelor Excel criptate Agile cu HotXLS
Referință rapidă: obfuscare XOR BIFF pe care Excel 16 o acceptă
- FILEPASS (
$002F) urmează BOF-ul globals; în BIFF5 corpul lui e 4 bytes: cheia, apoi verifier-ul - Cheia =
CreateXorKey_Method1(password)conform [MS-OFFCRYPTO] §2.3.7.2, niciodată aleatoare; pentrusecrete$014D - Array = bytes parolei + pad, XOR cu byte-ul de jos al cheii la pozițiile pare și cu cel de sus la cele impare, apoi XorRor (rotație la dreapta 1)
- Criptarea unui byte: rotație la stânga 5, apoi XOR; decriptarea: XOR, apoi rotație la dreapta 5 (§2.3.7.3)
- XorArrayIndex = (offset de stream al byte-ului + lungimea datelor înregistrării) mod 16; antetele și prefixele clare contează la offset
- BOUNDSHEET își păstrează primii 4 bytes clari; BOF, FILEPASS și INTERFACEHDR rămân complet clare
- Parole: ASCII, cel mult 15 caractere
- HotXLS: index reparat în v2.384.47, cheia și XorRor reparate în v2.384.54, fișierele XOR HotXLS mai vechi detectate prin neconcordanța cheii
- Pentru protecție reală folosiți măcar BIFF8 RC4 CryptoAPI, sau XLSX Agile Encryption
HotXLS gestionează protecția cu parolă BIFF5 și BIFF8, Standard și Agile Encryption pentru XLSX și callback-ul de parolă la citire dintr-o singură bibliotecă Delphi și C++Builder, cu detaliile de interoperabilitate de mai sus rezolvate pentru voi. Vezi componenta Delphi spreadsheet HotXLS pentru ediții, platforme și o descărcare de probă