Technical Article

הקשחת מנתח PDF ב-Pascal מפני קבצים זדוניים

קובץ PDF אינו מסמך שאתה פותח. זוהי תוכנית קטנה שאתה מריץ. כל גופן מוטמע הוא מפרש מבוסס מחסנית הממתין ל-charstrings, כל תמונה היא מפענח המוזן בשדות רוחב, גובה ועומק סיביות שהקובץ בחר, וכל זרם מגיע עטוף במסננים שהפרמטרים שלהם נקבעו על ידי הקובץ. אף אחד מהמספרים האלה אינו שלך. הם הגיעו ממי שהפיק את הקובץ, שבסביבת עבודה אמיתית הוא חשבונית של לקוח או קובץ מצורף משולח לא ידוע. המפענחים שהופכים את הבתים הללו לפיקסלים וגליפים הם שטח התקיפה, ומנתח הבוטח בקלט שלו שם נמצא במרחק קובץ פגום אחד מקריסה או גרוע מכך.

הספרייה PDFlibPas עברה סבב הקשחה שהתייחס לכל נתיב הפענוח כעוין, לאורך תוכניות הגופנים (TrueType,‏ Type1,‏ CFF וטבלאות CMap), מפענחי התמונות (PNG,‏ GIF,‏ TIFF,‏ JBIG2 ו-CCITT Group 3 ו-Group 4), ומסנני הזרם (LZW,‏ ASCII85 ומנבאי Flate). להלן חמישה סוגי פגמים שהיא סגרה, כל אחד מבוסס על התנהגות Delphi הספציפית שאפשרה אותו. הם תוקנו בגרסאות הנוכחיות, ואותן תבניות חוזרות על עצמן בכל קוד Pascal שמנתח קלט לא מהימן.

גלישת שלמים המוסרת לך באפר קטן מדי

באג בטיחות הזיכרון הקלאסי במפענח תמונות הוא מכפלת ממדים שמתקפלת (wraps). מפענח קורא רוחב, גובה, ספירת רכיבים ועומק סיביות, מכפיל אותם כדי לקבוע את גודל הפלט שלו, מקצה מספר זה של בתים, ואז כותב את התמונה בממדיה האמיתיים. אם ההכפלה מתבצעת באריתמטיקה של 32 סיביות, המכפלה יכולה להתקפל לערך קטן גם כאשר כל גורם בודד נמצא בטווח שפוי, כך שההקצאה מצליחה אך יוצאת קטנה מדי, והפענוח חורג מקצה הזיכרון שהוקצה. זוהי גלישת שלמים (CWE-190), המובילה לכתיבה מחוץ לגבולות ה-heap‏ (CWE-787) צעד אחד מאוחר יותר.

נתיב התמונות המשותף כבר הגביל כל מימד ל-65535; המפענחים העצמאיים לא ירשו כולם את ההגבלה הזו. ביטוי כגון ByteCount * FHeight, או ביטוי לכל פיקסל כגון FWidth * Components * BitDepth, הוא מכפלה של 32 סיביות ב-Delphi כאשר שני האופרנדים הם שלמים של 32 סיביות, ללא קשר לרוחב המשתנה שאליו אתה מייחס את התוצאה. רוחב וגובה של 60,000 הם כל אחד סביר עבור סריקה גדולה, אך המכפלה שלהם בבתים חורגת מטווח ה-32 סיביות החתום והאורך המתקבל קטן. אותה מלכודת הייתה קיימת בצעד המנבא של ZLib,‏ BitsPerComponent * Colors * Columns.

התיקון הוא להפוך לפחות אופרנד אחד ל-Int64 כך שכל הביטוי יוערך ב-64 סיביות, ולאחר מכן להשוות מול MaxInt ולסרב לקובץ לפני הצמצום בחזרה לקריאה ל-SetLength.

// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);

מה שהופך את זה לבעיית Delphi ולא לבעיה כללית הוא הצמצום השקט. הקצאת ביטוי רחב מדי ליעד של 32 סיביות היא המרה חוקית שהקומפיילר לא יזהיר לגביה כברירת מחדל, ובדיקת טווחים (range checking) אינה תופסת התקפלות שמתרחשת לפני שהערך משמש כאינדקס. השאר את המכפלה ב-32 סיביות והשפה תתן לך בשקט אורך שמשקר לגבי כמות הזיכרון שהפענוח עומד לגעת בו.

טיפוס שדה שהופך את מנגנון ההגנה לבלתי אפשרי להפעלה

