เอกสาร PDF ไม่ใช่สิ่งที่คุณเพียงเปิดอ่าน แต่มันเปรียบเสมือนโปรแกรมขนาดเล็กที่คุณสั่งให้ทำงาน ฟอนต์ที่ฝังอยู่ด้านในทุกตัวคือตัวตีความคำสั่งแบบสแต็ก (stack-based interpreter) ที่คอยรับคำสั่งรูปสัญลักษณ์ตัวอักษร รูปภาพทุกรูปคือส่วนถอดรหัสข้อมูลที่ทำงานบนฟิลด์ขนาดความกว้าง ความสูง และความลึกบิตข้อมูลที่ตัวไฟล์กำหนดมา และสตรีมข้อมูลทุกชุดจะวิ่งเข้ามาในรูปแบบที่ถูกครอบด้วยตัวกรองที่ตัวไฟล์ระบุพารามิเตอร์เอาไว้ ตัวเลขเหล่านี้ไม่ใช่ตัวเลขที่คุณกำหนดเอง แต่มันถูกกำหนดโดยใครก็ตามที่เป็นคนผลิตไฟล์ ซึ่งในการทำงานจริงอาจเป็นใบแจ้งหนี้ของลูกค้าหรือไฟล์แนบจากผู้ส่งที่ไม่รู้จัก ตัวถอดรหัสที่แปลงข้อมูลไบต์เหล่านั้นให้เป็นพิกเซลรูปภาพและตัวอักษรคือพื้นที่เสี่ยงต่อการถูกโจมตี และตัววิเคราะห์ (parser) ที่ให้ความไว้วางใจข้อมูลนำเข้าเหล่านี้โดยไม่มีการตรวจสอบย่อมเสี่ยงต่อการหยุดทำงานหรือสร้างความเสียหายที่รุนแรงกว่านั้นเพียงแค่เปิดไฟล์ที่ออกแบบมาไม่ถูกต้องไฟล์เดียว
ยูนิต PDFlibPas ได้รับการปรับปรุงความปลอดภัยโดยกำหนดให้ทุกพาธการถอดรหัสต้องดำเนินการแบบมีการป้องกันความเสี่ยงสูงสุด ครอบคลุมตั้งแต่การทำงานของฟอนต์ (TrueType, Type1, CFF และตาราง CMap) ตัวถอดรหัสรูปภาพ (PNG, GIF, TIFF, JBIG2 และ CCITT Group 3 และ Group 4) ไปจนถึงตัวกรองสตรีม (LZW, ASCII85 และฟังก์ชันทำนายค่า Flate) รายละเอียดด้านล่างนี้คือข้อบกพร่องห้าประเภทใหญ่ที่ได้รับการแก้ไขและปิดตัวลง ซึ่งแต่ละจุดมีต้นเหตุมาจากพฤติกรรมการประมวลผลเฉพาะของ Delphi ข้อผิดพลาดเหล่านั้นได้รับการแก้ไขเรียบร้อยแล้วในเวอร์ชันปัจจุบัน และแนวทางความบกพร่องลักษณะเดียวกันนี้มักจะเกิดขึ้นได้เสมอในโค้ด Pascal ใด ๆ ที่ทำการวิเคราะห์ข้อมูลอินพุตจากแหล่งข้อมูลที่ไม่น่าเชื่อถือ
ปัญหาจำนวนเต็มล้นขอบเขตที่ส่งผลให้ได้พื้นที่จัดเก็บข้อมูลขนาดเล็กเกินไป
ข้อผิดพลาดด้านความปลอดภัยของหน่วยความจำแบบคลาสสิกในตัวถอดรหัสรูปภาพคือผลคูณของมิติภาพเกิดปัญหาล้นขีดจำกัดและวนกลับ ตัวถอดรหัสจะอ่านค่าความกว้าง ความสูง จำนวนส่วนประกอบ และความลึกบิตข้อมูล จากนั้นนำตัวเลขเหล่านี้มาคูณกันเพื่อกำหนดขนาดผลลัพธ์เพื่อจองหน่วยความจำตามจำนวนไบต์ที่คำนวณได้ และนำข้อมูลรูปภาพจริงเขียนลงไป หากกระบวนการคูณคำนวณในรูปจำนวนเต็ม 32 บิต ผลคูณที่ได้อาจจะวนกลับมาเป็นตัวเลขขนาดเล็กแม้ว่าส่วนคูณแต่ละตัวจะมีขนาดปกติวิสัย ส่งผลให้การจองหน่วยความจำประมวลผลได้สำเร็จแต่มีขนาดเล็กเกินไปมาก และตัวถอดรหัสจะเขียนข้อมูลทะลุหน่วยความจำที่จองไว้ นี่คือพฤติกรรมประเภท CWE-190 (จำนวนเต็มล้นขอบเขต) ที่นำไปสู่การเขียนข้อมูลนอกพื้นที่ของฮีป (heap out-of-bounds write: CWE-787) ในขั้นตอนถัดไป
พาธจัดเก็บรูปภาพหลักมีการกำหนดควบคุมมิติไว้ไม่ให้เกิน 65535 แต่ตัวถอดรหัสแยกอิสระบางตัวไม่ได้รับการกำหนดค่านี้ โค้ดคำนวณแบบนำจำนวนไบต์แถวคูณความสูงอย่าง ByteCount * FHeight หรือการคูณหาขนาดต่อพิกเซลอย่าง FWidth * Components * BitDepth จะถูกประมวลผลเป็นผลคูณขนาด 32 บิตใน Delphi เมื่อตัวคูณทั้งสองฝั่งเป็นจำนวนเต็มขนาด 32 บิต โดยไม่สนใจว่าตัวแปรที่คุณจะนำมารับผลลัพธ์จะกว้างใหญ่เพียงใด ขนาดความกว้างและความสูงที่ 60000 พิกเซลนั้นถือเป็นตัวเลขปกติวิสัยสำหรับรูปภาพสแกนขนาดใหญ่ แต่ผลคูณไบต์ของมันจะล้นขีดจำกัดจำนวนเต็มมีเครื่องหมายขนาด 32 บิต และได้ผลลัพธ์เป็นตัวเลขขนาดเล็ก กับดักแบบเดียวกันนี้เคยพบในฟังก์ชันทำนายค่าระยะพิกเซลของ ZLib ด้วยเช่นกัน ได้แก่ BitsPerComponent * Colors * Columns
แนวทางแก้ไขคือต้องแปลงให้ตัวคูณอย่างน้อยหนึ่งตัวเป็นประเภท Int64 เพื่อบังคับให้สมการทั้งหมดถูกคำนวณที่ระดับ 64 บิต หลังจากนั้นเปรียบเทียบผลลัพธ์กับค่า MaxInt เพื่อตรวจสอบและปฏิเสธไฟล์อินพุตหากมีขนาดใหญ่เกินความเหมาะสม ก่อนที่จะลดขนาดตัวเลขลงมาเพื่อเรียกใช้ฟังก์ชัน SetLength
// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);
สิ่งที่ทำให้เรื่องนี้เป็นประเด็นเฉพาะของ Delphi มากกว่าภาษาอื่นคือขั้นตอนการลดขนาดข้อมูลแบบเงียบ การมอบหมายผลลัพธ์ที่กว้างเกินไปให้แก่ตัวแปรปลายทางขนาด 32 บิตเป็นการแปลงประเภทข้อมูลที่คอมไพเลอร์อนุญาตและไม่มีการเตือนความเข้มงวดเป็นค่าเริ่มต้น และระบบการตรวจสอบขอบเขตก็จะไม่สแกนจับตัวแปรที่มีการวนกลับก่อนที่มันจะถูกนำไปใช้งานเป็นดัชนีชี้ตำแหน่งหน่วยความจำ หากทิ้งผลคูณไว้ที่ระดับ 32 บิต ตัวภาษาจะส่งคืนค่าความยาวที่ผิดเพี้ยนไปรายงานตำแหน่งหน่วยความจำที่ตัวถอดรหัสกำลังจะเข้าเรียกใช้งาน
ประเภทข้อมูลของฟิลด์ข้อมูลที่ส่งผลให้ตัวป้องกันไม่มีโอกาสทำงาน
ไฟล์ประเภท TIFF คือห่วงโซ่เชื่อมต่อระหว่างไดเรกทอรีไฟล์รูปภาพ โดยแต่ละไดเรกทอรีจะระบุตำแหน่งออฟเซตไบต์ของไดเรกทอรีตัวถัดไป ไฟล์ที่เป็นอันตรายสามารถปรับแต่งค่าข้อมูลพิกัดให้ตัวเชื่อมชี้ย้อนกลับมาหาตัวเองเป็นลูปวงกลม และทำให้โปรแกรมอ่านที่วิเคราะห์เส้นทางทำงานค้างอยู่ในลูปโดยไม่มีเงื่อนไขสั่งจบการทำงาน นี่คือสถานการณ์ประเภท CWE-835 (ลูปไม่สิ้นสุดจากการควบคุมของผู้โจมตี) และวิธีการป้องกันคือการใช้ตัวแปรตัวนับจำนวนรอบเพื่อสั่งยุติการทำงานเมื่อผ่านขีดจำกัดที่ไม่มีไฟล์ปกติไฟล์ใดจะพิมพ์ยาวขนาดนั้น
ตัวแปรนับจำนวนหน้าถูกประกาศเป็นประเภท Word ซึ่งใน Delphi สามารถเก็บค่าข้อมูลได้ตั้งแต่ 0 ถึง 65535 ตัวลูปมีตัวป้องกันขอบเขตเพื่อสั่งยุติการทำงานระบุว่า "ให้หยุดเมื่อจำนวนหน้านับได้เกิน 65535" ซึ่งดูถูกต้องตามหลักคิดดีจนกระทั่งคุณสังเกตเห็นว่าตัวแปรที่นำมาเปรียบเทียบและค่าขีดจำกัดนั้นใช้ระดับประเภทข้อมูลเดียวกัน ตัวแปรประเภท Word ไม่มีทางมีค่าตัวเลขมากกว่า 65535 ได้ การเปรียบเทียบจึงเป็นเท็จ (false) เสมอในระดับโครงสร้างคำสั่ง: เมื่อตัวนับจำนวนรอบทำงานถึง 65535 การบวกค่าเพิ่มครั้งถัดไปจะวนกลับมาเป็น 0 ตัวป้องกันจึงไม่มีวันมองเห็นค่าที่สูงเกินขีดจำกัด และส่งผลให้ห่วงโซ่ความสัมพันธ์ IFD วนซ้ำค้างไม่สิ้นสุด
แนวทางแก้ไขคือต้องเพิ่มขนาดของประเภทข้อมูลให้กว้างขึ้น เพื่อให้ตัวป้องกันสามารถรองรับเปรียบเทียบตัวเลขที่ตัวแปรเก็บได้จริง เมื่อปรับปรุงประกาศ TPDFTIFF.FPageCount ให้เป็น Integer การตรวจสอบ FPageCount > 65535 จะกลับมาทำงานและคำนวณได้อย่างถูกต้อง ตัวลูปจะสามารถยุติการทำงานได้สำเร็จ และพร็อพเพอร์ตี้สาธารณะ PageCount ก็ได้รับการปรับปรุงประเภทข้อมูลให้สอดคล้องกันโดยไม่สร้างผลกระทบต่อโค้ดเรียกใช้งานส่วนบน ทุกครั้งที่ตัวป้องกันขอบเขตถูกเขียนในลักษณะ Value > MaxValueOfType(Value) และตัวแปรนั้นมีประเภทข้อมูลสูงสุดเท่ากับขีดจำกัดนั้น ตัวเงื่อนไขจะเป็นเท็จเสมอ: ให้ทำการขยายขนาดประเภทข้อมูลของตัวแปร หรือเลือกใช้การตรวจสอบแบบเท่ากันเทียบกับค่าสูงสุดเพื่อให้สามารถทริกเกอร์แจ้งเตือนได้
การปิดฟังก์ชันตรวจสอบขอบเขตบนพาธประมวลผลความเร็วสูง
เมื่อเปิดใช้งานฟังก์ชันตรวจสอบขอบเขต (range checking) ตัว Delphi จะแทรกคำสั่งวิเคราะห์ดัชนีในอาร์เรย์และสตริงทุกจุดเพื่อช่วยแยกความแตกต่างระหว่างการดักข้อยกเว้นกรณีดัชนีเกินขอบเขตด้วย ERangeError เทียบกับการปล่อยให้ดัชนีดังกล่าวดำเนินการเขียนหรืออ่านข้อมูลพ้นหน่วยความจำที่อ็อบเจกต์นั้นครอบครอง ในพาธประมวลผลความเร็วสูง (hot paths) บางครั้งโปรแกรมเมอร์จะสั่งปิดฟังก์ชันนี้ด้วยไดเรกทีฟเฉพาะที่ {$R-} ซึ่งเป็นสิ่งที่พอเข้าใจเหตุผลได้ตราบใดที่ดัชนีเหล่านั้นยังคงเป็นข้อมูลที่มีความถูกต้องและเชื่อถือได้อยู่
เมธอดเข้าถึงข้อมูลรายการตัวอักษรของตัวตีความฟอนต์ TPDFlibStringList.Get ทำงานบนพาธประมวลผลความเร็วสูงลักษณะนี้ บนระบบ Windows มันถูกคอมไพล์โดยปิดขั้นตอนการตรวจสอบขอบเขตและเข้าชี้ตำแหน่งดัชนีในหน่วยความจำสำรองตรง ๆ ดังนั้นดัชนีที่ระบุเกินขอบเขตจึงไม่แสดงรายงานข้อผิดพลาดแต่เป็นการเข้าใช้หน่วยความจำโดยตรง วิธีนี้ทำงานได้ดีตราบใดที่ดัชนีมีความถูกต้อง แต่จะสร้างปัญหารุนแรงทันทีในตัวตีความคำสั่ง CFF หรือ Type2 charstring ซึ่งข้อมูลดัชนีที่ใช้อาจจะถูกกำหนดมาจากไฟล์อินพุต ตัวคำสั่งที่ดึงข้อมูลตัวดำเนินการ (operand) จากโครงสร้างสแต็กที่ว่างเปล่าจะได้ค่าดัชนีเป็นลบหนึ่ง หรือรหัสชี้ glyph คลาดเคลื่อนไปหนึ่งตัวเทียบกับจำนวนจริงจะชี้ไปยังตำแหน่งหน่วยความจำที่พ้นโครงสร้างจริงไปหนึ่งช่อง เมื่อปิดการตรวจสอบขอบเขต ทั้งสองกรณีจะกลายเป็นการเข้าถึงหน่วยความจำเกินขอบเขตจริงแทนที่จะแสดงข้อยกเว้นแจ้งเตือน และเนื่องจากช่องข้อมูลเหล่านั้นเก็บสตริงประเภท AnsiString ที่มีการนับการอ้างอิง การอ่านพิกัดที่คลาดเคลื่อนจึงอาจทำลายค่าตัวนับอ้างอิงของสตริงให้เสียหายตามไปด้วย
ขั้นตอนการเพิ่มความปลอดภัยไม่ได้สั่งปรับเปลี่ยนให้เปิดการทำงานตรวจสอบขอบเขตสำหรับพาธประมวลผลความเร็วสูงอีกครั้ง แต่เราเลือกที่จะเขียนโค้ดตรวจสอบดัชนีให้ถูกต้องอย่างแน่นอนล่วงหน้าก่อนประมวลผล: โดยก่อนดึงข้อมูลด้านบนสแต็กตัวดำเนินการ ตัวตีความคำสั่งจะต้องตรวจสอบว่าสแต็กไม่ว่างเปล่า และตัวป้องกันดัชนีทุกตัวถูกเขียนให้อยู่ภายใต้เงื่อนไขแบบมีค่าน้อยกว่า (less-than) จำนวนนับอย่างเคร่งครัด แทนที่จะเป็นแบบน้อยกว่าหรือเท่ากับซึ่งอาจหลุดข้อผิดพลาดคลาดเคลื่อนไปหนึ่งหลักได้ ไดเรกทีฟพิเศษนี้ได้โยกย้ายหน้าที่ความรับผิดชอบการดูแลขอบเขตหน่วยความจำจากตัวคอมไพเลอร์มาอยู่ที่คุณแทน และการตรวจสอบความถูกต้องที่คุณเลือกถอดถอนออกไปจึงต้องใส่ทดแทนกลับคืนด้วยตนเองในทุกจุดเข้าใช้งาน
การเรียกใช้ฟังก์ชันซ้ำแบบไม่มีเพดานสูงสุดในตัวตีความคำสั่ง charstring
คำสั่งประเภท Type2 charstring สามารถเรียกใช้งานฟังก์ชันย่อย (subroutine) ได้ และตัวฟังก์ชันย่อยเองก็สามารถเรียกใช้งานฟังก์ชันอื่นต่อกันเป็นลำดับชั้น ตัวดำเนินการเรียกฟังก์ชันทั้งแบบท้องถิ่นและสากลจึงยินยอมให้ตัวไฟล์เป็นผู้สั่งการความลึกของการซ้อนฟังก์ชันได้ ฟังก์ชันย่อยที่เขียนให้เรียกใช้ตัวมันเองไม่ว่าจะโดยตรงหรือเป็นลูปวงกลม จะสร้างปัญหาเรียกซ้ำไม่สิ้นสุด (recurse without end) จนกระทั่งพื้นที่หน่วยความจำสแต็กหมดลงและกระบวนการทำงานถูกสั่งปิดลงทันที นี่คือสภาวะประเภท CWE-674 (การเรียกใช้ฟังก์ชันซ้ำแบบไม่ควบคุม)
ตัวตีความคำสั่ง Type1 มีการเขียนคำสั่งป้องกันปัญหานี้อยู่แล้ว โดยมีตัวแปรนับระดับความลึกของการเรียกใช้งานและกำหนดระดับขีดจำกัดสูงสุด PLType1MaxCallDepth เพื่อปฏิเสธการลงลึกเกินกว่าขนาดความลึกที่มาตรฐานของ Type1 กำหนดไว้ ตัวตีความคำสั่ง Type2 ซึ่งถูกเพิ่มเติมเข้าในภายหลังและมีโครงสร้างคล้ายคลึงกันไม่ได้นำระบบตัวป้องกันนี้มาใช้งาน ส่งผลให้ฟอนต์ที่จงใจสร้างมาให้มีฟังก์ชันย่อยที่เรียกใช้งานรหัสหมายเลขตัวเองสามารถวิ่งทะลุผ่านและสร้างปัญหาสแต็กข้อมูลล้น (stack overflow) ได้ทันที
// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out
แนวทางแก้ไขคือต้องกำหนดขีดจำกัดความลึกของพาธ Type2 ให้เทียบเท่ากับที่พาธ Type1 มีอยู่แล้ว ทุกขั้นตอนการลงลึกประมวลผลที่มีโครงสร้างข้อมูลควบคุมจากผู้โจมตี ไม่ว่าจะเป็นฟังก์ชันย่อยของฟอนต์ อาร์เรย์ซ้อน หรือห่วงโซ่การอ้างอิงไขว้ ล้วนจำเป็นต้องกำหนดเพดานขีดจำกัดความลึกสูงสุดที่ไฟล์ข้อมูลนำเข้าไม่สามารถปรับขยายเพิ่มขึ้นได้เอง
หน่วยความจำที่ไม่มีการเคลียร์ค่าเริ่มต้นที่เกิดรั่วไหลเข้าไปในข้อมูลผลลัพธ์
ข้อบกพร่องที่ซ่อนเร้นและแก้ไขยากที่สุดคือข้อมูลบางส่วนในฮีปเกิดรั่วไหลเข้าไปในข้อมูลผลลัพธ์ที่ถอดรหัสแล้ว สาเหตุหลักมาจากคุณลักษณะการทำงานของฟังก์ชัน SetLength ที่ละเลยได้ง่าย เมื่อคุณขยายขนาด AnsiString ด้วยคำสั่ง SetLength ตัว Delphi จะทำการจองพื้นที่ข้อมูลไบต์ในหน่วยความจำแต่ไม่ได้จัดการล้างข้อมูลเก่าให้เป็นศูนย์ ข้อมูลใหม่ที่ได้มาจึงมีโอกาสมีค่าเศษซากเก่าค้างอยู่ในหน่วยความจำฮีปนั้น หากโค้ดทำงานโดยการเขียนข้อมูลทับไบต์ทุกจุดในลำดับถัดไป ปัญหานี้จะไม่ส่งผลใด ๆ เลย แต่หากมีบางพาธการทำงานที่เหลือช่องข้อมูลไบต์ไว้โดยไม่มีการเขียนข้อมูลทับและส่งค่าดังกล่าวกลับออกไปเป็นเอาต์พุต ข้อมูลเก่าตกค้างเหล่านั้นจะเดินทางออกไปพร้อมกับผลลัพธ์ด้วย นี่คือลักษณะปัญหาประเภท CWE-457 (การใช้หน่วยความจำโดยไม่มีการเคลียร์ค่าเริ่มต้น) และเมื่อผลลัพธ์วิ่งข้ามขอบเขตความปลอดภัย ข้อมูลนั้นจะกลายเป็นการรั่วไหลของข้อมูลความลับทันที
พาธการถอดรหัสของ AES-CBC ประสบปัญหานี้อย่างตรงจุด พื้นที่จัดเก็บข้อมูลเอาต์พุตถูกกำหนดขนาดด้วย SetLength และตัวถอดรหัสจะประมวลผลข้อความเข้ารหัสลับทีละบล็อกขนาด 16 ไบต์ เมื่อขนาดความยาวของข้อความที่เข้ารหัสลับไม่ลงตัวกับขนาด 16 ไบต์ (ซึ่งความยาวนี้เป็นสิ่งที่ผู้โจมตีสามารถจงใจระบุเข้ามาได้) บล็อกส่วนปลายสุดท้ายจะไม่ได้รับคำสั่งเขียนข้อมูลทับ ส่งผลให้ข้อมูลส่วนปลายคงค่าเดิมในฮีปที่ SetLength ละเลยทิ้งไว้ และส่งคืนข้อมูลเอาต์พุตนั้นกลับไปให้ในฐานะข้อมูลที่ถอดรหัสแล้ว วิธีป้องกันมีสองขั้นตอนร่วมกันและจำเป็นต้องรันควบคู่กัน: โดยในปัจจุบันจุดเข้าใช้งานการถอดรหัสจะทำหน้าที่ปฏิเสธข้อความที่เข้ารหัสลับที่ขนาดความยาวไม่ลงตัวกับขนาดบล็อก และในขั้นปลายทางผลลัพธ์จะถูกเคลียร์ข้อมูลด้วยฟังก์ชัน FillChar ก่อนประมวลผลเพื่อให้อินพุตส่วนใดที่ไม่ได้คำสั่งเขียนทับจะส่งคืนเป็นค่าศูนย์แทนการส่งค่าตกค้างในหน่วยความจำ
ข้อบกพร่องเหล่านี้ได้รับการแก้ไขและปิดตัวลงในยูนิตเวอร์ชันปัจจุบันของ PDFlibPas ซึ่งเป็นเอ็นจิ้นหลักสำหรับ Delphi และ C++Builder หากขอบเขตงานของคุณต้องการตรวจสอบว่าตัวไฟล์ได้รับการปกป้องอย่างไรเพิ่มเติม สามารถศึกษาได้จากบันทึกประกอบใน การตรวจสอบการเข้ารหัสและสิทธิ์การใช้งาน และคู่มือใน การพรีไฟลต์วิเคราะห์ PDF/A และ PDF/UA ซึ่งครอบคลุมขั้นตอนการวิเคราะห์ของตัววิเคราะห์เดียวกัน และทั้งหมดนี้มีพร้อมใช้งานภายใต้ PDFlibPas Delphi PDF Library ร่วมกับ API การโหลด การแสดงผล และการลงนามดิจิทัลที่มีในบล็อกนี้