Технічна стаття

Обфускація XOR у HotXLS XLS: виведення key і XorRor

HotXLS пише XOR-обфусковані workbooks Excel 5.0/95 (BIFF5), які Excel 16 відкриває лише тоді, коли три деталі збігаються з [MS-OFFCRYPTO] дослівно: FILEPASS key мусить бути CreateXorKey_Method1(password), 16-байтний XOR array мусить будуватися через XorRor (зсув праворуч на один біт), а кожен byte мусить використовувати XorArrayIndex = (stream offset + record length) mod 16. HotXLS вивів index правильно у v2.384.47, а key і ротацію — у v2.384.54. До того кожен password-захищений BIFF5 файл, який він продукував, відкривався в HotXLS без проблем і падав в Excel

Останнє речення — вся історія в мініатюрі. Reader і writer, що поділяють ту саму неправильну ідею, ідеально згоджуються один з одним, тож round-trip тести лишаються зеленими, поки єдиний важливий consumer каже ні. Excel 16 сказав ні двічі, двома різними повідомленнями, і кожне вказувало на різний шар схеми. Ця стаття проходить ті шари в тому порядку, в якому їх перевіряє Excel, з байт-рівневими деталями, придатними і коли ви викликаєте HotXLS, і коли пишете власний BIFF reader

Що насправді зберігає BIFF XOR obfuscation?

BIFF XOR obfuscation зберігає у файлі лише два 16-бітні words, а все інше перераховується з password. Запис FILEPASS ($002F) сидить одразу після workbook globals BOF, і в BIFF5 файлі його body — рівно 4 bytes: XOR key, за яким іде password verifier. Ніякого salt, жодного algorithm identifier і жодного зашифрованого verifier blob, як у схемах RC4 та AES

З тих двох words reader відбудовує три речі:

  • verifier — 16-бітний hash байтів password, XOR-нутий з $CE4B. Порівняння зі збереженим word — це перевірка password, причому єдина
  • XOR key — 16-бітне значення з CreateXorKey_Method1 у [MS-OFFCRYPTO] §2.3.7.2, що рухається двома сталими таблицями (InitialCode, 15 words, і XorMatrix, 105 words)
  • XOR array — 16 bytes із байтів password, доповнених фіксованим 16-байтним pad, кожен XOR-нутий з молодшим key byte (парні позиції) чи старшим (непарні), а тоді зсунутий циклічно праворуч на один біт

Record headers лишаються plain text, як і кілька цілих записів, які схема виключає, серед них BOF, FILEPASS і INTERFACEHDR. Кожен інший record body трансформується byte за byte: зсув ліворуч на 5 бітів, потім XOR з одним елементом 16-байтного array. Розшифрування, яке [MS-OFFCRYPTO] §2.3.7.3 описує як DecryptData_Method1, — дзеркальне: спершу XOR, потім зсув праворуч на 5

У HotXLS ви ніколи не торкаєтеся нічого з цього напряму. Задали password, обрали формат — і SaveAs виводить FILEPASS і трансформує 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 підтримує лише XOR obfuscation; xletAuto обрав би його також
  Wb.EncryptionType := xletXor;
  // Тримайте ASCII і щонайбільше 15 символів (див. нижче)
  Wb.EncryptionPassword := 'secret';

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

Чому Excel каже, що password неправильний, хоч verifier збігається?

Excel відхиляє password, бо не довіряє збереженому key: Excel виводить key із введеного password через CreateXorKey_Method1 і порівнює з key word у FILEPASS, тож файл, у якого key — що завгодно інше, провалює перевірку password навіть із правильним verifier. Специфікація описує key як вихід password, а не як вільний parameter, і Excel 16 наполягає на цьому прочитанні

HotXLS writer до v2.384.54 заповнював key word двома випадковими bytes. На папері це виглядає нешкідливо, адже verifier — документована перевірка password, а array будується з того key, який файл декларує. Сам HotXLS читав ті файли без клопоту, бо його reader брав key із FILEPASS як даність. Excel 16, отримавши той самий файл і правильний password, відповідав, що password неправильний. Від v2.384.54 key виводиться, тож FILEPASS для password secret завжди тримає key $014D і verifier $DAA7 — значення, звірені з незалежною імплементацією специфікації

Діаграма HotXLS запису FILEPASS BIFF5 XOR, який Excel 16 перевіряє під час відкриття: plain чотирибайтний body тримає XOR key і password verifier, Excel виводить key із введеного password через CreateXorKey_Method1 і відхиляє випадковий key помилкою password навіть коли verifier збігається; HotXLS зберігає виведений key 014D для secret
Body FILEPASS — лише два words, але Excel заново виводить key з вашого password і порівнює; випадково заповнений key провалює перевірку навіть із правильним verifier, саме тому HotXLS виводить його від v2.384.54

