מאמר טכני

DocMDP של PDFium: Widget /P שהסתיר עריכות עמודים

ב-builds של PDFium Component לפני v3.126.2, ה-TPdf.AnalyzeSignatureRevisions יכל לדרג עריכת תוכן עמוד אמיתית בתור שינוי הערה מותר תחת DocMDP P=3, כי גרף התפקידים של המהדורות שלו התייחס אל הפניית ה-/P של ה-widget של החתימה בחזרה אל העמוד שלה בתור בעלות. מאז v3.126.2, PDFium Component מפריד קשתות ניווט מקשתות של payload בבעלות, ולכן תוכן עמוד נשאר תוכן עמוד. דיווח הבאג שמאחורי התיקון הזה נראה הרמטי על הנייר. חוזה מוסמך מתיר הערות, צד נגדי מוסיף שמירה מצטברת אחת, והמנתח אומר שכל שינוי מאוחר מותר. ואז מישהו עושה diff לעמודים המרונדרים וסכום התשלום בעמוד 2 שונה

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

מדוע עריכת עמוד עברה בתור שינוי הערה תחת DocMDP P=3?

עריכת העמוד עברה כי גרף התפקידים הישן הלך אחרי כל הפניה עקיפה במילון כאילו האובייקט שאליו מפנים שייך למפנה, וה-widget של החתימה מצביע בחזרה אל העמוד שלו. מילון הערה נושא /P, הפניה עקיפה לאובייקט העמוד שעליו הוא יושב (ISO 32000-1 §12.5.2). הרשומה הזאת היא רמז ניווט. ה-widget אינו הבעלים של העמוד; העמוד הוא הבעלים של ה-widget דרך מערך ה-/Annots שלו

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

  1. ה-widget של החתימה הוא /Subtype /Widget עם /FT /Sig, ולכן הוא מקבל את תפקיד ההערה
  2. ה-/P של ה-widget דוחף את תפקיד ההערה אל מילון העמוד, שכבר מחזיק את תפקיד העמוד
  3. העמוד דוחף את שני התפקידים אל /Contents, /Resources, ודרך /Parent מעלה אל עץ ה-Pages והלאה אל כל עמוד אח
  4. מילון stream תוכן כמו << /Length 812 >> אינו מחזיק /Type, ולכן המסווג נפל בחזרה אל ביטי התפקיד ובדק את תפקיד ההערה לפני תפקיד העמוד
דיאגרמת PDFium Component של גרף התפקידים של DocMDP לפני v3.126.2, שבה הפניית ה-‎/P של ה-widget לחזרה דוחפת את תפקיד ההערה אל מילון העמוד, העמוד מפיץ אותו דרך ‎/Contents אל content stream בלי רשומת ‎/Type, המסווג מוציא prckAnnotation ודירוג P=3 מחזיר prdAllowed
לפני v3.126.2 גרף התפקידים התייחס לכל הפניה עקיפה בתור בעלות, ולכן רשומת ה-‎/P של ה-widget דחפה את תפקיד ההערה אל העמוד, ועריכת עמוד אמיתית יצאה מהמנתח בתור שינוי הערה מותר

ה-content stream המשונה לכן יצא בתור prckAnnotation. תחת ISO 32000-1 §12.8.2.2, DocMDP P=3 מתיר שינויי הערות, ולכן ההחלטה הייתה prdAllowed והדוח התגלגל אל prasAllowed. אותו קובץ תחת P=2 נדחה, אבל רק במקרה: P=2 אוסר שינויי הערות, ולכן ה-stream שתויג שגוי נדחה מהסיבה הלא נכונה. לולאת הפצה קבועה של ארבעה מעברים הוסיפה חולשה שנייה. Payload שהגיע דרך מערך עקיף, או דרך שרשרת ארוכה שמספרי האובייקטים שלה רצים אחורה, עשוי שלא לקבל אף תפקיד בכלל

מדוע מאמת חתימות חייב לשאול מי הבעלים של אובייקט?

