Техническа статия

XOR обфускация на XLS в HotXLS: извеждане на ключа и XorRor

HotXLS записва Excel 5.0/95 (BIFF5) работни книги с XOR обфускация, които Excel 16 отваря само когато три детайла съвпадат с [MS-OFFCRYPTO] точно: ключът в FILEPASS трябва да е CreateXorKey_Method1(password), 16-байтовият XOR масив трябва да е построен с XorRor (ротация надясно с един бит), а всеки байт трябва да ползва XorArrayIndex = (stream отместване + дължина на записа) mod 16. HotXLS оправи индекса в v2.384.47, а ключа и ротацията в v2.384.54. Преди това всеки BIFF5 файл с парола, който произвеждаше, се отваряше без проблем в HotXLS и проваляше в Excel

Това последно изречение е цялата история в миниатюра. Четец и writer, споделящи една и съща грешна идея, се съгласяват идеално един с друг, така че round-trip тестовете остават зелени, докато единственият потребител, който има значение, казва не. Excel 16 каза не два пъти, с две различни съобщения, и всяко съобщение сочеше различен слой на схемата. Тази статия минава през тези слоеве в реда, по който Excel ги проверява, с детайли на ниво байт, полезни както ако викате HotXLS, така и ако пишете собствен BIFF четец

Какво всъщност съхранява BIFF XOR обфускацията?

BIFF XOR обфускацията съхранява само две 16-битови думи във файла, а всичко останало се преизчислява от паролата. Записът FILEPASS ($002F) стои веднага след workbook globals BOF, а в BIFF5 файл тялото му е точно 4 байта: XOR ключът, следван от password verifier-а. Няма salt, няма идентификатор на алгоритъм и няма криптиран verifier blob от рода на тези, които RC4 и AES схемите носят

От тези две думи четец възстановява три неща:

  • Verifier-ът — 16-битов хеш на байтовете на паролата, XOR-нат с $CE4B. Сравнението му със съхранената дума е проверката за парола, и единствената
  • XOR ключът — 16-битова стойност от CreateXorKey_Method1 в [MS-OFFCRYPTO] §2.3.7.2, водена от две константни таблици (InitialCode, 15 думи, и XorMatrix, 105 думи)
  • XOR масивът — 16 байта от байтовете на паролата, допълнени с фиксиран 16-байтов pad, всеки XOR-нат с ниския байт на ключа (четни позиции) или високия (нечетни), след това ротирани надясно с един бит

Заглавките на записите остават в чист текст, както и шепа цели записи, които схемата освобождава, между тях BOF, FILEPASS и INTERFACEHDR. Всяко друго тяло на запис се преобразува байт по байт: ротация наляво с 5 бита, после XOR с един елемент от 16-байтовия масив. Дешифрирането, което [MS-OFFCRYPTO] §2.3.7.3 описва като DecryptData_Method1, е огледалният образ: първо XOR, после ротация надясно с 5

В HotXLS никога не пипате нищо от това директно. Зададете парола, изберете формата, и SaveAs извежда FILEPASS и преобразува потока:

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 поддържа само XOR обфускация; и xletAuto щеше да я избере
  Wb.EncryptionType := xletXor;
  // Дръжте я ASCII и най-много 15 знака (виж по-долу)
  Wb.EncryptionPassword := 'secret';

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

Защо Excel казва, че паролата е грешна, когато verifier-ът съвпада?

Excel отхвърля паролата, защото не вярва на съхранения ключ: Excel извежда ключа от въведената парола с CreateXorKey_Method1 и го сравнява с ключовата дума в FILEPASS, така че файл с друг ключ проваля проверката за парола дори verifier-ът да е верен. Спецификацията описва ключа като изход от паролата, не като свободен параметър, а Excel 16 налага точно това четене

HotXLS writer-ът преди v2.384.54 пълнеше ключовата дума с два случайни байта. На хартия изглежда безобидно, тъй като verifier-ът е документираната проверка за парола, а масивът се строи от какъвто и да е ключ, който файлът декларира. HotXLS сам четеше тези файлове без проблем, защото четецът му вземаше ключа от FILEPASS като даденост. Excel 16, със същия файл и правилната парола, отговаряше, че паролата не е вярна. От v2.384.54 насам ключът се извежда, така че FILEPASS за паролата secret винаги държи ключ $014D и verifier $DAA7 — стойности, сверени срещу независима имплементация на спецификацията

