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

การแยกวิเคราะห์ PDF Dictionaries อย่างปลอดภัยใน Delphi: Name Tokens

การค้นหาสตริงย่อย เช่น Pos('/Length', Dict) เป็นเครื่องมือที่ไม่ถูกต้องสำหรับการอ่าน PDF dictionary เนื่องจากคีย์ชื่อ PDF แชร์คำนำหน้าร่วมกัน: /Length เป็นคำนำหน้าของ /Length1 และ /Encrypt เป็นคำนำหน้าของ /EncryptMetadata ข้อกำหนด ISO 32000-1 §7.3.5 กำหนดให้ชื่อเป็นโทเค็นที่จะสิ้นสุดลงที่ตัวคั่นหรือช่องว่างเท่านั้น ดังนั้นคีย์จะถือว่าถูกค้นพบก็ต่อเมื่อไบต์หลังคีย์นั้นเป็นหนึ่งในอักขระเหล่านั้น ตัวอ่านพจนานุกรมที่ข้ามการตรวจสอบดังกล่าวนี้จะอ่านค่าที่ไม่ถูกต้องจากไฟล์ที่ถูกต้องอย่างสมบูรณ์ในท้ายที่สุด

บั๊กที่สอนเราในเรื่องนี้ดูไม่เหมือนปัญหาของตัววิเคราะห์คำศัพท์ (lexer) เลย การสแกนความเข้ากันได้เริ่มรายงานว่าสตรีม FontFile เสียหาย: โปรแกรมฟอนต์ที่คลายบีบอัดแล้วเป็นเพียงเศษชิ้นส่วน และถูกตัดขาดกลางตาราง ไฟล์เปิดใช้งานได้ดีในทุกโปรแกรมดูเอกสาร ข้อมูลสตรีมบนดิสก์ยังคงครบถ้วน สาเหตุหลักอยู่ที่บรรทัดหนึ่งของตัวอ่านพจนานุกรมที่ใช้ร่วมกันของเรา: Pos('/Length', ...) จับคู่กับ /Length1 ซึ่งเป็นคีย์มาตรฐานที่พจนานุกรมสตรีม FontFile เก็บไว้ตามตาราง ISO 32000-1 Table 127 และตัวอ่านนำค่าจำนวนเต็มหลังจาก /Length1 ไปใช้เป็นความยาวสตรีม บังเอิญว่าตัวเขียนไฟล์นั้นจัดทำอนุกรม /Length1 ก่อน /Length ซึ่งสามารถทำได้ตามใจชอบเนื่องจากรายการพจนานุกรมไม่มีการจัดเรียงลำดับตามข้อกำหนด §7.3.7 สตรีมจึงถูกตัดให้สั้นลงเหลือค่าไบต์ขยะ และการตรวจสอบปลายน้ำทั้งหมดที่ใช้ข้อมูลนี้จึงเกิดปัญหาโดยไม่มีการแจ้งเตือน

ทำไมการจับคู่สตริงย่อยจึงทำให้การแยกวิเคราะห์ PDF dictionary เสียหาย?

การจับคู่สตริงย่อยล้มเหลวเนื่องจากเนมสเปซชื่อ PDF เต็มไปด้วยกลุ่มคำนำหน้าที่จงใจสร้างขึ้น และเนื่องจากไม่ระบุลำดับรายการพจนานุกรม ตาราง Table 127 ของ ISO 32000-1 กำหนด /Length1, /Length2 และ /Length3 สำหรับสตรีมฟอนต์ที่ฝังอยู่ โดยทั้งหมดนี้อยู่ติดกับ /Length ในพจนานุกรมเดียวกัน พจนานุกรมการเข้ารหัสจับคู่ /Encrypt ในเทรลเลอร์กับ /EncryptMetadata ภายในนั้น คีย์ที่สั้นจะแย่ยิ่งกว่า: การค้นหาที่สร้างขึ้นเป็น Pos('/' + Key, ...) โดยมี Key = 'N' จะไปตรงกับ /Name หรือ /Nums ได้อย่างง่ายดาย การชนกันเหล่านี้ไม่จำเป็นต้องเกิดจากไฟล์ที่ผิดรูปแบบ ตัวเขียนที่จัดเรียง /Length1 ไว้ก่อน /Length เป็นไปตามข้อกำหนดอย่างสมบูรณ์ ซึ่งหมายความว่าบั๊กสตริงย่อยนี้ไม่ใช่ช่องว่างความทนทานต่ออินพุตที่เสียหาย — มันคือช่องว่างความถูกต้องสำหรับอินพุตที่ถูกต้อง

