קובץ xlsx הוא ארכיון ZIP, ול-ZIP אין טבלת תוכן סמכותית יחידה. HotXLS Excel Library עבור דלפי ו-C++Builder מתייחסת לעמימות הזו כמשטח תקיפה: מפענח סוף-ספריית-התוכן-המרכזית שלה מקבל רשומת מועמד רק אחרי שארבע בדיקות צולבות עצמאיות מסכימות, כך שספריה מזויפת שמוסתרת בהערת ZIP לעולם לא מנצחת
התרחיש שהופך את זה לקונקרטי הוא שגרתי. שרת מקבל העלאות גיליון-אלקטרוני מלקוחות. הקובץ עובר סריקת אנטי-וירוס, נכתב לתיקיית ספול, והשירות שלך בדלפי פותח אותו כדי לשלוף שלוש עמודות. הכול נראה בסדר, חוץ מזה שהסורק והמפענח שלך לא הסכימו על מה הארכיון החזיק. הסורק ספר סט אחד של חברים; הטוען שלך ספר סט שונה מאותם בייטים. אף אחד מהם אינו באגי במובן הרגיל. הם פשוט פתרו עמימות בפורמט ZIP בשני כיוונים שונים, ותוקף בחר את הבייטים כך שהם יעשו זאת
איפה האמת על ארכיון ZIP באמת חיה?
היא חיה ממש בסוף, במבנה בן 22 בייטים שנקרא רשומת סוף ספריית התוכן המרכזית. קובץ ZIP אינו נקרא מהתחלה עד הסוף: כל חבר נושא כותרת קובץ מקומית ממש לפני הנתונים הדחוסים שלו, אבל האינדקס הסמכותי הוא ספריית התוכן המרכזית, ריצה של רשומות סמוך לסוף ששמות כל רשומה ונותנות את ההיסט של הכותרת המקומית שלה. כדי למצוא את ספריית התוכן המרכזית אתה חייב למצוא קודם את ה-EOCD, כי ה-EOCD הוא מה שאומר איפה הספרייה מתחילה וכמה רשומות היא מחזיקה. HotXLS ממדלת אותה כ-TEndOfCentralDirectoryRecord, שהשדות שלה ממופים אחד-לאחד על הפריסה-בדיסק: FDiskNumber בהיסט 4, FStartDisk ב-6, FThisDiskEntries ב-8, FTotalEntries ב-10, FSizeOfCD ב-12, FOffsetOfStartCD ב-16, ו-FCommentLen ב-20. הסך הזה הוא FMinSize, מחושב בבנאי כ-4*3 + 5*2. אחריו מגיעה הערת הארכיון, עד 65535 בייטים של תוכן שרירותי, מה שהופך את FMaxSize ל-65557 ומשמעו שהרשומה אינה במיקום קבוע. אתה חייב לצאת לחפש אותה
למה סריקה לאחור לחתימת ה-EOCD לא מספיקה?
מפני שארבעת הבייטים שאתה סורק עבורם, PK\005\006, יכולים להופיע חוקית בתוך הערת הארכיון, בתוך נתונים דחוסים, או בתוך EOCD שני שתוקף הוסיף בכוונה. מפענח שעוצר בחתימה הראשונה שהוא פוגש בעודו הולך לאחור ניתן להיגוי בקלות רבה: מקם EOCD דמה קרוב לזנב והמפענח הנאיבי עוקב אחריו, בעוד מפענח שסורק בסדר אחר, או שמתייחס לחתימה האחרונה בקובץ כסמכותית, עוקב אחרי האמיתית. זו משפחת התקפות עמימות ה-ZIP, והתגמול שלה הוא בדיוק הפיצול שתואר לעיל, שבו מנוע הסריקה והיישום הצורך רואים סטים שונים של רשומות מקובץ אחד
TEndOfCentralDirectoryRecord.Parse אכן סורק לאחור. היא קובעת את startscan לבייט האחרון, מהדקת את endscan ל-lsize - FMaxSize או אפס, והולכת בחלון במאגרי 256-בייט שחופפים בשלושה בייטים כך שחתימה שמשתרעת על פני גבול מאגר לעולם לא מוחמצת. ההבדל הוא מה שקורה בפגיעה. מציאת החתימה מפיקה רק היסט Candidate. HotXLS אז קוראת את 22 הבייטים בהיסט הזה, מנתחת אותם עם ReadEOCD, ודורשת שהשדות שנוצרו יהיו עקביים פנימית עם הקובץ שהם טוענים לתאר לפני ש-FOffsetEOCD אפילו מוקצה
Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
SetLength(RecordBuf, FMinSize);
inputstream.Position := Candidate;
if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
begin
ReadEOCD(RecordBuf[0], 0);
if (Candidate + FMinSize + FCommentLen = lsize) and
(FDiskNumber = 0) and (FStartDisk = 0) and
(FThisDiskEntries = FTotalEntries) and
(Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
begin
FOffsetEOCD := Candidate;
Result := FOffsetEOCD;
Exit;
end;
end;
end;
קרא את הפרדיקט כארבע טענות נפרדות שזיוף חייב לספק בו-זמנית. Candidate + FMinSize + FCommentLen = lsize דורש שאורך ההערה המוצהר יגיע בדיוק לסוף הקובץ, וזה מה שהורג את תרגיל דמה-בהערה: EOCD מזויף שקבור בתוך הערה אמיתית לא יכול גם להסביר כל בייט אחרי עצמו. FDiskNumber = 0 ו-FStartDisk = 0 דוחים את שדות הפריסה על-פני-דיסקים-מרובים שאף xlsx מעולם לא השתמש בהם לגיטימית ושקיימים בארכיונים מלאכותיים רק כדי לבלבל. FThisDiskEntries = FTotalEntries דוחה את תרגיל ספירת-הפיצול שבו מפענח אחד קובע את גודל הלולאה שלו משדה אחד ומפענח אחר מהשני. ו-Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate דורש שספריית התוכן המרכזית תסתיים בדיוק היכן שה-EOCD מתחיל, כך שהספרייה לא יכולה להיות מכוונת לאיזה blob לא-קשור במקום אחר בקובץ. ה-cast ל-Int64 באחרון הזה חשוב: שני האופרנדים הם 32-סיביות, ובלי הרחבה, זוג מלאכותי יכול לעטוף ולספק את הבדיקה אריתמטית תוך הצבעה לשום מקום שפוי
כותרות מקומיות חייבות להסכים עם ספריית התוכן המרכזית
בדיקות ה-EOCD מקבעות איזו ספרייה סמכותית; הן עדיין לא מבטיחות שהספרייה אומרת אמת על חברים בודדים. כל רשומה מתוארת פעמיים בקובץ ZIP, פעם אחת מרכזית ופעם אחת בכותרת המקומית שלה, ושום דבר בפורמט לא מכריח את שני התיאורים להתאים, כך שקורא שסומך על ספריית התוכן המרכזית וקורא שסומך על כותרות מקומיות יכולים לחלץ תוכן שונה מארכיון אחד. TZipEntry.ParseLocalHeader סוגר את הפער הזה על ידי ניתוח הכותרת המקומית ב-FCdFile.LocalFileHeaderOffset והשוואת שני העותקים שדה-שדה, מחזירה קוד שלילי מובחן עבור כל סוג אי-הסכמה: שם הרשומה המקנוני, שיטת הדחיסה, סיביות דגל המטרה-הכללית, וכשדגל תיאור-הנתונים לא מוגדר, ה-CRC32 ושני הגדלים. עם הדגל הזה מוגדר, העותקים המקומיים רשאים להיות אפס, כי הערכים האמיתיים חיים בתיאור עוקב, אבל כל ערך מקומי לא-אפס עדיין חייב להתאים. בדיקה סופית דוחה רשומות שהנתונים שלהן ירוצו מעבר לסוף הקובץ, משווה Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) כנגד inputstream.Size. כל כישלון מתפשט מ-TCentralDirectory.Parse כתוצאה לא-1 ו-TZipArchive.OpenArchive הופכת אותו ל-Can't open zip archive, במקום למסור לך אובייקט ארכיון חצי-מהימן. כשאתה צריך רק לדעת אילו גיליונות קובץ מכיל, הרצת האימות הזה לפני פענוח מלא זולה, ונתיב בדיקת הגיליונות הקליל נותן לך בדיוק את זה בלי לממש נתוני תא
מה קורה כשהבייטים עצמם משקרים?
הסכמה מבנית עדיין לא אומרת כלום על הפאילוד, כך ש-HotXLS עוטפת כל זרם רשומה ב-TZipVerifiedStream, שאוכפת את הגודל וה-CRC32 המוצהרים בעודך הקורא קורא. זו בכוונה לא בדיקה בדיעבד: פצצת דחיסה שהגודל הלא-דחוס המוצהר שלה הוא 4 קילו-בייט אבל שמתנפחת לגיגה-בייטים נעצרת בסימון 4 הקילו-בייט, לא אחרי שהנזק נעשה. העטיפה מהדקת כל קריאה לבייטים המוצהרים שנותרו, מעלה ZIP entry ended before its declared size אם המקור מתייבש מוקדם, בודקת בייט נוסף אחד בהשלמה ומעלה ZIP entry exceeds its declared size אם נשאר משהו, ולבסוף משווה את ה-CRC32 המצטבר ב-VerifyComplete, מעלה ZIP entry uncompressed size mismatch או ZIP entry CRC32 mismatch
if Count > 0 then
begin
Result := FSource.Read(Buffer, Count);
if Result <= 0 then
raise Exception.Create('ZIP entry ended before its declared size');
FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
Inc(FPosition, Result);
end
else
Result := 0;
if FPosition = FExpectedSize then
begin
if FSource.Read(Probe, 1) <> 0 then
raise Exception.Create('ZIP entry exceeds its declared size');
VerifyComplete;
end;
תוצאה אחת שווה לתכנן עבורה. הזרם קדימה-בלבד בעיצוב; Seek לכל מקום מלבד המיקום הנוכחי מעלה ZIP entry stream is forward-only, עם ויתור יחיד עבור soEnd עם היסט אפס כך ששאילתות גודל עדיין עובדות. זה הטרייד-אוף הנכון עבור קלט לא-מהימן, כי זרם שאתה יכול להריץ אחורה הוא זרם שאתה יכול להביס את הנהלת החשבונות של ה-CRC שלו, אבל זה כן אומר שקוד צרכן שמצפה לזרם ניתן-לחיפוש צריך מאגר משלו. אותה משמעת קדימה-בלבד תומכת בהקורא הישיר הזורם, שהוא ה-API להושיט אליו יד כשחוברת העבודה שהועלתה גדולה מספיק שאתה לא רוצה שהיא תישאר בזיכרון כלל
מגבלות משאבים לפני הקצאה, לא אחריה
שלושה קבועים ב-lxZipArchive חוסמים מה ארכיון בודד יכול לבקש מהתהליך לעשות, ו-TZipEntries.Add מחילה אותם בעוד ספריית התוכן המרכזית עדיין נקראת, לפני שבייט אחד של נתוני רשומה נגע. ZipMaxEntryUncompressedSize מגבילה חבר בודד ל-1 GiB, ZipMaxTotalUncompressedSize מגבילה את הארכיון ל-4 GiB, ו-ZipMaxCompressionRatio של 10000 דוחה כל רשומה מכווצת שההתפשטות המוצהרת שלה חורגת מפי עשרת-אלפים, לצד המקרה המנוון של גודל לא-דחוס לא-אפס מזווג עם גודל דחוס אפס. שמות רשומות עוברים דרך CanonicalZipEntryName באותה קריאה, שדוחה תווי NUL משוטמעים, נקודתיים, וכל קטע נתיב .. עם Invalid ZIP entry name, ושהופכת לאותיות-קטנות ומנרמלת קטעים כך ששני חברים שנבדלים רק ברישיות או במפרידים-מיותרים מתנגשים כ-Duplicate ZIP entry name במקום להאפיל בשקט זה על זה
הגנה מעמיקה מעל שכבת ה-ZIP
שכבת ה-ZIP היא שכבה אחת מכמה, והתבנית חוזרת בכל מקום ש-HotXLS מפענחת מבנה שנשלט על ידי תוקף. הדוגמה הברורה ביותר יושבת במפענח הנוסחה BIFF: TXLSFormula.GetTranslated חוזרת רקורסיבית דרך טוקני tMemFunc, כך שזרם טוקן rgce מלאכותי ב-.xls ישן יכול להתקנן שרירותית עמוק ולמצות את המחסנית. השער הוא קבוע, MaxTranslateDepth = 256, שנבחר כנגד עובדה ידועה במעלה-הזרם ולא ניחוש. אקסל מגביל קינון נוסחאות ל-64, כך ש-256 משאיר רווח פי-ארבעה ואף פעם לא יכול לדחות נוסחה שגיליון-אלקטרוני אמיתי הפיק, תוך שהוא עדיין מפסיק זרם זדוני זמן ארוך לפני שהמחסנית נגמרת
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
שים לב שהשער מחזיר nil במקום להעלות חריגה. נוסחה עמוקה מדי מכדי להיות אמיתית מניבה שום עץ תחביר, הניתוח הסובב ממשיך, וחוברת העבודה עדיין נטענת. האסימטריה הזו מכוונת וכדאי להעתיק אותה במגבלות שלך עצמך: מגבלה שקיימת כדי לעצור מיצוי-משאבים צריכה להשפיל את היחידה הקטנה ביותר שהיא יכולה, לא להפסיק את המסמך. אותו היגיון חל כשאתה מרחיב את שכבת החישוב, כך שאם אתה רושם מטפלים משלך דרך ה-API של פונקציות מותאמות אישית של מנוע הנוסחאות, תן להם גבולות ארגומנט ורקורסיה משלהם במקום להניח שהקורא כבר בדק
מה הבדיקות האלה לא קונות לך
היה מדויק לגבי הגבול. ארבע בדיקות ה-EOCD הצולבות הופכות את אינדקס הארכיון לחד-משמעי, כך ש-HotXLS וכל קורא תואם אחר פותרים את אותו קובץ לאותו סט רשומות; הם לא אומרים כלום על האם הסט הזה תמים. הסכמת כותרת מקומית עוצרת את תרגיל שתי-ההשקפות, לא פאילוד זדוני שמתואר בעקביות. הזרם המאומת עוצר קטיעה, גלישה ושחיתות, לא חלק XML לגמרי תקין-בצורתו שמקודד משהו שלא ציפית. ושום דבר מזה לא נוגע במאקרו: פרויקט VBA בתוך חוברת עבודה תקינה-מבנית לגמרי עדיין פרויקט VBA, וההחלטה לשמור, להסיר, או לסרב לו שייכת לשכבת המדיניות שלך, לא למפענח ה-ZIP
מה שאתה מקבל בתמורה הוא גבול כישלון נקי. xlsx לא-מהימן או נפתח כארכיון חד-משמעי אחד שהרשומות שלו תואמות לגדלים ולסכומי הביקורת המוצהרים שלהן, או שהוא מעלה חריגה עם הודעה ששמה את האינווריאנט הספציפי שהוא שבר, והשירות שלך יכול לבודד בהסגר על סמך החריגה במקום לנחש. מפענח ה-ZIP ושכבות המפענח שמעליו משתלבים כחלק מ-רכיב HotXLS Excel עבור דלפי ו-C++Builder, שלא זקוק לא לאקסל ולא לאוטומציית OLE על המכונה שמבצעת את הפענוח, וההיעדר הזה הוא בעצמו צמצום משמעותי במה שקובץ שהועלה יכול להגיע אליו