מאמר טכני

ניתוח PDF בטוח-זיכרון: התגוננות מפני מסמכים זדוניים

צינור קליטת מסמכים (intake pipeline) מקבל קבצים שנכתבו על ידי זרים. חשבוניות, סריקות, קבצים מצורפים מטופס אינטרנט: כל אחד מהם טוען להיות PDF ונושא מאות מספרים שהמנתח שלכם מצופה לפעול לפיהם. אורכי זרמים, ממדי תמונה, היסטי בתים, הפניות לאובייקטים — כל אחד מהם נבחר על ידי מי שיצר את הקובץ, והעלאה קטועה או מסמך פגום במכוון ישימו בסופו של דבר את אחד מהמספרים האלה במקום שבו הוא גורם נזק. ההבדל בין מנתח ששורד את הקובץ הזה לבין אחד שקורס, או ממשיך לרוץ עם זיכרון מושחת, הוא סט קטן של הרגלים שאינם תלויים בספריית PDF ספציפית כלשהי

ההרגלים חולקים הנחת יסוד אחת: ערך שנקרא מהקובץ הוא טענה (claim), לא מדידה. הוא הופך לשמיש רק לאחר שנבדק מול משהו שהמנתח מדד בעצמו — הגודל האמיתי של הקובץ, מספר הבתים האמיתי שמפענח יצר, עומק הרקורסיה האמיתי. מה שמופיע להלן הוא הנחת היסוד הזו מיושמת במקומות שבהם מנתחי מסמכים למעשה נשברים

אורך מוצהר הוא טענה, לא מדידה

אי ההתאמה הפשוטה ביותר היא אורך הזרם. אובייקט זרם (stream object) של PDF מצהיר על ספירת הבתים שלו במפתח /Length, והנתונים האמיתיים יושבים בין מילות המפתח stream ו-endstream. שום דבר לא מאלץ את השניים להסכים. קובץ קטוע מכיל פחות בתים אמיתיים מהספירה המוצהרת; קובץ ממחולל (generator) שבור יכול להצהיר על אורך שמגיע אל מעבר לסוף הקובץ או אל תוך אובייקט שכן. הקצו זיכרון מהערך המוצהר והעתיקו עד ל-endstream ותגלשו אל מעבר למאגר (buffer); קראו בדיוק את הספירה המוצהרת מבלי לבדוק זמינות ותצעדו מעבר לסוף הקובץ. תנו לערך המוצהר להניע הקצאה רק לאחר הצמדתו (clamping) למרחק הנמדד עד לסוף הנתונים, והתייחסו לאי הסכמה כאל נקודת החלטה — תקנו על ידי סריקה עבור endstream, או דחו את הזרם — לעולם לא כמשהו שיש להאמין לו בשקט

פרמטרים של תמונה שמתארים רסטר גדול יותר משהקציתם

זרמי תמונה מעלים את רף הסיכון מכיוון ששני סטים בלתי תלויים של מספרים מתארים את אותם פיקסלים. מילון התמונה נושא את /Width ו-/Height, ומאגרי רסטר בדרך כלל מקבלים את גודלם מהם. מסנן הפענוח נושא גיאומטריה משלו: CCITTFaxDecode לוקח את /Columns, /Rows, ו-/K מתוך ה-DecodeParms שלו, כאשר /K בוחר את סכמת Group 3 או Group 4 והמפענח פולט (Columns + 7) div 8 בתים לשורת סריקה (scanline). קובץ המצהיר /Width 100 אך מוסר למסנן /Columns 1728 — ברירת המחדל — גורם למפענח לייצר למעלה מפי שישה עשר בתים לשורה ממה שהמאגר מצפה, והגלישה (overflow) נוחתת שורת סריקה אחת בכל פעם אל תוך כל מה שיושב אחרי ההקצאה. כאשר /Rows חסר, המפענח רץ עד שהנתונים אומרים לו לעצור, ולכן חסמו (bound) גם את ספירת השורות. DCTDecode סובל מאותו תפר: נתוני ה-JPEG נושאים רוחב וגובה משלהם בסימון ה-SOF שלהם, ושום דבר לא מחייב אותם להתאים למילון

