Articolo tecnico

XOR XLS di HotXLS: derivazione della chiave e XorRor

HotXLS scrive cartelle di lavoro offuscate con XOR in formato Excel 5.0/95 (BIFF5) che Excel 16 apre solo quando tre dettagli combaciano con [MS-OFFCRYPTO] alla lettera: la chiave FILEPASS dev'essere CreateXorKey_Method1(password), l'array XOR a 16 byte dev'essere costruito con XorRor (rotazione a destra di un bit), e ogni byte dev'essere usato con XorArrayIndex = (offset di stream + lunghezza del record) mod 16. HotXLS ha azzeccato l'indice nella v2.384.47 e la chiave e la rotazione nella v2.384.54. Prima di allora, ogni file BIFF5 protetto da password che produceva si apriva liscio in HotXLS e falliva in Excel

Quell'ultima frase è tutta la storia in miniatura. Un reader e un writer che condividono la stessa idea sbagliata sono d'accordo alla perfezione tra loro, quindi i test di round trip restano verdi mentre l'unico consumatore che conta dice di no. Excel 16 ha detto di no due volte, con due messaggi diversi, e ogni messaggio puntava a un livello diverso dello schema. Questo articolo attraversa quei livelli nell'ordine in cui Excel li controlla, con dettagli a livello di byte che ti servono sia se chiami HotXLS sia se scrivi il tuo reader BIFF

Che cosa conserva davvero l'offuscamento XOR di BIFF?

L'offuscamento XOR di BIFF conserva nel file solo due word a 16 bit, e tutto il resto viene ricalcolato dalla password. Il record FILEPASS ($002F) sta subito dopo il BOF delle workbook globals, e in un file BIFF5 il suo corpo è esattamente 4 byte: la chiave XOR seguita dal verifier della password. Niente salt, niente identificatore di algoritmo e niente blob di verifier cifrato del tipo che portano gli schemi RC4 e AES

Da quelle due word un reader ricostruisce tre cose:

  • Il verifier, un hash a 16 bit dei byte della password XORato con $CE4B. Confrontarlo con la word salvata è il controllo della password, e l'unico
  • La chiave XOR, un valore a 16 bit da CreateXorKey_Method1 in [MS-OFFCRYPTO] §2.3.7.2, guidata da due tabelle di costanti (InitialCode, 15 word, e XorMatrix, 105 word)
  • L'array XOR, 16 byte fatti dei byte della password riempiti con un pad fisso a 16 byte, ognuno XORato con il byte basso della chiave (posizioni pari) o il byte alto (posizioni dispari), poi ruotato a destra di un bit

Gli header dei record restano in chiaro, e così un pugno di record interi che lo schema esenta, tra cui BOF, FILEPASS e INTERFACEHDR. Ogni altro corpo di record viene trasformato byte per byte: rotazione a sinistra di 5 bit, poi XOR con una voce dell'array a 16 byte. La decifratura, che [MS-OFFCRYPTO] §2.3.7.3 descrive come DecryptData_Method1, è l'immagine speculare: prima XOR, poi rotazione a destra di 5

In HotXLS non tocchi nulla di tutto ciò direttamente. Imposti una password, scegli il formato, e SaveAs emette FILEPASS e trasforma lo stream:

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 supporta solo l'offuscamento XOR; xletAuto la sceglierebbe uguale
  Wb.EncryptionType := xletXor;
  // Tienilo ASCII e di al massimo 15 caratteri (vedi sotto)
  Wb.EncryptionPassword := 'secret';

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

Perché Excel dice che la password è sbagliata quando il verifier combacia?

Excel rifiuta la password perché non si fida della chiave salvata: Excel deriva la chiave dalla password digitata con CreateXorKey_Method1 e la confronta con la word chiave del FILEPASS, quindi un file la cui chiave è qualsiasi altra cosa fallisce il controllo della password anche quando il verifier è corretto. La specifica descrive la chiave come un output della password, non come un parametro libero, e Excel 16 impone questa lettura

Il writer HotXLS prima della v2.384.54 riempiva la word chiave con due byte casuali. Sulla carta sembra innocuo, visto che il verifier è il controllo della password documentato e l'array viene costruito dalla chiave che il file dichiara. HotXLS stesso leggeva quei file senza problemi, perché il suo reader prendeva la chiave dal FILEPASS per buona. A Excel 16, con lo stesso file e la password corretta, rispondeva che la password non era corretta. Dalla v2.384.54 la chiave è derivata, quindi il FILEPASS per la password secret contiene sempre la chiave $014D e il verifier $DAA7, valori verificati in croce con un'implementazione indipendente della specifica

