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 — стойности, сверени срещу независима имплементация на спецификацията
Самото извеждане е кратко, щом двете таблици са на място. Минете паролата обратно, гледайте бит 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, ако тествате собствен четец
Защо правилният ключ пак дава повреден файл?
Верен ключ пак дава повреден файл, когато 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 16 | offset mod 16 |
| Ред на байтовото преобразуване | Ротация наляво с 5, после XOR | XOR, после ротация наляво с 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 байта се прескачат от преобразуването, но пак броят и към отместването, и към дължината на записа
Сложени заедно, преобразуването на запис е няколко реда. И пак — това е скица на правилото, не нещо, което трябва да викате:
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 за издания, платформи и пробно изтегляне