บทความเทคนิค

PDF Decode Bomb ใน Delphi: งบประมาณ Filter Chain ของ HotPDF

ไฟล์ PDF ขนาด 20 KB ที่ทำให้ service process ค้างจน OOM killer ต้องเข้ามาจัดการ ไม่ใช่บั๊กในโค้ดของคุณ แต่คือ decompression bomb HotPDF Delphi component แบบ VCL เนทีฟสำหรับ Delphi และ C++Builder จำกัดสิ่งนี้ด้วย DecodeBudgetBytes ซึ่งเป็นเพดานต่อ filter chain มีค่าเริ่มต้น 268435456 ไบต์ และคิดค่าทุกขั้นตอนการถอดรหัสเทียบกับงบประมาณเดียวที่ใช้ร่วมกัน

ไฟล์ 20 KB ที่กิน worker process จนหมด

รูปแบบของเหตุการณ์นี้เหมือนกันเสมอ worker ที่คอยดึงคิวเพื่อสร้าง thumbnail รับไฟล์อัปโหลดเข้ามา หน่วยความจำที่ใช้อยู่พุ่งเกิน 12 GB ในเวลาไม่ถึงสองวินาที แล้ว process ก็หายไปโดยไม่มี stack trace ไฟล์นั้นมีขนาด 20 KB มีหนึ่งหน้า หนึ่ง content stream และ array /Filter ที่มีห้า entry ทุกชื่อใน array นั้นคือ filter ที่สเปกกำหนดไว้ ทุกขั้นตอนถอดรหัสได้โดยไม่มีข้อผิดพลาด และไม่มีอะไรในไฟล์ผิดรูปแบบเลย นั่นคือสิ่งที่ทำให้ปัญหาประเภทนี้ยุ่งยาก ไม่มีไบต์เสียหายให้ปฏิเสธ

นี่ไม่ใช่ปัญหาแบบเดียวกับการถอดรหัส filter เดียวให้ถูกต้อง การทำ LZWDecode และ predictor ใน /DecodeParms ให้ถูกต้องเป็นเรื่องของตัวมันเอง ครอบคลุมใน บทความเรื่อง LZW, predictor และ DecodeParms บนเอกสารที่โหลดแล้ว ตรงนี้ตัวถอดรหัสทุกตัวถูกต้องอยู่แล้ว ความล้มเหลวคือสิ่งที่ตัวถอดรหัสที่ถูกต้องทำเมื่อคุณรันห้าตัวต่อกันโดยไม่มีใครนับผลรวม ISO 32000-1 §7.4 ระบุชัดว่า /Filter อาจเป็นชื่อเดียวหรือ array ของชื่อ และ array จะถูกใช้ตามลำดับ entry แรกก่อน แต่ไม่ได้พูดถึงว่าแต่ละขั้นตอนขยายข้อมูลนำเข้าได้มากแค่ไหน และไม่ได้พูดถึงผลรวมสะสมของ chain เลย ขั้นตอน ASCIIHexDecode จะลดข้อมูลนำเข้าลงเหลือประมาณครึ่งหนึ่ง ซึ่งฟังดูไม่เป็นอันตราย ขั้นตอน FlateDecode บนไบต์ศูนย์ต่อเนื่องกันจะได้อัตราส่วนถึงหลักพัน ต่อกันเป็นลูกโซ่แล้วเลขคณิตกลายเป็นการคูณ 20 KB กลายเป็น 20 MB กลายเป็น 20 GB และแต่ละขั้นตอนก็เป็นการถอดรหัสที่สอดคล้องตามมาตรฐานของ stream ที่ถูกกฎหมาย

กายวิภาค decode bomb ของ PDF ใน Delphi: ไฟล์อัปโหลดยี่สิบกิโลไบต์ที่มีชั้น /Filter ซ้อนถูกต้องตามกฎห้าชั้น ทวีการขยายตัวจนจัดสรรไปหลายกิกะไบต์และโปรเซส worker ตาย
ฟิลเตอร์ที่ถูกต้องตามข้อกำหนดห้าตัวคูณการขยายตัวของมัน จนไฟล์อัปโหลด 20 KB จองหน่วยความจำเป็นกิกะไบต์ โดยไม่มีไบต์เสียสักไบต์ให้ปฏิเสธ

