לפני גרסה 3.114.8, PDFiumPas ייצר חומר מפתח להצפנת PDF ביעדים שאינם Windows עם הפונקציה Random של ספריית הריצה, ומכיוון שאף דבר לא קרא ל-Randomize, כל תהליך הפיק את אותו רצף בייטים. בנייני Free Pascal על Linux ו-macOS כתבו לכן מפתחות הצפנת קבצים זהים, salts, IVs של CBC וקידומות nonce של AES-GCM מריצה לריצה. גרסה 3.114.8 קוראת במקום זאת מ-/dev/urandom ומעלה חריגה כשהיא לא יכולה
הפגם עצמו הוא לולאה של ארבע שורות. השיעור השימושי יותר הוא למה סוויטת בדיקות שמצפינה ומפענחת מאות מסמכים, עם AESV3 ו-AESV4, עם ובלי PDF MAC, נשארה ירוקה כל הזמן. אקראיות שהיא קבועה לכל תהליך בלתי נראית לכל בדיקה שרצה בתוך תהליך אחד, וכך בדיוק בדרך כלל כותבים בדיקות הצפנה
איפה PDFiumPas זקוק לבייטים אקראיים?
כל בייט אקראי במחסנית ההצפנה של PDFiumPas מגיע משגרה אחת, AesGenerateRandomBytes ביחידת FPdfAes, כך שמקור אחד רע מזהם את כולה. מנהל האבטחה הסטנדרטי ב-ISO 32000-2 §7.6.4 והרחבת AESV4 ב-ISO/TS 32003 צורכים את הבייטים האלה במקומות הבאים:
- מפתח הצפנת הקובץ בן 32 הבייטים, שנוצר טרי על ידי DeriveEncryptionKeys עבור כל מסמך ואז נעטף לתוך /UE ו-/OE תחת מפתחות שנגזרו מהסיסמה
- שני salts בני 16 בייטים, אחד מאוחסן ב-16 הבייטים האחרונים של /U ואחד ב-16 הבייטים האחרונים של /O, כל אחד מפוצל ל-salt אימות של 8 בייטים ו-salt מפתח של 8 בייטים
- בייטים 12 עד 15 של הטקסט הגלוי מאחורי /Perms, ש-ISO 32000-2 ממלא בנתונים אקראיים לפני שהבלוק מוצפן תחת מפתח הקובץ
- IV של CBC בן 16 בייטים שמוצמד לפני כל מחרוזת וזרם מוצפנים במסמך AESV3
- קידומת nonce של 8 בייטים עבור מסמכי AESV4, ואחריה מונה לכל אובייקט בן 4 בייטים שמתחיל מאפס
- ה-/KDFSalt בן 32 הבייטים ומפתח ה-MAC כש-EnableIntegrityProtection מוגדר
למה כל תהליך הפיק את אותו מפתח?
AesGenerateRandomBytes השתמש במחולל של מערכת ההפעלה רק ב-Windows; בכל מקום אחר הוא מילא את החוצץ מהמחולל הפסאודו-אקראי של ה-RTL, והמחולל הזה מתחיל מ-RandSeed = 0 אלא אם התוכנית קוראת ל-Randomize. ההערה מעל הלולאה טענה שהמחולל נזרע מ-GetTickCount64. אף שורת קוד לא עשתה זאת מעולם, מה שהפך את ההערה למקום היחיד שבו הזרע התקיים:
// הענף שאינו Windows של AesGenerateRandomBytes לפני 3.114.8
// (ההערה מעליו הבטיחה זריעה של GetTickCount64 שמעולם לא בוצעה)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
הרצף מתחיל מחדש בכל תהליך ומתקדם בתוכו, כך שהמסמך הראשון שכל תהליך מצפין חולק את מפתח הקובץ שלו עם המסמך הראשון של כל תהליך אחר שמריץ את אותו build, השני עם השני, וכן הלאה. מפתח הקובץ ב-R5, ב-R6 וב-R7 לא תלוי בסיסמה בכלל, מכיוון שהסיסמה רק עוטפת אותו, מה שאומר שכל מי שמסוגל לשחזר את הרצף מחזיק את המפתח בלי לדעת סיסמה. AESV4 מוסיף כשל שני: אותו מפתח עם אותה קידומת של 8 בייטים ומונה שמתחיל מחדש מאפס חוזרים על nonce של GCM, מה ש-NIST SP 800-38D §8 אוסר באופן מוחלט. nonce של GCM שחוזר תחת אותו מפתח חושף את ה-XOR של שני הטקסטים הגלויים ומחשוף את מפתח המשנה לאימות, כך שהתגים ש-הצפנת AESV4-GCM וה-token של ה-PDF MAC נשענים עליהם מאבדים כל משמעות. סודיות ושלמות הולכות יחד
היקף הנזק צר ממה שהפסקה עשויה לרמוז. בנייני Windows מעולם לא הושפעו, כי הענף של Windows קרא תמיד ל-CryptGenRandom דרך advapi32 עם CRYPT_VERIFYCONTEXT והעלה חריגה כשזה נכשל. מה שנחשף היה פלט מבניינים שאינם Windows שקדמו ל-3.114.8, מה שבפועל אומר יישומי Lazarus ו-Free Pascal על Linux ו-macOS, עוד כניסה לרשימת מלכודות Delphi מול FPC בבנייני PDFium
למה Randomize מעולם לא היה התיקון הנכון?
קריאה ל-Randomize הייתה מסתירה את התסמין בלי לתקן את המקור, כי RandSeed הוא ערך של 32 סיביות ו-Randomize גוזר אותו מהשעון. זה תוחם את מספר זרמי המפתח האפשריים ב-2^32, וידיעה גסה של מתי הקובץ נכתב מצמצמת את החיפוש הרבה מתחת לזה, שזה כלום לצד מפתח AES של 256 סיביות. חומר מפתח חייב להגיע ממאגר האנטרופיה של הקרנל, ולכן AesGenerateRandomBytes ב-3.114.8 קורא מ-/dev/urandom, עובר בלולאה על קריאות קצרות, ומעלה חריגה אם המאגר לא מסוגל לספק כל בייט מבוקש:
Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
Remaining := Count;
while Remaining > 0 do
begin
Got := FileRead(Handle, P^, Remaining);
if Got <= 0 then
Break; // כשל או סוף זרם בלתי צפוי
Inc(P, Got);
Dec(Remaining, Got);
end;
if Remaining = 0 then
Exit;
finally
FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');
הסירוב מכוון, והוא תואם את מה שהענף של Windows עשה תמיד כש-CryptGenRandom אינו זמין. שמירה מוצפנת שנכשלה היא אירוע שאתה שם לב אליו באותו יום; שמירה מוצלחת עם מפתחות צפויים היא כזאת שאתה שומע עליה ממישהו אחר. שתי תוצאות מעשיות נובעות מכך. מיכל מינימלי או chroot בלי /dev מאוכלס נכשל כעת בהצפנה במקום להידרדר בשקט, אז עגנו את /dev. ומכיוון שהחריגה מתפשטת החוצה מ-TPdf.SaveAsEncrypted אחרי שקובץ היעד נפתח עם fmCreate, נשאר אחורי קובץ פלט ריק שמטפל השגיאות שלכם צריך למחוק
למה בדיקות הלוך ושוב לעולם לא תפסו את זה?
בדיקת הלוך ושוב לא יכולה לראות אקראיות קבועה, כי פענוח משחזר את מפתח הקובץ שההצפנה בחרה, מה שלא יהיה. הבדיקה מצפינה מסמך, פותחת אותו שוב עם הסיסמה, מסירה את העטיפה מהמפתח מ-/UE, ומפענחת כל אובייקט; מפתח צפוי מוסר עטיפה ומפענח בדיוק כמו אקראי, והתגים של GCM מאומתים כי חושבו עם אותו מפתח בדיוק. אפילו בדיקה שמצפינה פעמיים ומאמתת שהפלטים שונים עוברת, מכיוון שהקריאה השנייה באותו תהליך שואבת את הבייטים הבאים של הרצף. התכונה שחשובה, מפתח שונה בכל תהליך, ניתנת לצפייה רק בהשוואת פלט בין תהליכים. בכל פעם שאותו קוד גם מייצר וגם צורך ערך, הבדיקות עיוורות למעמדות שלמים של פגמים, ואקראיות היא הדוגמה הטהורה ביותר
איך בודקים אקראיות מפתחות בין תהליכים?
מריצים probe קטן פעמיים בתור תהליכים נפרדים על פלטפורמת היעד ומשווים את הפלט. ה-probe שלהלן קורא ל-DeriveEncryptionKeys ומדפיס את ה-salt המאוחסן בבייטים 32 עד 47 של כניסת /U. הערך הזה נכתב לעין כל לתוך כל קובץ מוצפן, כך שהדפסתו ביומני ה-CI לא חושפת דבר, והוא מגיע מאותו מחולל כמו מפתח הקובץ:
program SaltProbe;
{$mode delphi}
uses
SysUtils, FPdfEncrypt;
var
Opts: TPdfEncryptOptions;
Keys: TPdfEncryptionKeys;
I: Integer;
Hex: string;
begin
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'probe';
Opts.Revision := erR6;
DeriveEncryptionKeys(Opts, Keys);
Hex := '';
for I := 32 to 47 do // /U = hash של 32 בייטים + salt של 16 בייטים
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // חייב להשתנות בכל ריצה
end.
חברו את ה-probe ל-build עבור כל יעד שאינו Windows: הריצו אותו פעמיים, והכשילו את העבודה אם שתי השורות זהות. אותה השוואה עובדת גם על קבצים שכבר במחזור. קחו שני PDF מוצפנים שנכתבו על ידי ריצות שונות של אותו יישום, קראו את המחרוזות /U מהמילונים Encrypt שלהם, והשוו את 16 הבייטים האחרונים; salts זהים מזהים build מושפע, ואת המסמכים יש להצפין מחדש מהטקסט הגלוי שלהם עם 3.114.8 ומעלה כדי שכל אחד יקבל מפתח קובץ טרי. ההרגל הכללי הוא להפעיל נתיבי קוד שאינם Windows על הפלטפורמה עצמה במקום לסמוך על הריצה ב-Windows, אותו היגיון מאחורי backend התמתוג של libcurl לבניינים שאינם Windows
PDFiumPas הוא רכיב PDF עבור Delphi ו-Lazarus שנבנה על גבי מנוע ה-PDFium, עם AES-256, AES-GCM וה-token של ה-PDF MAC שממומשים ילידית ב-Pascal וחומר מפתח שנשאב מהמחולל של מערכת ההפעלה בכל פלטפורמה. פרטים והורדות בעמוד רכיב ה-PDFium עבור Delphi