מאמר טכני

פצצות פענוח PDF בדלפי: תקציבי שרשרת מסננים של HotPDF

קובץ PDF בן 20 קילו-בייט שמצמיד תהליך שירות עד שה-OOM killer לוקח אותו אינו באג בקוד שלך, זו פצצת דחיסה. HotPDF, רכיב ה-VCL PDF הילידי עבור דלפי ו-C++Builder, חוסמת כזו עם DecodeBudgetBytes, תקרה לכל-שרשרת-מסננים שברירת המחדל שלה 268435456 בייטים וגובה כל שלב פענוח כנגד תקציב משותף יחיד

קובץ ה-20 קילו-בייט שאכל תהליך עובד

צורת האירוע תמיד זהה. עובד תור שמרנדר תמונות ממוזערות מרים העלאה, זיכרון תפוס עולה מעל 12 ג'יגה-בייט תוך פחות משתי שניות, והתהליך נעלם ללא stack trace. הקובץ הוא 20 קילו-בייט. יש לו עמוד אחד, זרם תוכן אחד, ומערך /Filter עם חמש רשומות. כל שם במערך הזה הוא מסנן שהתקן מגדיר, כל שלב מפענח ללא שגיאה, ושום דבר בקובץ אינו פגום. זה מה שהופך את מחלקת הקלט הזו למוזרה: אין בייט פגום לדחות

זו לא אותה בעיה כמו פענוח מסנן בודד נכון. לקבל את LZWDecode ואת מנבא /DecodeParms נכון הוא נושא בפני עצמו, מכוסה בהסקירה של LZW, מנבאים ו-DecodeParms על מסמכים נטענים. כאן כל מפענח כבר נכון. הכישלון הוא מה שמפענחים נכונים עושים כשאתה מריץ חמישה מהם ברצף ואף אחד לא סופר את הסך הכולל. תקן ISO 32000-1 §7.4 מפורש שה-/Filter יכול להיות שם בודד או מערך שמות, ושמערך מוחל ברצף, הרשומה הראשונה קודם. הוא לא אומר כלום על כמה שלב יכול להרחיב את הקלט שלו, וכלום על הצבירה על פני השרשרת. שלב ASCIIHexDecode בערך חוצה את הקלט שלו, מה שנשמע לא מזיק. שלב FlateDecode על ריצה של בייטים אפס מגיע ליחסים באלפים. שרשר אותם והחשבון הוא כפלי: 20 קילו-בייט הופך ל-20 מגה-בייט הופך ל-20 ג'יגה-בייט, וכל צעד בודד הוא פענוח תואם של זרם חוקי

למה מגבלה לכל-מסנן נכשלת לעצור פצצת פענוח?

מפני שמגבלה לכל-מסנן מזוינת מחדש בכל אלמנט של מערך ה-/Filter. שרשרת של חמישה שלבים תחת תקרה של 256 MiB לכל-שלב מאשרת 1.25 GiB, והשלב האחרון עדיין מתחיל עם הקצבה לגמרי טרייה ללא קשר למה שארבעת הקודמים הפיקו. המגבלה נאכפת בכנות ולא מגבילה שום דבר שחשוב. ל-HotPDF הייתה בדיוק את הצורה הזו לפני v2.447.0, והייתה לה פרצה שנייה לצידה. מפענח ה-LZW נשא תקרת MaxOutputBytes ונתיב מנבא התמונה חישב את השורות שלו עצמו, כך ששני אלה היו חסומים מקומית. ל-FlateDecode, ASCIIHexDecode, ASCII85Decode, ו-RunLengthDecode לא הייתה שום תקרה כלל: כל אחד כתב לתוך TMemoryStream עד שהוא נגמר מקלט או שהמקצה נכשל. אז שרשרת עוינת הייתה לה שתי דרכים דרך. היא יכלה להשתמש במסנן לגמרי לא-מוגן, או שהיא יכלה להשתמש במוגנים ופשוט להוסיף עוד מהם

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

עוקב תקציב אחד לכל שרשרת מסננים

התיקון ב-HotPDF v2.447.0 הוא להפוך את הנהלת החשבונות למקיפה את השרשרת ולא את השלב. כל שרשרת מסננים בונה THPDFDecodeBudgetTracker אחד, וכל מפענח כותב דרך THPDFBudgetWriteStream שעוטף את היעד האמיתי. העטיפה קוראת ל-Budget.Consume(Count) לפני שהיא מעבירה בייט אחד, כך שהדחייה קורית בעוד זרם היעד עדיין בגודלו הישן. הסדר הזה הוא כל הטעם: בדיקה שמתבצעת אחרי שהמאגר כבר גדל היא אבחון, לא הגנה

// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // read the source, do not copy it
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter names the stage and bumps FilterCount; the wrapper
    // stream calls Budget.Consume before writing into NextStream
    Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
    CurrentStream.Free;
    CurrentStream := NextStream;
  end;
finally
  Budget.FinishFilter;
  Budget.Free;
end;

התקרות המקומיות לא נעלמו, הן הפכו להיטלים של התקציב המשותף. שלב ה-LZW כעת קובע Decoder.MaxOutputBytes := Budget.RemainingBytes, כך שהתקרה הפרטית שלו היא מה שנשאר לשרשרת ולא הקצבה עצמאית. שלב מנבא התמונה נפתח עם BeginFilter וגובה את דרישת השורה שלו דרך Consume לפני ההקצאה, מה שאומר שפלט המנבא מחויב לאותו תקציב כמו המסננים הגנריים שהזינו אותו. זה משנה במיוחד בנתיב התמונה, שבו שרשרת המסננים והמנבא הם שני חצאים של פעולה אחת, כמכוסה בחילוץ תמונות ממסמכים נטענים דרך מסנני הפענוח שלהן