ทำไม limit แบบต่อ filter ถึงหยุด decode bomb ไม่ได้

เพราะ limit แบบต่อ filter จะถูกเติมเต็มใหม่ทุก element ของ array /Filter chain ห้าขั้นตอนภายใต้เพดานต่อขั้นตอน 256 MiB จะให้สิทธิ์รวม 1.25 GiB และขั้นตอนสุดท้ายก็ยังเริ่มต้นด้วยโควตาใหม่เอี่ยมไม่ว่าสี่ขั้นตอนก่อนหน้าจะสร้างอะไรออกมาก็ตาม limit นี้ถูกบังคับใช้อย่างซื่อสัตย์แต่ไม่จำกัดอะไรที่สำคัญเลย HotPDF มีรูปแบบนี้พอดีก่อนเวอร์ชัน v2.447.0 และมันมีช่องโหว่ที่สองควบคู่กันด้วย ตัวคลายบีบอัด LZW พกเพดาน MaxOutputBytes และเส้นทาง image predictor คิดจำนวนแถวของตัวเอง ดังนั้นสองอย่างนี้จึงถูกจำกัดในระดับท้องถิ่น FlateDecode, ASCIIHexDecode, ASCII85Decode และ RunLengthDecode ไม่มีเพดานเลย แต่ละตัวเขียนลงใน TMemoryStream จนกว่าข้อมูลนำเข้าจะหมดหรือ allocator ยอมแพ้ ดังนั้น chain ที่เป็นภัยจึงมีสองทางผ่าน มันอาจใช้ filter ที่ไม่มีการป้องกันเลยตัวเดียว หรือใช้ filter ที่มีการป้องกันแล้วเพิ่มจำนวนของมันเข้าไปเรื่อย ๆ

มีรายละเอียดที่สามที่การแก้ไขแบบไร้เดียงสามักพลาด ตัวเลขที่คุณต้องสนใจไม่ใช่ขนาดของผลลัพธ์ที่ถอดรหัสสุดท้าย แต่คือค่าสูงสุด และค่าสูงสุดมักอยู่ใน buffer ระหว่างกลาง chain ที่จบด้วย content stream ขนาดพอประมาณ 4 MB สามารถ allocate 8 GB ที่ขั้นตอนที่สาม แล้วส่งคืนสิ่งที่ดูสมเหตุสมผลอย่างยิ่ง การตรวจสอบความยาวของผลลัพธ์หลังเสร็จสิ้นไม่บอกอะไรคุณเลยเกี่ยวกับการ allocate ที่ทำให้ process ตาย

ตัวติดตามงบประมาณหนึ่งตัวต่อหนึ่ง filter chain

การแก้ไขใน HotPDF v2.447.0 คือทำให้การคิดบัญชีครอบคลุมทั้ง chain แทนที่จะครอบคลุมแค่ขั้นตอนเดียว แต่ละ filter chain สร้าง THPDFDecodeBudgetTracker หนึ่งตัว และทุกตัวถอดรหัสเขียนผ่าน THPDFBudgetWriteStream ที่ห่อ target จริงไว้ ตัวห่อจะเรียก Budget.Consume(Count) ก่อนที่จะส่งต่อไบต์แม้แต่ตัวเดียว ดังนั้นการปฏิเสธจะเกิดขึ้นในขณะที่ target stream ยังคงมีขนาดเดิม ลำดับนั้นคือแก่นของทั้งหมด การตรวจสอบที่ทำหลังจาก buffer โตขึ้นไปแล้วคือการวินิจฉัย ไม่ใช่การป้องกัน

// ย่อมาจากตัวถอดรหัส chain ของ HotPDF: ตัวติดตามหนึ่งตัวสำหรับทั้ง
// array /Filter ตัวห่อ stream แบบมีขอบเขตหนึ่งตัวต่อหนึ่งขั้นตอน
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
  for I := 0 to FilterCount - 1 do
  begin
    if I = 0 then
      InputStream := StreamObj.Stream    // อ่านต้นฉบับโดยตรง ไม่คัดลอกมัน
    else
      InputStream := CurrentStream;
    NextStream := TMemoryStream.Create;
    InputStream.Position := 0;
    // BeginFilter ตั้งชื่อขั้นตอนและเพิ่ม FilterCount; stream ตัวห่อ
    // จะเรียก Budget.Consume ก่อนที่จะเขียนลงใน NextStream
    Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
    CurrentStream.Free;
    CurrentStream := NextStream;
  end;