Diagramma HotXLS del record BIFF5 XOR FILEPASS che Excel 16 controlla all'apertura: il corpo semplice di quattro byte contiene la chiave XOR e il verifier della password, Excel deriva la chiave dalla password digitata con CreateXorKey_Method1 e rifiuta una chiave casuale con un errore di password anche quando il verifier combacia; HotXLS salva la chiave derivata 014D per secret
Il corpo del FILEPASS sono solo due word, ma Excel riederiva la chiave dalla tua password e confronta; una chiave riempita a caso fallisce il controllo anche con un verifier corretto, ed è per questo che HotXLS la deriva dalla v2.384.54

La derivazione in sé è breve una volta che le due tabelle sono al loro posto. Percorri la password all'indietro, guarda il bit 6 di ogni byte sette volte mentre lo sposti a sinistra, e XORa dentro una voce di XorMatrix ogni volta che il bit è a 1. Quello che segue è uno schizzo di principio che riproduce l'algoritmo della specifica e combacia con l'implementazione HotXLS; non è un'API HotXLS:

// Schizzo di principio di [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// e CreateXorArray_Method1 (solo illustrazione, non 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;                       // la chiave vede solo 15 byte
  if Len = 0 then
    Exit;
  Result := InitialCode[Len - 1];
  Element := $68;                    // ultima voce di 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));   // rotazione a destra di 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;

Per secret questo produce l'array 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF, che è una fixture comoda se stai provando un reader tuo

Diagramma HotXLS della costruzione dell'array XOR per l'offuscamento BIFF5: sedici byte seminati dalla password e da un pad fisso vengono XORati con il byte basso della chiave nelle posizioni pari e il byte alto nelle posizioni dispari, poi ruotati a destra di un bit con XorRor, producendo la fixture 1F 32 17 B9 per la password secret
I byte dell'array vengono dalla password, dal pad e dai due byte della chiave, una sola rotazione alla fine; la rotazione a sinistra di 2 funzionava solo quando la trasformazione dei byte girava nell'ordine opposto, ed Excel segue l'ordine della specifica

Perché la chiave giusta produce comunque un file rovinato?

Una chiave corretta produce comunque un file rovinato quando l'array XOR viene ruotato nel verso sbagliato: [MS-OFFCRYPTO] definisce il passo dell'array come XorRor, una rotazione a destra di un bit, e un array ruotato a sinistra di due decifra ogni corpo di record in puro rumore. Sistemata la chiave, Excel 16 è passato oltre la richiesta di password e dritto in un errore diverso, un report che il file ha un problema e non può essere aperto

Il vecchio codice HotXLS ruotava ogni byte dell'array a sinistra di 2 bit, una forma che circola in parecchie implementazioni BIFF. Dato che HotXLS usava la stessa rotazione su entrambi i lati, il suo reader non se n'è mai accorto. Excel 16 non può più salvare file Excel 5.0/95, e non offre XOR salvando BIFF8, quindi non c'era un campione Excel nativo su cui fare il diff. Le prove dovevano arrivare dall'altra direzione: scrivere uno stream BIFF5 in chiaro, ricodificarlo in otto modi, e far aprire ogni variante a Excel 16. Le otto varianti incrociavano tre scelte indipendenti:

SceltaOpzione AOpzione B
Rotazione dell'arrayXorRor (rotazione a destra di 1)Rotazione a sinistra di 2
Indice dell'array(offset + lunghezza record) mod 16offset mod 16
Ordine della trasformazione dei byteRotazione a sinistra di 5, poi XORXOR, poi rotazione a sinistra di 5

Excel 16 ne ha aperte esattamente due su otto: XorRor con rotazione-poi-XOR e l'indice con lunghezza record, e una variante che solo sembra diversa. Rotazione-a-sinistra-2 con XOR-poi-rotazione e lo stesso indice è la stessa funzione travestita. La rotazione si distribuisce sull'XOR, quindi rol5(p xor rol2(b)) eguaglia rol5(p) xor rol7(b), e su un valore a 8 bit una rotazione a sinistra di 7 è una rotazione a destra di 1. In breve, rol5 ∘ rol2 = ror1, ed è per questo che l'array con rotazione a sinistra di 2 sembra plausibile in isolamento: è corretta solo insieme all'ordine di trasformazione opposto. Accoppiata all'ordine della specifica, corrompe ogni byte trasformato

Lo stesso esperimento ha chiuso una seconda domanda. Le varianti che toglievano la lunghezza del record dall'indice sono tutte fallite, il che ha confermato la regola dell'indice che HotXLS aveva adottato una release prima sulla sola forza del testo della specifica

