חיפושי תתי-מחרוזות כמו Pos('/Length', Dict) הם הכלי השגוי לקריאת מילון PDF, מכיוון שמפתחות שמות של PDF משתפים תחיליות (prefixes): המפתח /Length הוא תחילית של /Length1, והמפתח /Encrypt הוא תחילית של /EncryptMetadata. תקן ISO 32000-1 §7.3.5 מגדיר שם כאסימון המסתיים רק בתו מפריד או בתו רווח לבן, ולכן מפתח נחשב כנמצא רק כאשר הבייט שאחריו הוא אחד מתווים אלה. קורא מילונים המדלג על בדיקה יחידה זו יקרא בסופו של דבר ערך שגוי מקובץ תקין לחלוטין
הבאג שלימד אותנו זאת לא נראה כלל כמו בעיית ניתוח לקסיקלי. סריקת תאימות החלה לדווח על זרם FontFile כפגום: תוכנית הגופנים שחולצה מהכיווץ הייתה חלקית, קטועה באמצע הטבלה. הקובץ נפתח כשורה בכל מציג. נתוני הזרם בדיסק היו שלמים. השורש של הבעיה ישב בשורה בודדת של קורא המילונים המשותף שלנו: הפקודה Pos('/Length', ...) התאימה ל-/Length1, מפתח סטנדרטי שמילוני זרמי FontFile נושאים לפי טבלה 127 של ISO 32000-1, והקורא לקח את המספר השלם שאחרי /Length1 כאורך הזרם. כותב המסמך הספציפי הזה החליט במקרה לכתוב את /Length1 לפני /Length, מה שהוא חופשי לחלוטין לעשות, שכן רשומות מילון אינן מסודרות לפי §7.3.7. הזרם נקטע למספר בייטים שגוי, וכל בדיקה בהמשך שצרכה אותו נכשלה בשקט
מדוע התאמת תתי-מחרוזות שוברת ניתוח מילוני PDF?
התאמת תתי-מחרוזות נשברת מכיוון שמרחב השמות של PDF מלא במשפחות תחיליות מכוונות, ומכיוון שסדר רשומות המילון אינו מוגדר. טבלה 127 של תקן ISO 32000-1 מגדירה את /Length1, /Length2 ו-/Length3 עבור זרמי גופנים מוטמעים, כולם יושבים לצד /Length באותו מילון. מילון ההצפנה מצמד את /Encrypt שבסוף המסמך עם /EncryptMetadata שבתוכו. מפתחות קצרים גרועים עוד יותר: חיפוש הבנוי כ-Pos('/' + Key, ...) עם Key = 'N' ינחת בשמחה על /Name או /Nums. אף אחד מהתנגשויות אלו אינו דורש קובץ פגום. כותב המסדר את /Length1 לפני /Length תואם לחלוטין לתקן, מה שאומר שבאג תת-המחרוזת אינו בעיית עמידות נגד קלט שבור — הוא בעיית נכונות נגד קלט תקין
מצב הכשל הוא גם מהסוג השקט. ערך /Length שגוי אינו מעלה חריגה; הוא פשוט מוסר לכם פרוסת בייטים קצרה או ארוכה יותר מזו שהזרם תופס בפועל. אם פרוסה זו מזינה בדיקת תת-קבוצת גופנים, ניתוח CMap או סריקת מטא-נתונים, הצרכן רואה זבל ובדרך כלל אינו מדווח על דבר כלל, מכיוון שחצי מזרם zlib פשוט נכשל בחילוץ מהכיווץ והקוד ממשיך הלאה. שלחנו בדיוק סוג זה של פגם ותיקנו אותו בגרסה v2.14.3 של הקורא המשותף שלנו, לאחר שביקורת סעיף אחר סעיף של תקן ISO 32000-1 §7.2–§7.3 סימנה כל חיפוש מפתח בסגנון Pos כחשוד
מה שתקן ISO 32000-1 §7.3.5 מגדיר בפועל כשם
סעיף 7.3.5 הוא קצר ומדויק: אובייקט שם הוא לוכסן המלווה ברצף של תווים רגילים, והאסימון מסתיים במפריד הראשון או בתו הרווח הלבן הראשון. המפרידים הם שמונת תווים של סוגריים בתוספת הלוכסן וסימן האחוז — ( ) < > [ ] { } / % — ורווח לבן הוא null, tab, line feed, form feed, carriage return ורווח (§7.2.2–§7.2.3). כלל סיום זה הוא כל הסיפור. /Length1 אינו "/Length ואחריו 1"; הוא אסימון יחיד שאינו ניתן לחלוקה, בדיוק כפי ש-LengthOne ו-Length הם מזהים שונים ב-Pascal. כל קורא המוצא מפתחות על ידי חיפוש בייטים גולמי מיישם מחדש את הנתח הלקסיקלי ללא כלל הסיום
להלן צורת הפגם, מצומצמת לעיקריה. גרסה זו מתקמפלת, עוברת בדיקות מול קבצים שכותביהם מסדרים את /Length ראשון, ומשחיתה זרמים עבור כותבים שלא עושים זאת
// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
P: Integer;
begin
Result := -1;
P := Pos('/Length', Dict);
if P > 0 then
Result := ReadIntAt(Dict, P + Length('/Length'));
end;
התאמת אסימון שלם: בדקו את הבייט שאחרי המפתח
הפרדיקט הנכון נובע ישירות מסעיף §7.3.5: התאמה מועמדת היא מפתח אמיתי רק אם התו מיד אחריו הוא מפריד, רווח לבן, או סוף החוצץ. כל דבר אחר הוא שם ארוך יותר שפשוט משתף תחילית, ולכן החיפוש חייב להמשיך מעבר לו במקום לוותר. התיקון בקורא שלנו החליף כל חיפוש Pos גולמי בשגרה משותפת יחידה הבנויה על כלל זה
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
P, After: Integer;
begin
Result := 0;
P := Pos(Key, Dict);
while P > 0 do
begin
After := P + Length(Key);
if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
Exit(P); // token ends here: genuine key
P := PosEx(Key, Dict, P + 1); // prefix of a longer name: keep looking
end;
end;
שני פרטים בלולאה זו נושאים משקל. ראשית, היא ממשיכה לחפש במקום להחזיר כישלון בהתנגשות התחילית הראשונה, מכיוון שהסדר /Length1 120 /Length 4076 הוא חוקי והמפתח האמיתי עדיין נמצא בהמשך. שנית, מקרה סוף החוצץ נחשב כמסיים, שכן קטע מילון יכול להסתיים באופן חוקי מיד אחרי שם. נקודה עדינה יותר שכדאי לבקר בקוד שלכם: אותו כלל חל על הצד השמאלי של ההתאמה אם מחרוזת החיפוש שלכם אינה כוללת את הלוכסן, אחרת Pos('Length', ...) יכול לנחות בתוך /PieceLength. עיגון מחרוזת החיפוש עם הלוכסן המוביל /, כמו למעלה, מטפל בקצה השמאלי מכיוון ש-/ הוא בעצמו מפריד המסיים את האסימון הקודם
כיצד קובץ PDF עוין יכול להפוך באג מנתח להקצאת זיכרון ענקית?
קובץ משובש או זדוני מעלה שגיאות לקסיקליות אלו לרמת התרוקנות משאבים, מכיוון שמספרים שלמים במילון מזינים לעיתים קרובות גדלי הקצאות. הביקורת שלנו מצאה שרשרת בדיוק בצורה זו בהרחבת זרמי אובייקטים. הרשומה /N של מילון ObjStm מציינת כמה אובייקטים מכווצים הזרם מחזיק, וקוד ההרחבה קרא ל-SetLength על מערך שגודלו נקבע לפיה. מנתח המספרים השלמים, לעומת זאת, השאיר את פרמטר הפלט שלו ללא שינוי במקרה של כישלון בעודו מחזיר אותו — כך שערך /N שאינו מספרי מסר ל-SetLength ערך מחסנית לא מאותחל. ערך זבל חיובי שם פירושו בקשת הקצאה בג'יגה-בייטים, המופעלת על ידי כמה בייטים של קלט פגום, בזמן סריקה בלבד של מסמך שעדיין לא הסכמתם לבטוח בו
לתיקון היו שני חלקים עצמאיים, ושניהם ניתנים להכללה. המנתח מחזיר כעת ערך 0 מפורש במקרה של כישלון, לעולם לא זיכרון לא מאותחל. והצרכן אינו בוטח יותר בחישובי /N ללא בדיקה: אזור הכותרת של ObjStm לפני /First שומר זוג מספרים שלמים — מספר אובייקט והיסט — עבור כל אובייקט מכווץ, וכל זוג תופס לפחות ארבעה בייטים כולל מפרידים. כל ערך /N מעל FirstVal div 4 + 1 הוא לכן בלתי אפשרי מבחינה פיזית עבור גודל הכותרת המוצהר ונדחה לפני שמתרחשת הקצאה כלשהי. המגבלה עולה השוואה אחת והיא נגזרת מנתונים שכבר נמצאים ביד, שזו התבנית שיש לחפש: תקרת גבול שהקובץ עצמו מוכיח, לא קבוע שרירותי
// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // header cannot contain that many pairs
// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // refuse before allocating the buffer
שתי תקרות נוספות משלימות את מעטפת ההגנה בקורא שלנו, שתיהן נשלחו בגרסה v2.12.0. קורא הזרמים מסרב לכל /Length הגדול מהקובץ כולו לפני הקצאת חוצץ התוצאה — זרם אינו יכול להיות גדול מהמכל בו הוא חי, כך שהבדיקה נקייה מתוצאות חיוביות שגויות. ונתיב החילוץ מהכיווץ מגביל את הפלט המורחב ל-256 MiB, מה שסוגר את פצצת ה-zlib הקלאסית שבה כמה קילו-בייטים של קלט מתרחבים ללא הגבלה; המגבלה נדיבה עבור כל זרם PDF אמיתי תוך שמירה על עמידות במקרה הגרוע ביותר. המוטיב בשלושתם זהה: כל גודל שקובץ מצהיר עליו הוא טענה, והמנתח מאמת כל טענה מול משהו שהוא יכול למדוד לפני הקצאת זיכרון עבורה. אותה גישת ביקורת חלה רמה אחת למטה בגבול הקישור, המכוסה בהקשחת ממשק ה-ABI של PDFium ובטיחות הזיכרון ב-Delphi
היכן שכלל האסימון השלם אינו מספיק
מגבלות כנות, כדי שלא תבטחו יתר על המידה בשגרה שלמעלה. התאמת אסימון שלם מתקנת את זיהוי המפתחות, אך חיפוש בייטים שטוח על פני מילון עדיין אינו יכול לדעת אם התאמה יושבת בתוך מילון מקונן, ליטרל מחרוזת או הערה — הפקודה FindDictKey באובייקט דף יכולה לנחות על מפתח בתוך מילון המשנה /Resources שלו אם תמסרו לה טווח רחב מדי. הקורא שלנו מגביל את הטווח לגוף אובייקט בודד תחילה ומתייחס להקשרי מחרוזות והערות כפריט ביקורת נפרד שעדיין פתוח. בטיחות תת-מחרוזות היא שלב אחד בסולם, לא הסולם כולו: עקביות הפניות מצולבות היא דיסציפלינה משלה, המכוסה באימות אובייקטים וזרמי xref, וקטלוג האיומים הרחב יותר עבור מסמכים שלא אתם חיברתם נמצא בביקורת סיכוני אבטחה ב-PDF
אם אתם מתחזקים קורא מילונים שנכתב ידנית ב-Delphi או ב-Lazarus, רשימת המשימות מאירוע זה קצרה. חפשו את כל מופעי Pos('/ בקוד ונתבו את הלהיטים דרך פונקציית עזר של אסימון שלם. רשמו את משפחות התחיליות שהמפתחות שלכם משתתפים בהן — /Length, /Encrypt, /N, /Type מול /Type1 מופיעים כולם בקבצים אמיתיים. לאחר מכן עברו על כל מספר שלם שמגיע ל-SetLength, GetMem או לולאת העתקה ושאלו מה מגביל אותו: גודל הקובץ, תקרה הנגזרת מהכותרת, או כלום. שכבת הניתוח המתוארת כאן היא הבסיס מתחת לרכיב PDFium שלנו, שבו קורא רמת הבייטים ומנוע הרינדור בודקים זה את זה בכל מסמך שהם נוגעים בו