כלל המגננה הוא מכני: חשבו את גודל הרסטר המצופה מפרמטרי הפענוח המאומתים — ה-/Columns וה-/Rows של המסנן עצמו עבור CCITT, ממדי ה-SOF עבור DCT — בדקו אותו מול המגבלות שלכם, הקצו זיכרון מתוכו, ואמתו במהלך הפענוח שהפלט לעולם לא חורג מההקצאה. כאשר המילון והמסנן אינם מסכימים על הגיאומטריה, יישבו ביניהם או דחו את התמונה. מה שמנתח לעולם אסור לו לעשות זה לקבוע את גודל המאגר מתוך סט אחד של מספרים ולתת למפענח לרוץ על פני הסט השני

מלכודות של אריתמטיקה והקצאה ב-Delphi

שלוש התנהגויות של Delphi חותרות אפילו תחת מנתח שמתכוון לאמת. הראשונה היא כפל של 32 סיביות: Delphi מעריכה את המכפלה של שני אופרנדים (operands) מסוג Integer ב-32 סיביות ללא קשר לרוחב היעד, כך ש-Width * Height * BytesPerPixel יכול לגלוש סיבובית (wrap) אפילו כשכל גורם עובר את בדיקת השפיות שלו. סריקה של 30,000 על 30,000 בשלושה בתים לפיקסל היא 2.7 מיליארד בתים, שגולש סיבובית למספר שלילי באריתמטיקת 32 סיביות עם סימן (signed); גורמים מעט שונים יגלשו סיבובית לאורך חיובי קטן שיקצה וייתן למאגר גודל קטן מדי. אלצו את הביטוי כולו להיות רחב על ידי המרת (casting) האופרנד הראשון — Size := Int64(Width) * Height * BytesPerPixel — ואז השוו מול חסם מפורש לפני שכל דבר יגיע ל-SetLength

השנייה היא בדיקת טווחים (range checking). תצורת השחרור (release configuration) של ברירת המחדל של Delphi מגיעה כשהיא כבויה, כך שאינדקס מחוץ לטווח המחושב מנתוני הקובץ אינו מעלה שגיאה — הוא קורא או כותב לזיכרון הסמוך למערך. הדליקו אותה בחזרה עם {$R+} (ו-{$Q+} עבור גלישה סיבובית באריתמטיקה) בראש כל יחידה שעושה אינדוקס עם ערכים הנגזרים מקובץ. העלות בלתי ניתנת למדידה לעומת ה-I/O שמנתח עושה בכל מקרה, וזה ממיר שחיתות שקטה ל-ERangeError הניתנת לתפיסה

השלישית היא TMemoryStream.SetSize עם Int64 המסופק על ידי הקובץ. ב-RTL (ספריית זמן ריצה) נוכחי הוא מקצה כל מה שהקובץ ביקש, ולכן זרם יחיד הטוען לארבעה ג'יגה-בייט הופך לכשל של חוסר זיכרון (out-of-memory) באמצע תהליך הקליטה. ב-RTLs ישנים יותר, בהם SetSize לוקח Longint, הערך מוצר (narrowed) בשקט קודם לכן: $100000010 מוצהר הופך ל-16, ההקצאה מצליחה, והכתיבה של הנתונים האמיתיים רצה הרחק מעבר לו. אמתו כל גודל מול גודל המקור הנמדד ומול חסם קשיח (hard cap) לפני שקריאת הקצאה כלשהי תראה אותו

היסטים (Offsets) שמצביעים אל מחוץ לקובץ

