Technical Article

مقاوم‌سازی یک تجزیه‌کننده PDF در Pascal در برابر فایل‌های مخرب

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

کتابخانه PDFlibPas یک مرحله مقاوم‌سازی را پشت سر گذاشت که با کل مسیر رمزگشایی در برنامه‌های فونت (TrueType، Type1، CFF و جدول‌های CMap)، رمزگشاهای تصویر (PNG، GIF، TIFF، JBIG2 و CCITT گروه ۳ و ۴) و فیلترهای جریان (LZW، ASCII85 و پیش‌بینی‌کننده‌های Flate) به عنوان مسیر خصمانه برخورد کرد. آنچه در ادامه می‌آید پنج کلاس نقص است که این کتابخانه آن‌ها را بست، که هر کدام ریشه در رفتار خاص Delphi دارند که آن‌ها را ممکن ساخته بود. این نواقص در نسخه‌های فعلی برطرف شده‌اند و همان اشکال در هر کد Pascal که ورودی‌های غیرقابل اعتماد را تجزیه می‌کند تکرار می‌شوند

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

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

مسیر تصویر مشترک قبلاً هر بعد را به ۶۵۵۳۵ محدود کرده بود؛ رمزگشاهای مستقل همگی این محدودیت را به ارث نبرده بودند. یک عبارت ضرب بایت‌های ردیف در ارتفاع مانند ByteCount * FHeight، یا یک عبارت برای هر پیکسل مانند FWidth * Components * BitDepth، در Delphi یک حاصل‌ضرب ۳۲ بیتی است زمانی که هر دو عملوند اعداد صحیح ۳۲ بیتی هستند، صرف نظر از اینکه متغیری که نتیجه را به آن اختصاص می‌دهید چقدر عریض باشد. عرض و ارتفاع ۶۰۰۰۰ هرکدام برای یک اسکن بزرگ محتمل هستند، اما حاصل‌ضرب آن‌ها بر حسب بایت از محدوده ۳۲ بیتی علامت‌دار فراتر می‌رود و طول آن کوچک نشان داده می‌شود. همین تله در گام پیش‌بینی‌کننده 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);

آنچه این را به یک مشکل در Delphi به جای یک مشکل عمومی تبدیل می‌کند، باریک‌سازی بی‌صدا است. اختصاص یک عبارت بیش از حد عریض به یک مقصد ۳۲ بیتی یک تبدیل قانونی است که کامپایلر به طور پیش‌فرض درباره آن هشداری نمی‌دهد، و بررسی محدوده (Range checking) سرریزی را که قبل از استفاده از مقدار به عنوان نمایه رخ می‌دهد، متوجه نمی‌شود. حاصل‌ضرب را در ۳۲ بیت رها کنید تا زبان به آرامی طول نادرستی درباره میزان حافظه‌ای که قرار است رندر لمس کند به شما تحویل دهد

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

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

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

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

غیرفعال بودن بررسی محدوده در یک مسیر داغ

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

دسترسی‌دهنده لیست که مفسرهای فونت به آن تکیه دارند، یعنی TPDFlibStringList.Get، دقیقاً چنین مسیری است. در ویندوز، این بخش با غیرفعال بودن بررسی محدوده کامپایل می‌شود و مستقیماً مخزن پشتیبان خود را نمایه می‌کند، بنابراین یک نمایه خارج از محدوده یک خطا نیست بلکه یک دسترسی مستقیم به حافظه است. این موضوع زمانی که نمایه همیشه معتبر باشد خوب است، و در داخل یک مفسر رشته کاراکتر CFF یا Type2، جایی که نمایه می‌تواند از فایل بیاید، دیگر معتبر نیست. رشته کاراکتری که یک عملوند را از یک پشته خالی بیرون می‌کشد، نمایه‌ای با مقدار منفی یک تولید می‌کند؛ یک شناسه گلیف که نسبت به تعداد گلیف یک واحد خطا دارد، یک خانه بعد از انتها را نمایه می‌کند. با غیرفعال بودن بررسی محدوده، هر دو به جای یک استثنای قابل دریافت، به یک دسترسی واقعی خارج از محدوده تبدیل می‌شوند و از آنجا که خانه‌ها حاوی مقادیر AnsiString با شمارش ارجاع هستند، یک خواندن سرگردان نیز می‌تواند شمارش ارجاع یک رشته را خراب کند

