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

XOR-обфускация XLS в HotXLS: вывод ключа и XorRor

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

Последняя фраза — вся история в миниатюре. Читатель и писатель, разделяющие одну и ту же неверную идею, идеально согласны друг с другом, так что round-trip тесты остаются зелёными, а единственный важный потребитель говорит нет. Excel 16 сказал нет дважды, двумя разными сообщениями, и каждое указывало на свой слой схемы. Эта статья проходит эти слои в том порядке, как их проверяет Excel, с байтовыми деталями, пригодными и когда вы зовёте HotXLS, и когда пишете собственный BIFF-читатель

Что BIFF XOR-обфускация вообще хранит?

BIFF XOR-обфускация хранит в файле всего два 16-битных слова, всё остальное пересчитывается из пароля. Запись FILEPASS ($002F) сидит сразу после BOF глобальных данных книги, и в BIFF5-файле её тело — ровно 4 байта: XOR-ключ, за ним верификатор пароля. Ни соли, ни идентификатора алгоритма, ни шифрованного блока верификатора, какие носят схемы RC4 и AES

Из этих двух слов читатель восстанавливает три вещи:

  • Верификатор — 16-битный хэш байтов пароля, XOR-нутый с $CE4B. Сравнение его с сохранённым словом и есть проверка пароля, причём единственная
  • XOR-ключ — 16-битное значение из CreateXorKey_Method1 в [MS-OFFCRYPTO] §2.3.7.2, управляемое двумя таблицами констант (InitialCode, 15 слов, и XorMatrix, 105 слов)
  • XOR-массив — 16 байтов из байтов пароля, дополненных фиксированной 16-байтовой набивкой, каждый 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 говорит, что пароль неверен, хотя верификатор совпадает?

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

Писатель HotXLS до v2.384.54 заполнял слово ключа двумя случайными байтами. На бумаге это выглядит безобидно: верификатор — задокументированная проверка пароля, а массив строится из какого ни на есть ключа, объявленного файлом. Сам HotXLS читал такие файлы без труда, потому что его читатель принимал ключ из FILEPASS как данность. Excel 16, получив тот же файл и правильный пароль, отвечал, что пароль неверен. С v2.384.54 ключ выводится, так что FILEPASS для пароля secret всегда держит ключ $014D и верификатор $DAA7 — значения сверены с независимой реализацией спецификации

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

Сам вывод короток, как только обе таблицы на месте. Идите по паролю с конца, семь раз смотрите на бит 6 каждого байта, сдвигая его влево, и XOR-ьте очередную ячейку XorMatrix каждый раз, когда бит взведён. Ниже — принципиальный набросок, воспроизводящий алгоритм спецификации и совпадающий с реализацией HotXLS; это не API HotXLS:

// Принципиальный набросок [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// и CreateXorArray_Method1 (только иллюстрация, не API HotXLS)
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 — удобная контрольная заготовка, если тестируете собственный читатель

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

Почему верный ключ всё равно даёт битый файл?

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

Старый код HotXLS сдвигал каждый байт массива влево на 2 бита — вариант, гуляющий по нескольким реализациям BIFF. Поскольку HotXLS применял тот же сдвиг по обе стороны, собственный читатель ничего не замечал. Excel 16 уже не сохраняет файлы Excel 5.0/95, а при сохранении BIFF8 XOR не предлагает, так что родного образца Excel для диффа не было. Доказательства пришлось добывать с другой стороны: записать один открытый BIFF5-поток, перекодировать его восемью способами и дать Excel 16 открыть каждый вариант. Восемь вариантов пересекли три независимых выбора:

ВыборВариант AВариант B
Сдвиг массиваXorRor (сдвиг вправо на 1)Сдвиг влево на 2
Индекс массива(смещение + длина записи) mod 16смещение 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 для байта — это его смещение в потоке книги плюс длина всех данных записи, к которой он принадлежит, по модулю 16. Индекс потому стартует заново от зависящего от записи значения для каждой записи и растёт на единицу с каждым байтом внутри неё. Псевдокод спецификации называет входы FileOffset и Data.Length, что легко прочесть как одно лишь смещение начала записи, — и именно это неверное прочтение HotXLS и выпускал до v2.384.47

Три детали решают, сойдутся ли ваши индексы с Excel:

  • 4-байтовый заголовок записи не преобразуется никогда, но позиции в потоке всё же занимает, так что первый байт тела записи сидит на смещении заголовка + 4
  • Слагаемое длины — полная длина данных записи, а не число реально преобразованных байтов
  • BOUNDSHEET частично открыт: его первые 4 байта, lbPlyPos, смещение в потоке BOF листа, остаются читаемыми, чтобы парсер мог найти листы. Эти 4 байта преобразование пропускает, но к смещению и к длине записи они всё равно идут в счёт
Схема HotXLS правила XorArrayIndex для XOR-обфускации BIFF5: каждый байт тела использует смещение в потоке плюс полную длину данных записи по модулю 16, открытый четырёхбайтовый заголовок и префикс BOUNDSHEET lbPlyPos всё равно идут в счёт смещения, а игнорирование слагаемого длины записи было дефектом, который 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 — смещение в потоке
// 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 считал индекс по одной лишь позиции в потоке, а писатель брал открытый байтовый офсет. Оба игнорировали длину записи, так что обе половины снова сходились друг с другом и ни с кем больше. Независимо написанный декодер читал вывод 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 применяет этот потолок к ключу, а верификатор и массив держит на их обычных правилах полной длины и 16 байтов, единообразно по обе стороны. Сам Excel отказывает паролям длиннее 15 символов для этого формата, так что считайте 15 настоящим максимумом
  • Набор символов. HotXLS переводит пароль в байты через системную ANSI-кодовую страницу. Спецификация описывает взятие младшего байта каждого UTF-16-символа, что для ASCII совпадает. Без образцов Excel, защищённых не-ASCII паролями, истины для остального нет, так что для XOR-файлов держитесь ASCII-паролей

На стороне чтения 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-битный верификатор, ключ — 16 бит, а 16-байтовый массив повторяется по всему потоку, так что предсказуемое содержимое записей BIFF выдаёт байты массива вовсе без пароля. HotXLS пишет XOR только потому, что у файлов Excel 5.0/95 нет другого выбора, а повод выпускать такие файлы сегодня — легаси-потребитель, который не читает ничего новее

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

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

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

Шпаргалка: BIFF XOR-обфускация, которую принимает Excel 16

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

HotXLS ведает защитой паролем BIFF5 и BIFF8, XLSX Standard и Agile Encryption и колбэком пароля на стороне чтения — из одной библиотеки для Delphi и C++Builder, с интероп-деталями выше, взятыми на себя. Издания, платформы и пробная загрузка — на странице HotXLS Delphi spreadsheet component