مقاله فنی

اعتبارسنجی ZIP EOCD برای فایل‌های XLSX غیرقابل‌اعتماد در Delphi

یک فایل xlsx یک آرشیو ZIP است، و ZIP هیچ فهرست محتوای معتبر واحدی ندارد. HotXLS Excel Library برای Delphi و C++Builder آن ابهام را به‌عنوان یک سطح حمله در نظر می‌گیرد: parser end of central directory آن یک رکورد نامزد را فقط پس از توافق چهار بررسی متقابل مستقل می‌پذیرد، پس یک directory جعلی پنهان در یک comment ZIP هرگز نمی‌برد

سناریویی که این را ملموس می‌کند عادی است. یک سرور آپلود صفحه‌گسترده از مشتریان می‌پذیرد. فایل از یک اسکن آنتی‌ویروس عبور می‌کند، در یک دایرکتوری spool نوشته می‌شود، و سرویس دلفی شما آن را باز می‌کند تا سه ستون را بیرون بکشد. همه‌چیز خوب به‌نظر می‌رسد، به‌جز اینکه اسکنر و parser شما درباره اینکه آرشیو چه چیزی داشت توافق نداشتند. اسکنر یک مجموعه از عضوها را شمرد؛ loader شما مجموعه‌ای متفاوت را از همان بایت‌ها شمرد. هیچ‌کدام از آن‌ها به معنای معمولی باگ‌دار نیستند. آن‌ها صرفاً یک ابهام در فرمت ZIP را در دو جهت متفاوت حل کردند، و یک مهاجم بایت‌ها را طوری انتخاب کرد که این‌طور شود

حقیقت درباره یک آرشیو ZIP واقعاً کجا زندگی می‌کند؟

در همان انتها زندگی می‌کند، در یک ساختار ۲۲بایتی به نام رکورد end of central directory. یک فایل ZIP از جلو به عقب خوانده نمی‌شود: هر عضو یک هدر فایل محلی بلافاصله پیش از داده فشرده‌اش حمل می‌کند، اما index معتبر central directory است، یک اجرا از رکوردها نزدیک انتها که هر entry را نام می‌برد و offset هدر محلی آن را می‌دهد. برای پیدا کردن central directory ابتدا باید EOCD را پیدا کنید، چون EOCD چیزی است که می‌گوید directory کجا شروع می‌شود و چند رکورد نگه می‌دارد. HotXLS آن را به‌عنوان TEndOfCentralDirectoryRecord مدل می‌کند، که فیلدهای آن یک‌به‌یک روی چیدمان روی-دیسک نگاشت می‌شوند: FDiskNumber در offset 4، FStartDisk در 6، FThisDiskEntries در 8، FTotalEntries در 10، FSizeOfCD در 12، FOffsetOfStartCD در 16، و FCommentLen در 20. آن مجموع FMinSize است، محاسبه‌شده در constructor به‌صورت 4*3 + 5*2. پس از آن comment آرشیو می‌آید، تا ۶۵۵۳۵ بایت محتوای دلبخواه، که FMaxSize را ۶۵۵۵۷ می‌کند و یعنی رکورد در موقعیت ثابتی نیست. باید دنبالش بگردید

چرا اسکن به‌عقب برای سیگنیچر EOCD کافی نیست؟

چون چهار بایتی که برایش اسکن می‌کنید، PK\005\006، می‌توانند به‌طور قانونی داخل comment آرشیو، داخل داده فشرده، یا داخل یک EOCD دوم که یک مهاجم عمداً append کرده ظاهر شوند. یک parser که در اولین سیگنیچری که هنگام حرکت به‌عقب برخورد می‌کند متوقف شود بی‌اهمیت قابل‌هدایت است: یک EOCD طعمه نزدیک انتها بگذارید و parser ساده‌لوحانه آن را دنبال می‌کند، درحالی‌که یک parser که به ترتیبی متفاوت اسکن می‌کند، یا آخرین سیگنیچر در فایل را معتبر در نظر می‌گیرد، واقعی را دنبال می‌کند. این خانواده حملات ابهام ZIP است، و پاداشش دقیقاً همان تقسیمی است که بالا توصیف شد، جایی که موتور اسکن‌کننده و برنامه مصرف‌کننده مجموعه‌های entry متفاوتی را از یک فایل می‌بینند