טבלת ההפניות הצולבות (cross-reference table) ממפה מספרי אובייקטים להיסטי בתים מוחלטים (absolute byte offsets), והמנתח מחפש (seeks) לכל מקום אליו היא מצביעה. בקובץ פגום או עוין ההיסטים הללו נוחתים מעבר לסוף הקובץ או בתוך מבנים שאינם קשורים. TStream הופך את הכישלון לשקט: קביעת ה-Position מעבר ל-Size אינה שגיאה, ו-Read רגיל מעבר לסוף פשוט מחזיר פחות בתים מהמבוקש, ולכן קוד שמדלג על בדיקת הספירה ממשיך לנתח בתים מיושנים מהאובייקט הקודם. המגננה היא נקודת חנק (chokepoint) — עזר אחד שדרכו עוברים כל seek ו-read המונעים על ידי הקובץ, המאמת את ההיסט ואת הספירה מול גודל הקובץ הנמדד לפני שהזרם זז

uses
  System.SysUtils, System.Classes;

const
  MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB

type
  EPdfBoundsError = class(Exception);

// Every file-driven seek and read goes through here. Offset and Count are
// file-supplied claims; Source.Size is the measurement they must fit.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
  var Buffer: TBytes);
begin
  if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
     (Offset > Source.Size) or (Count > Source.Size - Offset) then
    raise EPdfBoundsError.CreateFmt(
      'object extent %d+%d exceeds file size %d',
      [Offset, Count, Source.Size]);
  SetLength(Buffer, Count);
  if Count = 0 then
    Exit;
  Source.Position := Offset;
  Source.ReadBuffer(Buffer[0], Count);
end;

נתבו (Route) דרכו היסטי הפניות צולבות, היקפי זרמים (stream extents), וקריאות של קבצים מוטמעים, והיסט רע הופך לדחייה נקייה הנוקבת במספרים במקום להפרת גישה (access violation) שלוש קריאות מאוחר יותר

מעגלים ועומק בגרף האובייקטים

PDF הוא גרף, לא עץ. כל ערך עשוי להיות הפניה עקיפה, הפניה עשויה להיפתר להפניה אחרת — /Length 12 0 R, כאשר אובייקט 12 מחזיק 13 0 R — ושום דבר לא מונע משרשרת להיסגר חזרה על עצמה. פותר (resolver) שעוקב אחר הפניות באופן תמים יבצע רקורסיה עד שמחסנית הזיכרון (native stack) תמוצה, ומיצוי מחסנית אינו משהו שאתם תופסים (catch); הוא מסיים את התהליך. מערכים ומילונים מקוננים עמוק מגיעים לאותו סוף ללא כל מעגל בכלל

השתמשו בשני שומרים (guards) יחד: מונה עומק מפורש חוסם את המקרה הכן-אך-עמוק בגבול ששום קובץ לגיטימי לא מתקרב אליו, וקבוצת ביקור (visited set) תופסת מעגל אמיתי בביקורו השני, והופכת אותו לשגיאה מדויקת שניתן לדווח עליה במקום למעידה על גבול

uses
  System.SysUtils, System.Generics.Collections;

const
  MAX_RESOLVE_DEPTH = 32; // far deeper than any legitimate reference chain

type
  EPdfStructureError = class(Exception);

  TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
    pvDictionary, pvStream, pvReference);

  TPdfValue = record
    Kind: TPdfValueKind;
    RefNumber: Integer; // meaningful when Kind = pvReference
    // ... payload fields for the remaining kinds
  end;

