مقاله فنی

تجزیه ایمن دیکشنری‌های PDF در دلفی: توکن‌های نام

جستجوهای زیررشته مانند Pos('/Length', Dict) ابزار اشتباهی برای خواندن یک دیکشنری PDF هستند، زیرا کلیدهای نام PDF پیشوندهای مشترکی دارند: /Length پیشوند /Length1 است و /Encrypt پیشوند /EncryptMetadata است. استاندارد ISO 32000-1 §7.3.5 نام را به عنوان توکنی تعریف می‌کند که فقط در یک جداکننده یا فضای خالی پایان می‌یابد، بنابراین یک کلید فقط زمانی پیدا شده به حساب می‌آید که بایت بعد از آن یکی از آن کاراکترها باشد. خواننده دیکشنری که این یک بررسی را نادیده بگیرد، در نهایت مقدار اشتباهی را از یک فایل کاملاً معتبر خواهد خواند

باگی که این موضوع را به ما آموخت، هیچ شباهتی به یک مشکل لکسر (lexer) نداشت. یک اسکن انطباق شروع به گزارش یک استریم FontFile به عنوان آسیب‌دیده کرد: برنامه فونت غیرفشرده یک قطعه بود که در وسط جدول قطع شده بود. فایل در هر نمایشگری به خوبی باز می‌شد. داده‌های استریم روی دیسک دست‌نخورده بود. علت اصلی در یک خط از خواننده دیکشنری مشترک ما قرار داشت: Pos('/Length', ...) با /Length1 مطابقت پیدا کرده بود، یک کلید استاندارد که دیکشنری‌های استریم FontFile طبق جدول ۱۲۷ استاندارد ISO 32000-1 حمل می‌کنند، و خواننده عدد صحیح بعد از /Length1 را به عنوان طول استریم در نظر گرفته بود. نویسنده آن فایل خاص به طور تصادفی /Length1 را قبل از /Length سریال‌سازی کرده بود، که طبق بخش ۷.۳.۷ کاملاً مجاز به انجام آن است، زیرا ورودی‌های دیکشنری بدون ترتیب هستند. استریم به تعداد بایت‌های زباله کوتاه شد و هر بررسی پایین‌دستی که آن را مصرف می‌کرد بی سروصدا کور شد

چرا مطابقت زیررشته باعث خراب شدن تجزیه دیکشنری PDF می‌شود؟

مطابقت زیررشته به این دلیل خراب می‌شود که فضای نام PDF پر از خانواده‌های پیشوند عمدی است و به این دلیل که ترتیب ورودی‌های دیکشنری نامشخص است. جدول ۱۲۷ استاندارد 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 که هر جستجوی کلید به سبک Pos را مشکوک نشان می‌داد، برطرف نمودیم

بخش ۷.۳.۵ استاندارد ISO 32000-1 در واقع نام را چه تعریف می‌کند

بخش ۷.۳.۵ کوتاه و دقیق است: یک شیء نام یک solidus (اسلش) است که با دنباله‌ای از کاراکترهای معمولی دنبال می‌شود و توکن با اولین کاراکتر جداکننده یا فضای خالی خاتمه می‌یابد. جداکننده‌ها هشت کاراکتر پرانتز و کروشه به علاوه اسلش و علامت درصد هستند — ( ) < > [ ] { } / % — و فضای خالی شامل نال، تب، لاین فید، فرم فید، کریج ریترن و فاصله است (بخش‌های ۷.۲.۲ تا ۷.۲.۳). آن قانون خاتمه کل داستان است. عبارت /Length1 معادل "/Length به همراه یک ۱" نیست؛ این یک توکن واحد و غیرقابل تقسیم است، دقیقاً همانطور که LengthOne و Length شناسه‌های متفاوتی در پاسکال هستند. هر خواننده‌ای که کلیدها را با جستجوی بایت خام پیدا می‌کند، لکسر را با قانون خاتمه حذف شده پیاده‌سازی مجدد می‌نماید

در اینجا شکل نقص به موارد ضروری کاهش یافته است. این نسخه کامپایل می‌شود، تست‌ها را در برابر فایل‌هایی که نویسندگان آنها ابتدا /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;

تطابق کامل توکن: بایت بعد از کلید را بررسی کنید

پیش‌فرض صحیح مستقیماً از بخش ۷.۳.۵ پیروی می‌کند: یک تطابق نامزد تنها زمانی یک کلید واقعی است که کاراکتر بلافاصله بعد از آن یک جداکننده، فضای خالی یا انتهای بافر باشد. هر چیز دیگری نام طولانی‌تری است که صرفاً یک پیشوند را به اشتراک می‌گذارد، بنابراین جستجو باید به جای تسلیم شدن، از آن عبور کند. راه حل در خواننده ما، هر جستجوی خام 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 یک ترتیب قانونی است و کلید واقعی هنوز در پیش است. دوم اینکه حالت انتهای بافر به عنوان خاتمه‌دهنده به حساب می‌آید، زیرا یک قطعه دیکشنری می‌تواند به طور قانونی درست بعد از یک نام پایان یابد. یک نکته ظریف‌تر که ارزش حسابرسی در کد خود را دارد: اگر رشته جستجوی شما شامل solidus نباشد، همان قانون در سمت چپ تطابق اعمال می‌شود، در غیر این صورت Pos('Length', ...) می‌تواند در داخل /PieceLength بنشیند. لنگر انداختن رشته جستجو با / پیشرو، همانطور که در بالا نشان داده شد، لبه سمت چپ را مدیریت می‌کند زیرا / خود جداکننده‌ای است که به توکن قبلی پایان می‌دهد