Диаграма на HotXLS за BIFF5 XOR FILEPASS записа, който Excel 16 проверява при отваряне: обикновеното четирибайтово тяло държи XOR ключа и password verifier-а, Excel извежда ключа от въведената парола с CreateXorKey_Method1 и отхвърля случаен ключ с грешка за парола дори verifier-ът да съвпада; HotXLS съхранява изведения ключ 014D за secret
Тялото на FILEPASS е само две думи, но Excel преизвежда ключа от вашата парола и сравнява; случаен ключ проваля проверката дори с верен verifier — затова HotXLS го извежда от v2.384.54 насам

Самото извеждане е кратко, щом двете таблици са на място. Минете паролата обратно, гледайте бит 6 на всеки байт седем пъти, докато го измествате наляво, и XOR-вайте по един елемент на XorMatrix всеки път, когато битът е вдигнат. Следващото е принципна скица, която възпроизвежда алгоритъма на спецификацията и съвпада с имплементацията в HotXLS; не е HotXLS API:

// Принципна скица на [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// и CreateXorArray_Method1 (само илюстрация, не 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;                       // ключът вижда само 15 байта
  if Len = 0 then
    Exit;
  Result := InitialCode[Len - 1];
  Element := $68;                    // последният елемент на 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));   // ротация надясно с един бит
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;

За secret това дава масива 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF — удобен fixture, ако тествате собствен четец

Диаграма на HotXLS за построяването на XOR масива при BIFF5 обфускация: шестнайсет байта, посяти от паролата и фиксиран pad, се XOR-ват с ниския байт на ключа на четни позиции и с високия на нечетни, после се ротират надясно с един бит с XorRor, давайки fixture 1F 32 17 B9 за паролата secret
Байтовете на масива идват от паролата, pad-а и двата байта на ключа, с една ротация на края; ротация наляво с 2 работеше само когато байтовото преобразуване минаваше в обратен ред, а Excel следва реда на спецификацията

Защо правилният ключ пак дава повреден файл?

Верен ключ пак дава повреден файл, когато XOR масивът е ротиран в грешната посока: [MS-OFFCRYPTO] дефинира стъпката за масива като XorRor — ротация надясно с един бит — а масив, ротирал наляво с два, дешифрира всяко тяло на запис до шум. Поправката на ключа прати Excel 16 отвъд подкановата за парола и направо в различна грешка — доклад, че файлът има проблем и не може да бъде отворен

Старият HotXLS код ротираше всеки байт на масива наляво с 2 бита — форма, която обикаля в няколко BIFF имплементации. Тъй като HotXLS ползваше същата ротация и на двете страни, собственият му четец никога не забеляза. Excel 16 вече не може да записва Excel 5.0/95 файлове и не предлага XOR при запис на BIFF8, така че нямаше нативен Excel образец за сравнение. Доказателството трябваше да дойде от другата посока: напишете един stream в чист текст, прекодирайте го по осем начина и накарайте Excel 16 да отвори всеки вариант. Осемте варианта пресичаха три независими избора:

ИзборВариант AВариант B
Ротация на масиваXorRor (ротация надясно с 1)Ротация наляво с 2
Индекс на масива(offset + дължина на записа) mod 16offset mod 16
Ред на байтовото преобразуванеРотация наляво с 5, после XORXOR, после ротация наляво с 5

Excel 16 отвори точно два от осемте: XorRor с ротация-преди-XOR и индекса с дължината на записа, и един вариант, който само изглежда различен. Ротация-наляво-2 с XOR-преди-ротация и същият индекс е същата функция в маска. Ротацията се разпределя върху XOR, така че rol5(p xor rol2(b)) е равно на rol5(p) xor rol7(b), а върху 8-битова стойност ротация наляво с 7 е ротация надясно с 1. Накратко, rol5 ∘ rol2 = ror1 — затова масивът с ротация наляво 2 изглежда правдоподобен изолиран: той е верен само заедно с обратния ред на преобразуването. С реда на спецификацията той поврежда всеки преобразуван байт

