Műszaki cikk

HotXLS XLS XOR obfuszkáció: kulcsszármaztatás és XorRor

A HotXLS Excel 5.0/95 (BIFF5) XOR-obfuszkált munkafüzeteket ír, amiket az Excel 16 csak akkor nyit meg, ha három részlet pontosan egyezik az [MS-OFFCRYPTO]-val: a FILEPASS kulcs CreateXorKey_Method1(password) kell legyen, a 16 bájtos XOR tömb XorRorral épül (egébites jobra forgatás), és minden bájt XorArrayIndex-e (stream offszet + rekordhossz) mod 16. A HotXLS az indexet v2.384.47-ben, a kulcsot meg a forgatást v2.384.54-ben hozta helyre. Azelőtt minden jelszóval védett BIFF5 fájl, amit produkált, HotXLS-ben szépen nyílt, Excelben nem

Az az utolsó mondat a teljes történet miniatürizálva. Egy olvasó és egy író, ami ugyanazt a rossz gondolatot osztja meg, tökéletesen egyetért egymással, így a körbevisz tesztek zöldek maradnak, miközben az egyetlen fogyasztó, akinek számít, nemet mond. Az Excel 16 kétszer mondott nemet, két különböző üzenettel, és mindegyik üzenet a séma másik rétegére mutatott. Ez a cikk azokat a rétegeket járja abban a sorrendben, ahogy az Excel ellenőriz, bájtszintű részlettel, ami akkor is használható, ha HotXLS-t hívsz, akkor is, ha saját BIFF olvasót írsz

Mit tárol valójában a BIFF XOR obfuszkáció?

A BIFF XOR obfuszkáció csak két 16 bites szót tárol a fájlban, minden mást a jelszóból számolnak újra. A FILEPASS rekord ($002F) közvetlenül a workbook globals BOF után ül, és egy BIFF5 fájlban a teste pontosan 4 bájt: az XOR kulcs, majd a jelszóverifikátor. Nincs só, nincs algoritmusazonosító és nincs olyan titkosított verifikátor blob, amilyet az RC4 és AES sémák hordoznak

Ebből a két szóból egy olvasó három dolgot épít újra:

  • A verifikátort, a jelszóbájtok 16 bites hashét $CE4B-vel XOR-olva. Az összehasonlítása a tárolt szóval a jelszóteszt, és az egyetlen
  • Az XOR kulcsot, egy 16 bites értéket a [MS-OFFCRYPTO] §2.3.7.2-es CreateXorKey_Method1-éből, két konstans tábla vezérli (InitialCode, 15 szó, meg az XorMatrix, 105 szó)
  • Az XOR tömböt, 16 bájtot a jelszóbájtokból egy rögzített 16 bájtos paddal kiegészítve, mindegyiket az alsó kulcsbájttal (páros pozíciók) vagy a felső kulcsbájttal (páratlan pozíciók) XOR-olva, majd egébittel jobra forgatva

A rekordfejek sima szövegben maradnak, ahogy egy maréknyi teljes rekord is, amiket a séma felment, köztük a BOF, FILEPASS és INTERFACEHDR. Minden más rekord teste bájtonként transzformálódik: 5 bittel balra forgatás, majd XOR az egyik 16 bájtos tömbelemmel. A visszafejtés, amit a [MS-OFFCRYPTO] §2.3.7.3-a DecryptData_Method1-ként ír le, a tükörképe: előbb XOR, aztán 5-tel jobra forgatás

HotXLS-ben mindezzel soha nem érintkezel közvetlenül. Állíts be jelszót, válaszd ki a formátumot, és a SaveAs FILEPASS-t bocsát ki, meg átalakítja a streamet:

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;

  // A BIFF5 csak XOR obfuszkációt támogat; az xletAuto is ezt választaná
  Wb.EncryptionType := xletXor;
  // Tartsd ASCII-nek és legfeljebb 15 karakternek (lásd lent)
  Wb.EncryptionPassword := 'secret';

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

Miért mondja az Excel, hogy rossz a jelszó, amikor a verifikátor egyezik?

