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

การแยกวิเคราะห์ PDF อย่างปลอดภัยในหน่วยความจำใน Delphi: คู่มือการป้องกัน

ไพป์ไลน์การรับเอกสารยอมรับไฟล์ที่เขียนโดยคนแปลกหน้า ไม่ว่าจะเป็นใบแจ้งหนี้, เอกสารสแกน, ไฟล์แนบจากเว็บฟอร์ม: แต่ละไฟล์อ้างว่าเป็น PDF และพกพาตัวเลขหลายร้อยตัวที่คาดหวังให้ตัวแยกวิเคราะห์ (parser) ของคุณทำงานด้วย ความยาวของสตรีม, ขนาดรูปภาพ, byte offsets, การอ้างอิงอ็อบเจ็กต์ — ทุกอย่างถูกกำหนดโดยใครก็ตามที่สร้างไฟล์นั้นขึ้นมา และการอัปโหลดที่ไม่สมบูรณ์หรือเอกสารที่จงใจทำมาให้มีรูปแบบที่ผิดปกติ ในที่สุดจะเอาตัวเลขเหล่านั้นไปวางไว้ในที่ที่ก่อให้เกิดความเสียหาย ความแตกต่างระหว่าง parser ที่รอดพ้นจากไฟล์นั้นได้ กับตัวที่เกิดข้อผิดพลาด (crashes) หรือยังคงทำงานต่อไปด้วยหน่วยความจำที่เสียหาย คือกลุ่มนิสัยเล็กๆ น้อยๆ ที่ไม่ต้องพึ่งพาไลบรารี PDF ใดๆ โดยเฉพาะ

นิสัยเหล่านี้มีหลักการร่วมกันข้อหนึ่ง: ค่าที่อ่านจากไฟล์คือคำกล่าวอ้าง ไม่ใช่ค่าที่วัดได้จริง มันจะใช้งานได้ก็ต่อเมื่อผ่านการตรวจสอบกับสิ่งที่ parser วัดได้ด้วยตัวเอง — ขนาดที่แท้จริงของไฟล์, จำนวนไบต์จริงที่ตัวถอดรหัสสร้างขึ้น, ความลึกจริงของการเรียกซ้ำ (recursion) สิ่งที่ตามมาคือหลักการที่ถูกนำไปใช้ในจุดที่ parser ของเอกสารมักจะเกิดความเสียหายจริง

ความยาวที่ประกาศไว้คือคำกล่าวอ้าง ไม่ใช่การวัด

ความไม่ตรงกันที่ง่ายที่สุดคือความยาวของสตรีม อ็อบเจ็กต์สตรีม PDF จะประกาศจำนวนไบต์ของมันในคีย์ /Length และข้อมูลจริงจะอยู่ระหว่างคำหลัก stream และ endstream ไม่มีอะไรบังคับให้ทั้งสองสิ่งนี้ต้องตรงกัน ไฟล์ที่ถูกตัดปลายจะเก็บจำนวนไบต์จริงน้อยกว่าที่ประกาศไว้; ไฟล์จากตัวสร้างที่พังสามารถประกาศความยาวที่ทะลุเลยส่วนท้ายของไฟล์หรือเข้าไปในอ็อบเจ็กต์ข้างเคียง การจัดสรรหน่วยความจำจากค่าที่ประกาศและคัดลอกไปจนถึง endstream จะทำให้คุณเกิด buffer overrun; การอ่านตามจำนวนที่ประกาศพอดีโดยไม่ตรวจสอบความพร้อมใช้งาน จะทำให้คุณอ่านทะลุเลยส่วนท้ายของไฟล์ ให้ค่าที่ประกาศขับเคลื่อนการจัดสรรเฉพาะหลังจากที่จำกัดค่า (clamping) ให้ไม่เกินระยะทางที่วัดได้จนถึงส่วนท้ายของข้อมูลเท่านั้น และให้ถือว่าความไม่ตรงกันนี้เป็นจุดตัดสินใจ — ไม่ว่าจะซ่อมแซมด้วยการสแกนหา endstream หรือปฏิเสธสตรีมนั้น — อย่าทำเหมือนเป็นสิ่งที่จะเชื่อโดยปริยายอย่างเงียบๆ

พารามิเตอร์ของรูปภาพที่อธิบายขนาด raster ใหญ่กว่าที่คุณจัดสรรไว้

