یک pipeline ورودی اسناد، فایل هایی را می پذیرد که غریبه ها نوشته اند. فاکتور، اسکن، attachment ارسال شده از یک web form، همه ادعا می کنند PDF هستند و صدها عدد را با خود می آورند که parser شما باید بر اساس آن ها عمل کند. طول stream، ابعاد تصویر، byte offset و object reference، تک تک این اعداد را کسی انتخاب کرده که فایل را ساخته و بالاخره یک upload ناقص یا یک سند عمداً malformed یکی از همان عددها را در جایی قرار می دهد که خرابکاری کند. تفاوت بین parserی که از آن فایل جان سالم به در می برد و parserی که crash می کند یا با حافظه خراب به کار ادامه می دهد، یک مجموعه کوچک از عادت ها است که به هیچ PDF library خاصی وابسته نیست
این عادت ها یک فرض مشترک دارند: مقداری که از فایل خوانده می شود یک ادعا است، نه یک اندازه گیری. این مقدار فقط وقتی قابل استفاده می شود که با چیزی مقایسه شود که parser خودش اندازه گرفته است، مثل اندازه واقعی فایل، تعداد واقعی byte هایی که decoder تولید کرده، یا عمق واقعی یک بازگشت. ادامه مقاله همین فرض را روی جاهایی اعمال می کند که parser های سند واقعاً در آن ها می شکنند
طول اعلام شده یک ادعا است، نه یک اندازه گیری
ساده ترین ناسازگاری طول stream است. یک PDF stream object تعداد byte هایش را در کلید /Length اعلام می کند و داده واقعی بین کلیدواژه های stream و endstream قرار می گیرد. هیچ چیز این دو را مجبور به تطابق نمی کند. فایل truncate شده byte واقعی کم تری از مقدار اعلام شده دارد، و فایل ساخته شده توسط generator خراب ممکن است طولی اعلام کند که از انتهای فایل رد شود یا وارد object همسایه شود. اگر بر اساس مقدار اعلام شده allocate کنید و تا endstream کپی ادامه دهید از buffer بیرون می زنید؛ اگر دقیقاً همان مقدار اعلام شده را بدون بررسی availability بخوانید از انتهای فایل عبور می کنید. بگذارید مقدار اعلام شده فقط بعد از clamp شدن نسبت به فاصله اندازه گیری شده تا انتهای داده، تخصیص را هدایت کند، و اختلاف را نقطه تصمیم بدانید، یعنی یا با جست وجوی endstream repair کنید یا stream را رد کنید، نه اینکه آن را بی صدا باور کنید
پارامترهای تصویری که raster بزرگ تری از allocation شما توصیف می کنند
image stream ها ریسک را بالا می برند چون دو مجموعه مستقل از اعداد یک پیکسل واحد را توصیف می کنند. image dictionary شامل /Width و /Height است و buffer های raster معمولاً بر اساس همین ها اندازه می گیرند. decode filter هندسه خودش را دارد: CCITTFaxDecode از /Columns، /Rows و /K در DecodeParms استفاده می کند، جایی که /K scheme مربوط به Group 3 یا Group 4 را انتخاب می کند و decoder برای هر scanline مقدار (Columns + 7) div 8 byte تولید می کند. فایلی که /Width 100 اعلام کند اما به filter مقدار /Columns 1728 بدهد، یعنی مقدار پیش فرض، decoder را مجبور می کند بیش از شانزده برابر byte بیش تر در هر ردیف نسبت به انتظار buffer تولید کند و overflow هم ردیف به ردیف روی هر چیزی که بعد از allocation نشسته فرود می آید. وقتی /Rows غایب باشد decoder تا وقتی داده اجازه دهد پیش می رود، پس row count را هم باید محدود کنید. DCTDecode هم همین seam را دارد: خود داده JPEG عرض و ارتفاعش را در SOF marker حمل می کند و هیچ چیز آن را مجبور نمی کند با dictionary یکی باشد
قاعده دفاعی کاملاً مکانیکی است: اندازه raster مورد انتظار را از روی decode parameter های اعتبارسنجی شده حساب کنید، یعنی /Columns و /Rows خود filter برای CCITT و ابعاد SOF برای DCT، آن را با limit های خودتان بسنجید، بر مبنای آن allocate کنید و هنگام decode هم verify کنید که خروجی هرگز از allocation فراتر نرود. وقتی dictionary و filter درباره هندسه اختلاف دارند، آن ها را reconcile کنید یا تصویر را رد کنید. کاری که parser هرگز نباید بکند این است که buffer را از روی یک مجموعه عدد اندازه بگیرد و decoder را بر اساس مجموعه دیگر اجرا کند
دام های arithmetic و allocation در Delphi
سه رفتار Delphi حتی parserی را که قصد اعتبارسنجی دارد تضعیف می کنند. اولی ضرب 32 بیتی است: Delphi حاصل ضرب دو operand از نوع Integer را بدون توجه به عرض مقصد با 32 بیت ارزیابی می کند، بنابراین Width * Height * BytesPerPixel می تواند wrap شود حتی وقتی هر عامل به تنهایی از sanity check گذشته است. یک اسکن 30000 در 30000 با سه byte در هر pixel برابر 2.7 میلیارد byte است که در arithmetic علامت دار 32 بیتی منفی wrap می شود؛ ترکیب های کمی متفاوت هم به طول مثبت کوچکی wrap می شوند که allocate می شود اما buffer را کوچک می گیرد. کل عبارت را با cast کردن operand اول wide کنید، یعنی Size := Int64(Width) * Height * BytesPerPixel، و بعد از آن قبل از رسیدن به SetLength با یک cap صریح مقایسه کنید
دومی range checking است. پیکربندی release پیش فرض Delphi آن را خاموش تحویل می دهد، پس index خارج از بازه که از داده فایل محاسبه شده exception نمی دهد و در عوض حافظه کنار آرایه را می خواند یا می نویسد. آن را با {$R+} و برای overflow حسابی با {$Q+} در ابتدای هر unitی که با مقادیر مشتق شده از فایل index می زند دوباره روشن کنید. هزینه اش در برابر I/Oای که parser انجام می دهد قابل اندازه گیری نیست و در عوض خرابی خاموش را به ERangeError قابل گرفتن تبدیل می کند
سومی TMemoryStream.SetSize با یک Int64 تأمین شده از فایل است. در RTL فعلی این متد هر مقداری را که فایل خواسته allocate می کند، پس یک stream که ادعای چهار گیگابایت دارد در میانه intake به out-of-memory ختم می شود. در RTL های قدیمی تر که SetSize یک Longint می گیرد، مقدار پیش از تخصیص بی صدا narrow می شود: مثلاً $100000010 تبدیل به 16 می شود، allocation موفق می شود و نوشتن داده واقعی خیلی دورتر از آن ادامه پیدا می کند. هر اندازه ای را پیش از آنکه هر فراخوانی allocation آن را ببیند با اندازه اندازه گیری شده منبع و یک hard cap اعتبارسنجی کنید
offset هایی که بیرون فایل اشاره می کنند
cross-reference table شماره object ها را به byte offset مطلق نگاشت می کند و parser هم هرجا که table بگوید seek می کند. در فایل خراب یا خصمانه این offset ها بیرون انتهای فایل یا وسط ساختارهای نامربوط فرود می آیند. TStream این خرابی را بی صدا می کند: تنظیم Position به مقداری بزرگ تر از Size خطا نیست و یک Read ساده از انتهای فایل فقط byte کم تری از مقدار درخواستی برمی گرداند، بنابراین کدی که count را چک نکند به parse کردن byte های کهنه object قبلی ادامه می دهد. دفاع درست یک chokepoint است، یعنی یک helper که هر seek و read ناشی از فایل از آن عبور کند و offset و count را پیش از حرکت stream نسبت به اندازه اندازه گیری شده فایل اعتبارسنجی کند
uses
System.Generics.Collections, System.SysUtils;
// A safe recursive function to walk the PDF tree
procedure ParsePDFDictionary(DictObj: TPDFDictionary; Visited: TList<Integer>);
var
ObjID: Integer;
begin
ObjID := DictObj.ObjectID;
if Visited.Contains(ObjID) then
begin
Writeln('Warning: Circular reference detected. Aborting branch.');
Exit;
end;
Visited.Add(ObjID);
try
// Process child objects safely...
finally
// Allow siblings to traverse, but prevent vertical recursion loops
Visited.Remove(ObjID);
end;
end;
2. دفاع در برابر Zip bomb
وقتی stream را با فیلتر FlateDecode از حالت فشرده خارج می کنید، باید اندازه نهایی decompression را با سخت گیری محدود کنید. هرگز نباید فقط بر اساس کلید dictionary به نام /Length به شکل کورکورانه حافظه allocate کنید
const
MAX_DECOMPRESSED_SIZE = 1024 * 1024 * 50; // 50 MB safety limit
procedure DecompressPDFStream(CompressedStream, OutputTarget: TStream);
var
ZLibStream: TZDecompressionStream;
Buffer: array[0..8191] of Byte;
BytesRead, TotalRead: Integer;
begin
ZLibStream := TZDecompressionStream.Create(CompressedStream);
try
TotalRead := 0;
repeat
BytesRead := ZLibStream.Read(Buffer[0], SizeOf(Buffer));
if BytesRead > 0 then
begin
TotalRead := TotalRead + BytesRead;
if TotalRead > MAX_DECOMPRESSED_SIZE then
raise Exception.Create('Security Exception: Decompression bomb detected!');
OutputTarget.WriteBuffer(Buffer[0], BytesRead);
end;
until BytesRead = 0;
finally
ZLibStream.Free;
end;
end;
offset های cross-reference، گستره stream و read های embedded-file را از همین helper عبور دهید تا offset خراب به جای access violation سه call بعدتر، به یک رد شدن تمیز تبدیل شود که خود اعداد مسئله را نام می برد
چرخه و عمق در object graph
PDF یک graph است، نه یک tree. هر value می تواند یک indirect reference باشد، یک reference می تواند به reference دیگری resolve شود، مثل /Length 12 0 R که در آن object 12 مقدار 13 0 R را نگه می دارد، و هیچ چیز زنجیره را از بسته شدن روی خودش منع نمی کند. resolverی که naive reference ها را دنبال کند آن قدر recurse می کند تا native stack خالی شود، و تمام شدن stack چیزی نیست که بتوانید catch کنید؛ کل process را تمام می کند. array و dictionary های خیلی عمیق هم حتی بدون چرخه به همان سرنوشت می رسند
دو guard را با هم استفاده کنید: یک depth counter صریح که حالت صادق اما عمیق را در حدی محدود کند که هیچ فایل واقعی به آن نزدیک نشود، و یک visited set که چرخه واقعی را در بازدید دوم بگیرد و آن را به جای برخورد با limit به یک خطای دقیق و قابل گزارش تبدیل کند
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // far deeper than any legitimate reference chain
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // meaningful when Kind = pvReference
// ... payload fields for the remaining kinds
end;
// LoadObject is your own routine: it looks up the xref offset for
// ObjNumber, reads the object with ReadBounded, and parses it.
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('reference chain exceeds depth limit');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'circular reference through object %d', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // e.g. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // siblings may legally share this object
end;
end;
decompression یک تقویت کننده است
چند kilobyte ورودی FlateDecode می تواند به gigabyte ها inflate شود؛ compression عمومی به plaintext تکراری پاداش می دهد و مهاجم می تواند آن را تا آخرین حد تکراری کند. برای اندازه inflate شده هر stream متناسب با چیزی که مصرف کننده اش واقعاً می تواند نیاز داشته باشد cap بگذارید و یک بودجه دوم هم در سطح کل سند نگه دارید: پانصد stream که هر کدام اندکی پایین تر از cap مربوط به هر stream هستند، حافظه را همان قدر تمام می کنند که یک stream غول آسا. این check باید داخل loop مربوط به inflate باشد، جایی که output byte ها را همان لحظه تولید شدن می شمارد و در صورت عبور abort می کند، نه بعد از loop که حافظه از قبل خرج شده است. بودجه در سطح سند اگر به صورت ضریبی از اندازه فایل فشرده بیان شود خوب جواب می دهد، چون اسناد معتبر خیلی پایین تر از نسبت هایی خوشه می زنند که stream ساختگی به آن ها می رسد
دفاع لایه ای فراتر از unit های خودتان
همین کلاس از ایرادها داخل library ها هم زندگی می کند. دو case study در همین وبلاگ نمونه های واقعی را باز می کنند: wrap شدن integer، recursion نامحدود و buffer های مقداردهی نشده که در یک موتور native Pascal بسته شدند در مقاله سخت سازی یک parser پاسکال PDF در برابر فایل های مخرب، و خطرات calling convention، عرض integer و ownership هنگام bind کردن یک موتور C در مقاله سخت سازی binding مربوط به PDFium Component. برای intake واقعاً غیرقابل اعتماد، مثل public upload form یا mailbox بدون احراز هویت، parse و decode را در یک process جداگانه با دسترسی پایین هم اجرا کنید تا فایلی که از تمام guard های in-process عبور می کند به جای خواباندن کل service فقط یک job شکست خورده تولید کند
یک checklist برای preflight
پیش از انتشار build بعدی، parser را با این فهرست بررسی کنید: هر stream buffer باید از طول clamp شده اندازه بگیرد نه از مقدار اعلام شده؛ هر raster باید از decode parameter های اعتبارسنجی شده اندازه بگیرد و در برابر خروجی decoder چک شود؛ هر حاصل ضرب مربوط به dimension باید در Int64 ارزیابی و با cap صریح مقایسه شود؛ {$R+} باید در هر unitی که با مقادیر مشتق شده از فایل index می زند فعال باشد؛ هر seek باید نسبت به اندازه اندازه گیری شده فایل bounds-check شود؛ هر resolve مربوط به reference باید depth limit و cycle check داشته باشد؛ و هر loop مربوط به inflate باید خروجی را در برابر بودجه per-stream و per-document بشمارد. هیچ کدام از این check ها روی سند سالم زمان قابل اندازه گیری نمی گیرند و هر کدام corruption حافظه را به رد شدن تمیز و loggable تبدیل می کنند
یادداشت: HotPDF Component، PDFlibPas Delphi PDF Library و PDFium Component درون خود این bounds check ها، depth limit ها و expansion cap ها را اعمال می کنند، بنابراین pipeline ورودی که روی آن ها ساخته شده از همان ابتدا روی یک baseline سخت شده شروع می کند