אתה כותב חוברת עבודה, מצפין אותה בסיסמה, מוסר את הקובץ לעמית, והעמית פותח אותו ב-Excel. Excel מבקש את הסיסמה. העמית מקליד אותה, ו-Excel מקבל אותה. עד כה ההצפנה נראית נכונה. לאחר מכן Excel מציג תיבת דו-שיח שאומרת שהקובץ מושחת ולא ניתן לפתוח אותו, או שהוא נפתח לגיליון של תאים חסרי משמעות. הסיסמה הייתה נכונה. הקובץ שבור בכל זאת. זהו מצב הכשל המבלבל ביותר בהצפנת Office, מכיוון שהחלק שמאשר שהסיסמה נכונה והחלק שמחזיק את הנתונים שלך מוגנים על ידי שתי פעולות שונות, והצלחה באחת אינה מבטיחה דבר לגבי השנייה.
שני הבאגים המתוארים כאן היו בדיוק בצורה זו. בכל מקרה המאמת (verifier) עבר והגוף לא עבר, מה ששולח אותך לחפש באג בסיסמה או בגזירת המפתח שאינו קיים שם. התקלה האמיתית הייתה במורד הזרם, באופן שבו בתי החבילה עברו התמרה. שתי התקלות אינן תלויות זו בזו, אחת בנתיב ה-AES ואחת בנתיב ה-RC4, אך הן חולקות בעיית אבחון, ולכן כדאי לראות מדוע תוצאה נכונה למחצה היא הסוג הקשה ביותר לפענוח.
מדוע סיסמה שעוברת אינה מוכיחה דבר לגבי הגוף
הפורמט שבו משתמש קובץ XLSX מוצפן מודרני הוא הצפנה סטנדרטית של ECMA-376 (Standard Encryption), והוא שומר שני דברים מוצפנים זה לצד זה. אחד הוא ה-EncryptionVerifier: בלוק קטן המחזיק ערך אקראי ואת ה-hash של אותו ערך, מוצפנים באמצעות המפתח שנגזר מהסיסמה. השני הוא ה-EncryptedPackage: מכולת ה-zip המלאה של חוברת העבודה, מוצפנת באותו מפתח. המאמת קיים כדי שקורא יוכל לאשר סיסמה לפני שהוא משקיע מאמץ במגה-בייטים של גוף הקובץ. פענח את המאמת, בצע hash לערך האקראי, השווה אותו ל-hash השמור, ואם הם תואמים הסיסמה נכונה.
המלכודת היא שהמאמת והחבילה מוצפנים על ידי קריאות נפרדות מעל באפרים נפרדים. מפתח שנגזר נכון יפענח את המאמת נכון לא משנה מה יקרה לחבילה לאחר מכן. כך שאם גזירת המפתח שלך נכונה אך התמרת החבילה שלך שגויה, Excel יאשר את הסיסמה מהמאמת ואז ייכשל בגוף הקובץ. הסימפטום נקרא כ-"סיסמה נכונה, קובץ שבור", מה שמכוון את החקירה לנתיב הסיסמה, שהוא החלק היחיד שמעולם לא נשבר. אותה הפרדה שולטת במקרה ה-RC4 הישן: ה-hash של המאמת נבדק תחילה, וגוף שסוטה מחוץ לסנכרון עדיין משאיר את הבדיקה הזו ללא פגע.
באג ראשון: AES ב-ECB, לא ב-CBC
תקן [MS-OFFCRYPTO] §2.3.4.15 מפרט כי הצפנה סטנדרטית מצפינה את החבילה באמצעות AES במצב Electronic Codebook (ECB). כל בלוק של 16 בתים של החבילה המרופדת מוצפן באופן עצמאי באמצעות אותו מפתח. אין שרשור בין בלוקים ואין וקטור אתחול (IV). זוהי בחירה יוצאת דופן בסטנדרטים מודרניים, שבהם בדרך כלל נמנעים מ-ECB, אך תאימות (interop) אינה מקום להטיל ספק במפרט. 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;
באג שני: סטיית מפתח מחדש (re-key) של RC4 מחוץ לצעד
נתיב ה-.xls הישן משתמש בשיטת ה-RC4 CryptoAPI, והחוק שלו שונה במהותו. מפרט [MS-OFFCRYPTO] §2.3.6 קובע שהצופן עובר מפתח מחדש (re-keyed) בכל גבול של בלוק בן 1024 בתים. הזרם מחולק לבלוקים של 1024 בתים, מפתח RC4 חדש נגזר עבור בלוק מספר 0, 1, 2 וכן הלאה, ובתוך כל בלוק זרם המפתח (keystream) נצרך ברציפות מבית לבית. שני תנאים קבועים חייבים להתקיים יחד: מפתח מחדש בכל גבול, וצריכת זרם המפתח ללא מרווחים בתוך בלוק. RC4 הוא צופן זרם (stream cipher), ולכן זרם המפתח שלו הוא רצף מסודר יחיד; הבית ה-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 בתים בין אם בחירות אלה מוצאות חן בעיניך ובין אם לאו, ותפקידך היחיד הוא להפיק בתים שמפענחים למקור תחת אותו הליך בדיוק. מצב שהוא מודרני יותר, IV שנראה לא מזיק, מונה שמתחיל היכן שזה מרגיש טבעי; כל אחד מאלה הוא פגם ברגע שהוא סוטה ממה שהקורא מצפה לו. תאימות מול מפרט קפוא אינה מקורבת. היא מוגדרת בדיוק ברמת הבתים או שהיא שבורה.
זו גם הסיבה שהמאמת הוא בדיקה גרועה בפני עצמה. הוא אומר לך שגזירת המפתח עובדת, מה שנדרש אך רחוק מלהיות מספיק. בדיקה שרק פותחת קובץ מוצפן ומאשרת שהסיסמה עוברת תדווח על הצלחה בעוד גוף הקובץ אינו קריא. בדיקה אמיתית מפענחת את החבילה ומשווה את הבתים המשוחזרים לקלט המקורי, או מבצעס סבב מלא של חוברת עבודה דרך הצפנה ופענוח וקוראת תאים בחזרה. המאמת מוכיח את הסיסמה; רק הגוף מוכיח את ההצפנה.
הדרך הנתמכת לקרוא ולכתוב חוברות עבודה מוגנות
השטח הציבורי קטן. כדי לכתוב חוברת עבודה מודרנית מוגנת בסיסמה, מלא או פתח TXLSXWorkbook וקרא ל-SaveAsEncrypted עם שם קובץ וסיסמה; היא מפיקה סיראליזציה לחוברת העבודה ומריצה את צינור ההצפנה הסטנדרטי שהתיקון הראשון תיקן, ומחזירה 1 בהצלחה. כדי לקרוא, קרא ל-CanReadEncrypted כדי לבדוק אם קובץ הוא מכולת Compound File (OLE2) מוצפנת, ולאחר מכן הסתעף: 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, בלוקי המאמת ופריסת החבילה מכוסים ב-מדריך שלנו לפלט XLSX מוגן ב-AES. עבור השאלה הנפרדת של נעילת רמת הגיליון וכיצד הגנה מקיימת אינטראקציה עם הגדרת עמוד והדפסה, ראה המאמר על הגנה, הגדרת עמוד והדפסה. שניהם בונים על נתיב ההצפנה המתואר כאן, אשר נשלח כחלק מ-HotXLS spreadsheet component עבור Delphi ו-C++Builder לצד ממשקי ה-API לקריאה, כתיבה ורינדור המכוסים במקומות אחרים בבלוג זה.