Az Excel azért utasítja el a jelszót, mert nem bízik a tárolt kulcsban: az Excel a beütött jelszóból származtatja a kulcsot a CreateXorKey_Method1-gyel, és összeveti a FILEPASS kulcsszóval, így egy fájl, aminek a kulcsa bármi más, elbukja a jelszótesztet akkor is, ha a verifikátor helyes. A specifikáció a kulcsot a jelszó kimeneteként írja le, nem szabad paraméterként, és az Excel 16 érvényre juttatja ezt az olvasatot

A v2.384.54 előtti HotXLS író a kulcsszót két véletlen bájttal töltötte fel. Ez papíron ártalmatlannak néz ki, hiszen a verifikátor a dokumentált jelszóteszt, a tömb pedig abból a kulcsból épül, amit a fájl deklarál. A HotXLS maga gond nélkül olvasta azokat a fájlokat, mert az olvasója a FILEPASS-ból vette a kulcsot, adottnak. Az Excel 16 ugyanazt a fájlt és a helyes jelszót kapva azt felelte, hogy a jelszó nem helyes. v2.384.54 óta a kulcs származtatott, így a secret jelszóhoz a FILEPASS mindig $014D kulcsot és $DAA7 verifikátort hordoz, értékek, amiket a specifikáció egy független implementációjával ellenőriztek

HotXLS ábra a BIFF5 XOR FILEPASS rekordról, amit az Excel 16 megnyitáskor ellenőriz: a sima négybájtos test az XOR kulcsot és a jelszóverifikátort hordozza, az Excel a beütött jelszóból származtatja a kulcsot CreateXorKey_Method1-gyel, és véletlen kulcsot jelszóhibával utasít el akkor is, ha a verifikátor egyezik; a HotXLS a secret-hez a származtatott 014D kulcsot tárolja
A FILEPASS teste mindössze két szó, de az Excel a jelszavadból újraszámolja a kulcsot, és összeveti; egy véletlennel töltött kulcs elbukja a tesztet helyes verifikátor mellett is, ezért származtatja a HotXLS v2.384.54 óta

A származtatás maga rövid, ha a két tábla a helyén van. Járd végig a jelszót visszafelé, nézd meg minden bájt 6. bitjét hétszer, miközben balra tolod, és minden beállított bitnél XOR-olj bele egy XorMatrix elemet. Az alábbi elvi vázlat, ami reprodukálja a specifikáció algoritmusát, és egyezik a HotXLS implementációjával; nem HotXLS API:

// A [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1 elvi vázlata
// meg a CreateXorArray_Method1 (csak illusztráció, nem 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;                       // a kulcs csak 15 bájtot lát
  if Len = 0 then
    Exit;
  Result := InitialCode[Len - 1];
  Element := $68;                    // utolsó XorMatrix bejegyzés
  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));   // egébittel jobra forgatás
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;

A secret-hez ez a 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF tömböt adja, ami kéznél lévő fixture, ha a saját olvasódat tesztelitek

HotXLS ábra a XOR tömb építéséhez BIFF5 obfuszkációhoz: tizenhat bájt, a jelszóból és egy rögzített padből seedelve, páros pozíciókon az alsó kulcsbájttal, páratlan pozíciókon a felső kulcsbájttal XOR-olódik, majd XorRorral egébittel jobra forgatódik, ami a secret jelszóhoz az 1F 32 17 B9 fixturet adja
A tömbbájtok a jelszóból, a padből és a két kulcsbájtból jönnek, a végén egyetlen forgatással; a balra-2 forgatás csak akkor működött, ha a bájttranszformáció ellentétes sorrendben futott, az Excel pedig a specifikáció sorrendjét követi

Miért ad a jó kulcs is sérült fájlt?

Egy helyes kulcs is sérült fájlt ad, ha a XOR tömb rossz irányba forgatódik: a [MS-OFFCRYPTO] a tömblépést XorRorként definiálja, egébites jobra forgatásként, és egy kettővel balra forgatott tömb minden rekordtestet zajmá fejt vissza. A kulcs javítása az Excel 16-ot a jelszóprompton túl egy másik hibáig tolt, egy jelentésig, hogy a fájlnak baja van, és nem nyitható meg