finally
  Budget.FinishFilter;
  Budget.Free;
end;

เพดานท้องถิ่นไม่ได้หายไป มันกลายเป็นภาพฉายของงบประมาณที่ใช้ร่วมกัน ขั้นตอน LZW ตอนนี้ตั้ง Decoder.MaxOutputBytes := Budget.RemainingBytes ดังนั้นเพดานส่วนตัวของมันจึงเป็นสิ่งที่ chain เหลืออยู่ ไม่ใช่โควตาอิสระ ขั้นตอน image predictor เปิดด้วย BeginFilter และคิดค่าความต้องการแถวของมันผ่าน Consume ก่อนจะ allocate ซึ่งหมายความว่าผลลัพธ์ของ predictor ถูกคิดค่าเข้ากับงบประมาณเดียวกับ filter ทั่วไปที่ป้อนเข้ามา นี่สำคัญมากในเส้นทางรูปภาพโดยเฉพาะ ที่ filter chain กับ predictor เป็นสองครึ่งของการดำเนินการเดียวกัน ตามที่กล่าวไว้ใน การดึงรูปภาพจากเอกสารที่โหลดแล้วผ่าน decode filter ของมัน

ผู้เรียกเห็นอะไรเมื่อ budget ปฏิเสธ

ที่ก้นของ stack การปฏิเสธจะ raise EHPDFDecodeBudgetError เหนือกว่านั้น คำตอบขึ้นอยู่กับ contract ที่ API ที่เรียกมีอยู่แล้ว read method ระดับสูงที่รายงานความล้มเหลวผ่าน False หรือ nil ยังคงทำแบบนั้นต่อไป เพราะการเปลี่ยนผลลัพธ์ boolean ที่บันทึกไว้แล้วให้เป็น exception จะทำลาย caller ที่จัดการข้อมูลนำเข้าที่ผิดรูปแบบได้ถูกต้องอยู่แล้ว เส้นทาง loaded page content คือข้อยกเว้นโดยตั้งใจ มันจะ re-raise EHPDFDecodeBudgetError แทนที่จะปล่อยให้ content stream ที่ถูกตัดทอนแสดงผลเป็นหน้าที่ว่างเปล่าเฉย ๆ การออกแบบนั้นหมายความว่า False เปล่า ๆ นั้นคลุมเครือในตัวมันเอง ดังนั้น budget จึงเผยแพร่ record การวินิจฉัยควบคู่ไปด้วย THotPDF.GetLastDecodeBudgetInfo คืนสถานะของ chain ล่าสุดที่ instance ถอดรหัสไป

type
  THPDFDecodeBudgetInfo = record
    LimitBytes: Int64;
    DecodedBytes: Int64;
    PeakStageBytes: Int64;
    FilterCount: Integer;
    Exceeded: Boolean;
    ExceededFilter: AnsiString;
  end;

var
  Pdf: THotPDF;
  Info: THPDFDecodeBudgetInfo;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.DecodeBudgetBytes := 64 * 1024 * 1024;   // เข้มกว่าค่าเริ่มต้น
    Pdf.LoadFromFile('untrusted.pdf');
    if not Pdf.ExtractLoadedPageText(0, PageText) then
      if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
        LogWarning(Format(
          'decode refused in %s after %d bytes, peak stage %d, %d filters',
          [String(Info.ExceededFilter), Info.DecodedBytes,
           Info.PeakStageBytes, Info.FilterCount]));
  finally
    Pdf.Free;
  end;
end;

อ่านฟิลด์เหล่านี้ด้วยกันและมันจะแยกรูปแบบการโจมตีสองอย่างออกจากกัน เมื่อ PeakStageBytes ใกล้เคียงกับ DecodedBytes ขั้นตอนเดียวสร้างความเสียหายทั้งหมด และคุณกำลังเผชิญกับ filter ที่มีอัตราส่วนสูงตัวเดียว เมื่อ PeakStageBytes เป็นเศษส่วนเล็ก ๆ ของ DecodedBytes และ FilterCount สูง ไม่มีขั้นตอนใดขั้นตอนเดียวที่เกินเลย แต่ chain สะสมตัวเองผ่านเพดานไปแบบนี้ ซึ่งเป็นกรณีที่ limit แบบต่อ filter มองไม่เห็นพอดี ข้อควรระวังหนึ่งที่ควรเขียนไว้ใน handler ของคุณ GetLastDecodeBudgetInfo คืน False จนกว่า instance จะถอดรหัส filter อย่างน้อยหนึ่งตัว ดังนั้น False จากมันไม่ใช่หลักฐานว่าเอกสารสะอาด