TEndOfCentralDirectoryRecord.Parse واقعاً به‌عقب اسکن می‌کند. startscan را روی آخرین بایت تنظیم می‌کند، endscan را به lsize - FMaxSize یا صفر clamp می‌کند، و پنجره را در بافرهای ۲۵۶بایتی که سه بایت همپوشانی دارند طی می‌کند پس یک سیگنیچر که روی مرز بافر پخش شده هرگز از دست نمی‌رود. تفاوت چیزی است که هنگام یک برخورد رخ می‌دهد. پیدا کردن سیگنیچر فقط یک offset Candidate تولید می‌کند. HotXLS سپس ۲۲ بایت در آن offset را می‌خواند، آن‌ها را با ReadEOCD parse می‌کند، و مطالبه می‌کند فیلدهای نتیجه‌شده پیش از تخصیص اصلاً FOffsetEOCD با فایلی که ادعا می‌کنند توصیف می‌کنند از داخل سازگار باشند

Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
  SetLength(RecordBuf, FMinSize);
  inputstream.Position := Candidate;
  if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
  begin
    ReadEOCD(RecordBuf[0], 0);
    if (Candidate + FMinSize + FCommentLen = lsize) and
       (FDiskNumber = 0) and (FStartDisk = 0) and
       (FThisDiskEntries = FTotalEntries) and
       (Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
    begin
      FOffsetEOCD := Candidate;
      Result := FOffsetEOCD;
      Exit;
    end;
  end;
end;

این predicate را به‌عنوان چهار ادعای مجزا بخوانید که یک جعل باید همزمان برآورده کند. Candidate + FMinSize + FCommentLen = lsize مطالبه می‌کند طول comment اعلام‌شده دقیقاً به انتهای فایل برسد، که همان چیزی است که ترفند طعمه-در-comment را می‌کشد: یک EOCD جعلی که داخل یک comment واقعی مدفون شده نمی‌تواند همچنین هر بایت پس از خودش را حساب کند. FDiskNumber = 0 و FStartDisk = 0 فیلدهای spanning چند-دیسکی را که هیچ xlsxای هرگز مشروعاً استفاده نکرده و فقط در آرشیوهای دست‌ساز برای گیج‌کردن وجود دارند رد می‌کنند. FThisDiskEntries = FTotalEntries ترفند شمارش-شکافته را رد می‌کند که در آن یک parser حلقه خود را از یک فیلد و parser دیگر از فیلد دیگر اندازه می‌دهد. و Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate نیاز دارد central directory دقیقاً جایی پایان یابد که EOCD شروع می‌شود، پس directory نمی‌تواند به یک blob بی‌ربط در جای دیگری از فایل اشاره کند. cast به Int64 در آخری اهمیت دارد: هر دو عملوند ۳۲بیتی‌اند، و بدون گسترش، یک جفت دست‌ساز می‌تواند بپیچد و آزمون را از نظر حسابی برآورده کند درحالی‌که به هیچ‌جای معقولی اشاره نمی‌کند

هدرهای محلی باید با central directory موافق باشند

بررسی‌های EOCD تعیین می‌کنند کدام directory معتبر است؛ هنوز تضمین نمی‌کنند directory درباره عضوهای مجزا حقیقت می‌گوید. هر entry دوبار در یک فایل ZIP توصیف می‌شود، یک‌بار مرکزی و یک‌بار در هدر محلی‌اش، و هیچ‌چیز در فرمت دو توصیف را مجبور به مطابقت نمی‌کند، پس یک reader که به central directory اعتماد می‌کند و یک reader که به هدرهای محلی اعتماد می‌کند می‌توانند محتوای متفاوتی را از یک آرشیو استخراج کنند. TZipEntry.ParseLocalHeader آن شکاف را با parse کردن هدر محلی در FCdFile.LocalFileHeaderOffset و مقایسه دو کپی فیلد‌به‌فیلد می‌بندد، با بازگرداندن یک کد منفی مجزا برای هر نوع اختلاف: نام entry canonical‌شده، متد فشرده‌سازی، بیت‌های flag هدف عمومی، و، وقتی flag data descriptor خالی است، CRC32 و هر دو اندازه. با آن flag تنظیم‌شده کپی‌های محلی می‌توانند صفر باشند، چون مقادیر واقعی در یک descriptor پیرو زندگی می‌کنند، اما هر مقدار محلی غیرصفر همچنان باید مطابقت داشته باشد. یک بررسی نهایی entryهایی را رد می‌کند که داده آن‌ها از انتهای فایل فراتر می‌رود، با مقایسه Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) در برابر inputstream.Size. هر شکست از TCentralDirectory.Parse به‌عنوان یک نتیجه غیر-۱ منتشر می‌شود و TZipArchive.OpenArchive آن را به Can't open zip archive تبدیل می‌کند، به‌جای دادن یک شیء آرشیو نیمه‌قابل‌اعتماد به شما. وقتی فقط نیاز دارید بدانید یک فایل کدام sheetها را دارد، اجرای آن اعتبارسنجی پیش از یک parse کامل ارزان است، و مسیر بازرسی سبک sheet دقیقاً همان را بدون تحقق‌بخشیدن به داده سلول به شما می‌دهد