מאמת חתימות חייב לשאול מי הבעלים של אובייקט כי עדכונים מצטברים של PDF (ISO 32000-1 §7.5.6) מאפשרים לכל אחד לצרף מהדורה שמגדירה מחדש מספר אובייקט קיים, והגוף המוגדר מחדש לא מצהיר מה הוא. החתימה עדיין מאומתת, כי היא מכסה רק את בייטי המהדורה שלה עצמה. כל הגנה מפני מניפולציה אחרי חתימה נשענת לכן על מיפוי כל אובייקט שהשתנה אל המבנה שמשתמש בו, ואז על שאילתה האם החותם התיר למבנה הזה להשתנות

כמה מחלקות התקפה מפורסמות עובדות בדיוק בפער הזה. התקפות incremental saving מצרפות מהדורה שמחליפה תוכן עמוד ונשענות על מאמת שבודק רק את הטווח החתום. התקפות shadow שותלות תוכן נסתר לפני החתימה ומפעילות אותו אחר כך בשינוי קטן שנראה תמים. התקפות על מסמכים מוסמכים מנצלות את העובדה ש-P=2 ו-P=3 מתירים במפורש כמה עריכות מאוחרות, ואז מלבישות עריכה אסורה בתור מותרת. מאמת שמסווג אובייקטים לפי תוויות כמו /Type /Annot, או לפי כל נתיב הפניה שבמקרה מגיע אליהם, חשוף למחלקה השלישית: לתוקף מספיק מבנה אחד מותר שמגיע אל המבנה האסור

לכן השאלה אינה אילו אובייקטים השתנו אלא מי הבעלים שלהם. Content stream שמגיע מעמוד דרך /Contents הוא תוכן עמוד לא משנה מה עוד מצביע עליו. הערה שמצביעה בחזרה אל העמוד דרך /P אומרת היכן ההערה חיה, לא מה היא בבעלותה

איך PDFium Component v3.126.2 ממדל בעלות?

PDFium Component v3.126.2 מתייחס להפניות חוזרות בתור ניווט, משאיר אותן מחוץ להפצת תפקידים, ומכריע אילו מפתחות נחשבים ניווט מהתפקיד המבני של המילון שמחזיק אותם, ולא משם המפתח לבדו. הטבלה מסכמת את מפתחות הניווט שכבר לא נושאים בעלות

מילון הבעליםמפתחות שמטופלים בתור ניווטהפניה למפרט
עמוד או צומת Pages/Parent, /Kids, /AnnotsISO 32000-1 §7.7.3
הערה או widget/PISO 32000-1 §12.5.2
מילון widget או שדה/ParentISO 32000-1 §12.7.3
גרף האובייקטים של PDFium Component v3.126.2 שמפריד קשתות של payload בבעלות כמו ‎/Contents ו-‎/Annots, שמפיצות תפקידי עמוד והערה, מקשתות ניווט כמו ‎/P של widget, שאינן נושאות תפקידים, עם מפתחות הניווט לכל מילון בעלים ועם prckPageContent שנשאר על ה-content stream גם תחת ‎/Type מזויף
v3.126.2 משאיר הפניות חוזרות מחוץ להפצת תפקידים: תפקידים נעים רק דרך בעלות אמיתית, ולכן ה-content stream נשאר תוכן עמוד והרמז של ה-‎/P לא מכריע דבר

סינון לפי שם מפתח באופן גלובלי היה יוצר חור חדש. משאב פונט או XObject יכול באופן לגיטימי להיקרא /P, /Parent או /Annots, ומילון /Resources שמשמיט את רשומת ה-/P שלו מההפצה היה מאפשר לתוקף להסתיר XObject בבעלות עמוד מאחורי שם משאב תמים. ב-v3.126.2 מסנן הניווט חל רק כשמילון הבעלים באמת הוא עמוד, צומת Pages, הערה, widget או שדה. אם אחד מהמילונים האלה נושא מפתח ניווט משוכפל, כמו שתי רשומות /P ב-widget, המנתח לא מנחש איזו עותקת צופה תשתמש; בניית התפקידים נכשלת והחתימה נהיית Indeterminate

