مقاله فنی

ایمن‌سازی یک پارسر PDF پاسکال در برابر فایل‌های مخرب

یک PDF سندی نیست که شما باز کنید. یک برنامه کوچک است که شما اجرا می‌کنید. هر فونتِ جاسازی‌شده، یک مفسرِ مبتنی بر پشته است که در انتظارِ رشته‌های‌کاراکتری (charstrings) است، هر تصویر یک دیکدر است که با فیلدهای عرض، ارتفاع و عمق‌-بیت که فایل انتخاب کرده، تغذیه می‌شود، و هر جریانِ اطلاعاتی که می‌رسد در فیلترهایی پیچیده شده است که فایل پارامترهای آن را تنظیم کرده است. هیچ یک از این اعداد برای شما نیستند. آن‌ها از طرفِ تولید‌کننده فایل آمده‌اند، که در یک حجمِ‌کاریِ واقعی یک فاکتور مشتری یا پیوستی از یک فرستنده ناشناس است. رمزگشاها (decoders) که این بایت‌ها را به پیکسل و گلیف (glyphs) تبدیل می‌کنند، سطحِ حمله هستند، و پارسری که به ورودیِ خود در اینجا اعتماد کند فقط یک فایلِ ناقص تا خراب‌شدن یا بدتر از آن فاصله دارد

PDFlibPas یک مرحله ایمن‌سازی را طی کرد که تمامِ مسیر رمزگشایی را با برنامه‌های فونت (TrueType، Type1، CFF و جداول CMap)، رمزگشاهای تصویر (PNG، GIF، TIFF، JBIG2، و CCITT Group 3 و Group 4)، و فیلترهای جریان (LZW، ASCII85 و پیش‌بینی‌کننده‌های Flate) به‌عنوان یک خصومت (hostile) در نظر گرفت. در ادامه به پنج کلاس‌نقص که توسط آن بسته شدند پرداخته می‌شود، که هر کدام ریشه در رفتار خاصی از دلفی دارند که آن را ممکن ساخته است. این نواقص در نسخه‌های فعلی رفع شده‌اند، و همین الگوها در هر کد پاسکالی که ورودی غیر‌قابل‌اعتماد را پردازش کند، تکرار می‌شوند

یک سرریزِ عدد صحیح که یک بافر کوچک‌-اندازه به شما می‌دهد

باگ کلاسیکِ امنیتِ-حافظه در یک رمزگشای تصویر، حاصل‌ضرب ابعادی است که دور می‌زند (wrap). یک رمزگشا، عرض، ارتفاع، تعداد کامپوننت و عمق-بیت را می‌خواند، آن‌ها را ضرب می‌کند تا اندازه خروجی خود را به‌دست‌آورد، آن مقدار بایت را تخصیص می‌دهد و سپس تصویر را در ابعاد واقعی‌اش می‌نویسد. اگر ضرب در محاسبات ۳۲ بیتی انجام شود، حاصل‌ضرب می‌تواند به یک مقدار کوچک دور بزند، حتی زمانی که تک تکِ عوامل در محدوده‌ای معقول باشند، بنابراین تخصیص موفقیت‌آمیز است، بافر خروجی بسیار کوچکتر از حد نیاز درمی‌آید و فرایند رمزگشایی به فراتر از انتهای آن راه می‌یابد. این CWE-190 است، سرریزِ عدد صحیح که یک گام بعد به نوشتنِ خارج‌از‌محدوده در هیپ (CWE-787) ختم می‌شود