Същият експеримент разреши и втори въпрос. Вариантите, които махаха дължината на записа от индекса, всички проваляха, което потвърди правилото за индекса, прието от HotXLS една версия по-рано само на базата текста на спецификацията

Как се изчислява XorArrayIndex за всеки байт?

XorArrayIndex за байт е отместването му в workbook stream-а плюс дължината на целия запис, на който принадлежи, mod 16. Индексът следователно рестартира от зависеща от записа стойност за всеки запис и нараства с едно на байт вътре в него. Псевдокодът на спецификацията назовава входовете FileOffset и Data.Length, което лесно се прочита погрешно като само началното отместване на записа — а точно това погрешно четене беше това, което HotXLS изпращаше до v2.384.47

Три детайла решават дали индексите ви се изравняват с Excel:

  • 4-байтовата заглавка на запис никога не се преобразува, но пак заема позиции в потока, така че първият байт от тялото на запис стои на заглавно отместване + 4
  • Дължинният член е пълната дължина на данните на записа, не броят байтове, действително преобразувани
  • BOUNDSHEET е частично чист: първите му 4 байта, lbPlyPos — stream отместването на BOF на листа, остават четими, за да може парсер да намира листовете. Тези 4 байта се прескачат от преобразуването, но пак броят и към отместването, и към дължината на записа
Диаграма на HotXLS за правилото XorArrayIndex при BIFF5 XOR обфускация: всеки байт от тялото ползва stream отместването плюс пълната дължина на данните на записа по модул 16, чистата четирибайтова заглавка и префиксът lbPlyPos на BOUNDSHEET пак броят към отместването, а игнорирането на члена за дължина на записа беше дефектът, който HotXLS поправи в v2.384.47
Индексът на масива рестартира по веднъж на запис, не по веднъж на поток: заглавката и всеки чист префикс заемат позиции, дължината на данните на записа храни модула, а двете половини на стария HotXLS се съгласяваха на грешната формула

Сложени заедно, преобразуването на запис е няколко реда. И пак — това е скица на правилото, не нещо, което трябва да викате:

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

// Обфускира едно тяло на запис на място. BodyPos е stream отместването на
// Body[0], т.е. отместването на заглавката на записа + 4. PlainPrefix е 4 за
// BOUNDSHEET, пълната дължина за BOF / FILEPASS, 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;

// Четенето е огледалният образ: B := Body[I] xor Arr[...];
// после ротация надясно с 5, т.е. Rol8(B, 3)

Преди v2.384.47 HotXLS четецът смяташе индекса само от позицията в потока, а writer-ът ползваше чистото байтово отместване. И двете игнорираха дължината на записа, така че отново двете половини се съгласяваха една с друга и с никой друг. Независимо написан декодер прочете правилно изхода на v2.384.47 и по-стария изход като боклук, а осемвариантният тест с Excel 16 по-късно потвърди правилото срещу истинската цел

Какво става с XOR файловете, писани от по-стари версии на HotXLS?

HotXLS продължава да чете собствените си XOR файлове отпреди v2.384.54, като проверява ключа в FILEPASS: когато съхраненият ключ е равен на изведения от паролата, четецът строи XorRor масива по спецификацията, а когато се различава, четецът третира файла като по-стар HotXLS файл и възстановява масива с ротация наляво 2. Excel-писани файлове винаги носят изведения ключ, така че винаги минават по пътя на спецификацията

Тестът е евристика с точна честота на грешка. Стар файл, чийто случаен ключ случайно се е оказал равен на изведения ключ, би бил прочетен с грешния масив, а вероятността за това е 1 на 65,536. Резервният път покрива само ротацията на масива; правилото за индекса не се превключва, така че файловете, които той спасява, са писаните между v2.384.47 и v2.384.53. Ако все още държите BIFF5 XOR файлове от този прозорец, отворете ги с текущия HotXLS и ги запишете отново, за да получите файл, който Excel приема

Два детайла за паролата важат за всеки файл, стар или нов:

  • Дължина. CreateXorKey_Method1 чете само първите 15 байта на паролата, което е лимитът на спецификацията. HotXLS прилага този таван към ключа и пази verifier-а и масива по обичайните им правила за пълна дължина и 16 байта, последователно и на двете страни. Самият Excel отказва пароли, по-дълги от 15 знака, за този формат, така че приемете 15 за истинския максимум
  • Знаков набор. HotXLS превръща паролата в байтове чрез системната ANSI кодова страница. Спецификацията описва вземане на ниския байт на всеки UTF-16 знак, което съвпада за ASCII. Без Excel образци, защитени с не-ASCII пароли, няма истина от първа ръка за останалото, така че дръжте се към ASCII пароли за XOR файлове