โหมดความล้มเหลวยังเป็นแบบเงียบเชียบ ค่า /Length ที่ไม่ถูกต้องจะไม่ส่งข้อยกเว้นออกไป แต่มันจะมอบส่วนข้อมูลไบต์ที่สั้นกว่าหรือยาวกว่าที่สตรีมครอบครองจริง หากส่วนข้อมูลนั้นนำไปป้อนให้การตรวจสอบส่วนย่อยของฟอนต์ (font-subset), การแยกวิเคราะห์ CMap หรือการสแกนข้อมูลเมตา ผู้รับจะเห็นข้อมูลขยะและมักจะไม่รายงานอะไรเลย เนื่องจากสตรีม zlib ครึ่งเดียวจะไม่สามารถขยายตัวออกได้สำเร็จและโค้ดจะรันผ่านไป เราเคยจัดส่งข้อบกพร่องประเภทนี้และแก้ไขในเวอร์ชัน v2.14.3 ของตัวอ่านที่ใช้ร่วมกันของเรา หลังจากที่ได้ตรวจสอบแบบทีละส่วนตาม ISO 32000-1 §7.2–§7.3 และพบว่าการค้นหาคีย์สไตล์ Pos ทั้งหมดนั้นน่าสงสัย

ข้อกำหนด ISO 32000-1 §7.3.5 กำหนดลักษณะของชื่อไว้อย่างไรจริง ๆ

ส่วน §7.3.5 นั้นสั้นและชัดเจน: วัตถุชื่อคือเครื่องหมายสแลช (solidus) ตามด้วยลำดับของอักขระปกติ และโทเค็นจะสิ้นสุดลงด้วยตัวคั่นตัวแรกหรืออักขระช่องว่าง ตัวคั่นคืออักขระวงเล็บแปดตัวบวกกับเครื่องหมายสแลชและเปอร์เซ็นต์ — ( ) < > [ ] { } / % — และช่องว่างคือค่าว่าง (null), แท็บ (tab), การขึ้นบรรทัดใหม่ (line feed), การป้อนฟอร์ม (form feed), การป้อนกลับหัวรถเข็น (carriage return) และช่องว่าง (§7.2.2–§7.2.3) กฎการสิ้นสุดโทเค็นนั้นคือจุดสำคัญทั้งหมด /Length1 ไม่ใช่ "/Length ตามด้วย 1"; มันเป็นโทเค็นเดียวที่ไม่สามารถแยกออกได้ เช่นเดียวกับที่ LengthOne และ Length เป็นตัวระบุที่แตกต่างกันใน Pascal ตัวอ่านใด ๆ ที่ค้นหาคีย์ด้วยการค้นหาไบต์ดิบกำลังปรับใช้ตัววิเคราะห์คำศัพท์ใหม่โดยลบกฎการสิ้นสุดออกไป

นี่คือรูปแบบของข้อบกพร่องที่ลดทอนลงเหลือจุดสำคัญ เวอร์ชันนี้คอมไพล์ผ่าน ผ่านการทดสอบกับไฟล์ที่ตัวเขียนจัดเรียง /Length ไว้ก่อน และสร้างความเสียหายให้กับสตรีมของตัวเขียนที่ไม่ได้จัดเรียงเช่นนั้น

// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
  P: Integer;
begin
  Result := -1;
  P := Pos('/Length', Dict);
  if P > 0 then
    Result := ReadIntAt(Dict, P + Length('/Length'));