مسیر اشتراکیِ تصویر پیش‌تر هر بُعد را به ۶۵۵۳۵ محدود کرده بود؛ رمزگشاهای مستقل همه آن کِلَمپ را به ارث نبرده بودند. یک عبارت به شکل ردیف-بایت-ضرب‌در-ارتفاع مانند ByteCount * FHeight یا یک عبارت برای هر-پیکسل مانند FWidth * Components * BitDepth، در دلفی زمانی که هر دو عملوند اعداد صحیح ۳۲-بیتی هستند یک حاصل‌ضرب ۳۲-بیتی است، فارغ از اینکه متغیری که نتیجه را به آن اختصاص می‌دهید چقدر عرض داشته باشد. عرض و ارتفاعی معادل ۶۰۰۰۰، هر دو برای یک اسکن بزرگ محتمل هستند، اما ضربِ آن‌ها در بایت از محدوده یک ۳۲-بیتیِ علامت‌دار فراتر می‌رود و مقدار طول (length)، کوچک محاسبه می‌شود. همین تله در پیش‌بینی‌کننده stride برای ZLib با نام BitsPerComponent * Colors * Columns نیز وجود داشت

راه‌حل این است که حداقل یکی از عملوندها را Int64 قرار دهیم تا کل عبارت به صورت ۶۴ بیتی ارزیابی شود، سپس با MaxInt مقایسه کرده و پیش از کاهش‌اندازه و فراخوانیِ SetLength، فایل را رد کنیم

// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);

چیزی که این موضوع را به یک مشکل در دلفی تبدیل می‌کند، نه یک مشکل عمومی، همان باریک‌شدنِ بی‌صدایِ محدوده است. تخصیصِ یک عبارتِ بیش‌ازحدگسترده (too-wide) به یک متغیر هدفِ ۳۲-بیتی، یک تبدیلِ مجاز است که کامپایلر به‌طورپیش‌فرض درباره آن اخطار نمی‌دهد، و چک‌کردنِ‌محدوده (range checking) نمی‌تواند دور زدنی را که قبل از استفاده شدن به عنوان ایندکس رخ داده است متوجه شود. حاصل‌ضرب را در ۳۲ بیت باقی بگذارید و زبان بی‌سروصدا یک طول (length) به شما می‌دهد که در مورد مقدار حافظه‌ای که رمزگشایی در شُرُف لمس آن است، دروغ می‌گوید

یک نوع فیلد که اجرای محافظ را غیرممکن می‌کند

یک فایل TIFF زنجیره‌ای از دایرکتوری‌های فایل‌-تصویری است، که هر یک حاویِ آفستِ-بایتیِ موردِ-بعدی است. یک فایل مخرب می‌تواند آن زنجیره را به سمتِ خودش هدف بگیرد، و خواننده‌ای که آن را بدون داشتن شرایط-توقف بپیماید، برای همیشه اجرا می‌شود. این CWE-835 است، یک حلقه بی‌نهایت که توسط ورودیِ تحت‌کنترلِ‌-مهاجم هدایت می‌شود، و راه دفاع، یک شمارنده است که به محضِ گذشتن از حدی که هیچ فایل مشروعی به آن نمی‌رسد، متوقف می‌شود

شمارندهِ صفحه به عنوانِ Word معرفی شده بود، که در دلفی از ۰ تا ۶۵۵۳۵ را در خود جای می‌دهد. این حلقه، محافظِ-خاتمه‌ای را به این شکل به همراه داشت "وقتی که تعداد صفحه از ۶۵۵۳۵ فراتر رفت توقف کن"، که درست به نظر می‌رسد تا زمانی که متوجه می‌شوید عملوند و سقف-استاندارد در داشتنِ حد-بالایی با هم مشترک هستند. یک Word هرگز نمی‌تواند بیشتر از ۶۵۵۳۵ باشد، در نتیجه این مقایسه از نظرِ ساختاری همیشه نادرست است: زمانی که شمارنده به ۶۵۵۳۵ می‌رسد، افزونهِ-بعدی آن را دوباره به ۰ برمی‌گرداند، محافظ هرگز مقداری بالای سقف نمی‌بیند و یک زنجیره IFDِ تکرار‌شونده باعث می‌شود خواننده مدام به چرخیدن ادامه دهد

