Technical Article

Чому Excel відхиляє вашу зашифровану книгу: ECB та RC4

Ви записуєте книгу, шифруєте її паролем, передаєте колезі, і він відкриває її в Excel. Excel запитує пароль. Колега вводить його, і Excel приймає його. Поки що шифрування виглядає правильним. Але потім Excel виводить діалогове вікно, у якому повідомляє, що файл пошкоджено і його неможливо відкрити, або відкриває аркуш із незрозумілими символами в комірках. Пароль був правильним. Проте файл все одно зламаний. Це найбільш дезорієнтуючий режим збою в шифруванні Office, оскільки частина, яка повідомляє про правильність пароля, і частина, що містить ваші дані, захищені двома різними операціями, і правильність однієї з них ніяк не гарантує правильність іншої.

Обидві описані тут помилки мали саме такий характер. В обох випадках перевірка проходила успішно, а тіло файлу ні, що змушує шукати помилку в паролі чи отриманні ключа, якої там немає. Реальна проблема знаходилася далі по конвеєру, у способі перетворення байтів пакета. Ці дві вразливості є незалежними (одна на шляху AES, інша на шляху RC4), але вони мають спільну проблему діагностики, тому варто розібратися, чому наполовину правильний результат є найскладнішим для аналізу.

Чому успішна перевірка пароля нічого не доводить щодо тіла файлу

Формат, який використовує сучасний зашифрований файл XLSX, - це Standard Encryption за стандартом ECMA-376, і він зберігає дві зашифровані речі поруч. Перша - це EncryptionVerifier: невеликий блок, що містить випадкове значення та його хеш, зашифрований ключем, отриманим із пароля. Друга - це EncryptedPackage: увесь zip-контейнер книги, зашифрований тим самим ключем. Перевірка існує для того, щоб зчитувач міг підтвердити пароль перед тим, як витрачати ресурси на гігабайти тіла документа. Розшифруйте перевірку, отримайте хеш випадкового значення, порівняйте його зі збереженим хешем, і якщо вони збігаються, пароль є правильним.

Пастка полягає в тому, що перевірка та пакет шифруються окремими викликами для різних буферів. Ключ, отриманий правильно, успішно розшифрує перевірку незалежно від того, що станеться з пакетом після цього. Тому, якщо отримання ключа виконано правильно, але перетворення пакета є помилковим, Excel підтвердить пароль за допомогою перевірки, а потім зазнає збою на тілі документа. Симптом виглядає як "правильний пароль, зламаний файл", що спрямовує дослідження на шлях перевірки пароля - ту єдину частину, яка ніколи не була зламана. Таке ж розділення керує застарілим випадком RC4: спочатку перевіряється хеш перевірки, а тіло, яке втратило синхронізацію, все одно залишає цю перевірку успішною.

Помилка перша: AES в режимі ECB, а не CBC

Специфікація [MS-OFFCRYPTO] §2.3.4.15 визначає, що Standard Encryption шифрує пакет за допомогою AES у режимі електронної кодової книги (ECB). Кожен 16-байтовий блок доповненого пакета шифрується незалежно з тим самим ключем. Немає зв'язування між блоками і немає вектора ініціалізації. Це незвичний вибір за сучасними стандартами, де режиму ECB зазвичай уникають, але сумісність - не місце для сумнівів у специфікації. Excel розшифровує пакет як ECB, тому записувач повинен шифрувати його як ECB, інакше вони не узгодяться.

Помилка полягала в тому, що пакет шифрувався за допомогою AES у режимі CBC з використанням повністю нульового вектора ініціалізації. Ось чому це майже працює і чому "майже" є найгіршим варіантом. У CBC перший блок відкритого тексту об'єднується операцією XOR з IV перед шифруванням. Коли IV повністю складається з нулів, XOR нічого не змінює, тому перший блок CBC з нульовим IV дає точно такий самий шифротекст, як і ECB. Починаючи з другого блоку, CBC передає попередній блок шифротексту в наступний, тому кожен блок після першого відрізняється від ECB.

Тепер накладіть це на структуру. Макет пакета розміщує 8-байтовий префікс довжини у форматі little-endian на самому початку, тому частини файлу, які Excel перевіряє найпершими, знаходяться в першому або другому блоці. Перший блок, який випадково збігається, означає, що початкова перевірка проходить успішно, тоді як кожен наступний блок розшифровується як шум. Виправлення є очевидним, щойно названо режим: шифрувати кожен 16-байтовий блок за допомогою ECB та припинити зв'язування. У рушії XlsEncryptStdPackage обходить доповнений буфер кроками по 16 байтів і викликає AESEncryptECB128Block для кожного з них - той самий примітив, який уже використовується для блоків перевірки. Вихідний код містить коментар у циклі, який чітко формулює правило: CBC з нульовим IV відповідає ECB лише для першого блоку, тому решта пакета розшифрувалася б як сміття і Excel відхилив би його.

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    Book.Open('report.xlsx');
    // SaveAsEncrypted serializes the workbook, then runs the
    // ECMA-376 Standard Encryption pipeline: AES-128 ECB over the
    // package per [MS-OFFCRYPTO] 2.3.4.15. Returns 1 on success.
    if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
      raise Exception.Create('Encryption failed');
  finally
    Book.Free;
  end;
end;

Помилка друга: зміна ключів RC4 виходить із синхронізації