От страната на четенето TXLSWorkbook.OnPassword ви позволява да питате за парола, когато Open срещне FILEPASS запис. Събитието е TXLSPasswordEvent с var PassWord: WideString и var Retry: Boolean; задайте Retry на True, за да опитате отново, до три опита:

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: нужна е парола, но не е подадена; -1005: грешна парола
  if Rc <> 1 then
    raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
  ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;

Ако вече знаете паролата, Open(FileName, APassWord) прескача събитието изцяло

Достатъчно ли е сигурна XOR обфускацията за нещо?

BIFF XOR обфускацията не е криптиране и не пази нищо срещу мотивиран четец. Проверката за парола е 16-битов verifier, ключът е 16 бита, а 16-байтовият масив се повтаря през целия поток, така че предвидимите съдържания на BIFF записи излагат байтовете на масива без никаква парола. HotXLS пише XOR само защото Excel 5.0/95 файлове нямат друга възможност, а причината днес да произвеждате такива файлове е legacy потребител, който не чете нищо по-ново

Класическият двигател избира схемата чрез TXLSWorkbook.EncryptionType, а комбинацията със save формата се проверява строго:

  • xletAuto (по подразбиране) записва RC4 CryptoAPI за xlExcel97 и XOR за xlExcel5, в тон с това, което самият Excel е записвал за всеки формат
  • xletXor е валиден само за BIFF5; с xlExcel97 записът вдига exception вместо тихо да се върне назад
  • xletRC4 и xletRC4CryptoAPI са само за BIFF8, а искането им при BIFF5 запис също вдига exception

RC4 също е остарял, а детайлите по interoperability са разгледани в защо Excel отхвърля криптирана работна книга с вярна парола. Ако получателят може да чете XLSX, ползвайте вместо това XLSX двигателя: TXLSXWorkbook.SaveAsEncryptedAgile записва Agile Encryption (SHA-512 хеширане на паролата със spin count от 100,000 итерации и AES-256-CBC) — форматът, който Excel 2010 и по-нови записват по подразбиране, докато SaveAsEncrypted записва по-старото AES-128 Standard Encryption. Съображенията между двете са в криптирането на XLSX файлове с AES в Delphi, а страната на четенето е разгледана в четенето на Agile-криптирани Excel файлове с HotXLS

Бърза справка: BIFF XOR обфускация, която Excel 16 приема

  • FILEPASS ($002F) следва globals BOF; в BIFF5 тялото му е 4 байта: ключ, после verifier
  • Ключ = CreateXorKey_Method1(password) според [MS-OFFCRYPTO] §2.3.7.2, никога случаен; за secret той е $014D
  • Масив = байтове на паролата + pad, XOR с ниския байт на ключа на четни позиции и високия на нечетни, после XorRor (ротация надясно с 1)
  • Криптиране на байт: ротация наляво 5, после XOR; декриптиране: XOR, после ротация надясно 5 (§2.3.7.3)
  • XorArrayIndex = (stream отместване на байта + дължина на данните на записа) mod 16; заглавките и чистите префикси броят към отместването
  • BOUNDSHEET пази първите си 4 байта чисти; BOF, FILEPASS и INTERFACEHDR остават изцяло чисти
  • Пароли: ASCII, най-много 15 знака
  • HotXLS: индексът оправен в v2.384.47, ключът и XorRor в v2.384.54, по-старите HotXLS XOR файлове се разпознават по несъответствие на ключа
  • За истинска защита ползвайте поне BIFF8 RC4 CryptoAPI или XLSX Agile Encryption

HotXLS обслужва BIFF5 и BIFF8 защита с парола, XLSX Standard и Agile Encryption и read-side callback-а за парола от една Delphi и C++Builder библиотека, с интероп детайлите по-горе решени за вас. Вижте HotXLS Delphi spreadsheet component за издания, платформи и пробно изтегляне