Technical Article

הקשחת חותם PDF ב-Delphi מפני קובצי PKCS#12 זדוניים

כשאתה חותם על קובץ PDF, אתה בדרך כלל חושב על מפתח החתימה כמשהו שאתה שולט בו. הוא חי בקובץ .pfx שיצרת, מוגן בסיסמה שבחרת. הקוד שקורא את הקובץ הזה מרגיש כמו תשתית פשוטה, לא גבול הגנה. אינטואיציה זו שגויה ברגע שהאישור אינו שלך עוד. כלי שולחן עבודה המאפשר למשתמש לבחור כל קובץ .pfx, שרת המקבל תעודה שהועלתה, או חותם אצווה המוזן באישורים דרך הרשת, כולם מוסרים בתים המושפעים מתוקף לנתח (parser) לפני שמופק בית חתימה בודד. קורא PKCS#12 הוא שטח תקיפה, באותו מובן שמפענח תמונה או טוען גופן הם כאלה.

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

לאן הבתים נוסעים

ייבוא של קובץ .pfx לחתימה על מסמך אינו פעולה אחת, אלא צינור קצר, וכל שלב מנתח משהו שמתקיף עשוי היה לכתוב. המכולה היא מבנה PKCS#12 כפי שמוגדר ב-RFC 7292, קן של תיקי AuthenticatedSafe העטופים סביב מעטפת מוצפנת שמחזיקה את המפתח הפרטי. קריאתו פירושה מעבר על ASN.1, גזירת מפתח מהסיסמה, פענוח ולאחר מכן מסירת מפתח ה-RSA המשוחזר לקוד שמפתח את החתימה.

ב-HotPDF שלבים אלה ממופים ליחידות נפרדות. לוגיקת מכולת ה-PKCS#12 חיה ב-HPDFPFX. כל תג, אורך וערך שהיא נוגעת בהם מופעלים על ידי קורא ה-ASN.1 ב-HPDFASN1. גזירת המפתח ופענוח ה-PBES2 יושבים ב-HPDFCrypt לצד PBKDF2HMACSHA256. כאשר המפתח משוחזר, HPDFRSA ובונה ה-CMS SignedData ב-HPDFCMS הופכים אותו לחתימה המנותקת המוטמעת ב-PDF. נקודת הכניסה הציבורית המניעה את כל השרשרת היא קריאה אחת.

// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature embedded
else
  // signing did not complete
;

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

פגם ראשון: אורך ASN.1 שעוקף את ההגנה באמצעות גלישה

ASN.1 ב-DER ו-BER מקודד כל אלמנט כתג, אורך וכמות זו של בתי תוכן. האורך הוא השדה שבו עליך לבטוח אך לאמת, מכיוון שהוא אומר למנתח כמה רחוק לקרוא, והוא נכתב על ידי מי שהפיק את הקובץ. סעיף §8.1.3 בתקן X.690 מגדיר שני קידודים. הצורה הקצרה דוחסת אורך של 0 עד 127 לתוך בית בודד. הצורה הארוכה, המשמשת לכל דבר גדול יותר, מקצה בית מוביל אחד ששבע הסיביות הנמוכות שלו מציינות את מספר בתי האורך הבאים אחריו, ולאחר מכן מספר זה של בתים בפורמט big-endian נושא את הערך בפועל. ארבעה בתי אורך יכולים לכן להצהיר על גודל תוכן המתקרב לארבעה גיגה-בייט.

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

// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

הבעיה היא פעולת החיבור, לא ההשוואה. כאשר ContentLen קרוב ל-MaxInt (2147483647), הביטוי Pos + ContentLen גולש מטווח 32 הסיביות החתום ומתהפך למספר שלילי. סכום שלילי לעולם אינו גדול מ-Total, ולכן ההגנה מדווחת שהכל תקין ומאפשרת למנתח להמשיך עם אורך תוכן של כ-שני גיגה-בייט שהבאפר אינו מכיל. הנזק מתרחש בשלב הבא: הקורא מקצה באפר עבור האורך המוצהר ומעתיק לתוכו, פעולת SetLength ולאחריה Move הקורא מהמקור. למקור נותרו רק כמה מאות בתים, כך שההעתקה קוראת הרבה מעבר לקצה הקלט, קריאה מחוץ לגבולות שבמקרה הטוב קורסת ובמקרה הרע מדליפה זיכרון תהליך סמוך לתוך הניתוח.

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

// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

משתנה Int64 מחזיק את הסכום של שני ערכי 32 סיביות ללא אובדן, כך שההשוואה רואה את המספר האמיתי ודוחה את האורך המזויף. בדיקת האי-שליליות הנפרדת ב-ContentLen סוגרת את המקרה התואם שבו ערך מפוענח נוחת כשלילי בפני עצמו. ב-HotPDF הגנה זו חיה ב-HPDFASN1ParseNode, הפונקציה המפיקה את הצומת שכל שאר עוזרי העזר נבנים עליו. מכיוון ש-HPDFASN1Content קובע את גודל ה-SetLength וה-Move שלו ישירות מאורך התוכן של הצומת, צומת שעבר הגנה גרועה היה מרעיל כל קריאה שנלקחה ממנו. תיקון הגבול בנקודת הפענוח הוא מה שהופך את עוזרי העזר שמעליו לבטוחים.