สตรีมรูปภาพเพิ่มความเสี่ยงให้สูงขึ้นเนื่องจากชุดตัวเลขสองชุดที่เป็นอิสระจากกันกำลังอธิบายพิกเซลเดียวกัน พจนานุกรมภาพจะมี /Width และ /Height และโดยปกติแล้ว raster buffer จะถูกกำหนดขนาดจากค่าเหล่านั้น ตัวกรองการถอดรหัสจะมีเรขาคณิต (geometry) เป็นของตัวเอง: CCITTFaxDecode จะใช้ค่า /Columns, /Rows, และ /K จาก DecodeParms ของมัน โดยที่ /K จะเลือกระบบ Group 3 หรือ Group 4 และตัวถอดรหัสจะส่งออกข้อมูล (Columns + 7) div 8 ไบต์ต่อบรรทัดสแกน (scanline) ไฟล์ที่ประกาศ /Width 100 แต่ส่งค่าให้ตัวกรองเป็น /Columns 1728 — ซึ่งเป็นค่าเริ่มต้น — จะทำให้ตัวถอดรหัสสร้างข้อมูลมากกว่าสิบหกเท่าของจำนวนไบต์ต่อแถวที่บัฟเฟอร์คาดหวัง และข้อมูลที่ล้นออกมาจะตกลงไปทีละสแกนไลน์ในพื้นที่ใดๆ ที่อยู่หลังการจัดสรร เมื่อไม่มีค่า /Rows ตัวถอดรหัสจะทำงานไปเรื่อยๆ จนกว่าข้อมูลจะบอกให้หยุด ดังนั้นจงจำกัดขอบเขตของจำนวนแถวไว้ด้วย DCTDecode ก็มีช่องโหว่แบบเดียวกัน: ข้อมูล JPEG จะมีความกว้างและความสูงเป็นของตัวเองในเครื่องหมาย SOF และไม่มีอะไรบังคับให้พวกมันต้องตรงกับพจนานุกรม

กฎการป้องกันนั้นเป็นกลไกที่ตรงไปตรงมา: คำนวณขนาดของ raster ที่คาดหวังจากพารามิเตอร์การถอดรหัสที่ผ่านการตรวจสอบแล้ว — ค่า /Columns และ /Rows ของตัวกรองเองสำหรับ CCITT, ขนาดจาก SOF สำหรับ DCT — ตรวจสอบกับขีดจำกัดของคุณ, จัดสรรจากค่าเหล่านั้น, และยืนยันในระหว่างการถอดรหัสว่าผลลัพธ์จะต้องไม่เกินการจัดสรรที่กำหนดไว้ เมื่อพจนานุกรมและตัวกรองมีข้อมูลเกี่ยวกับเรขาคณิตที่ไม่ตรงกัน ให้ปรับทั้งสองให้ตรงกัน หรือไม่ก็ปฏิเสธภาพนั้น สิ่งที่ parser ต้องไม่ทำเด็ดขาดคือ การกำหนดขนาดบัฟเฟอร์จากตัวเลขชุดหนึ่ง และปล่อยให้ตัวถอดรหัสทำงานตามตัวเลขอีกชุดหนึ่ง

หลุมพรางทางคณิตศาสตร์และการจัดสรรหน่วยความจำของ Delphi

พฤติกรรมสามประการของ Delphi อาจบั่นทอนได้แม้กระทั่ง parser ที่ตั้งใจจะทำการตรวจสอบความถูกต้อง ประการแรกคือการคูณแบบ 32 บิต: Delphi จะประเมินผลคูณของตัวถูกดำเนินการ (operands) Integer สองตัวที่ระดับ 32 บิต โดยไม่คำนึงถึงความกว้างของปลายทาง ดังนั้น Width * Height * BytesPerPixel อาจเกิดการวนกลับ (wrap) ได้แม้ว่าปัจจัยแต่ละตัวจะผ่านการตรวจสอบความสมเหตุสมผลของตัวมันเองแล้วก็ตาม การสแกนขนาด 30000 คูณ 30000 ที่สามไบต์ต่อพิกเซลจะเท่ากับ 2.7 พันล้านไบต์ ซึ่งจะวนกลับไปเป็นค่าลบในคณิตศาสตร์แบบ signed 32-bit; ส่วนปัจจัยที่ต่างกันเล็กน้อยอาจวนกลับไปเป็นความยาวค่าบวกที่น้อยมาก ทำให้เกิดการจัดสรรบัฟเฟอร์ที่มีขนาดเล็กเกินไป บังคับให้นิพจน์ทั้งหมดขยายความกว้างด้วยการแคสต์ (casting) ตัวถูกดำเนินการตัวแรก — Size := Int64(Width) * Height * BytesPerPixel — จากนั้นเปรียบเทียบกับขีดจำกัดสูงสุดที่ชัดเจนก่อนที่คำสั่งใดๆ จะเข้าถึง SetLength

