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 — значення, звірені з незалежною імплементацією специфікації
Саме виведення коротке, коли дві таблиці на місці. Пройдіться 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
Чому правильний 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 |
|---|---|---|
| Ротація array | XorRor (зсув праворуч 1) | Зсув ліворуч 2 |
| Index array | (offset + record length) mod 16 | offset mod 16 |
| Порядок byte-трансформації | Зсув ліворуч 5, потім XOR | XOR, потім зсув ліворуч 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
Разом узяте, 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