כמה כללים נוספים סוגרים את נתיבי שינוי התוויות הנותרים:

  • צמתי Pages הם שורשי תפקיד-עמוד בזכות עצמם, ולכן משאבים שמורשים מעץ ה-Pages (ISO 32000-1 §7.7.3.4) נכנסים להקשר העמוד דרך בעלות אמיתית, ולא דרך הליכת /Parent מעמוד בן
  • תפקיד הערה או טופס שמגיע אל קטלוג, צומת Pages, עמוד, הערה או מילון שדה נעצר שם, כי אובייקטים מבניים אלה מבססים את התפקידים של עצמם ותפקיד payload נכנס אינו אמור לדרוס אותם
  • תפקיד העמוד הוא הסמכות בזמן סיווג: אובייקט בבעלות עמוד הוא prckPageContent גם אם מהדורה מאוחרת משכתבת אותו עם /FT מזויף, תווית /Type /Annot, או משתפת אותו עם appearance stream
  • Form XObject שמשמש רק בתור מראה של שדה או הערה שומר על קטגוריית הטופס או ההערה שלו, ולכן התחדשות מראה רגילה אחרי מילוי טופס עדיין מדורגת תחת כללי ההרשאה הרגילים
  • Widget בלי /FT משלו פותר את טיפוס השדה היורש דרך שרשרת ה-/Parent, ושרשרת שאינה נפתרת מכשילה את בניית התפקידים במקום לברור ברירת מחדל של הערה
  • ביטי תפקיד מכל מהדורה מאוחרת ממוזגים אל תפקידי המהדורה המכוסה, ולכן עדכון מאוחר לא יכול למחוק קשר בעלות-עמוד קודם על ידי ניתוק stream קודם ועריכתו אחר כך

נקודת שבע במקום מספר מעברים קבוע

נגישות התפקידים ב-v3.126.2 רצה בתור תור עבודה שחוזר עד שאף אובייקט לא מקבל ביט תפקיד חדש, מה שהוא נקודת שבע אמיתית ללא תלות בעומק שרשרת או במספור אובייקטים. מערכים עקיפים כמו מערך /Contents השמור בתור אובייקט עצמאי נסרקים גם הם. לכל אובייקט יכולים להצטרף לכל היותר ארבעה ביטי תפקיד נבדלים, ולכן התור חסום בארבע כניסות לכל מספר אובייקט; חריגה מהתקציב הזה מעלה prrResourceLimitExceeded. הפניה אל אובייקט חופשי, אי-התאמת generation או כותרת אובייקט שבורה מעלים prrMalformedRevisionChain, ו-payload בתוך object stream דחוס מעלה prrCompressedObjectUnresolved. כל אחד מהכשלים האלה מסתיים ב-prasIndeterminate, לעולם לא בפסק מותר, וכשהכשל קורה בזמן בניית תפקידי המהדורה המכוסה החתימה לא מדווחת Changes בכלל

הרוטינה הבאה מפרטת את עריכות תוכן העמודים ששורדות את הניתוח הזה. שינוי prckPageContent לעולם לא מדורג prdAllowed: DocMDP P=1, 2 או 3 הופך אותו ל-prdDisallowed, וחתימה בלי DocMDP מדרגת אותו prdSuspicious

uses
  SysUtils, TypInfo, PDFium, FPdfPades;

procedure ListPageContentEdits(const FileName: string);
const
  ShadowTag: array[Boolean] of string = (' (unreferenced shadow)', '');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  Sig: TPadesSignatureRevisionAnalysis;
  Change: TPadesRevisionObjectChange;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;   // record, אין מה לשחרר
    for i := 0 to High(Report.Signatures) do
    begin
      Sig := Report.Signatures[i];
      if not (prrPageContentChanged in Sig.Risks) then
        Continue;
      Writeln(Format('Signature %d (DocMDP P=%d): page content changed',
        [Sig.SignatureIndex, Sig.DocMdpPermission]));
      for j := 0 to High(Sig.Changes) do
      begin
        Change := Sig.Changes[j];
        if Change.Kind <> prckPageContent then
          Continue;
        Writeln(Format('  revision %d  object %d %d R  %s%s',
          [Change.RevisionIndex, Change.ObjectNumber, Change.Generation,
           GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Change.Decision)),
           ShadowTag[Change.IsAuthoritative]]));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