קובץ TIFF הוא שרשרת של מדריכי קובצי תמונה (IFD), שכל אחד מהם נושא את היסט הבתים של הבא אחריו. קובץ זדוני יכול להצביע עם השרשרת הזו בחזרה אל עצמה, וקורא שעובר עליה ללא תנאי עצירה ירוץ לנצח. זוהי לולאה אינסופית המונעת על ידי קלט בשליטת תוקף (CWE-835), וההגנה היא מונה שעוצר ברגע שהוא עובר גבול ששום קובץ לגיטימי לא היה מגיע אליו.

מונה העמודים הוצהר כ-Word, שב-Delphi מחזיק ערכים מ-0 עד 65535. הלולאה נשאה הגנת סיום מהצורה "עצור כאשר ספירת העמודים עולה על 65535", מה שנראה נכון עד שאתה מבחין שהאופרנד והסף חולקים גבול עליון זהה. משתנה Word אינו יכול להיות גדול מ-65535, ולכן ההשוואה היא מבנית תמיד false: כאשר המונה מגיע ל-65535 ההגדלה הבאה מחזירה אותו ל-0, מנגנון ההגנה מעולם לא רואה ערך מעל התקרה, ושרשרת IFD מחזורית משאירה את הקורא בלולאה אינסופית.

התיקון היה להרחיב את השדה כך שההגנה תוכל לבטא ערך שהמונה יכול באמת להחזיק. כאשר TPDFTIFF.FPageCount מוצהר כ-Integer, אותה השוואה של FPageCount > 65535 הופכת לניתנת להשגה, הלולאה מסתיימת, ומאפיין ה-PageCount הציבורי שינה את הטיפוס שלו בהתאמה מבלי לשבור אף קוד קורא. בכל פעם שבדיקת גבול היא מהצורה Value > MaxValueOfType(Value) והאופרנד כבר מוגדר בדיוק במקסימום הזה, התנאי הוא תמיד false: הרחב את הטיפוס, או בדוק שוויון מול המקסימום כדי שהיא תוכל לפעול.

בדיקת טווחים כבויה בנתיב מהיר (hot path)

כאשר בדיקת הטווחים מופעלת, Delphi מכניס בדיקת גבולות בכל אינדקס של מערך ומחרוזת, וזהו ההבדל בין אינדקס מחוץ לטווח המעורר חריגת ERangeError לבין אותו אינדקס הקורא או כותב לזיכרון שאינו שייך למבנה. נתיבים מהירים (hot paths) מבטלים לעיתים את הבדיקה עם הנחיית {$R-} מקומית, מה שניתן להגנה רק עד שהאינדקסים מפסיקים להיות מהימנים.

גישת הרשימה שמפרשי הגופנים נשענים עליה, TPDFlibStringList.Get, היא בדיוק נתיב כזה. ב-Windows היא מקומפלת ללא בדיקת טווחים ומאנדקסת את הזיכרון שלה ישירות, כך שאינדקס מחוץ לטווח אינו שגיאה אלא גישת זיכרון גולמית. זה בסדר כאשר האינדקס תמיד תקף, וזה מפסיק להיות בסדר בתוך מפרש charstring של CFF או Type2, שבו האינדקס יכול להגיע מהקובץ. מחרוזת תווים (charstring) שמושכת אופרנד ממחסנית ריקה מייצרת אינדקס של מינוס אחד; מזהה גליף שחורג באחד מול ספירת הגליפים מאנדקס משבצת אחת מעבר לקצה. כאשר בדיקת הטווחים כבויה, שניהם הופכים לגישה אמיתית מחוץ לגבולות במקום לחריגה שניתן ללכוד, ומכיוון שהמשבצות מחזיקות ערכי AnsiString בעלי ספירת הפניות, קריאה שגויה יכולה גם להשחית את ספירת ההפניות של המחרוזת.

ההקשחה לא החזירה את בדיקת הטווחים לנתיב המהיר. היא הפכה את האינדקסים לתקפים מראש באופן שניתן להוכחה: לפני לקיחת ראש מחסנית האופרנדים המפרש בודק שהמחסנית אינה ריקה, וכל הגנת אינדקס נכתבה כקטן-ממש (strict less-than) מול הספירה במקום קטן-או-שווה המאפשר שגיאת off-by-one. ההנחיה מעבירה את האחריות לגבולות מהקומפיילר אליך, והאימות שהיא הסירה חייב להיות מוחזר ידנית בכל נקודת כניסה.

רקורסיה בלתי מוגבלת במפרש charstring

מחרוזת תווים של Type2 יכולה לקרוא לתת-שגרה (subroutine), ותת-שגרה היא בעצמה charstring שיכולה לקרוא לאחרת, כך שאופרטורי קריאות תת-השגרה המקומיים והגלובליים מאפשרים לקובץ להחליט כמה עמוק זה יגיע. תת-שגרה הקוראת לעצמה, ישירות או דרך מעגל, מבצעת רקורסיה ללא סוף עד שמחסנית המערכת נגמרת והתהליך מת. זוהי רקורסיה בלתי מבוקרת (CWE-674).