A régi HotXLS kód minden tömbbájtot 2 bittel balra forgatott, egy olyan forma, ami több BIFF implementációban kering. Mivel a HotXLS mindkét oldalon ugyanazt a forgatást használta, a saját olvasója soha nem vette észre. Az Excel 16 már nem tud Excel 5.0/95 fájlokat menteni, BIFF8 mentésnél pedig nem kínál XOR-t, így nem volt natív Excel minta, amit diffelni lehetett volna. A bizonyítéknak a másik irányból kellett jönnie: írj ki egy sima szöveges BIFF5 streamet, kódold vissza nyolcféleképpen, és hagyd, hogy az Excel 16 nyissa meg mindegyiket. A nyolc változat három független választást keresztezett:

VálasztásA opcióB opció
TömbforgatásXorRor (egébittel jobra)2-vel balra forgatás
Tömbindex(offset + record length) mod 16offset mod 16
Bájttranszformáció sorrendje5-tel balra forgatás, aztán XORXOR, aztán 5-tel balra forgatás

Az Excel 16 pontosan kettőt nyitott meg a nyolcból: az XorRor-t forgatás-aztán-XORral meg a rekordhosszos indexszel, meg egy változatot, ami csak úgy néz ki másképp. A balra-2 forgatás XOR-aztán-forgatással és ugyanazzal az indexszel ugyanaz a függvény álcában. A forgatás eloszlik a XOR felett, így a rol5(p xor rol2(b)) egyenlő a rol5(p) xor rol7(b)-vel, és egy 8 bites értéken a 7-tel balra forgatás egébittel jobra forgatás. Röviden, rol5 ∘ rol2 = ror1, ezért néz ki hihetően önmagában a balra-2 tömb: csak együtt helyes az ellentétes transzformációs sorrenddel. A specifikáció sorrendjével párosítva minden transzformált bájtot tönkretesz

Ugyanez a kísérlet egy második kérdést is eldöntött. Azok a változatok, amik kihagyták a rekordhosszt az indexből, mind elbuktak, ami megerősítette azt az indexszabályt, amit a HotXLS egy kiadással korábban vett fel pusztán a specifikáció szövege alapján

Hogyan számolódik XorArrayIndex minden bájtra?

Egy bájt XorArrayIndex-e a workbook streambeli offszetje plusz annak a teljes rekordadatnak a hossza, amihez tartozik, mod 16. Az index ezért rekordonként rekordfüggő értékről indul újra, és rekordon belül bájtonként eggyel nő. A specifikáció pszeudokódja FileOffset-nek és Data.Length-nek nevezi a bemeneteket, ami könnyen úgy olvasható félre, hogy csak a rekordkezdő offszetről van szó, és pontosan az a félreolvasás, amit a HotXLS v2.384.47-ig szállított

Három részlet dönti el, hogy az indexeid sorban vannak-e az Excellel:

  • A 4 bájtos rekordfej sosem transzformálódik, de streampozíciókat foglal, így egy rekord első testbájtja a fej offszetje + 4 helyen ül
  • A hossztag a teljes rekordadat hossza, nem a ténylegesen transzformált bájtok száma
  • A BOUNDSHEET részben sima: az első 4 bájtja, a lbPlyPos, a sheet BOF-jának stream offszetje olvasható marad, hogy a parser megtalálja a sheeket. Az a 4 bájt kimarad a transzformációból, de az offszetbe és a rekordhosszba is beleszámít
HotXLS ábra az XorArrayIndex szabályról BIFF5 XOR obfuszkációhoz: minden testbájt a stream offszetét és a teljes rekordadat hosszát használja mod 16, a sima négybájtos fej és a BOUNDSHEET lbPlyPos prefix az offszetbe beleszámít, és a rekordhossz tag ignorálása volt az a hiba, amit a HotXLS v2.384.47-ben javított
A tömbindex rekordonként egyszer indul újra, nem streamenként egyszer: a fej és bármely sima prefix pozíciókat foglal, a rekordadat hossza táplálja a modulót, és a régi HotXLS mindkét fele egyetértett a rossz képletben

