HotXLS schreibt Excel 5.0/95-Arbeitsmappen (BIFF5) mit XOR-Obfuskation, die Excel 16 nur öffnet, wenn drei Details exakt zu [MS-OFFCRYPTO] passen: Der FILEPASS-Schlüssel muss CreateXorKey_Method1(password) sein, das 16-Byte-XOR-Array muss mit XorRor gebaut sein (Rotation rechts um ein Bit), und jedes Byte muss XorArrayIndex = (Stream-Offset + Record-Länge) mod 16 verwenden. HotXLS hatte den Index in v2.384.47 richtig und Schlüssel und Rotation in v2.384.54. Vorher öffnete jede passwortgeschützte BIFF5-Datei aus eigener Produktion in HotXLS sauber und scheiterte an Excel
Dieser letzte Satz ist die ganze Geschichte im Kleinen. Ein Reader und ein Writer, die dieselbe falsche Idee teilen, sind sich perfekt einig, also bleiben Roundtrip-Tests grün, während der einzige Consumer, der zählt, Nein sagt. Excel 16 sagte zweimal Nein, mit zwei verschiedenen Meldungen, und jede Meldung zeigte auf eine andere Schicht des Schemas. Dieser Artikel geht diese Schichten in der Reihenfolge durch, in der Excel sie prüft, mit Details auf Byte-Ebene, die Ihnen nützen, egal ob Sie HotXLS aufrufen oder einen eigenen BIFF-Reader schreiben
Was speichert die BIFF-XOR-Obfuskation eigentlich?
Die BIFF-XOR-Obfuskation legt nur zwei 16-Bit-Wörter in der Datei ab, alles andere wird aus dem Passwort neu berechnet. Der FILEPASS-Record ($002F) sitzt direkt nach dem Workbook-Globals-BOF, und in einer BIFF5-Datei ist sein Body exakt 4 Bytes groß: der XOR-Schlüssel gefolgt vom Passwort-Verifier. Es gibt kein Salt, keinen Algorithmus-Identifier und keinen verschlüsselten Verifier-Blob, wie ihn die RC4- und AES-Schemata mitführen
Aus diesen zwei Wörtern baut ein Reader drei Dinge nach:
- Den Verifier, einen 16-Bit-Hash der Passwort-Bytes, XOR-verknüpft mit
$CE4B. Sein Vergleich mit dem gespeicherten Wort ist die Passwort-Prüfung, und die einzige - Den XOR-Schlüssel, einen 16-Bit-Wert aus
CreateXorKey_Method1in [MS-OFFCRYPTO] §2.3.7.2, getrieben von zwei Konstantentabellen (InitialCode, 15 Wörter, undXorMatrix, 105 Wörter) - Das XOR-Array, 16 Bytes aus den Passwort-Bytes, aufgefüllt mit einem festen 16-Byte-Pad, jedes XOR-verknüpft mit dem niedrigen Schlüsselbyte (gerade Positionen) oder dem hohen Schlüsselbyte (ungerade Positionen), danach rechts um ein Bit rotiert
Record-Header bleiben im Klartext, ebenso eine Handvoll ganzer Records, die das Schema ausnimmt, darunter BOF, FILEPASS und INTERFACEHDR. Jeder andere Record-Body wird Byte für Byte transformiert: Rotation links um 5 Bits, dann XOR mit einem Eintrag des 16-Byte-Arrays. Die Entschlüsselung, die [MS-OFFCRYPTO] §2.3.7.3 als DecryptData_Method1 ausschreibt, ist das Spiegelbild: erst XOR, dann Rotation rechts um 5
In HotXLS fassen Sie davon nichts direkt an. Passwort setzen, Format wählen, und SaveAs emittiert FILEPASS und transformiert den 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 unterstützt nur die XOR-Obfuskation; xletAuto würde sie ebenfalls wählen
Wb.EncryptionType := xletXor;
// ASCII halten und höchstens 15 Zeichen (siehe unten)
Wb.EncryptionPassword := 'secret';
if Wb.SaveAs(FileName, xlExcel5) <> 1 then
raise Exception.Create('BIFF5 save failed');
end;
Warum meldet Excel ein falsches Passwort, obwohl der Verifier stimmt?
Excel weist das Passwort zurück, weil es dem gespeicherten Schlüssel nicht traut: Excel leitet den Schlüssel aus dem getippten Passwort per CreateXorKey_Method1 her und vergleicht ihn mit dem FILEPASS-Schlüsselwort, eine Datei mit irgendeinem anderen Schlüssel fällt also durch die Passwort-Prüfung, selbst wenn der Verifier korrekt ist. Die Spezifikation beschreibt den Schlüssel als Ausgabe des Passworts, nicht als freien Parameter, und Excel 16 erzwingt genau diese Lesart
Der HotXLS-Writer vor v2.384.54 füllte das Schlüsselwort mit zwei zufälligen Bytes. Auf dem Papier sieht das harmlos aus, denn der Verifier ist die dokumentierte Passwort-Prüfung, und das Array wird aus dem Schlüssel gebaut, den die Datei deklariert. HotXLS selbst las diese Dateien ohne Murren, weil sein Reader den Schlüssel aus FILEPASS als gegeben nahm. Excel 16, mit derselben Datei und dem korrekten Passwort konfrontiert, antwortete, das Passwort sei nicht korrekt. Seit v2.384.54 wird der Schlüssel abgeleitet, FILEPASS hält für das Passwort secret also stets Schlüssel $014D und Verifier $DAA7, Werte, gegenprüft mit einer unabhängigen Implementierung der Spezifikation
Die Ableitung selbst ist kurz, sobald die zwei Tabellen stehen. Gehen Sie das Passwort rückwärts durch, schauen Sie sieben Mal auf Bit 6 jedes Bytes, während Sie es nach links schieben, und XOR-verknüpfen Sie bei jedem gesetzten Bit einen XorMatrix-Eintrag. Das Folgende ist eine Prinzip-Skizze, die den Spezifikations-Algorithmus nachbildet und zur HotXLS-Implementierung passt; es ist keine HotXLS-API:
// Prinzip-Skizze zu [MS-OFFCRYPTO] 2.3.7.2 CreateXorKey_Method1
// und CreateXorArray_Method1 (nur Illustration, keine 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; // der Schlüssel sieht nur 15 Bytes
if Len = 0 then
Exit;
Result := InitialCode[Len - 1];
Element := $68; // letzter XorMatrix-Eintrag
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)); // Rotation rechts um ein Bit
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;
Für secret ergibt das das Array 1F 32 17 B9 14 BA 7B 7F 59 DD 59 7F 7A C0 A6 DF, ein praktisches Fixture, falls Sie Ihren eigenen Reader testen
Warum erzeugt selbst der richtige Schlüssel noch eine beschädigte Datei?
Ein korrekter Schlüssel erzeugt trotzdem eine beschädigte Datei, wenn das XOR-Array in die falsche Richtung rotiert wird: [MS-OFFCRYPTO] definiert den Array-Schritt als XorRor, eine Rotation rechts um ein Bit, und ein links um zwei rotiertes Array entschlüsselt jeden Record-Body zu Rauschen. Der Schlüssel-Fix brachte Excel 16 an der Passwortabfrage vorbei, direkt in einen anderen Fehler, eine Meldung, die Datei habe ein Problem und lasse sich nicht öffnen
Der alte HotXLS-Code rotierte jedes Array-Byte links um 2 Bits, eine Form, die in mehreren BIFF-Implementierungen zirkuliert. Weil HotXLS auf beiden Seiten dieselbe Rotation benutzte, fiel das seinem eigenen Reader nie auf. Excel 16 kann Excel-5.0/95-Dateien nicht mehr speichern und bietet beim Sichern von BIFF8 kein XOR an, es gab also keine native Excel-Referenzdatei zum Diffen. Die Beweislage musste aus der anderen Richtung kommen: einen Klartext-BIFF5-Stream schreiben, ihn achtmal neu kodieren und Excel 16 jede Variante öffnen lassen. Die acht Varianten kreuzten drei unabhängige Entscheidungen:
| Entscheidung | Option A | Option B |
|---|---|---|
| Array-Rotation | XorRor (Rotation rechts 1) | Rotation links 2 |
| Array-Index | (Offset + Record-Länge) mod 16 | Offset mod 16 |
| Reihenfolge der Byte-Transformation | Rotation links 5, dann XOR | XOR, dann Rotation links 5 |
Excel 16 öffnete exakt zwei der acht: XorRor mit Rotation-dann-XOR und dem Record-Längen-Index, plus eine Variante, die nur anders aussieht. Rotation-links-2 mit XOR-dann-Rotation und demselben Index ist dieselbe Funktion im Gewand der anderen. Rotation verteilt über XOR, also gilt rol5(p xor rol2(b)) gleich rol5(p) xor rol7(b), und auf einem 8-Bit-Wert ist eine Rotation links um 7 eine Rotation rechts um 1. Kurz: rol5 ∘ rol2 = ror1, weshalb das Rotation-links-2-Array für sich genommen plausibel wirkt: Es ist nur in Kombination mit der umgekehrten Transformationsreihenfolge korrekt. Paart man es mit der Spezifikations-Reihenfolge, korrumpiert es jedes transformierte Byte
Dasselbe Experiment entschied eine zweite Frage. Die Varianten ohne Record-Länge im Index scheiterten allesamt, was die Index-Regel bestätigte, die HotXLS eine Version zuvor allein auf die Kraft des Spezifikationstexts gestellt hatte
Wie wird XorArrayIndex für jedes Byte berechnet?
XorArrayIndex für ein Byte ist sein Offset im Workbook-Stream plus die Länge des gesamten Record-Datenblocks, zu dem es gehört, mod 16. Der Index startet also pro Record an einem record-abhängigen Wert neu und wächst darin um eins pro Byte. Der Spezifikations-Pseudocode nennt die Eingaben FileOffset und Data.Length, was leicht als bloßer Record-Start-Offset fehlgelesen wird, und genau diese Fehllesung hatte HotXLS bis v2.384.47 ausgeliefert
Drei Details entscheiden, ob Ihre Indizes mit Excels übereinstimmen:
- Der 4-Byte-Record-Header wird nie transformiert, belegt aber trotzdem Stream-Positionen, das erste Body-Byte eines Records sitzt also bei Header-Offset + 4
- Der Längen-Term ist die volle Record-Datenlänge, nicht die Anzahl der tatsächlich transformierten Bytes
- BOUNDSHEET ist teilweise Klartext: Seine ersten 4 Bytes,
lbPlyPos, der Stream-Offset des Sheet-BOF, bleiben lesbar, damit ein Parser die Sheets findet. Diese 4 Bytes überspringt die Transformation, zählen aber weiterhin zum Offset wie zur Record-Länge
Zusammengenommen ist die Transformierung pro Record eine Sache weniger Zeilen. Auch das ist eine Skizze der Regel, nichts, was Sie aufrufen müssten:
function Rol8(B: Byte; N: Integer): Byte;
begin
Result := Byte((B shl N) or (B shr (8 - N)));
end;
// Obfuskiert einen Record-Body in place. BodyPos ist der Stream-Offset von
// Body[0], also der Record-Header-Offset + 4. PlainPrefix ist 4 für
// BOUNDSHEET, die volle Länge für BOF / FILEPASS, 0 für die meisten Records
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;
// Lesen ist das Spiegelbild: B := Body[I] xor Arr[...];
// dann Rotation rechts um 5, also Rol8(B, 3)
Vor v2.384.47 berechnete der HotXLS-Reader den Index allein aus der Stream-Position, und der Writer nahm den reinen Byte-Offset. Beide ignorierten die Record-Länge, schon wieder einigten sich also die zwei Hälften miteinander und mit niemand sonst. Ein unabhängig geschriebener Decoder las die v2.384.47-Ausgabe korrekt und die ältere Ausgabe als Datenmüll, und der Acht-Varianten-Test mit Excel 16 bestätigte die Regel später gegen das echte Ziel
Was passiert mit XOR-Dateien aus älteren HotXLS-Versionen?
HotXLS liest seine eigenen XOR-Dateien aus der Zeit vor v2.384.54 weiter, indem es den FILEPASS-Schlüssel prüft: Stimmt der gespeicherte Schlüssel mit dem aus dem Passwort abgeleiteten überein, baut der Reader das Spezifikations-XorRor-Array, weicht er ab, behandelt der Reader die Datei als ältere HotXLS-Datei und rekonstruiert das Rotation-links-2-Array. Excel-geschriebene Dateien tragen stets den abgeleiteten Schlüssel, sie nehmen also stets den Spezifikations-Pfad
Der Test ist eine Heuristik mit präziser Fehlerrate. Eine alte Datei, deren Zufallsschlüssel zufällig dem abgeleiteten entsprach, würde mit dem falschen Array gelesen, die Chance dafür liegt bei 1 zu 65.536. Der Fallback deckt nur die Array-Rotation ab; die Index-Regel wird nicht umgeschaltet, die Dateien, die er rettet, sind also die aus der Zeit zwischen v2.384.47 und v2.384.53. Halten Sie noch BIFF5-XOR-Dateien aus diesem Fenster, öffnen Sie sie mit dem aktuellen HotXLS und sichern Sie sie erneut, um eine Datei zu bekommen, die Excel akzeptiert
Zwei Passwort-Details gelten für jede Datei, alt oder neu:
- Länge.
CreateXorKey_Method1liest nur die ersten 15 Passwort-Bytes, das ist das Spezifikations-Limit. HotXLS wendet diese Grenze auf den Schlüssel an und hält Verifier und Array bei ihren gewohnten Voll-Längen- und 16-Byte-Regeln, konsistent auf beiden Seiten. Excel selbst lehnt für dieses Format Passwörter über 15 Zeichen ab, behandeln Sie 15 also als das reale Maximum - Zeichensatz. HotXLS konvertiert das Passwort über die System-ANSI-Codepage in Bytes. Die Spezifikation beschreibt das Nehmen des niedrigen Bytes jedes UTF-16-Zeichens, was für ASCII übereinstimmt. Ohne Excel-Referenzdateien mit Nicht-ASCII-Passwörtern gibt es für den Rest keine Ground Truth, bleiben Sie bei XOR-Dateien also bei ASCII-Passwörtern
Auf der Lese-Seite lässt TXLSWorkbook.OnPassword Sie ein Passwort abfragen, wenn Open auf einen FILEPASS-Record trifft. Das Event ist ein TXLSPasswordEvent mit einem var PassWord: WideString und einem var Retry: Boolean; setzen Sie Retry auf True, um es erneut zu versuchen, bis zu drei Wiederholungen:
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: Passwort erforderlich, aber keines geliefert; -1005: falsches Passwort
if Rc <> 1 then
raise Exception.CreateFmt('Cannot open %s (code %d)', [FileName, Rc]);
ShowMessage(VarToStr(Wb.Sheets[1].Range['A1', 'A1'].Value));
end;
Wenn Sie das Passwort schon kennen, überspringt Open(FileName, APassWord) das Event komplett
Ist die XOR-Obfuskation für irgendetwas sicher genug?
Die BIFF-XOR-Obfuskation ist keine Verschlüsselung und schützt vor einem motivierten Leser nichts. Die Passwort-Prüfung ist ein 16-Bit-Verifier, der Schlüssel hat 16 Bits, und das 16-Byte-Array wiederholt sich über den ganzen Stream, vorhersehbare BIFF-Record-Inhalte legen die Array-Bytes also ohne jedes Passwort offen. HotXLS schreibt XOR nur, weil Excel-5.0/95-Dateien keine andere Option haben, und der Grund, solche Dateien heute zu erzeugen, ist ein Legacy-Consumer, der nichts Neueres lesen kann
Die klassische Engine wählt das Schema über TXLSWorkbook.EncryptionType, und die Kombination mit dem Speicherformat wird streng geprüft:
xletAuto(Default) schreibt RC4 CryptoAPI fürxlExcel97und XOR fürxlExcel5, passend zu dem, was Excel selbst für jedes Format schriebxletXorist nur für BIFF5 gültig; mitxlExcel97wirft das Sichern eine Exception, statt still zurückzufallenxletRC4undxletRC4CryptoAPIsind nur für BIFF8, und ihre Anfrage bei einem BIFF5-Sichern wirft ebenfalls eine Exception
RC4 ist auch in die Jahre gekommen, die Details der Interop-Fähigkeit behandelt warum Excel eine verschlüsselte Arbeitsmappe mit korrektem Passwort ablehnt. Kann der Empfänger XLSX lesen, nehmen Sie stattdessen die XLSX-Engine: TXLSXWorkbook.SaveAsEncryptedAgile schreibt Agile Encryption (SHA-512-Passwort-Hashing mit einem Spin-Count von 100.000 Iterationen und AES-256-CBC), das Format, das Excel 2010 und später standardmäßig schreiben, während SaveAsEncrypted die ältere AES-128-Standard-Verschlüsselung schreibt. Die Abwägungen zwischen beiden finden sich in XLSX-Dateien mit AES in Delphi verschlüsseln, die Lese-Seite in Agile-verschlüsselte Excel-Dateien mit HotXLS lesen
Kurzreferenz: BIFF-XOR-Obfuskation, die Excel 16 akzeptiert
- FILEPASS (
$002F) folgt dem Globals-BOF; in BIFF5 ist sein Body 4 Bytes groß: Schlüssel, dann Verifier - Schlüssel =
CreateXorKey_Method1(password)nach [MS-OFFCRYPTO] §2.3.7.2, nie zufällig; fürsecretist es$014D - Array = Passwort-Bytes + Pad, XOR mit dem niedrigen Schlüsselbyte an geraden und dem hohen an ungeraden Positionen, dann XorRor (Rotation rechts 1)
- Ein Byte verschlüsseln: Rotation links 5, dann XOR; entschlüsseln: XOR, dann Rotation rechts 5 (§2.3.7.3)
- XorArrayIndex = (Byte-Stream-Offset + Record-Datenlänge) mod 16; Header und Klartext-Präfixe zählen zum Offset
- BOUNDSHEET behält seine ersten 4 Bytes im Klartext; BOF, FILEPASS und INTERFACEHDR bleiben komplett Klartext
- Passwörter: ASCII, höchstens 15 Zeichen
- HotXLS: Index gefixt in v2.384.47, Schlüssel und XorRor gefixt in v2.384.54, ältere HotXLS-XOR-Dateien werden per Schlüssel-Mismatch erkannt
- Für echten Schutz mindestens BIFF8 RC4 CryptoAPI, oder XLSX Agile Encryption
HotXLS handhabt BIFF5- und BIFF8-Passwortschutz, XLSX-Standard- und Agile Encryption und den Passwort-Callback auf der Lese-Seite aus einer Delphi- und C++Builder-Bibliothek, mit den Interop-Details von oben für Sie erledigt. Editionen, Plattformen und einen Testdownload finden Sie unter der HotXLS Delphi spreadsheet component