HotXLS כותב חוברות עבודה מעורפלות-XOR של Excel 5.0/95 (BIFF5) ש-Excel 16 פותח רק כששלושה פרטים תואמים את [MS-OFFCRYPTO] בדיוק: מפתח ה-FILEPASS חייב להיות CreateXorKey_Method1(password), מערך ה-XOR בן 16 הבתים חייב להיבנות עם XorRor (הזזה ימינה בביט אחד), וכל בית חייב להשתמש ב-XorArrayIndex = (stream offset + record length) mod 16. HotXLS השיג את האינדקס נכון ב-v2.384.47 ואת המפתח וההזזה נכונים ב-v2.384.54. לפני כן, כל קובץ BIFF5 מוגן סיסמה שהפיק נפתח חלק ב-HotXLS ונכשל ב-Excel
המשפט האחרון הזה הוא הסיפור כולו במיניאטורה. קורא וכותב שחולקים את אותה טעות מסכימים אחד עם השני בצורה מושלמת, ולכן בדיקות round-trip נשארות ירוקות בזמן שהצרכן היחיד שחשוב אומר לא. Excel 16 אמר לא פעמיים, עם שתי הודעות שונות, וכל הודעה הצביעה על שכבה אחרת של הסכימה. המאמר הזה עובר על השכבות האלה בסדר שבו Excel בודק אותן, עם פירוט ברמת הבתים שישמש אתכם בין אם אתם קוראים ל-HotXLS ובין אם כותבים קורא BIFF משלכם
מה אובפוסקציית ה-XOR של BIFF באמת שומרת בקובץ?
ערפול ה-XOR של BIFF שומרת בקובץ רק שתי מילים בנות 16 ביט, וכל השאר מחושב מחדש מהסיסמה. רשומת ה-FILEPASS ($002F) יושבת מיד אחרי ה-BOF של workbook globals, ובקובץ BIFF5 הגוף שלה הוא בדיוק 4 בתים: מפתח ה-XOR ואחריו מאמת הסיסמה (verifier). אין salt, אין מזהה אלגוריתם ואין בלוב verifier מוצפן מהסוג שסכמות ה-RC4 וה-AES נושאות
משתי המילים האלה קורא בונה שלושה דברים:
- את ה-verifier, hash בן 16 ביט של בייטי הסיסמה מ-XOR עם
$CE4B. ההשוואה מול המילה השמורה היא בדיקת הסיסמה, והיחידה - את מפתח ה-XOR, ערך בן 16 ביט מ-
CreateXorKey_Method1ב-[MS-OFFCRYPTO] §2.3.7.2, שמונע על ידי שתי טבלאות קבועות (InitialCode, 15 מילים, ו-XorMatrix, 105 מילים) - את מערך ה-XOR, 16 בתים שנבנים מבייטי הסיסמה בתוספת pad קבוע בן 16 בתים, כל אחד מ-XOR עם בייט המפתח הנמוך (מיקומים זוגיים) או בייט המפתח הגבוה (מיקומים אי-זוגיים), ואז הזזה ימינה בביט אחד
כותרות הרשומות נשארות בטקסט רגיל, וכך גם כמה רשומות שלמות שהסכימה פוטרת, בהן BOF, FILEPASS ו-INTERFACEHDR. כל גוף רשומה אחר עובר טרנספורמציה בייט-בייט: הזזה שמאלה ב-5 ביטים ואז XOR עם כניסה אחת של המערך בן 16 הבתים. הפענוח, ש-[MS-OFFCRYPTO] §2.3.7.3 מאיית בתור DecryptData_Method1, הוא תמונת הראי: קודם XOR ואז הזזה ימינה ב-5
ב-HotXLS לא נוגעים באף אחד מאלה ישירות. מגדירים סיסמה, בוחרים פורמט, ו-SaveAs פולט את ה-FILEPASS ומטרנספורם את ה-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 תומך רק בערפול 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 לפני v2.384.54 מילא את מילת המפתח בשני בייטים אקראיים. על הנייר זה נראה לא מזיק, כי ה-verifier הוא בדיקת הסיסמה המתועדת והמערך נבנה מהמפתח שהקובץ מצהיר, מה שלא יהיה. HotXLS עצמו קרא את הקבצים האלה בלי בעיה, כי הקורא שלו לקח את המפתח מה-FILEPASS כנתון. Excel 16, שקיבל את אותו קובץ ואת הסיסמה הנכונה, ענה שהסיסמה אינה נכונה. מאז v2.384.54 המפתח נגזר, ולכן ה-FILEPASS עבור הסיסמה secret מחזיק תמיד מפתח $014D ו-verifier $DAA7, ערכים שאומתו בהצלבה מול מימוש עצמאי של המפרט
הגזירה עצמה קצרה ברגע ששתי הטבלאות במקום. הולכים על הסיסמה אחורה, מביטים בביט 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, שהוא fixture נוח אם בודקים קורא משלכם
מדוע מפתח נכון עדיין מפיק קובץ פגום?
מפתח נכון עדיין מפיק קובץ פגום כשאת מערך ה-XOR מזיזים לכיוון הלא נכון: [MS-OFFCRYPTO] מגדיר את שלב המערך בתור XorRor, הזזה ימינה בביט אחד, ומערך שמוזז שמאלה בשניים מפענח כל גוף רשומה לרעש. תיקון המפתח דילג את Excel 16 מעבר לשאילתת הסיסמה וישר אל שגיאה אחרת, דיווח שלקובץ יש בעיה ושאי אפשר לפותח אותו
הקוד הישן של HotXLS הזיז כל בייט מערך שמאלה ב-2 ביטים, צורה שמסתובבת בכמה מימושי BIFF. מכיוון ש-HotXLS השתמש באותה הזזה בשני הצדדים, הקורא שלו עצמו מעולם לא שם לב. Excel 16 כבר לא יכול לשמור קבצי Excel 5.0/95, והוא לא מציע XOR בשמירת BIFF8, ולכן לא הייתה דגימת Excel טבעית להשוות מולה. הראיות היו צריכות להגיע מהכיוון השני: כותבים stream אחד של BIFF5 בטקסט רגיל, מקודדים אותו מחדש בשמונה דרכים, ונותנים ל-Excel 16 לפתוח כל וריאציה. שמונה הווריאציות הצליבו שלוש בחירות בלתי תלויות:
| בחירה | אפשרות A | אפשרות B |
|---|---|---|
| הזזת המערך | XorRor (הזזה ימינה 1) | הזזה שמאלה 2 |
| אינדקס המערך | (offset + record length) 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 עבור בית הוא ה-offset שלו ב-workbook stream בתוספת האורך של כל נתוני הרשומה שאליה הוא שייך, mod 16. האינדקס לכן מתאפס מחדש לערך התלוי ברשומה בכל רשומה ומתקדם באחד לכל בית בתוכה. הפסאודוקוד של המפרט קורא לקלטים FileOffset ו-Data.Length, שקל לפרש בטעות כ-offset של תחילת הרשומה בלבד, והקריאה המוטעית הזאת היא בדיוק מה ש-HotXLS שילק עד v2.384.47
שלושה פרטים מכריעים אם האינדקסים שלכם מתיישרים עם Excel:
- כותרת הרשומה בת 4 הבתים מעולם לא מטרונספורמת, אבל היא עדיין תופסת מיקומי stream, ולכן בית הגוף הראשון של רשומה יושב ב-header offset + 4
- האיבר של האורך הוא אורך נתוני הרשומה המלא, לא מספר הבתים שבאמת הטרונספורמו
- BOUNDSHEET חלקו רגיל: ארבעת הבתים הראשונים שלו,
lbPlyPos, ה-offset ב-stream של BOF הגיליון, נשארים קריאים כדי שפרסר יוכל לאתר גיליונות. ארבעת הבתים האלה מדולגים על ידי הטרנספורמציה אבל עדיין נספרים גם ל-offset וגם לאורך הרשומה
ביחד, הטרנספורמציה לכל רשומה היא כמה שורות. שוב, זה שרטוט של הכלל, לא משהו שצריך לקרוא לו:
function Rol8(B: Byte; N: Integer): Byte;
begin
Result := Byte((B shl N) or (B shr (8 - N)));
end;
// מערפל גוף רשומה אחד במקום. BodyPos הוא ה-offset ב-stream של
// Body[0], כלומר ה-offset של כותרת הרשומה + 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 חישב את האינדקס ממיקום ה-stream בלבד והכותב השתמש ב-offset הבייטים הרגיל. שניהם התעלמו מאורך הרשומה, ולכן שוב שני החצאים הסכימו ביניהם ועם אף אחד אחר. מפענח שנכתב באופן עצמאי קרא נכון את הפלט של v2.384.47 ואת הפלט הישן יותר קרא כשטויות, ובדיקת שמונה-הווריאציות ב-Excel 16 אישרה מאוחר יותר את הכלל מול היעד האמיתי
מה קורה עם קובצי XOR שנכתבו בגרסאות HotXLS ישנות?
HotXLS ממשיך לקרוא את קובצי ה-XOR שלו שלפני v2.384.54 על ידי בדיקת מפתח ה-FILEPASS: כשהמפתח השמור שווה למפתח הנגזר מהסיסמה, הקורא בונה את מערך ה-XorRor של המפרט, וכשהוא שונה, הקורא מתייחס לקובץ בתור קובץ HotXLS ישן ובונה מחדש את מערך ההזזה-שמאלה-2. קבצים שנכתבו על ידי Excel תמיד נושאים את המפתח הנגזר, ולכן הם תמיד לוקחים את נתיב המפרט
הבדיקה היא heuristic עם שיעור כשל מדויק. קובץ ישן שהמפתח האקראי שלו במקרה היה שווה למפתח הנגזר ייקרא עם המערך השגוי, והסיכוי לזה הוא 1 מתוך 65,536. ה-fallback מכסה רק את הזזת המערך; כלל האינדקס לא מוחלף, ולכן הקבצים שהוא מציל הם אלה שנכתבו בין v2.384.47 ל-v2.384.53. אם עדיין מחזיקים קובצי BIFF5 XOR מהחלון הזה, פותחים אותם עם ה-HotXLS הנוכחי ושומרים אותם שוב כדי לקבל קובץ ש-Excel מקבל
שני פרטי סיסמה חלים על כל קובץ, ישן או חדש:
- אורך.
CreateXorKey_Method1קורא רק את 15 בייטי הסיסמה הראשונים, וזהו הגבול של המפרט. HotXLS מיישם את התקרה הזאת על המפתח ומשאיר את ה-verifier והמערך על הכללים הרגילים שלהם באורך מלא וב-16 בתים, בעקביות בשני הצדדים. Excel עצמו מסרב לסיסמאות ארוכות מ-15 תווים עבור הפורמט הזה, ולכן מתייחסים ל-15 בתור המקסימום האמיתי - מערך תווים. HotXLS ממיר את הסיסמה לבייטים דרך ה-ANSI code page המערכתי. המפרט מתאר לקיחת הבייט הנמוך של כל תו 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 מאובטחת מספיק למשהו?
ערפול ה-XOR של BIFF אינה הצפנה ואינה מגינה על שום דבר מפני קורא נחוש. בדיקת הסיסמה היא verifier בן 16 ביט, המפתח בן 16 ביט, והמערך בן 16 הבתים חוזר על עצמו לכל אורך ה-stream, כך שתוכן רשומות 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 (hashing סיסמה SHA-512 עם spin count של 100,000 איטרציות ו-AES-256-CBC), הפורמט ש-Excel 2010 ומעלה כותבים כברירת מחדל, בזמן ש-SaveAsEncrypted כותב את ההצפנה הסטנדרטית הישנה יותר של AES-128. האיזונים בין השניים נמצאים ב-הצפנת קובצי XLSX עם AES ב-Delphi, וצד הקריאה מכוסה ב-קריאת קובצי Excel מוצפני Agile עם HotXLS
עזר זריז: ערפול XOR של BIFF ש-Excel 16 מקבל
- FILEPASS (
$002F) בא אחרי BOF ה-globals; ב-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 = (byte stream offset + record data length) mod 16; כותרות וקידומות רגילות נספרות ל-offset
- BOUNDSHEET משאיר את ארבעת הבתים הראשונים שלו רגילים; 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, ובקריאת callback לסיסמה בצד הקריאה, מספריית Delphi ו-C++Builder אחת, כשפרטי ה-interop שלמעלה מטופלים בשבילכם. ראו את רכיב הגיליונות HotXLS ל-Delphi למהדורות, לפלטפורמות ולהורדת ניסיון