งบประมาณรีเซ็ตตรงไหน และเมื่อไรที่ศูนย์คือคำตอบที่ซื่อสัตย์

DecodeBudgetBytes จำกัด stream chain เดียว ไม่ใช่เอกสารเดียว และขอบเขตนั้นตั้งใจไว้แต่เข้าใจผิดได้ง่าย ทุก content stream ทุกไฟล์แนบ ทุก cross-reference stream และทุก object stream เริ่มต้นด้วย 256 MiB ใหม่เอี่ยม เอกสาร 4,000 หน้าจึงมีโอกาสอิสระ 4,000 ครั้งในการใช้เพดานเต็มทั้งหมด และ object stream ยิ่งเพิ่มจำนวนขึ้นไปอีก เพราะแต่ละตัวเองก็เป็น container ที่บีบอัดซึ่งเก็บ object หลายตัว ตามที่กล่าวไว้ใน บันทึกเรื่อง object stream และการอัปเดตแบบ incremental ถ้าความต้องการจริงของคุณคือขีดจำกัดหน่วยความจำรวมของ process นี่คือปัจจัยหนึ่งของสิ่งนั้น ไม่ใช่ทั้งหมด และมันควรอยู่หลังเพดานระดับ job หรือระดับ container

เปรียบเทียบเพดานรายตัวกรองที่ตื่นพร้อมใหม่ทุกชั้นของอาร์เรย์ /Filter กับ THPDFDecodeBudgetTracker ของ HotPDF ที่แชร์กันซึ่งกั้นห่วงโซ่ตัวกรองทั้งชุดผ่าน Budget.Consume ก่อนไบต์ถูกส่งต่อใน Delphi
tracker ที่ใช้ร่วมกันคิดบิลทุกขั้นเทียบกับเพดานเดียว และสตรีมตัวห่อแต่ละตัวเรียก Budget.Consume ก่อนส่งต่อไบต์เดียว

ศูนย์หมายถึงไม่จำกัด และมันคือค่าที่ถูกต้อง ไม่ใช่ทางหนี ตั้งค่านี้เมื่อคุณเป็นเจ้าของข้อมูลนำเข้า เช่น pipeline การประมวลผลคลังเก็บซ้ำสำหรับเอกสารที่ระบบของคุณเองสร้างขึ้น หรือขั้นตอน rasterization ที่ chain ของสแกนสี 600 dpi ตัวเดียวต้องการมากกว่าเพดานใด ๆ ที่คุณจะสบายใจ hard-code ไว้ ค่าติดลบจะถูกปฏิเสธตั้งแต่ต้นด้วย ERangeError เพราะงบประมาณติดลบไม่มีความหมายที่สอดคล้องกัน และการ clamp มันอย่างเงียบ ๆ จะซ่อนบั๊กการตั้งค่า

// pipeline คลังเก็บที่เชื่อถือได้: ระบุเจตนาให้ชัด แทนที่จะเดาเพดาน
ArchivePdf.DecodeBudgetBytes := 0;              // ไม่จำกัดอย่างชัดเจน

// การอัปโหลดที่ไม่น่าเชื่อถือ: กำหนดเพดานตามที่ชุดข้อมูลของคุณต้องการจริง
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;

// ความผิดพลาดในการตั้งค่าจะล้มเหลวแบบชัดเจน แทนที่จะถูก clamp เงียบ ๆ
try
  IngestPdf.DecodeBudgetBytes := -1;
except
  on E: ERangeError do
    LogWarning('DecodeBudgetBytes cannot be negative');
end;