מפרש ה-Type1 כבר התגונן מפני זה. הוא נשא מונה עומק קריאות ותקרה, PLType1MaxCallDepth, וסירב לרדת מעבר לו, מה שמשקף את מגבלת העומק שמפרש ה-Type1 עצמו נוקב בה. מפרש ה-Type2, שנוסף מאוחר יותר ודומה לו מבנית, לא נשא את אותו מנגנון הגנה, וגופן שנבנה ידנית עם תת-שגרה הקוראת למספר שלה עצמה עובר ישר דרך הבדיקה החסרה לתוך גלישת מחסנית (stack overflow).

// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out

התיקון היה לתת לנתיב Type2 את אותו עומק מוגבל שכבר היה לאחיו Type1. כל ירידה רקורסיבית על מבנה שנשלט על ידי תוקף, בין אם תת-שגרה של גופן, מערך מקונן או שרשרת הפניות צולבות, דורשת תקרת עומק שהקלט אינו יכול להרים.

זיכרון לא מאותחל המודלף לתוך הפלט

הפגם העדין ביותר הדליף תכני heap לתוך פלט מפוענח, והגורם הוא תכונה של SetLength שקל לשכוח. כאשר אתה מגדיל AnsiString באמצעות SetLength,‏ Delphi מקצה את הבתים אך אינו מאפס אותם, ולכן האזור החדש מחזיק את מה שהיה קודם לכן בזיכרון ה-heap הזה. אם כל בית נכתב לאחר מכן, זה מעולם לא משנה; אם נתיב משאיר חלק מהבאפר לא כתוב ואז מחזיר אותו כנתונים, הבתים המיושנים הללו יוצאים החוצה עם התוצאה. זהו שימוש בזיכרון לא מאותחל (CWE-457), וכאשר התוצאה חוצה גבול אמון היא הופכת להדלפת מידע.

נתיב הפענוח AES-CBC נתקל בדיוק בזה. באפר הפלט נקבע בגודלו באמצעות SetLength והמפענח עיבד את הטקסט המוצפן בלוק אחד של 16 בתים בכל פעם. כאשר אורך הטקסט המוצפן לא היה כפולת 16, אורך שתוקף יכול לבחור, הבלוק החלקי שבסוף מעולם לא נכתב, ולכן אותם בתים אחרונים שמרו על תכני ה-heap ש-SetLength השאיר מאחור והבאפר הוחזר כטקסט המפוענח של אובייקט מסמך. הטיפול כולל שתי הגנות, ואף אחת מהן לבדה אינה מספיקה: נקודת הכניסה לפענוח דוחה כעת כל טקסט מוצפן שאורכו אינו כפולה של גודל הבלוק, וכגיבוי הפלט מנוקה עם FillChar לפני השימוש כך שכל נתיב שנכשל בכתיבת אזור מחזיר אפסים במקום שאריות heap.

עם מה סבב ההקשחה משאיר אותך

חמשת הפגמים הם באגים שונים, אך הם מתחרזים. רוחב שלם שמקפל מכפלה, טיפוס שדה שנועל הגנה ל-false קבוע, בדיקת טווחים מבוטלת במקום שבו האינדקסים הפסיקו להיות בטוחים, רקורסיה ללא תחתית, ובאפר שהשפה סירבה לאפס. בכל אחד מהם Delphi עשה בדיוק את מה שהוא מגדיר, מכיוון שהשפה נותנת לך אריתמטיקה שמתקפלת, צמצום שהוא שקט, בדיקות טווחים שאתה יכול לכבות, רקורסיה ללא מגבלה מובנית, והקצאה שאינה מאתחלת. זהו החוזה, ומנתח Pascal עומד בו על ידי ניהול ידני של ארבעה דברים בכל גבול שהקובץ שולט בו: רוחב שלמים, בדיקת טווחים, עומק רקורסיה ואתחול באפר.

פגמים אלה סגורים בגרסאות הנוכחיות של PDFlibPas, המנוע עבור Delphi ו-C++Builder. אם עבודתך מגיעה גם לאופן שבו קובץ טוען שהוא מוגן, ההערות הנלוות ב-ביקורת הצפנה והרשאות וב-בדיקה מוקדמת של PDF/A ו-PDF/UA מכסות את צד הניתוח של אותו מנתח, וכל זה נשלח בתוך PDFlibPas Delphi PDF Library לצד ממשקי ה-API לטעינה, רינדור וחתימה המכוסים במקומות אחרים בבלוג זה.