Саме виведення коротке, коли дві таблиці на місці. Пройдіться password назад, сім разів подивіться на біт 6 кожного byte, зсуваючи його ліворуч, і 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;                       // key бачить лише 15 bytes
  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 це дає array 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF — зручний fixture, якщо ви тестуєте власний reader

Діаграма HotXLS побудови XOR array для BIFF5 obfuscation: шістнадцять bytes, посіяних з password і фіксованого pad, XOR-няться з молодшим key byte на парних позиціях і старшим на непарних, потім зсуваються циклічно праворуч на один біт через XorRor, даючи fixture 1F 32 17 B9 для password secret
Байти array походять із password, pad і двох key bytes, одна ротація в кінці; rotate left 2 працював лише коли byte-трансформація йшла в протилежному порядку, а Excel тримається порядку специфікації

Чому правильний key все одно дає пошкоджений файл?

Правильний key все ще дає пошкоджений файл, коли XOR array зсунуто не туди: [MS-OFFCRYPTO] визначає array-крок як XorRor, зсув праворуч на один біт, а array, зсунутий ліворуч на два, розшифровує кожен record body у шум. Виправлення key провело Excel 16 повз запит password прямо в іншу error — повідомлення, що файл має проблему і не може бути відкритий

Старий код HotXLS зсував кожен array byte ліворуч на 2 біти — форма, що ходить по кількох BIFF імплементаціях. Оскільки HotXLS уживав ту саму ротацію з обох боків, власний reader ніколи не помічав. Excel 16 вже не вміє зберігати файли Excel 5.0/95, а при збереженні BIFF8 не пропонує XOR, тож рідного Excel-зразка для diff не було. Свідчення мусило прийти з іншого боку: написати один plain-text BIFF5 stream, перекодувати його вісьмома способами і дати Excel 16 відкрити кожен варіант. Вісім варіантів перехрестили три незалежні вибори:

ВибірВаріант AВаріант B
Ротація arrayXorRor (зсув праворуч 1)Зсув ліворуч 2
Index array(offset + record length) mod 16offset mod 16
Порядок byte-трансформаціїЗсув ліворуч 5, потім XORXOR, потім зсув ліворуч 5

Excel 16 відкрив рівно два з восьми: XorRor з rotate-then-XOR та index із record length, і один варіант, що лише виглядає інакше. Rotate-left-2 з XOR-then-rotate і тим самим index — та сама функція в маскуванні. Ротація розподіляється над XOR, тож rol5(p xor rol2(b)) дорівнює rol5(p) xor rol7(b), а на 8-бітному значенні зсув ліворуч на 7 — це зсув праворуч на 1. Коротше, rol5 ∘ rol2 = ror1, саме тому rotate-left-2 array виглядає правдоподібно в ізоляції: він правильний лише разом із протилежним порядком трансформації. У парі з порядком специфікації він псує кожен трансформований byte

Той самий експеримент закрив і друге питання. Варіанти, що викидали record length з index, провалилися всі, чим підтвердилося index-правило, яке HotXLS прийняв релізом раніше, спираючись лише на текст специфікації

Як XorArrayIndex рахується для кожного byte?

XorArrayIndex для byte — це його offset у workbook stream плюс довжина всього record data, до якого він належить, mod 16. Тому index перезапускається зі значення, залежного від запису, для кожного record і зростає на одиницю за byte всередині нього. Псевдокод специфікації називає входи FileOffset і Data.Length, що легко прочитати як самий лише record start offset, і саме це неправильне прочитання HotXLS і поставляв до v2.384.47

Три деталі вирішують, чи ваші index збігаються з Excel:

  • 4-байтний record header ніколи не трансформується, але все одно займає stream позиції, тож перший body byte запису сидить на header offset + 4
  • Член довжини — це повна record data length, а не кількість byte, реально трансформованих
  • BOUNDSHEET — частково plain: його перші 4 bytes, lbPlyPos, stream offset BOF аркуша, лишаються читабельними, щоб parser міг знайти sheets. Ті 4 bytes трансформація пропускає, але вони все одно рахуються і в offset, і в record length
Діаграма HotXLS правила XorArrayIndex для BIFF5 XOR obfuscation: кожен body byte використовує stream offset плюс повну record data length mod 16, plain чотирибайтний header і префікс BOUNDSHEET lbPlyPos все одно рахуються в offset, а ігнорування члена record length було дефектом, який HotXLS виправив у v2.384.47
Array index перезапускається раз за record, а не раз за stream: header і будь-який plain префікс займають позиції, record data length живить modulo, і обидві половини старого HotXLS згоджувалися на неправильній формулі

Разом узяте, per-record перетворення — кілька рядків. Знову ж таки, це замальовка правила, а не те, що вам треба викликати:

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

// Обфускація одного record body на місці. BodyPos — stream offset
// Body[0], тобто record header offset + 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 reader рахував index лише з stream позиції, а writer уживав plain byte offset. Обидва ігнорували record length, тож знову дві половини згоджувалися одна з одною і ні з ким іншим. Незалежно написаний decoder читав вивід v2.384.47 правильно, а старіший вивід — як сміття, і восьмиваріантний тест Excel 16 згодом підтвердив правило проти реальної цілі