Együtt a rekordonkénti transzformáció néhány sor. Megint csak a szabály vázlata ez, nem kell hívnod:

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

// Egy rekord testének obfuszkálása helyben. A BodyPos a Body[0] stream
// offszetje, azaz a rekordfej offszetje + 4. A PlainPrefix 4 a
// BOUNDSHEET-hez, BOF / FILEPASS-nál a teljes hossz, a legtöbb rekordnál 0
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;

// Az olvasás a tükörképe: B := Body[I] xor Arr[...];
// aztán 5-tel jobra forgatás, azaz Rol8(B, 3)

v2.384.47 előtt a HotXLS olvasója az indexet a streampozícióból számolta önmagában, az író pedig a sima bájt offszetet használta. Mindkettő ignorálta a rekordhosszt, tehát megint a két fél értett egymással, és senki mással. Egy függetlenül írt dekódór a v2.384.47-es kimenetet helyesen olvasta, az öregebbet szemétként, a nyolcváltozatos Excel 16-os teszt pedig később a valódi céllal szemben erősítette meg a szabályt

Mi történik az öregebb HotXLS verziók XOR fájljaival?

A HotXLS továbbra is olvassa a saját v2.384.54 előtti XOR fájljait a FILEPASS kulcs megnézésével: ha a tárolt kulcs egyenlő a jelszóból származtatott kulccsal, az olvasó a specifikáció szerinti XorRor tömböt építi, ha eltér, a fájlt öregebb HotXLS fájlként kezeli, és a balra-2 tömböt építi újra. Az Excel által írt fájlok mindig a származtatott kulcsot hordozzák, így mindig a specifikációs útra kerülnek

A teszt heurisztika, pontos bukási aránnyal. Egy régi fájl, aminek a véletlen kulcsa véletlenül egyezett a származtatottal, rossz tömbbel olvasódott volna, ennek esélye 65 536-ban 1. A fallback csak a tömbforgatást fedi le; az indexszabály nem vált, így azok a fájlok menekülnek meg, amik a v2.384.47 és v2.384.53 közt íródtak. Ha még tartsz BIFF5 XOR fájlokat abból az ablakból, nyisd meg őket a jelenlegi HotXLS-szel, és mentsd újra, hogy olyan fájlt kapj, amit az Excel elfogad

Két jelszórészlet minden fájlra érvényes, régre és újra egyaránt:

  • Hossz. A CreateXorKey_Method1 csak az első 15 jelszóbájtot olvassa, ami a specifikációs korlát. A HotXLS ezt a sapkát a kulcsra alkalmazza, a verifikátort meg a tömböt a szokásos teljes hosszú és 16 bájtos szabályaikon tartja, mindkét oldalon következetesen. Maga az Excel 15 karakternél hosszabb jelszavakat utasít el ennél a formátumnál, így kezeld a 15-öt valódi maximumként
  • Karakterkészlet. A HotXLS a jelszót a rendszer ANSI kódlapján át váltja bájtokká. A specifikáció minden UTF-16 karakter alsó bájtjának levételét írja le, ami ASCII-nél egyezik. Nem ASCII jelszavakkal védett Excel minták nélkül nincs referenciánk a többire, ezért ragaszkodj ASCII jelszavakhoz XOR fájloknál

Az olvasó oldalon a TXLSWorkbook.OnPassword jelszót kérdezhetsz vele, amikor az Open FILEPASS rekordba botlik. Az esemény egy TXLSPasswordEvent, var PassWord: WideString-gel és var Retry: Boolean-nal; állítsd a Retry-t True-ra az újrapróbáláshoz, legfeljebb három próbáig:

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: kell jelszó, de nem érkezett; -1005: rossz jelszó
  if Rc <> 1 then
    raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
  ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;

Ha a jelszót már ismered, az Open(FileName, APassWord) teljesen kihagyja az eseményt