راه‌حل این بود که نوع فیلد را گسترده‌تر کنیم تا محافظ بتواند مقداری را بیان کند که شمارنده عملاً می‌تواند در خود نگه دارد. با معرفی TPDFTIFF.FPageCount به عنوان Integer، مقایسه FPageCount > 65535 قابل دسترسی می‌شود، حلقه متوقف می‌شود و نوع مشخصه عمومی PageCount نیز تغییر کرد تا بدون شکستنِ هیچ‌کدام از فراخوانی‌کننده‌ها (caller)، مطابقت یابد. هر زمان که یک بررسی حد (bound check) شکلی مثل Value > MaxValueOfType(Value) داشته باشد و عملوند هم پیشاپیش دقیقاً روی همان حداکثر تنظیم شده باشد، این شرط همواره مقداری ثابت و نادرست است: نوع را گسترده‌تر کنید یا برابری با حداکثر را بیازمایید تا بتواند به مرحله اجرا درآید

غیرفعال بودن Range checking روی یک مسیرِ داغ

با روشن بودن بررسی محدوده (range checking)، دلفی یک بررسی مرزی روی هر آرایه و ایندکس رشته (string index) انجام می‌دهد که تفاوتِ بینِ یک ایندکسِ خارج‌از‌محدوده‌ که یک ERangeError قابل-دریافت صادر می‌کند و همان ایندکسی است که از حافظه‌ای که متعلق به ساختارش نیست می‌خواند یا در آن می‌نویسد. مسیرهای داغ (Hot paths) بعضاً با استفاده از یک دستور {$R-} محلی آن را غیرفعال می‌کنند، که این امر تا زمانی قابل دفاع است که ایندکس‌ها دیگر قابل‌اطمینان نباشند

لیست-اکسسور که مفسرهای فونت روی آن تکیه می‌کنند، TPDFlibStringList.Get، دقیقاً چنین مسیری است. این اکسسور در ویندوز بدونِ روشن-بودن بررسی‌-محدوده کامپایل می‌شود و مستقیماً backing store خود را ایندکس می‌کند، بنابراین یک ایندکسِ-خارج‌از‌محدوده خطا محسوب نمی‌شود بلکه یک دسترسی به حافظه خام است. این زمانی که ایندکس همواره معتبر است مشکلی ندارد، اما زمانی که درون یک مفسر رشتهِ‌-کاراکتریِ Type2 یا CFF هستیم که در آن ایندکس می‌تواند از سمتِ فایل بیاید، دیگر کار درستی نیست. یک رشتهِ-کاراکتری که یک عملوند از یک پشتهِ-خالی دریافت می‌کند ایندکسِ منفی-یک را تولید می‌کند؛ یک شناسه‌گرِ گلیف که فقط یک واحد از تعداد-گلیف فاصله داشته باشد، یک جایگاهِ (slot) فراتر-از-انتها را ایندکس می‌کند. با خاموش بودنِ بررسیِ-محدوده هر دو مورد به یک دسترسیِ حقیقیِ-خارج‌از-محدوده تبدیل می‌شوند به جای اینکه یک خطای-قابل‌دریافت رخ دهد، و از آنجایی که جایگاه‌ها شامل مقادیر AnsiString با شمارش‌-ارجاع هستند، یک خواندنِ-سرگردان (stray read) حتی می‌تواند شمارش‌ارجاع (reference count) یک رشته را هم مخدوش کند