פגם שני: ספירת חזרות של PBKDF2 המשמשת כנשק

הפגם השני אינו שגיאת זימון, אלא הקובץ שאומר ל-CPU שלך כמה קשה לעבוד. PKCS#12 מגן על חומרי המפתח שלו באמצעות PBES2, השיטה המבוססת על סיסמה מ-PKCS#5, המוגדרת ב-RFC 8018. ה-PBES2 מריץ פונקציית גזירת מפתח, כאן PBKDF2 עם HMAC-SHA-256, ולאחר מכן צופן, כאן AES-256-CBC. ה-PBKDF2 מקבל ספירת חזרות (iteration count), וספירה זו היא פרמטר הנישא בקובץ. כל מטרתה היא להיות איטית: יותר חזרות פירושן שכל ניחוש סיסמה עולה יותר, מה שטוב נגד תוקף לא מקוון. תקן RFC 8018 §4.2 מצהיר במפורש שספירה גדולה יותר טובה יותר לאבטחה, ומגדיר בכוונה שאין לה תקרת מקסימום.

הפתיחות הזו מצוינת כאשר אתה יצרת את הקובץ. היא הופכת לנשק כאשר התוקף עשה זאת. ספירת החזרות היא גורם עבודה הנשלט על ידי התוקף, וגורם עבודה בשליטת תוקף הוא מניעת שירות מבוססת מורכבות אלגוריתמית (algorithmic-complexity denial of service). קובץ .pfx מזויף יכול לקודד ספירת חזרות במיליארדים; המנתח קורא אותו בצייתנות וקורא ל-PBKDF2 עבור מספר כזה של סבבי HMAC-SHA-256, והתהליך נעלם לתוך לולאה שלא תחזור במשך דקות או שעות עבור קובץ בודד. בשרת חתימות המטפל בתעודה אחת לכל בקשה, העלאה יחידה מעובדת כזו משביתה עובד (worker).

הספירה הופכת את הגליפה (wraparound) לגרועה יותר לפני שהיא גורמת ל-CPU להסתובב. ערך החזרה חי בקובץ כ-ASN.1 INTEGER, שאין לו רוחב קבוע, בעוד השדה ש-PBKDF2 צורך בסופו של דבר הוא Integer של 32 סיביות. פענח את ה-INTEGER ישר לתוך השדה הזה וערך גדול ייקטע (truncate), וערך שתוכנן לנחות על סיבית הסימן (sign bit) יחזור כשלילי או כמספר קטן לא קשור, כך שאפילו גודל העבודה אינו עוד מה שהקובץ התכוון לבקש. התיקון קורא את הערך ברוחבו המלא ומגביל אותו לפני הצמצומו:

// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node);          // returns Int64
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // safe: already bounded

קריאה לתוך Int64 פירושה שהערך המפוענח הוא האמיתי, ולא רוח רפאים קטועה שלו. הגבול התחתון דוחה ספירות אפס ושליליות, שאינן הגיוניות לגזירת מפתח. הגבול העליון, מאה מיליון, יושב הרבה מעל לכל קובץ PKCS#12 לגיטימי, המשתמש כיום בעשרות עד מאות אלפי חזרות בודדות, תוך הגבלת המקרה הגרוע ביותר לכמות עבודה מוגבלת ושרידה. רק לאחר שהערך עבר את הטווח הזה הוא מצומצם לשדה של 32 סיביות, כך שהקטיעה אינה יכולה עוד להפתיע אף אחד. ב-HotPDF הגבלה זו חיה ב-ParsePBES2Params, שבו פרמטרי ה-PBKDF2 מפעונחים בדרך ל-PBKDF2HMACSHA256.

מדוע שני התיקונים הם אותו תיקון

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

הנחיות מעשיות לצינור חתימה

השיעור הצר הוא לאמת קלט אישורים לא מהימן כפי שהיית מאמת כל העלאה לא מהימנה. הגבל את הגודל של קובץ .pfx שאתה מקבל, שכן קובץ לגיטימי הוא בקילובייטים, לא במגה-בייטים. התייחס לכשל ניתוח כאל קלט דחוי שגרתי, לא שגיאה השווה הצגת מעקב מחסנית (stack trace) למשתמש. אם אתה חותם בשרת, הרץ את הייבוא במקום שבו עובד שהושבת אינו יכול להפיל את השירות יחד איתו, והצב מגבלת זמן (timeout) סביב הפעולה כך שקובץ יקר באופן לא צפוי יוגבל בזמן אמת כמו גם בתקרת החזרות.

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

זרימת עבודת החתימה שהבדיקות הללו מגנות עליה מכוסה מקצה לקצה ב-מדריך שלנו לחתימות דיגיטליות של PAdES ב-Delphi, ואותה עמדת מגננה המוחלת על הצפנת מסמכים, כולל נתיב מפתח ה-AES-256 שחולק את בסיס הקוד הזה, מתוארת ב-מאמר על הצפנת AES-256 ואבטחה. כל זה נשלח כחלק מ-HotPDF Component עבור Delphi ו-C++Builder, לצד ממשקי ה-API לטעינה, עריכה, הצפנה וחתימה המכוסים במקומות אחרים בבלוג זה.