Biztonságos-e a XOR obfuszkáció bármire?

A BIFF XOR obfuszkáció nem titkosítás, és motivált olvasóval szemben semmit sem véd. A jelszóteszt egy 16 bites verifikátor, a kulcs 16 bites, és a 16 bájtos tömb az egész streamen át ismétlődik, így a kiszámítható BIFF rekordtartalmak tömbbájtokat fednek fel jelszó nélkül is. A HotXLS csak azért ír XOR-t, mert az Excel 5.0/95 fájloknak nincs más opciója, és az ok, hogy ma ilyen fájlt produkálj, egy olyan örökölt fogyasztó, ami semmi újabbat nem tud olvasni

A klasszikus engine a TXLSWorkbook.EncryptionType-on át választja a sémát, és a mentési formátummal való kombinációt szigorúan ellenőrzi:

  • Az xletAuto (alapérték) RC4 CryptoAPI-t ír xlExcel97-hez és XOR-t xlExcel5-höz, egyezve azzal, amit maga az Excel írt mindegyik formátumhoz
  • Az xletXor csak BIFF5-re érvényes; xlExcel97-gel a mentés kivételt dob, csendes visszaesés helyett
  • Az xletRC4 és xletRC4CryptoAPI csak BIFF8-as, és BIFF5 mentésnél ugyancsak kivételt ad

Az RC4 is idős, és az interoperabilitásának részleteit a miért utasítja el az Excel a titkosított munkafüzetet helyes jelszóval cikk fedi le. Ha a címzett tud XLSX-et olvasni, használd helyette az XLSX enginet: a TXLSXWorkbook.SaveAsEncryptedAgile Agile Encryptiont ír (SHA-512 jelszóhash 100 000 iterációs spin counttal és AES-256-CBC-vel), azt a formátumot, amit az Excel 2010 és újabb alapból ír, míg a SaveAsEncrypted az öregebb AES-128 Standard Encryptiont. A kettő közti kompromisszumok a XLSX fájlok titkosítása AES-szel Delphiben cikkben vannak, az olvasó oldalát pedig a Agile-titkosított Excel fájlok olvasása HotXLS-szel fedi le

Gyorsreferencia: BIFF XOR obfuszkáció, amit az Excel 16 elfogad

  • A FILEPASS ($002F) a globals BOF-ot követi; BIFF5-ben a teste 4 bájt: kulcs, aztán verifikátor
  • Kulcs = CreateXorKey_Method1(password) a [MS-OFFCRYPTO] §2.3.7.2 szerint, soha nem véletlen; a secret-hez $014D
  • Tömb = jelszóbájtok + pad, páros pozíciókon az alsó kulcsbájt, páratlan pozíciókon a felső kulcsbájt XOR-olva, aztán XorRor (egébittel jobra)
  • Bájt titkosítása: 5-tel balra forgatás, aztán XOR; visszafejtés: XOR, aztán 5-tel jobra forgatás (§2.3.7.3)
  • XorArrayIndex = (bájt stream offszet + rekordadat hossz) mod 16; a fejek és sima prefixek beleszámítanak az offszetbe
  • A BOUNDSHEET megtartja az első 4 bájtját simán; a BOF, FILEPASS és INTERFACEHDR teljesen sima marad
  • Jelszavak: ASCII, legfeljebb 15 karakter
  • HotXLS: index javítva v2.384.47-ben, kulcs és XorRor javítva v2.384.54-ben, öregebb HotXLS XOR fájlok kulcseltérés alapján felismerve
  • Valódi védelemhez legalább BIFF8 RC4 CryptoAPI, vagy XLSX Agile Encryption

A HotXLS kezeli a BIFF5 és BIFF8 jelszavakat, az XLSX Standard és Agile Encryptiont, meg az olvasó oldali jelszócallbacket egyetlen Delphi és C++Builder libraryből, a fenti interoperabilitási részletekkel együtt kezelve. Kiadásokért, platformokért és próbaverzióért lásd a HotXLS Delphi spreadsheet component oldalt