Technical Article

Dlaczego Excel odrzuca zaszyfrowany skoroszyt: ECB i RC4

Zapisujesz skoroszyt, szyfrujesz go hasłem, przekazujesz plik koledze, a kolega otwiera go w programie Excel. Excel prosi o hasło. Kolega wpisuje je, a Excel je akceptuje. Do tego momentu szyfrowanie wygląda na poprawne. Następnie Excel wyświetla okno dialogowe informujące, że plik jest uszkodzony i nie można go otworzyć, lub otwiera go jako arkusz bezużytecznych komórek. Hasło było prawidłowe. Plik mimo to jest uszkodzony. Jest to najbardziej dezorientujący tryb awarii w szyfrowaniu pakietu Office, ponieważ część sprawdzająca hasło i część przechowująca dane są chronione przez dwie różne operacje, a poprawne wykonanie jednej z nich nie gwarantuje poprawności drugiej.

Oba opisane tutaj błędy miały dokładnie taki kształt. W każdym przypadku weryfikator hasła przechodził pomyślnie, natomiast korpus danych – nie, co kieruje dochodzenie w stronę poszukiwania błędów w hasłach lub generowaniu kluczy, których tam nie ma. Rzeczywisty błąd znajdował się dalej, w sposobie przekształcania bajtów pakietu. Te dwie wady są niezależne, jedna na ścieżce AES, druga na ścieżce RC4, ale dzielą ten sam problem diagnostyczny, więc warto przyjrzeć się, dlaczego częściowo poprawny wynik jest najtrudniejszy do zinterpretowania.

Dlaczego pomyślne hasło niczego nie dowodzi w kwestii korpusu

Formatem używanym przez nowoczesne, zaszyfrowane pliki XLSX jest Szyfrowanie Standardowe ECMA-376 (Standard Encryption), które przechowuje obok siebie dwie zaszyfrowane rzeczy. Jedną z nich jest EncryptionVerifier: mały blok zawierający losową wartość oraz skrót (hash) tej wartości, zaszyfrowany za pomocą klucza wygenerowanego z hasła. Drugą rzeczą jest EncryptedPackage: cały kontener zip skoroszytu, zaszyfrowany tym samym kluczem. Weryfikator istnieje po to, by czytnik mógł potwierdzić hasło przed zaangażowaniem mocy obliczeniowej w odszyfrowanie megabajtów danych. Odszyfruj weryfikator, oblicz skrót losowej wartości, porównaj go z zapisanym skrótem – jeśli pasują, hasło jest prawidłowe.

Pułapka polega na tym, że weryfikator i pakiet są szyfrowane oddzielnymi wywołaniami na osobnych buforach. Klucz, który został prawidłowo wygenerowany, odszyfruje weryfikator poprawnie bez względu na to, co stanie się później z pakietem. Jeśli więc generowanie klucza jest poprawne, ale transformacja pakietu błędna, Excel potwierdzi hasło z weryfikatora, a następnie wyłoży się na korpusie danych. Objaw wygląda jako "prawidłowe hasło, uszkodzony plik", co kieruje śledztwo na ścieżkę hasła, która jako jedyna nigdy nie była uszkodzona. Ten sam podział dotyczy starszego przypadku z szyfrem RC4: skrót weryfikatora jest sprawdzany jako pierwszy, a korpus dryfujący poza fazę nadal pozostawia to sprawdzenie nienaruszonym.

Błąd pierwszy: AES w trybie ECB, a nie CBC

Specyfikacja [MS-OFFCRYPTO] §2.3.4.15 określa, że szyfrowanie standardowe zabezpiecza pakiet za pomocą algorytmu AES w trybie Electronic Codebook (ECB). Każdy 16-bajtowy blok dopełnionego pakietu jest szyfrowany niezależnie tym samym kluczem. Nie ma powiązania (chaining) między blokami ani wektora inicjalizacyjnego. Jest to nietypowy wybór według nowoczesnych standardów, gdzie tryb ECB jest zazwyczaj unikany, ale interoperacyjność to nie miejsce na kwestionowanie specyfikacji. Excel odszyfrowuje pakiet w trybie ECB, więc generator musi go szyfrować w ECB, inaczej obie strony się nie porozumieją.