การเลือกตัวเลขนี้ควรได้รับความใส่ใจมากกว่าที่มักได้รับ เพราะงบประมาณที่ตั้งต่ำเกินไปคือ outage ที่คุณสร้างขึ้นเอง รันคลังไฟล์ที่มีอยู่ของคุณด้วยค่าเริ่มต้น บันทึก PeakStageBytes และ DecodedBytes ของทุก chain แล้วตั้งเพดานให้สูงกว่าค่าสูงสุดที่สังเกตได้พร้อม headroom จริง ตัวเลขกลม ๆ ที่เลือกเพราะฟังดูปลอดภัยจะปฏิเสธสแกนขนาดใหญ่ที่ถูกกฎหมายในเวลาที่แย่ที่สุด และความล้มเหลวนั้นจะดูเหมือนการโจมตีเป๊ะ ๆ ใน log ของคุณ

ขั้นตอนตัดสินใจแสดงว่าผู้เรียกอ่าน GetLastDecodeBudgetInfo หลัง HotPDF ปฏิเสธการ decode ที่มีตัวกรองซ้อนกันใน Delphi อย่างไร: ไบต์พีคของชั้นใกล้เคียงกับไบต์ที่ถอดแล้วชี้ว่ามีตัวกรองโลภหนึ่งตัว ขณะที่พีคต่ำกับตัวกรองจำนวนมากชี้ว่าเป็นห่วงโซ่ที่สะสม
การเทียบพีคต่อขั้นกับไบต์ที่ถอดรหัสรวม แยกฟิลเตอร์โลภตัวเดียวออกจากห่วงโซ่ที่สะสมจนทะลุเพดาน

การคัดลอกที่ไม่เกิดขึ้นอีกต่อไป

การส่งทุกขั้นตอนผ่าน budget wrapper กลับกลายเป็นว่าทำให้ chain ถูกกว่าเดิมแทนที่จะแพงขึ้น เมื่อ stream มี filter ขั้นตอนแรกตอนนี้อ่าน source stream โดยตรงแทนที่จะคัดลอกไบต์ที่เข้ารหัสไว้ลงใน scratch buffer ก่อน และจากตรงนั้นมีแค่สอง buffer ที่มีชีวิตอยู่พร้อมกัน คือข้อมูลนำเข้าปัจจุบันและผลลัพธ์ของขั้นตอนที่กำลังเขียน การคัดลอกดิบยังคงอยู่ในสองกรณีที่จำเป็น คือ stream ที่ไม่มี filter เลย และรูปภาพที่ caller ต้องการรักษาการเข้ารหัสล่าสุดไว้ เพราะทั้งสองกรณีส่งคืน stream ที่ caller เป็นเจ้าของและ seek ได้อย่างอิสระ โค้ดฉบับที่ไม่มีการป้องกันจะ allocate มากกว่าและจำกัดน้อยกว่า ซึ่งเป็นความสัมพันธ์ปกติระหว่างสองสิ่งนี้ ควรพูดให้ชัดเจนไว้ว่า ไม่มีอะไรในนี้ที่ทำให้ PDF ใด ๆ ปลอดภัยที่จะโหลด มันปิดช่องโหว่ denial-of-service เฉพาะเจาะจงและต้นทุนต่ำมากช่องหนึ่งเท่านั้น คือช่องที่ไฟล์เล็ก ๆ ซื้อ allocation ขนาดใหญ่ผ่าน nested filter Integer overflow ในบัญชีไบต์ถูกป้องกันแยกต่างหาก และคำถามที่กว้างกว่าเรื่องการ parse เอกสารที่เป็นภัยโดยไม่เชื่อถือ offset ภายในของมันเป็นวินัยคนละเรื่องกัน decode budget เป็นขอบเขตหนึ่งในหลาย ๆ ขอบเขต และคุณค่าของมันคือมันเป็นตัวเดียวที่คุณตั้งค่าได้จาก property เดียวก่อนที่คุณจะแตะไฟล์เลย

งบประมาณต่อ chain, record การวินิจฉัยของมัน และเส้นทาง decode ของเอกสารที่โหลดแล้วที่มันปกป้อง ทั้งหมดนี้มาพร้อมกับตัว component เอง โดยไม่มี dependency การคลายบีบอัดภายนอกให้ตั้งค่าหรือแพตช์เลย ถ้าคุณกำลังประเมินวิธีจำกัดข้อมูล PDF ที่ไม่น่าเชื่อถือภายใน service ของ Delphi หรือ C++Builder หน้า HotPDF Delphi PDF component มีรายการชุดเครื่องมือของเอกสารที่โหลดแล้วที่ limit เหล่านี้ใช้บังคับ