Come si calcola XorArrayIndex per ogni byte?

XorArrayIndex per un byte è il suo offset nello stream della cartella di lavoro più la lunghezza dell'intero dato del record a cui appartiene, mod 16. L'indice quindi riparte da un valore dipendente dal record per ogni record e incrementa di uno per byte al suo interno. Lo pseudocodice della specifica nomina gli input FileOffset e Data.Length, che è facile leggere per sbaglio come il solo offset d'inizio del record, e quella lettura sbagliata è esattamente ciò che HotXLS ha spedito fino alla v2.384.47

Tre dettagli decidono se i tuoi indici vanno d'accordo con Excel:

  • L'header del record a 4 byte non viene mai trasformato, ma occupa comunque posizioni di stream, quindi il primo byte del corpo di un record sta a header offset + 4
  • Il termine lunghezza è la lunghezza completa del dato del record, non il numero di byte effettivamente trasformati
  • BOUNDSHEET è in parte in chiaro: i suoi primi 4 byte, lbPlyPos, l'offset di stream del BOF del foglio, restano leggibili così un parser può localizzare i fogli. Quei 4 byte vengono saltati dalla trasformazione ma contano comunque sia per l'offset sia per la lunghezza del record
Diagramma HotXLS della regola XorArrayIndex per l'offuscamento BIFF5 XOR: ogni byte del corpo usa l'offset di stream più la lunghezza completa del dato del record modulo 16, l'header semplice di quattro byte e il prefisso lbPlyPos di BOUNDSHEET contano comunque per l'offset, e ignorare il termine lunghezza record era il difetto che HotXLS ha corretto nella v2.384.47
L'indice dell'array riparte una volta per record, non una volta per stream: l'header e ogni prefisso in chiaro occupano posizioni, la lunghezza del dato del record entra nel modulo, e entrambe le metà del vecchio HotXLS erano d'accordo sulla formula sbagliata

Messi insieme, la trasformazione per record sono poche righe. Di nuovo, è uno schizzo della regola, non qualcosa che devi chiamare:

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

// Offusca il corpo di un record in place. BodyPos è l'offset di stream di
// Body[0], cioè l'offset dell'header del record + 4. PlainPrefix è 4 per
// BOUNDSHEET, la lunghezza piena per BOF / FILEPASS, 0 per la maggior parte dei record
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;

// La lettura è l'immagine speculare: B := Body[I] xor Arr[...];
// poi rotazione a destra di 5, cioè Rol8(B, 3)

Prima della v2.384.47 il reader HotXLS calcolava l'indice dalla sola posizione di stream e il writer usava il semplice offset del byte. Entrambi ignoravano la lunghezza del record, quindi di nuovo le due metà erano d'accordo tra loro e con nessun altro. Un decoder scritto indipendentemente leggeva correttamente l'output della v2.384.47 e come spazzatura quello più vecchio, e il test a otto varianti su Excel 16 ha poi confermato la regola contro il bersaglio reale

Che cosa succede ai file XOR scritti da versioni HotXLS più vecchie?

HotXLS continua a leggere i suoi stessi file XOR precedenti alla v2.384.54 controllando la chiave FILEPASS: quando la chiave salvata eguaglia la chiave derivata dalla password, il reader costruisce l'array XorRor della specifica, e quando differisce, il reader tratta il file come un file HotXLS più vecchio e ricostruisce l'array con rotazione a sinistra di 2. I file scritti da Excel portano sempre la chiave derivata, quindi prendono sempre il percorso della specifica

Il test è un'euristica con una percentuale di fallimento precisa. Un vecchio file la cui chiave casuale per caso eguagliasse la chiave derivata verrebbe letto con l'array sbagliato, e la probabilità è 1 su 65.536. Il fallback copre solo la rotazione dell'array; la regola dell'indice non viene commutata, quindi i file che salvaguarda sono quelli scritti tra la v2.384.47 e la v2.384.53. Se tieni ancora file BIFF5 XOR di quella finestra, Aprili con l'HotXLS attuale e risalvali per ottenere un file che Excel accetta