وقتی خود بایت‌ها دروغ می‌گویند چه اتفاقی می‌افتد؟

توافق ساختاری همچنان چیزی درباره payload نمی‌گوید، پس HotXLS هر stream entry را در TZipVerifiedStream می‌پیچد، که اندازه اعلام‌شده و CRC32 را همان‌طور که فراخواننده می‌خواند اعمال می‌کند. این عمداً یک بررسی post-hoc نیست: یک بمب decompression که اندازه فشرده‌نشده اعلام‌شده‌اش ۴ کیلوبایت است اما به گیگابایت‌ها باد می‌کند در نشانه ۴ کیلوبایت متوقف می‌شود، نه پس از آسیب. wrapper هر خواندن را به بایت‌های اعلام‌شده باقی‌مانده clamp می‌کند، اگر منبع زود خشک شود ZIP entry ended before its declared size بلند می‌کند، هنگام تکمیل برای یک بایت اضافی کاوش می‌کند و اگر چیزی باقی مانده باشد ZIP entry exceeds its declared size بلند می‌کند، و در نهایت CRC32 در حال اجرا را در VerifyComplete مقایسه می‌کند، و ZIP entry uncompressed size mismatch یا ZIP entry CRC32 mismatch بلند می‌کند

if Count > 0 then
begin
  Result := FSource.Read(Buffer, Count);
  if Result <= 0 then
    raise Exception.Create('ZIP entry ended before its declared size');
  FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
  Inc(FPosition, Result);
end
else
  Result := 0;

if FPosition = FExpectedSize then
begin
  if FSource.Read(Probe, 1) <> 0 then
    raise Exception.Create('ZIP entry exceeds its declared size');
  VerifyComplete;
end;

یک پیامد ارزش برنامه‌ریزی دارد. stream طبق طراحی فقط-رو‌به‌جلو است؛ یک Seek به هرجایی به‌جز موقعیت فعلی ZIP entry stream is forward-only بلند می‌کند، با یک امتیاز واحد برای soEnd با offset صفر پس پرسش‌های اندازه همچنان کار می‌کنند. آن معامله درستی برای ورودی غیرقابل‌اعتماد است، چون یک stream که می‌توانید rewind کنید stream‌ای است که حسابداری CRC آن را می‌توانید شکست دهید، اما یعنی کد مصرف‌کننده‌ای که یک stream قابل-seek انتظار دارد به بافر خودش نیاز دارد. همان انضباط فقط-رو‌به‌جلو زیربنای reader مستقیم streaming است، که API‌ای است که باید هنگامی که workbook آپلود‌شده آن‌قدر بزرگ است که اصلاً نمی‌خواهید resident در حافظه باشد به آن دست بزنید

محدودیت‌های منبع پیش از تخصیص، نه پس از آن

سه ثابت در lxZipArchive محدود می‌کنند یک آرشیو واحد چه چیزی می‌تواند از process بخواهد انجام دهد، و TZipEntries.Add آن‌ها را درحالی‌که central directory هنوز خوانده می‌شود، پیش از دست‌زدن به یک بایت از داده entry، اعمال می‌کند. ZipMaxEntryUncompressedSize یک عضو را به ۱ GiB سقف می‌کند، ZipMaxTotalUncompressedSize آرشیو را به ۴ GiB سقف می‌کند، و ZipMaxCompressionRatio برابر ۱۰۰۰۰ هر entry deflate‌شده‌ای را که گسترش اعلام‌شده آن از ده‌هزار برابر فراتر رود رد می‌کند، همراه با حالت تباهیده یک اندازه فشرده‌نشده غیرصفر جفت‌شده با یک اندازه فشرده صفر. نام‌های entry از CanonicalZipEntryName در همان فراخوانی عبور می‌کنند، که کاراکترهای NUL جاسازی‌شده، دونقطه، و هر segment مسیر .. را با Invalid ZIP entry name رد می‌کند، و segmentها را lowercase و normalize می‌کند پس دو عضو که فقط در بزرگ‌کوچکی یا در جداکننده‌های اضافی متفاوت‌اند به‌عنوان Duplicate ZIP entry name برخورد می‌کنند به‌جای اینکه خاموش روی یکدیگر سایه بیندازند