end;

การจับคู่โทเค็นเต็มรูปแบบ: ตรวจสอบไบต์หลังจากคีย์

คำทำนายที่ถูกต้องเป็นไปตามข้อกำหนด §7.3.5 โดยตรง: ข้อมูลที่ตรงกันจะเป็นคีย์จริงก็ต่อเมื่ออักขระที่อยู่ถัดจากมันทันทีเป็นตัวคั่น ช่องว่าง หรือจุดสิ้นสุดของบัฟเฟอร์ สิ่งอื่นใดคือชื่อที่ยาวกว่าซึ่งเพียงแค่แชร์คำนำหน้าเท่านั้น ดังนั้นการค้นหาต้องดำเนินต่อไปผ่านมันไปแทนที่จะละทิ้ง การแก้ไขในตัวอ่านของเราแทนที่การค้นหา Pos ดิบทุกรายการด้วยรูทีนที่ใช้ร่วมกันรายการเดียวที่สร้างขึ้นบนกฎนี้

function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
  Result := C in [#0, #9, #10, #12, #13, ' ',
    '(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;

// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
  P, After: Integer;
begin
  Result := 0;
  P := Pos(Key, Dict);
  while P > 0 do
  begin
    After := P + Length(Key);
    if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
      Exit(P);                       // token ends here: genuine key
    P := PosEx(Key, Dict, P + 1);    // prefix of a longer name: keep looking
  end;
end;

รายละเอียดสองประการในลูปนี้มีความสำคัญ ประการแรก ลูปจะยังคงค้นหาต่อไปแทนที่จะส่งคืนความล้มเหลวในการปะทะคำนำหน้าครั้งแรก เนื่องจาก /Length1 120 /Length 4076 เป็นการจัดเรียงที่ถูกกฎหมาย และคีย์จริงยังคงอยู่ข้างหน้า ประการที่สอง กรณีสิ้นสุดบัฟเฟอร์ถือเป็นตัวสิ้นสุดโทเค็น เนื่องจากส่วนของพจนานุกรมสามารถสิ้นสุดลงหลังจากชื่อได้อย่างถูกต้อง จุดที่ละเอียดอ่อนกว่าซึ่งสมควรตรวจสอบในโค้ดของคุณเอง: กฎเดียวกันนี้ใช้กับฝั่งซ้ายของข้อมูลที่ตรงกัน หากสตริงการค้นหาของคุณไม่มีเครื่องหมายสแลช มิฉะนั้น Pos('Length', ...) อาจไปตกอยู่ภายใน /PieceLength การกำหนดจุดยึดสตริงการค้นหาด้วยเครื่องหมายนำหน้า / ตามข้างต้นจะจัดการกับขอบด้านซ้ายได้เนื่องจาก / เป็นตัวคั่นในตัวเองซึ่งจะสิ้นสุดโทเค็นก่อนหน้า

PDF ที่เป็นอันตรายสามารถเปลี่ยนบั๊กของตัวแยกวิเคราะห์ให้กลายเป็นการจองหน่วยความจำระดับกิกะไบต์ได้อย่างไร?

ไฟล์ที่ผิดรูปแบบหรือเป็นอันตรายจะยกระดับข้อผิดพลาดทางไวยากรณ์เหล่านี้ให้กลายเป็นการใช้ทรัพยากรจนหมดสิ้น เนื่องจากจำนวนเต็มในพจนานุกรมมักป้อนให้เป็นขนาดของการจองพื้นที่ การตรวจสอบของเราพบห่วงโซ่ของรูปแบบนี้อย่างแน่นอนในการขยายออบเจกต์สตรีม (object-stream expansion) รายการ /N ของพจนานุกรม ObjStm ระบุจำนวนวัตถุที่บีบอัดที่สตรีมเก็บไว้ และโค้ดการขยายเรียกใช้ SetLength บนอาร์เรย์ที่ระบุขนาดตามนั้น อย่างไรก็ตาม ตัวแยกวิเคราะห์จำนวนเต็มปล่อยค่าพารามิเตอร์ส่งออกไว้โดยไม่มีการแตะต้องเมื่อเกิดความล้มเหลวในขณะที่ยังคงส่งคืนค่าออกมา — ดังนั้น /N ที่ไม่ใช่ตัวเลขจึงส่งต่อค่าสแต็กที่ไม่ได้กำหนดค่าเริ่มต้นให้กับ SetLength ค่าจำนวนเต็มบวกที่เป็นขยะ ณ จุดนั้นหมายถึงคำขอจองพื้นที่ในระดับกิกะไบต์ ซึ่งทริกเกอร์โดยอินพุตที่เสียหายเพียงไม่กี่ไบต์ในขณะที่เพียงแค่สแกนเอกสารที่คุณยังไม่ได้ยอมรับว่าเชื่อถือได้ด้วยซ้ำ

การซ่อมแซมประกอบด้วยสองส่วนที่เป็นอิสระต่อกัน และทั้งสองส่วนสามารถนำไปประยุกต์ใช้ได้ทั่วไป ตอนนี้ตัวแยกวิเคราะห์จะส่งคืนค่า 0 ที่ชัดเจนเมื่อเกิดความล้มเหลว และไม่ส่งคืนหน่วยความจำที่ไม่ได้กำหนดค่าเริ่มต้น และผู้ใช้จะไม่เชื่อถือ /N โดยไม่มีการคำนวณทางคณิตศาสตร์อีกต่อไป: ขอบเขตหัวข้อ ObjStm ก่อน /First จะเก็บคู่จำนวนเต็ม — หมายเลวัตถุและระยะออฟเซ็ต — สำหรับวัตถุที่บีบอัดแต่ละตัว และแต่ละคู่จะครอบครองพื้นที่อย่างน้อยสี่ไบต์รวมตัวคั่น ดังนั้น /N ใด ๆ ที่สูงกว่า FirstVal div 4 + 1 จึงเป็นไปไม่ได้ทางกายภาพสำหรับขนาดส่วนหัวที่ประกาศไว้ และจะถูกปฏิเสธก่อนที่จะมีการจองหน่วยความจำใด ๆ ขอบเขตดังกล่าวใช้ต้นทุนการเปรียบเทียบเพียงครั้งเดียวและได้รับมาจากข้อมูลที่มีอยู่แล้ว ซึ่งเป็นรูปแบบที่ควรค้นหา: เพดานสูงสุดที่ไฟล์พิสูจน์ได้ด้วยตัวเอง ไม่ใช่ค่าคงที่ที่กำหนดขึ้นเอง

// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
  NVal := 0;                          // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
  Exit;                               // header cannot contain that many pairs

// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
  Exit;                               // refuse before allocating the buffer

เพดานสูงสุดอีกสองรายการทำหน้าที่เติมเต็มแนวป้องกันในตัวอ่านของเรา ทั้งสองรายการจัดส่งในเวอร์ชัน v2.12.0 ตัวอ่านสตรีมจะปฏิเสธ /Length ใด ๆ ที่มีขนาดใหญ่กว่าไฟล์ทั้งหมดก่อนที่จะจองบัฟเฟอร์ผลลัพธ์ — สตรีมไม่สามารถมีขนาดใหญ่กว่าคอนเทนเนอร์ที่มันอาศัยอยู่ได้ ดังนั้นการตรวจสอบนี้จึงไม่มีผลบวกปลอม และเส้นทางการขยายข้อมูล (inflate path) จะจำกัดการส่งออกข้อมูลที่คลายบีบอัดไว้ที่ 256 MiB ซึ่งจะปิดการทำงานของ zlib bomb แบบคลาสสิกที่อินพุตไม่กี่กิโลไบต์จะขยายตัวอย่างไร้ขอบเขต ขีดจำกัดดังกล่าวเอื้อเฟื้อเพียงพอสำหรับสตรีม PDF จริงในขณะที่ทำให้กรณีที่แย่ที่สุดสามารถอยู่รอดได้ หัวข้อหลักในทั้งสามอย่างนั้นเหมือนกัน: ทุกขนาดที่ไฟล์ประกาศคือการอ้างสิทธิ์ และตัวแยกวิเคราะห์จะตรวจสอบการอ้างสิทธิ์แต่ละรายการกับสิ่งที่สามารถวัดได้ก่อนที่จะส่งมอบหน่วยความจำให้ ทัศนคติการตรวจสอบเดียวกันนี้ใช้กับระดับที่ต่ำกว่าหนึ่งระดับที่ขอบเขตการเชื่อมโยงข้อมูล ซึ่งครอบคลุมในการเสริมความปลอดภัยให้กับ PDFium ABI และความปลอดภัยของหน่วยความจำใน Delphi

จุดที่กฎโทเค็นเต็มใบยังไม่เพียงพอ

ขีดจำกัดที่แท้จริง เพื่อให้คุณไม่ไว้วางใจรูทีนข้างต้นมากเกินไป การจับคู่โทเค็นเต็มรูปแบบจะแก้ไขการระบุคีย์ แต่การค้นหาไบต์แบน ๆ บนช่วงพจนานุกรมยังคงไม่สามารถบอกได้ว่าข้อมูลที่ตรงกันนั้นอยู่ภายในพจนานุกรมที่ซ้อนกัน สตริงสัญพจน์ (literal string) หรือความคิดเห็นหรือไม่ — ฟังก์ชัน FindDictKey บนวัตถุหน้าเว็บสามารถไปตกที่คีย์ภายในพจนานุกรมย่อย /Resources ของมันได้หากคุณป้อนช่วงข้อมูลที่กว้างเกินไป ตัวอ่านของเราจะจำกัดช่วงข้อมูลให้อยู่ในเนื้อหาวัตถุเดียวก่อน และประมวลผลบริบทสตริงและความคิดเห็นเป็นรายการตรวจสอบแยกต่างหากที่ยังคงเปิดอยู่ ความปลอดภัยของสตริงย่อยเป็นเพียงขั้นหนึ่งของบันได ไม่ใช่บันไดทั้งหมด: ความสอดคล้องของการอ้างอิงข้ามเป็นระเบียบวินัยเฉพาะตัว ซึ่งครอบคลุมในการตรวจสอบความถูกต้องของวัตถุและสตรีม xref และแคตตาล็อกภัยคุกคามที่กว้างขึ้นสำหรับเอกสารที่คุณไม่ได้เป็นผู้สร้างจะอยู่ในการตรวจสอบความเสี่ยงด้านความปลอดภัยของ PDF

หากคุณดูแลตัวอ่านพจนานุกรมที่เขียนด้วยมือใน Delphi หรือ Lazarus รายการตรวจสอบจากเหตุการณ์นี้จะสั้นมาก ค้นหา (grep) ทุก Pos('/ ในโค้ดเบส และกำหนดเส้นทางข้อมูลที่พบผ่านตัวช่วยเหลือโทเค็นเต็มรูปแบบตัวเดียว แสดงรายการกลุ่มคำนำหน้าที่คีย์ของคุณเข้าร่วม — /Length, /Encrypt, /N, /Type เทียบกับ /Type1 ทั้งหมดนี้ปรากฏในไฟล์จริง จากนั้นไล่ดูจำนวนเต็มทุกตัวที่ไปถึง SetLength, GetMem หรือลูปคัดลอก และถามว่าขอบเขตใดจำกัดมันไว้: ขนาดไฟล์, เพดานสูงสุดที่ได้รับจากส่วนหัว หรือไม่มีอะไรเลย เลเยอร์การแยกวิเคราะห์ที่อธิบายไว้ที่นี่เป็นรากฐานภายใต้ PDFium Component ของเรา ซึ่งตัวอ่านระดับไบต์และเอ็นจินการเรนเดอร์จะตรวจสอบข้ามกันในทุกเอกสารที่สัมผัส