ایمن‌سازی مجدداً بررسی محدوده را روی مسیر داغ روشن نکرد. بلکه ابتدا ایندکس‌ها را به طورِ اثبات‌‌پذیری معتبر ساخت: پیش از در‌دست-گرفتن رأس پشته‌عملوند، مفسر عدم-خالی-بودنِ پشته را چک می‌کند، و تمامِ محافظانِ‌-ایندکس در قالب یک کم‌تر‌-از اکید (strict less-than) در مقابلِ تعداد-گلیف‌ها به جای کم‌تر-یا-مساوی که خطای یک-واحدی را می‌پذیرفت، بازنویسی شدند. این دستورالعمل، مسئولیت تعیین مرزها را از روی دوش کامپایلر برداشته و روی دوشِ شما می‌گذارد، و تأییدیه‌هایی که پیش‌تر حذف شده بودند باید به صورتِ دستی در تمامی نقاطِ-ورود، بازگردانده شوند

بازگشتِ-بی‌نهایت (Unbounded recursion) در مفسرِ charstring

یک Type2 charstring می‌تواند یک روتینِ‌-زیرمجموعه (subroutine) را فرا بخواند، و این روتین هم به نوبه خود، یک charstring است که می‌تواند روتینِ دیگری را فراخوانی کند. بنابراین اپراتورهای فراخوانیِ-روتینِ محلی و سراسری، این امکان را به فایل می‌دهند که تعیین کند چقدر عمیق پیش می‌روند. روتینی که خود را فرا‌می‌خواند، مستقیماً یا درونِ یک چرخه، به طور مداوم تکرار می‌شود تا زمانی که پشته-نیتیو خالی شود و پردازش بمیرد. این مسئله با عنوان CWE-674 یا بازگشت کنترل‌نشده شناخته می‌شود

مفسر Type1 پیشتر جلوی این امر را گرفته است. این مفسر دارای یک شمارنده‌-عمق-فراخوانی و یک سقف است، PLType1MaxCallDepth، و از فرورفتنِ بیشتر از آن، سر باز می‌زند که در حقیقت نشان‌دهندهِ سقف‌-عمقی است که خود مستندات Type1 به آن اشاره دارند. مفسر Type2 که بعداً اضافه شد و به لحاظِ ساختاری شبیهِ قبلی است، از چنین محافظی بی‌بهره ماند، بنابراین یک فونتِ‌دست‌ساز به همراه روتینی که خودش را فرامی‌خواند، به راحتی می‌تواند از طریقِ نبودِ چنین محافظی وارد پشته‌سرریز (stack overflow) شود

// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out

راه حل این بود که عمقِ محدود شده‌ای که همتای Type1 پیشتر داشت را به مفسر Type2 نیز اختصاص بدهیم. هر مسیرِ فرورفتن بازگشتی (recursive descent) بر روی ساختاری که در دستان حمله‌کننده است، اعم از روتین‌-زیرمجموعهِ فونت، یک آرایه تودرتو یا یک زنجیرِ کراس‌-رفرنس، نیازمندِ یک سقف است که ورودیِ متخاصم نتواند آن را دور بزند

حافظهِ-خام که درونِ-خروجی نشت می‌کند

این ریزترین‌نقصی بود که محتویات هِیپ (heap contents) را به داخل خروجیِ-دیکریپت‌شده نشت می‌داد، و دلیلِ-آن مربوط به یکی از خصیصه‌های SetLength است که به راحتی فراموش می‌شود. زمانی که یک AnsiString را به کمک SetLength بزرگ‌تر می‌کنید، دلفی بایت‌ها را تخصیص می‌دهد اما آن‌ها را صفر نمی‌کند، بنابراین منطقه جدید نگهدارنده‌ هر آنچه که قبلاً در آن هِیپ قرار داشته است می‌شود. اگر هر بایتِ-جدید نوشته شود این مشکل اصلاً بروز نمی‌کند؛ اما اگر بخشی از بافر در یک مسیر نوشته-نشده رها شود و سپس به عنوان داده برگشت داده شود، این بایت‌های خامِ باقیمانده به همراه داده بیرون می‌آیند. این CWE-457 است، استفاده‌ از حافظه‌ مقداردهی‌نشده، و زمانی که چنین خروجی‌ای از مرزِ-امنیت گذر می‌کند تبدیل به یک نشتِ-اطلاعات می‌شود