Due dettagli sulla password valgono per ogni file, vecchio o nuovo:

  • Lunghezza. CreateXorKey_Method1 legge solo i primi 15 byte della password, che è il limite della specifica. HotXLS applica quel tetto alla chiave e tiene verifier e array sulle loro solite regole di lunghezza piena e 16 byte, coerentemente su entrambi i lati. Excel stesso rifiuta password più lunghe di 15 caratteri per questo formato, quindi tratta 15 come il massimo reale
  • Insieme di caratteri. HotXLS converte la password in byte attraverso la code page ANSI di sistema. La specifica descrive il prendere il byte basso di ogni carattere UTF-16, che combacia per l'ASCII. Senza campioni Excel protetti da password non ASCII non c'è una verità di riferimento per il resto, quindi resta sulle password ASCII per i file XOR

Sul lato lettura, TXLSWorkbook.OnPassword ti lascia chiedere una password quando Open incontra un record FILEPASS. L'evento è un TXLSPasswordEvent con un var PassWord: WideString e un var Retry: Boolean; imposta Retry a True per riprovare, fino a tre retry:

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: serve una password ma non ne è stata fornita nessuna; -1005: password sbagliata
  if Rc <> 1 then
    raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
  ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;

Se conosci già la password, Open(FileName, APassWord) salta del tutto l'evento

L'offuscamento XOR è abbastanza sicuro per qualcosa?

L'offuscamento XOR di BIFF non è cifratura e non protegge nulla contro un lettore motivato. Il controllo della password è un verifier a 16 bit, la chiave è 16 bit, e l'array a 16 byte si ripete per tutto lo stream, quindi contenuti di record BIFF prevedibili espongono i byte dell'array senza alcuna password. HotXLS scrive XOR solo perché i file Excel 5.0/95 non hanno altra opzione, e il motivo per produrre tali file oggi è un consumatore legacy che non sa leggere nulla di più recente

Il motore classico sceglie lo schema tramite TXLSWorkbook.EncryptionType, e la combinazione con il formato di salvataggio viene controllata severamente:

  • xletAuto (default) scrive RC4 CryptoAPI per xlExcel97 e XOR per xlExcel5, seguendo ciò che Excel stesso scriveva per ogni formato
  • xletXor è valido solo per BIFF5; con xlExcel97 il salvataggio solleva un'eccezione invece di ricadere in silenzio
  • xletRC4 e xletRC4CryptoAPI sono solo BIFF8, e chiederli su un salvataggio BIFF5 solleva anch'esso un'eccezione

Anche RC4 è datato, e i dettagli per farlo interoperare sono trattati in perché Excel rifiuta una cartella di lavoro cifrata con una password corretta. Se il destinatario sa leggere XLSX, usa invece il motore XLSX: TXLSXWorkbook.SaveAsEncryptedAgile scrive Agile Encryption (hashing della password SHA-512 con uno spin count di 100.000 iterazioni e AES-256-CBC), il formato che Excel 2010 e successivi scrivono per default, mentre SaveAsEncrypted scrive la più vecchia Standard Encryption AES-128. I compromessi tra le due sono in cifrare file XLSX con AES in Delphi, e il lato lettura è coperto in leggere file Excel cifrati Agile con HotXLS

Riferimento rapido: offuscamento XOR BIFF che Excel 16 accetta

  • FILEPASS ($002F) segue il BOF delle globals; in BIFF5 il suo corpo è 4 byte: chiave, poi verifier
  • Chiave = CreateXorKey_Method1(password) secondo [MS-OFFCRYPTO] §2.3.7.2, mai casuale; per secret è $014D
  • Array = byte della password + pad, XOR con il byte basso della chiave alle posizioni pari e il byte alto alle dispari, poi XorRor (rotazione a destra di 1)
  • Cifrare un byte: rotazione a sinistra di 5, poi XOR; decifrare: XOR, poi rotazione a destra di 5 (§2.3.7.3)
  • XorArrayIndex = (offset di stream del byte + lunghezza del dato del record) mod 16; header e prefissi in chiaro contano per l'offset
  • BOUNDSHEET tiene i suoi primi 4 byte in chiaro; BOF, FILEPASS e INTERFACEHDR restano interamente in chiaro
  • Password: ASCII, al massimo 15 caratteri
  • HotXLS: indice corretto nella v2.384.47, chiave e XorRor corretti nella v2.384.54, i vecchi file XOR HotXLS rilevati dalla mancata combacianza della chiave
  • Per una protezione vera usa almeno BIFF8 RC4 CryptoAPI, oppure Agile Encryption XLSX

HotXLS gestisce la protezione con password BIFF5 e BIFF8, la Standard e la Agile Encryption XLSX, e il callback della password lato lettura da un'unica libreria Delphi e C++Builder, con i dettagli di interoperabilità qui sopra gestiti per te. Vedi il componente foglio di calcolo Delphi HotXLS per edizioni, piattaforme e download di prova