Що стається з XOR файлами, написаними старішими версіями HotXLS?

HotXLS продовжує читати власні до-v2.384.54 XOR файли, звіряючи FILEPASS key: коли збережений key дорівнює key, виведеному з password, reader будує XorRor array зі специфікації, а коли відрізняється, reader вважає файл старішим HotXLS файлом і перебудовує rotate-left-2 array. Файли, написані Excel, завжди несуть виведений key, тож вони завжди йдуть специфікаційним шляхом

Тест — евристика з точною частотою промахів. Старий файл, чиїй випадковий key випадково дорівнював би виведеному, читався б неправильним array, а шанс цього — 1 із 65 536. Fallback покриває лише ротацію array; index-правило не перемикається, тож файли, які він рятує, — це написані між v2.384.47 і v2.384.53. Якщо ви все ще тримаєте BIFF5 XOR файли з того вікна, відкрийте їх поточним HotXLS і збережіть знову, щоб отримати файл, який приймає Excel

Дві деталі password стосуються кожного файлу, старого чи нового:

  • Довжина. CreateXorKey_Method1 читає лише перші 15 password bytes — це ліміт специфікації. HotXLS застосовує ту стелю до key і тримає verifier та array на їхніх звичних правилах повної довжини і 16 bytes, послідовно з обох боків. Сам Excel відмовляє password довші за 15 символів для цього формату, тож рахуйте 15 реальним максимумом
  • Набір символів. HotXLS конвертує password у bytes через системну ANSI code page. Специфікація описує взяття молодшого byte кожного UTF-16 символу, що збігається для ASCII. Без Excel-зразків, захищених не-ASCII password, ground truth для решти немає, тож тримайтеся ASCII password для XOR файлів

На читацькому боці TXLSWorkbook.OnPassword дозволяє запитати password, коли Open натрапляє на запис FILEPASS. Подія — це TXLSPasswordEvent із var PassWord: WideString і var Retry: Boolean; поставте Retry у True, щоб спробувати знову, щонайбільше три 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: потрібен password, але не надано; -1005: неправильний password
  if Rc <> 1 then
    raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
  ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;

Якщо password вам уже відомий, Open(FileName, APassWord) оминає подію повністю

Чи достатньо XOR obfuscation безпечний хоч для чогось?

BIFF XOR obfuscation — це не шифрування і не захищає нічого від мотивованого читача. Перевірка password — 16-бітний verifier, key — 16 бітів, а 16-байтний array повторюється по всьому stream, тож передбачувані вмісти BIFF записів викривають байти array без жодного password. HotXLS пише XOR лише тому, що файли Excel 5.0/95 не мають іншої опції, а причина продукувати такі файли сьогодні — legacy consumer, який не читає нічого новішого

Classic engine обирає схему через TXLSWorkbook.EncryptionType, і комбінація з форматом збереження перевіряється строго:

  • xletAuto (усталено) пише RC4 CryptoAPI для xlExcel97 і XOR для xlExcel5, як сам Excel писав для кожного формату
  • xletXor дійсний лише для BIFF5; з xlExcel97 збереження піднімає exception замість тихого відкату
  • xletRC4 і xletRC4CryptoAPI — лише BIFF8, і запит їх на BIFF5 збереженні теж піднімає exception

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

Швидка довідка: BIFF XOR obfuscation, який приймає Excel 16

  • FILEPASS ($002F) іде за globals BOF; у BIFF5 його body — 4 bytes: key, потім verifier
  • Key = CreateXorKey_Method1(password) за [MS-OFFCRYPTO] §2.3.7.2, ніколи не випадковий; для secret це $014D
  • Array = password bytes + pad, XOR з молодшим key byte на парних позиціях і старшим на непарних, потім XorRor (зсув праворуч 1)
  • Зашифрувати byte: зсув ліворуч 5, потім XOR; розшифрувати: XOR, потім зсув праворуч 5 (§2.3.7.3)
  • XorArrayIndex = (byte stream offset + record data length) mod 16; headers і plain префікси рахуються в offset
  • BOUNDSHEET тримає перші 4 bytes plain; BOF, FILEPASS і INTERFACEHDR лишаються повністю plain
  • Password: ASCII, щонайбільше 15 символів
  • HotXLS: index виправлено у v2.384.47, key і XorRor — у v2.384.54, старіші HotXLS XOR файли розпізнаються за неспівпадінням key
  • Для реальної защити використовуйте щонайменше BIFF8 RC4 CryptoAPI або XLSX Agile Encryption

HotXLS опікується BIFF5 і BIFF8 password-захистом, XLSX Standard і Agile Encryption та читацьким password callback з однієї бібліотеки Delphi і C++Builder, з деталями interop вище, зробленими за вас. Видання, платформи та пробне завантаження — на сторінці HotXLS Delphi spreadsheet component