دفاع در عمق بالای لایه ZIP

لایه ZIP یکی از چند سطح است، و الگو هرجا HotXLS ساختار تحت کنترل مهاجم را parse می‌کند تکرار می‌شود. واضح‌ترین نمونه در parser فرمول BIFF نشسته: TXLSFormula.GetTranslated از طریق tokenهای tMemFunc بازگشت می‌کند، پس یک جریان token rgce دست‌ساز در یک .xls قدیمی می‌تواند به دلبخواه عمیق تودرتو شود و پشته را تمام کند. دروازه یک ثابت است، MaxTranslateDepth = 256، انتخاب‌شده در برابر یک واقعیت شناخته‌شده بالادستی به‌جای حدس زدن. Excel تودرتوسازی فرمول را در ۶۴ سقف می‌کند، پس ۲۵۶ چهار برابر فضای اضافی می‌گذارد و هرگز نمی‌تواند فرمولی را که یک صفحه‌گسترده واقعی تولید کرده رد کند، درحالی‌که همچنان یک جریان مخرب را به‌اندازه کافی زود پیش از تمام شدن پشته خاتمه می‌دهد

const
  MaxTranslateDepth = 256;
begin
  isOuter := FTranslateDepth = 0;
  if isOuter then
    ResetPendingArrays;
  Inc(FTranslateDepth);
  try
    if FTranslateDepth > MaxTranslateDepth then
    begin
      Result := nil;
      Exit;
    end;

توجه کنید دروازه به‌جای بلند کردن یک exception nil برمی‌گرداند. یک فرمول خیلی عمیق برای واقعی بودن هیچ درخت syntax تولید نمی‌کند، parse اطراف ادامه می‌یابد، و workbook همچنان بارگذاری می‌شود. آن نامتقارنی عمدی است و ارزش کپی کردن در محدودیت‌های خودتان را دارد: یک محدودیت که برای متوقف کردن تخلیه منابع وجود دارد باید کوچک‌ترین واحد ممکن را تنزل دهد، نه سند را متوقف کند. همان استدلال وقتی لایه محاسبه را گسترش می‌دهید اعمال می‌شود، پس اگر handlerهای خودتان را از طریق API تابع سفارشی موتور فرمول ثبت می‌کنید، به آن‌ها محدودیت‌های آرگومان و بازگشت خودشان را بدهید به‌جای فرض اینکه فراخواننده از قبل بررسی کرده

این بررسی‌ها چه چیزی برای شما نمی‌خرند

درباره مرز دقیق باشید. چهار بررسی متقابل EOCD index آرشیو را بدون‌ابهام می‌کنند، پس HotXLS و هر reader مطابق دیگری همان فایل را به همان مجموعه entry حل می‌کنند؛ چیزی درباره اینکه آیا آن مجموعه entry بی‌ضرر است نمی‌گویند. توافق هدر محلی ترفند دو-نما را متوقف می‌کند، نه یک payload مخرب که به‌طور یکسان توصیف شده. stream تأییدشده truncation، overflow و corruption را متوقف می‌کند، نه یک بخش XML کاملاً خوش‌فرم که چیزی را که انتظار نداشتید کدگذاری می‌کند. و هیچ‌کدام از این‌ها به macroها دست نمی‌زند: یک پروژه VBA داخل یک workbook از نظر ساختاری بی‌عیب هنوز یک پروژه VBA است، و تصمیم برای نگه داشتن، حذف یا رد آن به لایه خط‌مشی شما تعلق دارد، نه ZIP reader

چیزی که در عوض به‌دست می‌آورید یک مرز شکست تمیز است. یک xlsx غیرقابل‌اعتماد یا به‌عنوان یک آرشیو بدون‌ابهام باز می‌شود که اعضای آن با اندازه‌ها و checksumهای اعلام‌شده‌شان مطابقت دارند، یا با پیامی که invariant خاصی را که شکسته نام می‌برد بلند می‌شود، و سرویس شما می‌تواند روی exception قرنطینه کند به‌جای حدس زدن. ZIP reader و سطوح parser بالای آن به‌عنوان بخشی از کامپوننت HotXLS Excel برای Delphi و C++Builder عرضه می‌شوند، که نه به Excel و نه به OLE automation روی ماشینی که parsing انجام می‌دهد نیاز دارد، و آن غیبت خودش کاهش معناداری در چیزی است که یک فایل آپلود‌شده می‌تواند به آن برسد