ประการที่สองคือการตรวจสอบขอบเขต (range checking) การกำหนดค่ารุ่นเผยแพร่ (release configuration) เริ่มต้นของ Delphi จะถูกตั้งมาให้ปิดฟีเจอร์นี้ไว้ ดังนั้นดัชนีที่อยู่นอกเหนือขอบเขตซึ่งคำนวณจากข้อมูลของไฟล์จะไม่แสดงข้อผิดพลาดใดๆ — มันจะทำการอ่านหรือเขียนหน่วยความจำที่อยู่ติดกับอาร์เรย์แทน ให้เปิดใช้งานมันกลับด้วย {$R+} (และ {$Q+} สำหรับ arithmetic overflow) ที่ด้านบนของทุกๆ ยูนิตที่ทำการอ้างอิงดัชนีด้วยค่าที่ได้มาจากไฟล์ ต้นทุนของมันนั้นไม่สามารถวัดได้เมื่อเทียบกับ I/O ที่ parser ต้องทำอยู่แล้ว และมันจะเปลี่ยนความเสียหายที่แฝงอยู่เงียบๆ ให้กลายเป็นข้อผิดพลาด ERangeError ที่สามารถจับตาได้

ประการที่สามคือ TMemoryStream.SetSize กับค่า Int64 ที่ได้มาจากไฟล์ บน RTL ปัจจุบัน มันจะจัดสรรพื้นที่ตามที่ไฟล์ร้องขอทั้งหมด ดังนั้นสตรีมเดียวที่อ้างขนาดสี่กิกะไบต์จะกลายเป็นความล้มเหลวแบบ out-of-memory ในระหว่างกระบวนการรับไฟล์ บน RTL รุ่นเก่า ที่ SetSize รับค่า Longint ค่านั้นจะถูกลดขนาดลงเงียบๆ ก่อน: ค่าที่ประกาศ $100000010 จะกลายเป็น 16 จากนั้นการจัดสรรสำเร็จ และการเขียนข้อมูลจริงก็จะวิ่งเลยจุดจัดสรรไปไกลมาก ตรวจสอบทุกขนาดเทียบกับขนาดต้นทางที่วัดได้ และใช้ขีดจำกัดตายตัว (hard cap) ก่อนที่จะมีการเรียกใช้งานการจัดสรรใดๆ มารับรู้มัน

Offsets ที่ชี้ออกไปนอกไฟล์

ตาราง cross-reference จะจับคู่หมายเลขอ็อบเจ็กต์กับ absolute byte offsets และ parser จะค้นหาไปยังจุดใดก็ตามที่มันชี้ไป ในไฟล์ที่เสียหายหรือเป็นอันตราย offsets เหล่านั้นอาจตกลงไปเลยส่วนท้ายของไฟล์หรือภายในโครงสร้างที่ไม่เกี่ยวข้อง TStream ทำให้ความล้มเหลวนี้เงียบไป: การตั้งค่า Position ให้เกินกว่า Size นั้นไม่ใช่ข้อผิดพลาด และการทำงานแบบ Read ธรรมดาที่อ่านทะลุส่วนท้ายไป ก็จะแค่ส่งคืนไบต์จำนวนน้อยกว่าที่ร้องขอ ดังนั้นโค้ดที่ข้ามการตรวจสอบจำนวนการอ่าน จะยังคงดึงไบต์เก่าๆ จากอ็อบเจ็กต์ก่อนหน้ามาประมวลผลต่อไป การป้องกันทำได้ด้วยจุดควบคุมเดียว (chokepoint) — นั่นคือ ตัวช่วยหนึ่งตัวที่ทุกๆ การค้นหาและการอ่านที่ถูกขับเคลื่อนโดยไฟล์จะต้องผ่าน ซึ่งจะตรวจสอบความถูกต้องของ offset และจำนวนการอ่าน (count) เทียบกับขนาดไฟล์ที่วัดได้ ก่อนที่สตรีมจะเคลื่อนที่

uses
  System.SysUtils, System.Classes;

const
  MAX_OBJECT_BYTES = 64 * 1024 * 1024; // no single object may exceed 64 MB

type
  EPdfBoundsError = class(Exception);

// Every file-driven seek and read goes through here. Offset and Count are
// file-supplied claims; Source.Size is the measurement they must fit.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
  var Buffer: TBytes);