מה קורה כש-FieldMDP והערה חולקות אובייקט אחד?

כשה-/V של שדה וה-/Contents של הערה מצביעים על אותו אובייקט עקיף, v3.126.2 שומר על נעילת ה-FieldMDP בתוקף גם כשהשינוי מסווג בתור עריכת הערה. את התרחיש קל לבנות ביד: חותם נועל את השדה Total עם FieldMDP (ISO 32000-1 §12.8.2.4), והתוקף גורם ל-/Contents של הערת טקסט להפנות את אותו אובייקט מחרוזת שמחזיק את ערך השדה. תחת P=3 עריכת ההערה מותרת, ולכן לפני התיקון שכתוב אותה מחרוזת משותפת שינה ערך שדה נעול עם פסק מותר

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

  • תחת P=2 שינוי ההערה נפסל מלכתחילה, בדיוק כפי שהיה
  • עם FieldMDP All, כל שדה נעול, ולכן השינוי המשותף הוא prdDisallowed
  • עם FieldMDP Include או Exclude, המנתח לא יכול לעקוב סקלר משותף בחזרה אל שם שדה אחד, ולכן ההחלטה היא prdIndeterminate ולא ניחוש
  • בלי FieldMDP, כלל ההערה של P=3 חל והשינוי נשאר מותר
דיאגרמת ההחלטה של FieldMDP ב-PDFium Component שבה ה-‎/V של שדה Total נעול וה-‎/Contents של הערה מפנים אל אותו אובייקט עקיף, עם הסתעפות על DocMDP P=2, FieldMDP All, FieldMDP Include או Exclude ובלי FieldMDP אל פסקי prdDisallowed, prdIndeterminate או prdAllowed עבור אותה עריכה משותפת
כשאובייקט עקיף אחד נושא גם את תפקיד ההערה וגם את תפקיד הטופס, החלטת ההערה בודקת מחדש את נעילת ה-FieldMDP, ולכן אותה עריכה נעה בין מותר לפסול לבלתי מכריע

פרט דיווח אחד חשוב לקוד שער. המקרה המשותף מדווח בתור Kind = prckAnnotation עם Decision = prdIndeterminate, ו-prrFieldMdpUnresolved מתווסף לקבוצת הסיכונים רק עבור שינויים שסווגו בתור שדות טופס. שער שמחפש prrFieldMdpUnresolved ומתעלם מ-Status מפספס את המקרה הזה לגמרי

איך קוד Delphi צריך להיכשל בביטחון על ניתוח מהדורות?

קוד Delphi צריך לקבל מסמך חתום רק כשה-Status של הניתוח הוא prasNoLaterChanges או prasAllowed ואין סיכון מבני, והוא צריך להתייחס ל-prasIndeterminate ול-prasSuspicious בתור לא אמינים, ולא בתור אזהרות לרשום ולהעביר. Indeterminate פירושו שהמנתח לא יכל להוכיח שהמהדורות המאוחרות הותרו; עבור תוקף, קלט שמייצר Indeterminate בצורה אמינה שווה ערך לקלט שמייצר Allowed אם הקוד שלכם מעביר אותו. ה-AnalyzePadesSignatureRevisions הגלובלי מקבל כל TStream וקורא אותו ממיקום 0, מה שמתאים ל-handlers של העלאות שלא צריכים לרנדר את המסמך

uses
  Classes, SysUtils, TypInfo, FPdfPades;

const
  // הגדרות כפולות נרשמות בלי להוריד את ה-Status
  BlockingRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrCompressedObjectUnresolved,
    prrResourceLimitExceeded];

function SignedRevisionsAcceptable(const FileName: string;
  out Reason: string): Boolean;
var
  Source: TFileStream;
  Report: TPadesRevisionAnalysisReport;