مسیر دیکریپت‌کردنِ AES-CBC دقیقاً به همین مشکل برخورد کرد. بافرِ-خروجی توسطِ SetLength سایز‌بندی شده و دیکریپتور، متنِ‌-سایفر را به صورتِ بلاک‌بلاک (۱۶ بایتی) پردازش می‌کرد. زمانی که طولِ متن‌سایفر ضریبی از ۱۶ نبود، طولی که خودِ حمله-کننده آن را تعیین می‌کند، بلاکِ دنباله‌دارِ-جزئی (partial block) هیچ‌گاه نوشته نمی‌شد در نتیجه بایت‌های-نهایی همان محتویاتِ-هیپی را که SetLength برجاگذاشته بود به ارث می‌بردند و نهایتاً این بافر به عنوانِ پلِین‌تکستِ-دیکریپت‌شده برای یک شی‌-داکیومنت، به عقب پاس داده می‌شد. راه درمان نیازمند دو محافظ است، که هیچ‌یک به‌تنهایی کارساز نیست: ابتدا مسیر‌-ورودیِ دیکریپشن باید هر سایفر-تکستی را که ضریبی از سایر-بلاک نباشد، پس بزند و برای محکم‌کاری خروجی توسط FillChar پیش از هر‌گونه-استفاده‌ای پاک (Clear) شود تا هر-مسیری که نتوانست بخشی را بنویسد، به جای مقادیرِ-هییپ، صفرهایِ-خالی پس بدهد

این روند چه چیزی برای شما باقی می‌گذارد

این پنج-نقص باگ‌های متفاوتی هستند، اما قافیه یکسانی دارند. عرض یک-اینتجر که حول‌یک‌ضرب‌ می‌گردد (wraps a product)، یک نوع-فیلد که همواره محافظ-را به یکِ-غلطِ-ثابت قفل می‌کند، یک رنج‌چک که وقتی که ایندکس‌ها از امنیت در می‌آیند- غیرفعال می‌شود، یک ریکرژن بدون هیچ-کفِ-تعریف‌شده و یک بافری که زبان آن‌را به صفر باز‌نمی‌گرداند. در هر‌کدامِ از آن‌ها، دلفی دقیقاً آنچه را که-برایش‌تعریف-شده انجام می‌دهد، چرا که این زبان به شما ریاضیاتی می‌دهد که دور می‌زند، باریک‌شدنی که-بی‌صداست، رنج‌چک‌هایی که می‌توان غیرفعالش کرد، ریکرژن بدون حد-مجاز و تخصیصی که اینیشیالایز نمی‌کند. این یک-قرارداد-است و یک پارسر پاسکالی تنها با کنترل‌کردن این ۴ مورد در-هر‌-مرزی که فایل-ورودی اختیار-آن-را دارد، به-قرارداد-متعهد می‌ماند: عرض-اینتجر، رنج‌چکینگ، عمق-ریکرژن و اینیشیالایز-کردن بافر

این نقایص در نسخه‌های فعلی PDFlibPas برای دلفی و سی‌پلاس‌پلاس-بیلدر برطرف شده‌اند. اگر کارِ شما به این موضوع می‌رسد که چطور یک-فایل ادعای-رمز‌گذاری می‌کند، می‌توانید نکات-تکمیلی در رابطه با ممیزی‌-کردنِ انکریپشن و پرمیشن‌ها و پیش‌-از‌-پروازِ PDF/A و PDF/UA را بررسی کنید که-جنبهِ-آنالیزی پارسر-را مورد-بررسی قرار می‌دهند، و همه این‌ها به عنوان-یک-بخش-از کتابخانه PDF دلفی PDFlibPas در کنار بارگذاری، رندر و APIهای امضا که در جاهای-دیگر این-وبلاگ-توضیح-داده‌شده‌اند، ارائه-می‌شود