begin
  if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
     (Offset > Source.Size) or (Count > Source.Size - Offset) then
    raise EPdfBoundsError.CreateFmt(
      'object extent %d+%d exceeds file size %d',
      [Offset, Count, Source.Size]);
  SetLength(Buffer, Count);
  if Count = 0 then
    Exit;
  Source.Position := Offset;
  Source.ReadBuffer(Buffer[0], Count);
end;

กำหนดให้การเข้าถึง cross-reference offsets, ขนาดสตรีม (stream extents), และการอ่านไฟล์ที่แนบมา (embedded-file) ต้องผ่านตัวช่วยนี้ และ offset ที่ไม่ดีจะกลายเป็นการถูกปฏิเสธอย่างหมดจดที่ระบุตัวเลขออกมาอย่างชัดเจน แทนที่จะเกิด access violation หลังจากรันไปได้แล้วสามบรรทัด

วงจรความซ้ำซ้อน (Cycles) และความลึกในกราฟอ็อบเจ็กต์

PDF คือกราฟ ไม่ใช่ต้นไม้ (tree) ค่าใดๆ ก็ตามอาจเป็น indirect reference การอ้างอิงอาจนำไปสู่การอ้างอิงอีกตัว — /Length 12 0 R โดยที่อ็อบเจ็กต์ 12 เก็บค่า 13 0 R — และไม่มีอะไรขัดขวางไม่ให้สายเชื่อมโยงนี้วนกลับมาที่ตัวมันเอง โปรแกรมตัวแก้ไข (resolver) ที่ตามการอ้างอิงไปอย่างซื่อๆ จะทำงานเรียกซ้ำ (recurse) ไปเรื่อยๆ จนกว่า native stack จะหมด และ stack exhaustion ไม่ใช่สิ่งที่คุณจะดักจับได้; แต่มันจะยุติกระบวนการทำงานทั้งหมดทันที อาร์เรย์และพจนานุกรมที่ซ้อนกันอย่างลึกซึ้งก็จะพบจุดจบแบบเดียวกันได้โดยไม่จำเป็นต้องมีวงจรความซ้ำซ้อนใดๆ เลย

ใช้ระบบป้องกันสองอย่างร่วมกัน: ตัวนับความลึก (depth counter) แบบกำหนดชัดเจน จะกำหนดขอบเขตสำหรับกรณีที่ไฟล์ปกติดีแต่มีความลึกมากเกินไป ให้อยู่ในขีดจำกัดที่ไม่มีไฟล์ปกติใดจะเข้าถึงได้, และชุดตรวจสอบสิ่งที่เคยเข้าถึงแล้ว (visited set) จะคอยดักจับวงจรที่แท้จริงได้ในการเข้าชมครั้งที่สอง ซึ่งจะเปลี่ยนมันให้เป็นข้อผิดพลาดที่แม่นยำและรายงานได้ แทนที่จะเป็นการพังทลายของระบบ

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) คือตัวขยายสัญญาณ

อินพุต FlateDecode เพียงไม่กี่กิโลไบต์สามารถขยายกลายเป็นขนาดหลายกิกะไบต์ได้; การบีบอัดแบบอเนกประสงค์นั้นได้เปรียบกับข้อความแบบมีซ้ำบ่อย และผู้โจมตีสามารถทำให้มันมีการซ้ำซ้อนมากที่สุดได้อย่างง่ายดาย ดังนั้นควรจำกัดขนาดการพองตัวของสตรีมแต่ละรายการให้เหมาะสมกับความจำเป็นของสิ่งที่นำไปใช้งาน และกำหนดงบประมาณส่วนที่สองแบบแยกตามเอกสาร: สตรีมห้าร้อยรายการที่เพิ่งขยายขนาดเกือบถึงจุดจำกัด จะใช้หน่วยความจำจนหมดอย่างแน่นอนพอๆ กับสตรีมยักษ์เพียงหนึ่งตัว การตรวจสอบควรอยู่ในลูปการขยายตัว (inflation loop) เพื่อติดตามนับจำนวนไบต์เอาต์พุตที่ถูกผลิตขึ้นมา และทำการยุติการทำงานเมื่อเกินขีดจำกัด ไม่ใช่ทำหลังจากสิ้นสุดลูปซึ่งหน่วยความจำก็ถูกใช้จนหมดไปแล้ว การตั้งงบประมาณเอกสารที่แสดงเป็นจำนวนเท่าของขนาดไฟล์ที่ถูกบีบอัดจะทำงานได้ดี เนื่องจากเอกสารที่ถูกต้องส่วนใหญ่จะมีอัตราส่วนต่ำกว่าที่สตรีมที่ถูกสร้างเพื่อโจมตีสามารถขยายไปถึงได้อย่างมาก