چگونه یک PDF خصمانه می‌تواند یک باگ تجزیه‌کننده را به تخصیص حافظه گیگابایتی تبدیل کند؟

یک فایل بدفرم یا مخرب این اشتباهات واژگانی را به اتمام منابع افزایش می‌دهد، زیرا دیکشنری‌های عددی مکرراً اندازه‌های تخصیص را تغذیه می‌کنند. ممیزی ما زنجیره‌ای دقیقاً به این شکل را در گسترش استریم شیء (object-stream) پیدا کرد. ورودی /N یک دیکشنری ObjStm می‌گوید استریم چه تعداد شیء فشرده را نگه می‌دارد و کد گسترش متد SetLength را روی آرایه‌ای با اندازه آن فراخوانی می‌کرد. با این حال، تجزیه‌کننده عدد صحیح پارامتر خروجی خود را در صورت بروز خطا بدون دستکاری رها می‌کرد در حالی که همچنان آن را برمی‌گرداند — بنابراین یک /N غیر عددی یک مقدار پشته مقداردهی‌نشده را به SetLength می‌داد. یک عدد صحیح مثبت زباله در آنجا به معنای درخواست تخصیص در حد گیگابایت است که با چند بایت ورودی خراب ایجاد می‌شود، آن هم در حالی که صرفاً در حال اسکن سندی هستید که هنوز حتی با اعتماد به آن موافقت نکرده‌اید

اصلاح دارای دو بخش مستقل بود و هر دو تعمیم می‌یابند. تجزیه‌کننده اکنون در صورت بروز خطا یک ۰ صریح برمی‌گرداند و هرگز حافظه مقداردهی‌نشده را برنمی‌گرداند. و مصرف‌کننده دیگر به /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 بزرگتر از کل فایل را قبل از تخصیص بافر نتیجه رد می‌کند — یک استریم نمی‌تواند بزرگتر از کانتینری باشد که در آن زندگی می‌کند، بنابراین این بررسی عاری از مثبت کاذب است. و مسیر inflate خروجی غیرفشرده را به ۲۵۶ مگابایت محدود می‌کند، که بمب کلاسیک zlib را متوقف می‌سازد که در آن چند کیلوبایت ورودی بدون محدودیت منبسط می‌شود؛ این محدودیت برای هر استریم واقعی PDF سخاوتمندانه است در حالی که بدترین حالت را قابل بقا نگه می‌دارد. موضوع در هر سه مورد یکسان است: هر اندازه‌ای که یک فایل اعلام می‌کند یک ادعا است و تجزیه‌کننده قبل از متعهد کردن حافظه به آن، هر ادعا را در برابر چیزی که می‌تواند اندازه‌گیری کند تأیید می‌نماید. همین وضعیت ممیزی در یک لایه پایین‌تر در مرز اتصال نیز اعمال می‌شود، که در سخت‌سازی ABI کتابخانه PDFium و امنیت حافظه در دلفی پوشش داده شده است

جایی که قانون کامل توکن کافی نیست

محدودیت‌های صادقانه، به طوری که به روال بالا بیش از حد اعتماد نکنید. تطابق کامل توکن شناسایی کلید را اصلاح می‌کند، اما جستجوی بایت مسطح روی یک محدوده دیکشنری هنوز نمی‌تواند بگوید که آیا یک تطابق در داخل یک دیکشنری تو در تو، یک رشته واقعی یا یک کامنت قرار دارد — متد FindDictKey روی یک شیء صفحه در صورتی که محدوده بیش از حد وسیعی را به آن بدهید، می‌تواند روی کلیدی در داخل زیردیکشنری /Resources آن بنشیند. خواننده ما ابتدا محدوده را به بدنه یک شیء واحد محدود می‌کند و با بافت‌های رشته و کامنت به عنوان یک مورد ممیزی مجزا و همچنان باز رفتار می‌نماید. امنیت زیررشته یک پله از نردبان است و نه کل نردبان: سازگاری مرجع متقابل رشته تخصصی خود است که در اعتبارسنجی استریم‌های شیء و xref پوشش داده شده است، و کاتالوگ تهدیدهای گسترده‌تر برای اسنادی که خودتان ایجاد نکرده‌اید در حسابرسی ریسک‌های امنیتی PDF قرار دارد

اگر یک خواننده دیکشنری دست‌نویس را در دلفی یا لازاروس نگهداری می‌کنید، چک‌لیست این حادثه کوتاه است. عبارت Pos('/ را در پایگاه کد جستجو کنید و موارد پیدا شده را از طریق یک هلپر توکن کامل هدایت نمایید. فهرست کنید خانواده‌های پیشوندی را که کلیدهای شما در آنها شرکت می‌کنند — مقادیر /Length، /Encrypt، /N، /Type در برابر /Type1 همگی در فایل‌های واقعی ظاهر می‌شوند. سپس هر عدد صحیحی را که به متدهای SetLength، GetMem یا یک حلقه کپی می‌رسد بررسی کنید و بپرسید چه چیزی آن را محدود می‌کند: اندازه فایل، یک سقف مشتق شده از هدر یا هیچ چیز. لایه تجزیه توضیح داده شده در اینجا پایه و اساس کامپوننت ما PDFium Component است، جایی که خواننده سطح بایت و موتور رندر، یکدیگر را در هر سندی که لمس می‌کنند بررسی متقابل می‌نمایند