begin
  Result := False;
  Reason := '';
  Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Report := AnalyzePadesSignatureRevisions(Source);
  finally
    Source.Free;
  end;
  if Report.SignatureCount = 0 then
  begin
    Reason := 'no signature anchors the analysis';
    Exit;
  end;
  if Report.Risks * BlockingRisks <> [] then
  begin
    Reason := 'structural risk in the revision chain';
    Exit;
  end;
  case Report.Status of
    prasNoLaterChanges, prasAllowed:
      Result := True;
  else
    // prasIndeterminate ו-prasSuspicious הן דחיות, לא אזהרות
    Reason := 'revision status ' +
      GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
  end;
end;

שני גבולות ראויים לניסוח חד. ה-TPadesRevisionAnalysisReport לא אומר דבר על שלמות CMS או על אמון בתעודות, ולכן השער הזה יושב לצד אימות קריפטוגרפי ואימות אמון, לא במקומם. וגרף בעלות נכון לא הופך את P=3 לבטוח עבור כל זרימת עבודה. P=3 באמת מתיר הערות, והערה עם appearance אטום יכולה לכסות טקסט חתום בלי לגעת באף content stream. אם המסמכים המוסמכים שלכם הם חוזים ולא עותקי סקירה, או מסמכים עם P=2 או מנתבים שינויי הערה מותרים אל בן אדם, כמו בעוזר הזה:

function AllowedAnnotationEditsUnderP3(
  const Report: TPadesRevisionAnalysisReport): Integer;
var
  i, j: Integer;
begin
  Result := 0;
  for i := 0 to High(Report.Signatures) do
    if Report.Signatures[i].DocMdpPermission = 3 then
      for j := 0 to High(Report.Signatures[i].Changes) do
        if (Report.Signatures[i].Changes[j].Kind = prckAnnotation) and
           (Report.Signatures[i].Changes[j].Decision = prdAllowed) then
          Inc(Result);
end;

צ'קליסט ביקורת מהדורות חתימות

השתמשו ברשימה הזאת כדי לבדוק אם צינור האימות שלכם היה חשוף ואם הוא עכשיו נכשל בביטחון:

  • Builds של PDFium Component לפני v3.126.2 יכלו לדווח prasAllowed עבור עריכות תוכן עמודים במסמכי DocMDP P=3; הריצו מחדש את TPdf.AnalyzeSignatureRevisions על קבצי P=3 מוסמכים שקיבלו builds ישנים
  • בדקו מחדש מסמכי P=3 עם נעילות FieldMDP שבהם ערך שדה והערה עשויים לחלוק אובייקט עקיף
  • קבלו רק prasNoLaterChanges ו-prasAllowed; התייחסו ל-prasIndeterminate ול-prasSuspicious בתור לא אמינים
  • בדקו גם את Report.Risks וגם את Report.Status, כי prrDuplicateObjectDefinition לא משנה את ה-Status לבדו
  • אל תקראו מערך Changes ריק בתור תוצאה נקייה כשה-Status של החתימה הוא Indeterminate; בניית תפקידים שנכשלה לא מדווחת שינויים
  • אל תסתמכו על prrFieldMdpUnresolved לבדו כדי לתפוס בעיות FieldMDP, כי המקרה של ההערה המשותפת עולה רק דרך ההחלטה וה-Status
  • הכריעו אם שינויי הערה מותרים תחת P=3 דורשים סקירה אנושית בזרימת העבודה שלכם
  • נתחו את בייטי הקובץ המקוריים; מסמך ששוכתב על ידי SaveAs כבר לא מחזיק את שרשרת המהדורות

ניתוח מהדורות הוא שכבה אחת של בדיקת חתימה. צוותו אותו עם בדיקת חתימות דיגיטליות של PDF ורמות PAdES עבור המילון ורמת ה-baseline, ועם ביקורת סיכוני אבטחה רחבה של PDF עבור JavaScript, פעולות הפעלה וקבצים משולבים. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions וגרף התפקידים מודע-הבעלות שתואר כאן נשלחים ב-PDFium Component עבור Delphi, C++Builder ו-Lazarus