การป้องกันเชิงลึกที่เหนือกว่าเพียงแค่ในยูนิตของคุณ

ปัญหาความบกพร่องระดับเดียวกันนั้นก็มีอยู่ภายในไลบรารีต่างๆ กรณีศึกษาสองเรื่องในบล็อกนี้จะพาไปดูตัวอย่างในโลกความเป็นจริง: กรณีที่เลขจำนวนเต็มเกิดการวนกลับ (integer wraps), การเรียกซ้ำที่ไม่จำกัด (unbounded recursion), และการปิดบัฟเฟอร์ที่ไม่ได้ถูกสร้างค่าเริ่มต้น (uninitialized buffers) ในเอนจินแบบ Native Pascal ในบทความเรื่อง การเสริมความปลอดภัยให้กับ Pascal PDF Parser จากไฟล์อันตราย, และความเสี่ยงเกี่ยวกับ calling-convention, ขนาดความกว้างของ integer, และความเป็นเจ้าของในการเชื่อมต่อกับ C engine ใน การเสริมความปลอดภัยให้กับ PDFium Component Binding สำหรับการรับไฟล์ที่ไม่น่าเชื่อถืออย่างแท้จริง — เช่น ผ่านแบบฟอร์มอัปโหลดสาธารณะ, กล่องจดหมายที่ไม่มีการตรวจสอบตัวตน — ควรทำการรันเพื่อแยกวิเคราะห์และถอดรหัสในกระบวนการที่ทำงานแยกต่างหากพร้อมสิทธิ์ขั้นต่ำด้วย (low-privilege process) เพื่อให้ไฟล์ที่สามารถหลบหลีกระบบตรวจสอบ in-process ของเราได้ทุกชั้นทำได้เพียงแค่ทำให้งานนั้นล้มเหลว แทนที่จะทำให้บริการทั้งหมดต้องล่ม

รายการตรวจสอบก่อนใช้งาน (preflight checklist)

ก่อนที่โค้ดในบิลด์ถัดไปจะถูกส่งออก ให้ทดสอบการทำงานของ parser ตามรายการเหล่านี้: บัฟเฟอร์สตรีมทุกตัวจะต้องกำหนดขนาดจากความยาวที่ผ่านการจำกัดขอบเขตแล้วแทนที่จะใช้ตามที่ประกาศไว้; raster ทุกตัวต้องกำหนดขนาดจากพารามิเตอร์การถอดรหัสที่ผ่านการตรวจสอบความถูกต้องแล้วและตรวจสอบผลลัพธ์จากตัวถอดรหัส; ผลคูณมิติของสัดส่วนทั้งหมดถูกประเมินใน Int64 และเปรียบเทียบกับขีดจำกัดตัวพิมพ์ใหญ่อย่างชัดเจน; {$R+} ต้องถูกตั้งให้ทำงานในทุกยูนิตที่ใช้ดัชนีกับค่าที่ได้มาจากไฟล์; ทุกการค้นหาถูกตรวจสอบขอบเขตกับขนาดไฟล์ที่วัดได้; ทุกการตีความการอ้างอิงจะถูกจำกัดความลึกและตรวจสอบลูปความซ้ำซ้อน; และทุกลูปการคลายการบีบอัดจะต้องนับเอาต์พุตและเทียบกับงบประมาณต่อสตรีมและต่อเอกสาร การตรวจสอบเหล่านี้ไม่มีอันไหนเลยที่จะส่งผลกระทบต่อเวลาอย่างมีนัยสำคัญเมื่อใช้กับเอกสารที่ถูกต้อง และการตรวจสอบแต่ละรายการจะเปลี่ยนความเสียหายในหน่วยความจำให้เป็นการปฏิเสธที่หมดจดและสามารถบันทึกเป็นประวัติได้อย่างเหมาะสม

หมายเหตุ: คอมโพเนนต์ของ losLab ได้แก่ HotPDF Component, PDFlibPas Delphi PDF Library, และ PDFium Component ได้นำการตรวจสอบขอบเขต, ข้อจำกัดความลึก, และการควบคุมการขยายตัวมาใช้งานภายในอย่างสมบูรณ์แล้ว ดังนั้นไพป์ไลน์การรับข้อมูลที่สร้างขึ้นจากสิ่งเหล่านี้จะเริ่มต้นด้วยบรรทัดฐานที่มีความปลอดภัยสูงกว่า