מה הקורא רואה כשהתקציב מסרב?

בתחתית המחסנית, סירוב מעלה EHPDFDecodeBudgetError. מעל זה, התשובה תלויה בחוזה שכבר היה ל-API הקורא. שיטות קריאה ברמה גבוהה שדיווחו על כשל דרך False או nil ממשיכות לעשות בדיוק את זה, כי הפיכת תוצאה בוליאנית מתועדת לחריגה הייתה שוברת קוראים שכבר טיפלו נכון בקלט פגום. נתיב תוכן העמוד הנטען הוא היוצא-מן-הכלל המכוון: הוא מעלה מחדש EHPDFDecodeBudgetError במקום לתת לזרם תוכן שקוצץ להיצג כעמוד שיצא פשוט ריק. העיצוב הזה אומר ש-False פשוט הוא עמום כשלעצמו, כך שהתקציב מפרסם רשומת אבחון לצידו: THotPDF.GetLastDecodeBudgetInfo מחזירה את מצב השרשרת האחרונה שהמופע פענח

type
  THPDFDecodeBudgetInfo = record
    LimitBytes: Int64;
    DecodedBytes: Int64;
    PeakStageBytes: Int64;
    FilterCount: Integer;
    Exceeded: Boolean;
    ExceededFilter: AnsiString;
  end;

var
  Pdf: THotPDF;
  Info: THPDFDecodeBudgetInfo;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.DecodeBudgetBytes := 64 * 1024 * 1024;   // tighter than the default
    Pdf.LoadFromFile('untrusted.pdf');
    if not Pdf.ExtractLoadedPageText(0, PageText) then
      if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
        LogWarning(Format(
          'decode refused in %s after %d bytes, peak stage %d, %d filters',
          [String(Info.ExceededFilter), Info.DecodedBytes,
           Info.PeakStageBytes, Info.FilterCount]));
  finally
    Pdf.Free;
  end;
end;

קרא את השדות האלה יחד והם מפרידים בין שתי צורות התקיפה. כש-PeakStageBytes קרוב ל-DecodedBytes, שלב אחד עשה את כל הנזק ואתה מסתכל על מסנן יחיד ביחס-גבוה. כש-PeakStageBytes הוא חלק קטן מ-DecodedBytes ו-FilterCount גבוה, אף שלב בודד לא היה קיצוני והשרשרת צברה את דרכה מעבר לתקרה, וזה בדיוק המקרה שמגבלה לכל-מסנן לא יכולה לראות. אזהרה אחת שווה לכתוב למטפל שלך: GetLastDecodeBudgetInfo מחזירה False עד שהמופע פענח לפחות מסנן אחד, כך ש-False ממנו אינו הוכחה שהמסמך היה נקי

איפה התקציב מתאפס, ומתי אפס הוא התשובה הכנה

DecodeBudgetBytes חוסם שרשרת זרם אחת, לא מסמך אחד, וגבול זה מכוון אך קל לקריאה שגויה. כל זרם תוכן, כל קובץ משוטמע, כל זרם הפניה צולבת וכל זרם אובייקטים מתחיל עם 256 MiB טרי. למסמך בן 4,000 עמודים לכן יש 4,000 הזדמנויות עצמאיות לבזבז את התקרה המלאה, וזרמי אובייקטים מכפילים את הספירה עוד יותר כי כל אחד הוא בעצמו קונטיינר דחוס שמחזיק אובייקטים רבים, כמתואר בההערות על זרמי אובייקטים ועדכונים incremental. אם הדרישה האמיתית שלך היא מגבלה על זיכרון התהליך הכולל, התכונה הזו היא קלט אחד לזה, לא כל זה, וצריכה לשבת מאחורי תקרה ברמת-עבודה או ברמת-קונטיינר

אפס פירושו ללא-הגבלה, וזו הגדרה לגיטימית ולא פתח מילוט. קבע אותה כשאתה הבעלים של הקלט: pipeline לעיבוד מחדש של ארכיון על מסמכים שהמערכת שלך עצמה הפיקה, או שלב רסטריזציה שבו שרשרת סריקת צבע יחידה ב-600 dpi באמת זקוקה ליותר מכל תקרה שהיית מרוצה לקבע בקוד. ערכים שליליים נדחים מיד עם ERangeError, כי לתקציב שלילי אין משמעות קוהרנטית וחיתוך שקט שלו היה מסתיר באג תצורה

// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0;              // explicit unlimited

// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// Configuration mistakes fail loudly instead of clamping
try
  IngestPdf.DecodeBudgetBytes := -1;
except
  on E: ERangeError do
    LogWarning('DecodeBudgetBytes cannot be negative');
end;

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

ההעתקה שכבר לא קורית

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

התקציב לכל-שרשרת, רשומת האבחון שלו, ונתיבי הפענוח של מסמכים נטענים שהוא מגן עליהם כולם משתלבים כחלק מהרכיב עצמו, ללא תלות דחיסה חיצונית להגדיר או לתקן. אם אתה מעריך כיצד לחסום קלט PDF לא-מהימן בתוך שירות דלפי או C++Builder, עמוד HotPDF Delphi PDF component מפרט את ערכת הכלים למסמכים נטענים שהמגבלות האלה חלות עליה