// LoadObject is your own routine: it looks up the xref offset for
// ObjNumber, reads the object with ReadBounded, and parses it.
function ResolveObject(ObjNumber, Depth: Integer;
  Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
  if Depth > MAX_RESOLVE_DEPTH then
    raise EPdfStructureError.Create('reference chain exceeds depth limit');
  if Visited.ContainsKey(ObjNumber) then
    raise EPdfStructureError.CreateFmt(
      'circular reference through object %d', [ObjNumber]);
  Visited.Add(ObjNumber, True);
  try
    Result := LoadObject(ObjNumber);
    if Result.Kind = pvReference then // e.g. /Length 12 0 R
      Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
  finally
    Visited.Remove(ObjNumber); // siblings may legally share this object
  end;
end;

פריסת דחיסה היא מגבר

כמה קילובייטים של קלט של FlateDecode יכולים להתנפח לג'יגה-בייטים; דחיסה לשימוש כללי מתגמלת טקסט קריא (plaintext) שחוזר על עצמו, ותוקף יכול להפוך אותו לחזרתי ככל האפשר. הגבילו את הגודל המנופח של כל זרם למה שהצרכן שלו יכול להזדקק לו באופן סביר, ושמרו על תקציב שני פר-מסמך: חמש מאות זרמים, שכל אחד מהם נמצא רק קצת מתחת למגבלה של כל זרם, מתישים את הזיכרון באותה מידה כמו זרם ענק אחד. הבדיקה שייכת אל תוך לולאת הניפוח (inflation loop), סופרת את בתי הפלט כפי שהם נוצרים ומבטלת במקרה של הפרה, לא אחרי הלולאה כשהזיכרון כבר בוזבז. תקציב מסמך המבוטא ככפולה של גודל הקובץ הדחוס עובד היטב, שכן מסמכים לגיטימיים מתקבצים הרחק מתחת ליחסים שזרם מהונדס (crafted) מגיע אליהם

הגנה לעומק מעבר ליחידות שלכם

אותם סוגי פגמים חיים בתוך ספריות. שני מקרי בוחן בבלוג זה סוקרים מופעים אמיתיים: גלישות של מספרים שלמים, רקורסיה בלתי מוגבלת, ומאגרים לא מאותחלים שנסגרו במנוע פסקל טבעי (native Pascal engine) בהקשחת מנתח PDF של פסקל מפני קבצים זדוניים, וסכנות מוסכמות-קריאה (calling-convention), רוחב שלמים, ובעלות על זיכרון של כריכת (binding) מנוע C בהקשחת כריכת רכיב PDFium. לקליטה (intake) שהיא באמת בלתי מהימנה — טופס העלאה ציבורי, תיבת דואר לא מאומתת — הפעילו גם את עבודת הניתוח והפענוח בתהליך נפרד בעל הרשאות נמוכות, כך שהקובץ שמביס כל שומר שפועל בתוך התהליך יעלה בעבודה (job) שנכשלה במקום בשירות שהושבת

רשימת תיוג (Checklist) לפני טיסה

לפני שהבנייה (build) הבאה משתחררת, העבירו את המנתח מול הרשימה הזו: כל מאגר של זרם מקבל את גודלו מתוך אורך מוצמד (clamped) ולא מהאורך המוצהר; כל רסטר מקבל את גודלו מפרמטרי מפענח מאומתים ונבדק מול פלט המפענח; כל מכפלה של ממדים מוערכת ב-Int64 ומושווית מול גבול מפורש; {$R+} פעיל בכל יחידה המבצעת אינדוקס עם ערכים הנגזרים מהקובץ; כל פעולת חיפוש (seek) נבדקת מול הגבולות של גודל הקובץ הנמדד; כל פתרון של הפניה (reference resolution) מוגבל בעומקו ונבדק לאיתור מעגלים; כל לולאת ניפוח סופרת את הפלט מול תקציבים פר-זרם ופר-מסמך. אף אחת מהבדיקות הללו לא עולה זמן מדיד על מסמך לגיטימי, וכל אחת מהן ממירה שחיתות זיכרון לדחייה נקייה הניתנת לרישום (loggable)

הערה: רכיב ה-HotPDF Component של losLab, ה-PDFlibPas Delphi PDF Library, ורכיב ה-PDFium Component מיישמים את בדיקות הגבולות, מגבלות העומק ומכסות הפריסה (expansion caps) הללו באופן פנימי, כך שצינור קליטה הבנוי עליהם מתחיל מקו בסיס מוקשח