مقاوم‌سازی، بررسی محدوده را برای مسیر داغ دوباره فعال نکرد. بلکه ابتدا نمایه‌ها را به طور اثبات‌پذیری معتبر ساخت: مفسر قبل از گرفتن بالای پشته عملوندها، خالی نبودن پشته را بررسی می‌کند و هر محافظ نمایه به صورت یک علامت کوچک‌ترِ اکید نسبت به تعداد نوشته شد، نه یک علامت کوچک‌تر یا مساوی که خطای off-by-one را بپذیرد. این دستور مسئولیت مرزها را از کامپایلر به شما منتقل می‌کند و اعتبارسنجی حذف‌شده باید به صورت دستی در هر نقطه ورود قرار داده شود

بازگشت نامحدود در مفسر رشته کاراکتر

یک رشته کاراکتر Type2 می‌تواند یک زیرروال (Subroutine) را فراخوانی کند، و یک زیرروال خود یک رشته کاراکتر است که می‌تواند دیگری را فراخوانی کند، بنابراین عملگرهای فراخوانی محلی و سراسری زیرروال به فایل اجازه می‌دهند تا تصمیم بگیرد چقدر عمیق برود. زیرروالی که خود را به طور مستقیم یا از طریق یک چرخه فراخوانی می‌کند، بدون پایان تکرار می‌شود تا زمانی که پشته بومی تمام شده و فرآیند بمیرد. این همان نقص CWE-674 یعنی بازگشت کنترل‌نشده (Uncontrolled recursion) است

مفسر Type1 قبلاً در برابر این مشکل محافظت شده بود. این مفسر شامل یک شمارنده عمق فراخوانی و یک سقف به نام PLType1MaxCallDepth بود و از پایین رفتن فراتر از آن امتناع می‌کرد، که نشان‌دهنده محدودیت عمقی است که خود مشخصات Type1 نام می‌برد. مفسر Type2 که بعداً اضافه شد و از نظر ساختاری مشابه بود، همان محافظ را نداشت و یک فونت دست‌ساز با زیرروالی که شماره خود را فراخوانی می‌کند، مستقیماً از روی بررسی مفقود عبور کرده و وارد سرریز پشته می‌شد

// 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

راه حل این بود که به مسیر Type2 همان عمق محدودی را بدهیم که هم‌خانواده Type1 آن قبلاً داشت. هرگونه نزول بازگشتی روی ساختار تحت کنترل مهاجم، چه زیرروال‌های فونت، چه یک آرایه تو در تو، یا یک زنجیره ارجاع متقابل، به یک سقف عمق نیاز دارد که ورودی نتواند آن را افزایش دهد

حافظه مقداردهی اولیه نشده که به خروجی نشت می‌کند

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

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

آنچه این مرحله برای شما بر جای می‌گذارد

این پنج نقص باگ‌های متفاوتی هستند، اما با هم همخوانی دارند. عرض عدد صحیح که یک حاصل‌ضرب را سرریز می‌کند، نوع فیلدی که یک محافظ را به یک مقدار نادرست ثابت متصل می‌سازد، بررسی محدوده‌ای که در جایی که نمایه‌ها دیگر ایمن نبودند غیرفعال شده، بازگشتی بدون کف، و بافری که زبان از صفر کردن آن امتناع ورزید. در هر یک از این‌ها، Delphi دقیقاً همان چیزی را انجام داد که تعریف کرده است، زیرا زبان به شما محاسباتی می‌دهد که سرریز می‌شود، باریک‌سازی که بی‌صدا است، بررسی‌های محدوده‌ای که می‌توانید خاموش کنید، بازگشتی بدون محدودیت داخلی و تخصیصی که مقداردهی اولیه نمی‌کند. این قرارداد است و یک تجزیه‌کننده Pascal با داشتن چهار چیز به صورت دستی در هر مرزی که فایل کنترل می‌کند به آن پاسخ می‌دهد: عرض عدد صحیح، بررسی محدوده، عمق بازگشت و مقداردهی اولیه بافر

این نواقص در نسخه‌های فعلی PDFlibPas، یعنی موتور مخصوص Delphi و C++Builder بسته شده‌اند. اگر کار شما به نحوه ادعای محافظت از فایل نیز مربوط می‌شود، یادداشت‌های همراه در مورد ممیزی رمزگذاری و مجوزها و در مورد پیش‌پرواز PDF/A و PDF/UA بخش تجزیه و تحلیل همان تجزیه‌کننده را پوشش می‌دهند، و همه این‌ها در داخل کتابخانه PDF Delphi PDFlibPas در کنار APIهای بارگذاری، رندر و امضا که در بخش‌های دیگر این وبلاگ پوشش داده شده‌اند، عرضه می‌شود