Błąd polegał na tym, że pakiet był szyfrowany za pomocą AES w trybie CBC z użyciem wektora inicjalizacyjnego składającego się z samych zer. Oto dlaczego to niemal działa i dlaczego to "niemal" jest najgorszym możliwym miejscem do wylądowania. W trybie CBC pierwszy blok tekstu jawnego jest przed szyfrowaniem poddawany operacji XOR z IV. Gdy IV składa się z samych zer, ta operacja XOR niczego nie zmienia, więc pierwszy blok CBC-z-zerowym-IV daje dokładnie taki sam szyfrogram jak tryb ECB. Od drugiego bloku CBC wprowadza poprzedni szyfrogram do kolejnego, przez co każdy blok po pierwszym odbiega od ECB.

Teraz nałóż to na strukturę. Układ pakietu umieszcza 8-bajtowy prefiks długości w formacie little-endian na samym początku, więc części pliku sprawdzane przez Excel najwcześniej znajdują się w pierwszym bloku lub dwóch. Pierwszy blok, który przypadkowo pasuje, oznacza pomyślne przejście najwcześniejszej walidacji, podczas gdy każdy kolejny blok odszyfrowuje się jako szum. Poprawka jest prosta po nazwisku trybu: szyfruj każdy 16-bajtowy blok za pomocą ECB i zatrzymaj powiązanie bloków. W silniku funkcja XlsEncryptStdPackage przechodzi przez dopełniony bufor w krokach 16-bajtowych i wywołuje AESEncryptECB128Block na każdym z nich, co jest tym samym prymitywem, którego użyto już dla bloków weryfikatora. Kod źródłowy niesie w pętli jasny komentarz: CBC z zerowym IV pasuje do ECB tylko dla pierwszego bloku, więc reszta pakietu odszyfrowałaby się jako śmieci i Excel odrzuciłby plik.

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;

Błąd drugi: ponowne generowanie klucza RC4 rozjeżdża się w fazie

Starsza ścieżka plików .xls wykorzystuje schemat RC4 CryptoAPI, a jego reguła jest inna. Specyfikacja [MS-OFFCRYPTO] §2.3.6 określa, że szyfr jest ponownie inicjowany kluczem przy każdej granicy bloku o rozmiarze 1024 bajtów. Strumień jest dzielony na bloki po 1024 bajty, świeży klucz RC4 jest generowany dla bloku numer 0, 1, 2 i tak dalej, a wewnątrz każdego bloku strumień klucza (keystream) jest konsumowany w sposób ciągły z bajtu na bajt. Dwa niezmienniki muszą zachodzić jednocześnie: ponowna inicjalizacja klucza na każdej granicy oraz konsumpcja strumienia klucza bez przerw wewnątrz bloku. RC4 to szyfr strumieniowy, więc jego strumień klucza to pojedyncza uporządkowana sekwencja; n-ty pobierany bajt jest określony przez liczbę bajtów pobranych wcześniej. Deszyfrowanie to ta sama operacja XOR na tej samej sekwencji, co oznacza, że generator i konsument muszą pobierać dokładnie te same bajty na dokładnie tych samych pozycjach.

W tym tkwi cała trudność. Szyfr strumieniowy nie posiada resynchronizacji. Jeśli zmarnujesz jeden bajt strumienia klucza, każdy kolejny bajt zostanie poddany operacji XOR z niewłaściwym bajtem strumienia klucza i błąd nigdy się nie naprawi – kaskadowo przejdzie do końca bloku i, gdy pozycja bieżąca będzie błędna, do każdego kolejnego bloku. Błąd w silniku robił dokładnie to. Licznik bloków startował od wartości wartownika minus jeden, a procedura pomijania zakładała, że licznik pasuje już do bieżącego bloku. Zaczynając od tego wartownika, ponownie generowała klucz i konsumowała pełny 1024-bajtowy blok strumienia klucza, który nigdy nie powinien zostać skonsumowany, doprowadzając przy tym pozostały licznik do wartości ujemnej. Od tego momentu deszyfrator był o cały blok przesunięty w fazie. Weryfikator (sprawdzany przed tym wszystkim) nadal przechodził pomyślnie, więc hasło wyglądało na prawidłowe, podczas gdy każda komórka danych wychodziła jako śmieci.