Застарілий шлях .xls використовує схему RC4 CryptoAPI, і його правила є іншими за своєю суттю. Специфікація [MS-OFFCRYPTO] §2.3.6 визначає, що шифр змінює ключ на кожній межі 1024-байтового блоку. Потік ділиться на блоки по 1024 байти, новий ключ RC4 створюється для блоку номер 0, 1, 2 і так далі, а всередині кожного блоку потік ключів споживається безперервно від байта до байта. Два інваріанти мають виконуватися разом: зміна ключа на кожній межі та споживання потоку ключів без пропусків всередині блоку. RC4 є потоковим шифром, тому його потік ключів є єдиною впорядкованою послідовністю; n-й зчитаний вами байт визначається тим, скільки байтів ви зчитали перед ним. Дешифрування є тією самою операцією XOR з тією ж послідовністю, що означає, що відправник і одержувач повинні зчитувати однакові байти на однакових позиціях.

У цьому й полягає вся складність. Потоковий шифр не має ресинхронізації. Якщо ви втратите один байт потоку ключів, кожен наступний байт буде об'єднаний операцією XOR з неправильним байтом потоку ключів, і ця помилка ніколи не виправиться сама; вона пошириться до кінця блоку, а після зсуву позиції - і на кожен наступний блок. Помилка тут робила саме це. Лічильник блоків починався з маркерного значення мінус один, а процедура пропуску вважала, що лічильник уже відповідає поточному блоку. Починаючи з цього маркера, вона змінювала ключ і виконувала повний 1024-байтовий блок потоку ключів, який ніколи не мав споживатися, і в процесі робила залишок лічильника від'ємним. З цього моменту дешифратор повністю виходив із фази на один блок. Перевірка, виконана до цього, все одно проходила успішно, тому пароль виглядав правильним, хоча кожна комірка даних перетворювалася на сміття.

Виправлена логіка знаходиться в TXLSDecrypterRC4. Обидва методи Skip та Decrypt поділяють один цикл: змінювати ключ лише тоді, коли поточна позиція переходить у новий блок, де індекс блоку є позицією, поділеною на REKEY_BLOCK_SIZE (1024), а потім споживати до кінця поточного блоку і не більше. MakeKey викликається з індексом блоку, а не із застарілим чи маркерним індексом, а позиція просувається на точну кількість оброблених байтів, щоб Skip та Decrypt залишалися синхронізованими за фазою із записувачем. Урок полягає в найменшій одиниці: один втрачений байт - це не маленька помилка в потоковому шифрі, це повна втрата всього, що знаходиться далі.

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    // CanReadEncrypted checks the Compound File (OLE2) signature so
    // you can branch before attempting a normal Open. OpenEncrypted
    // routes plain files to Open and handles the encrypted container.
    if Book.CanReadEncrypted('legacy.xls') then
      Book.OpenEncrypted('legacy.xls', 'S3cret!')
    else
      Book.Open('legacy.xls');
    // read cells here
  finally
    Book.Free;
  end;
end;

Сумісність із замороженою специфікацією - це відповідність до байта

Обидва дефекти зводяться до одного принципу, і його варто висловити окремо, оскільки він змінює підхід до вибору проектування. Коли споживачем вашого виходу є фіксована зовнішня програма, яку ви не можете змінити, режим шифру та частота зміни ключів не є деталями реалізації, які ви можете оптимізувати чи спростити. Они є частиною мережевого контракту. Excel розшифровуватиме з ECB і змінюватиме ключі на межах 1024 байтів незалежно від того, подобається вам це чи ні, і ваше єдине завдання - створити байти, які розшифруються до оригіналу за цією процедурою. Більш сучасний режим, вектор ініціалізації, який здається нешкідливим, лічильник, що починається з більш зручного місця, - будь-яке з цих рішень є дефектом, щойно воно відхиляється від очікувань переглядача. Сумісність із замороженою специфікацією не є приблизною. Вона або точна до байта, або зламана.

Ось чому перевірка сама по собі є поганим тестом. Вона лише повідомляє, що отримання ключа працює, що є необхідним, але далеко не достатнім. Тест, який лише відкриває зашифрований файл і підтверджує правильність пароля, повідомить про успіх, тоді як тіло документа залишиться непридатним для читання. Справжній тест розшифровує пакет і порівнює відновлені байти з вихідними даними, або виконує повний цикл шифрування-розшифрування книги та зчитує комірки назад. Перевірка доводить пароль; лише тіло доводить шифрування.

Підтримуваний спосіб читання та запису захищених книг

Публічний інтерфейс невеликий. Щоб записати захищену паролем сучасну книгу, заповніть або відкрийте TXLSXWorkbook і викличте SaveAsEncrypted з ім'ям файлу та паролем. Метод серіалізує книгу та запускає конвеєр Standard Encryption, який виправив наш перший крок, повертаючи 1 при успіху. Для читання викличте CanReadEncrypted, щоб перевірити, чи є файл зашифрованим контейнером Compound File, а потім зробіть розгалуження: OpenEncrypted обробляє зашифрований шлях і повертається до Open для звичайних файлів, а метод Open з паролем доступний безпосередньо. Обробка режимів та цикл зміни ключів, описані вище, знаходяться під цими викликами; ви надаєте пароль та ім'я файлу, а рушій відповідає специфікації за вас.

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create(nil);
  try
    Book.Open('quarterly.xlsx');
    Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
    // Reopen on the consumer side
    Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
  finally
    Book.Free;
  end;
end;

Структура захищеного виходу, потік EncryptionInfo, блоки перевірки та макет пакета описані в нашій інструкції з захищеного за допомогою AES виходу XLSX. Щодо окремого питання блокування на рівні аркуша та взаємодії захисту з налаштуванням сторінок і друком, див. статтю про захист, налаштування сторінок та друк. Обидва рішення базуються на описаному тут шляху шифрування, який постачається в складі компонента електронних таблиць HotXLS для Delphi та C++Builder разом з API читання, запису та рендерингу, описаними в інших статтях цього блогу.