Poprawiona logika żyje w TXLSDecrypterRC4. Zarówno Skip, jak i Decrypt współdzielą jedną pętlę: inicjalizuj klucz ponownie tylko wtedy, gdy pozycja bieżąca przekracza granicę nowego bloku, gdzie indeks bloku to pozycja podzielona przez REKEY_BLOCK_SIZE (1024), a następnie konsumuj dane do reszty bieżącego bloku i nic więcej. Metoda MakeKey jest wywoływana z indeksem bloku, nigdy ze starym indeksem lub indeksem wartownika, a pozycja przesuwa się o dokładną liczbę przetworzonych bajtów, dzięki czemu metody Skip i Decrypt pozostają wyrównane w fazie z generatorem. Lekcja tkwi w najmniejszej jednostce: pojedynczy zmarnowany bajt to nie mały błąd w szyfrze strumieniowym – to całkowita utrata wszystkiego, co znajduje się dalej.

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;

Zgodność z zamrożoną specyfikacją to dopasowanie co do bajtu

Oba błędy sprowadzają się do tej samej podstawowej zasady i warto ją sformułować samodzielnie, ponieważ zmienia ona wagę decyzji projektowych. Gdy odbiorcą Twoich danych wyjściowych jest zewnętrzny, niezmienny program, którego nie możesz modyfikować, tryb szyfru i częstotliwość ponownego kluczowania nie są szczegółami implementacji, które możesz optymalizować lub upraszczać. Są częścią kontraktu transmisji. Program Excel będzie deszyfrował za pomocą ECB i generował klucze na granicach 1024-bajtowych bez względu na to, czy te wybory Ci się podobają, czy nie, a Twoim jedynym zadaniem jest wyprodukowanie bajtów, które odszyfrują się do oryginału przy użyciu dokładnie tej procedury. Tryb, który jest bardziej nowoczesny, wektor IV, który wydaje się nieszkodliwy, licznik startujący od miejsca, które wydaje się naturalne – każda z tych rzeczy jest defektem w momencie, gdy odbiega od oczekiwań czytnika. Zgodność z zamrożoną specyfikacją nie jest przybliżona. Jest dokładna co do bajtu albo nie działa wcale.

Z tego powodu weryfikator hasła jest słabym testem jednostkowym na własną rękę. Mówi tylko, że generowanie klucza działa, co jest konieczne, ale niewystarczające. Test, który tylko otwiera zaszyfrowany plik i potwierdza pomyślne hasło, zaraportuje sukces, podczas gdy korpus danych pozostanie nieczytelny. Prawdziwy test odszyfrowuje pakiet i porównuje odzyskane bajty z oryginalnymi danymi wejściowymi lub przetwarza skoroszyt w obie strony przez szyfrowanie i deszyfrowanie, a następnie odczytuje komórki. Weryfikator dowodzi hasła; tylko korpus dowodzi szyfrowania.

Obsługiwany sposób odczytu i zapisu chronionych skoroszytów

Publiczny interfejs jest niewielki. Aby zapisać nowoczesny, chroniony hasłem skoroszyt, zapełnij lub otwórz obiekt TXLSXWorkbook i wywołaj SaveAsEncrypted z nazwą pliku oraz hasłem; serializuje on skoroszyt i uruchamia potok szyfrowania standardowego ECMA-376 (który poprawiła pierwsza poprawka), zwracając 1 przy powodzeniu. Aby przeczytać plik, wywołaj CanReadEncrypted w celu sprawdzenia, czy plik jest zaszyfrowanym kontenerem Compound File, a następnie wykonaj rozgałęzienie: OpenEncrypted obsługuje ścieżkę zaszyfrowaną i cofa się do Open dla zwykłych plików, a metoda Open z hasłem jest dostępna bezpośrednio. Obsługa trybów i pętla ponownego generowania kluczy opisane powyżej znajdują się pod tymi wywołaniami; podajesz hasło oraz nazwę pliku, a silnik dopasowuje się do specyfikacji w Twoim imieniu.

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;

Kształt chronionego wyjścia, strumień EncryptionInfo, bloki weryfikatora oraz układ pakietu zostały omówione w naszym artykule na temat chronionego wyjścia XLSX AES. Dla osobnego pytania o blokowanie na poziomie arkusza i o to, jak ochrona współgra z konfiguracją strony i drukowaniem, zobacz artykuł na temat ochrony, konfiguracji strony i drukowania. Oba te rozwiązania bazują na opisanej tutaj ścieżce szyfrowania, która jest dostarczana jako część komponentu HotXLS spreadsheet component dla Delphi i C++Builder, obok interfejsów API do odczytu, zapisu i renderowania omówionych w innych miejscach tego bloga.