Technical Article

การเพิ่มความปลอดภัยให้แก่ตัวลงนาม PDF ใน Delphi จากไฟล์ PKCS#12 ที่เป็นอันตราย

เมื่อคุณลงนามในเอกสาร PDF คุณมักจะคิดว่าคีย์สำหรับลงนามนั้นเป็นสิ่งที่คุณสามารถควบคุมได้ทั้งหมด มันอยู่ในไฟล์ .pfx ที่คุณเป็นผู้สร้างขึ้นและได้รับการป้องกันด้วยรหัสผ่านที่คุณเป็นผู้เลือก โค้ดที่อ่านไฟล์นั้นอาจให้ความรู้สึกเป็นเพียงข้อต่อส่งข้อมูลธรรมดา ไม่ใช่แนวป้องกันการเข้าถึง แต่สัญชาตญาณนั้นจะผิดทันทีที่ใบรับรอง (certificate) ดังกล่าวไม่ได้มาจากคุณอีกต่อไป ไม่ว่าจะเป็นเครื่องมือบนเดสก์ท็อปที่อนุญาตให้ผู้ใช้เลือกไฟล์ .pfx ใด ๆ ก็ได้ เซิร์ฟเวอร์ที่รับข้อมูลประจำตัวที่อัปโหลดขึ้นมา หรือระบบลงนามแบบกลุ่ม (batch signer) ที่ได้รับใบรับรองผ่านระบบเครือข่าย ทั้งหมดนี้ล้วนต้องส่งข้อมูลไบต์ที่ผู้โจมตีอาจดัดแปลงได้ไปยังตัววิเคราะห์ (parser) ก่อนที่ข้อมูลการลงนามไบต์แรกจะถูกสร้างขึ้นด้วยซ้ำ ตัวอ่าน PKCS#12 จึงถือเป็นพื้นที่สำหรับโจมตี (attack surface) ในลักษณะเดียวกับตัวถอดรหัสรูปภาพหรือตัวโหลดฟอนต์

บทความนี้จะพาไปสำรวจข้อบกพร่องสองประการที่เคยอยู่ในตัวอ่านดังกล่าว ซึ่งเกิดขึ้นในพาธการนำเข้าข้อมูลประจำตัวเพื่อลงนาม ปัญหานี้ไม่ใช่เรื่องแปลกใหม่ ทั้งสองเกิดจากสาเหตุหลักเดียวกันซึ่งพบในตัววิเคราะห์เลขฐานสองเกือบทุกตัวที่เขียนขึ้นด้วยภาษาที่มีการระบุความกว้างของจำนวนเต็มแบบคงที่: นั่นคือ ค่าความยาวหรือจำนวนนับจากไฟล์ได้รับการไว้วางใจเกินความเหมาะสมไปหนึ่งขั้นตอน ข้อบกพร่องแรกนำไปสู่การอ่านข้อมูลนอกขอบเขตหน่วยความจำ (out-of-bounds read) และข้อบกพร่องที่สองนำไปสู่กระบวนการค้างของระบบจนกระทั่งคุณต้องสั่งยุติการทำงาน

เส้นทางการเดินทางของข้อมูลไบต์

การนำเข้าไฟล์ .pfx เพื่อลงนามในเอกสารไม่ใช่การทำงานขั้นตอนเดียว แต่มันคือไพป์ไลน์สั้น ๆ และในแต่ละขั้นตอนจะทำการวิเคราะห์สิ่งใด ๆ ที่ผู้โจมตีอาจเป็นผู้เขียนขึ้น ตัวคอนเทนเนอร์คือโครงสร้าง PKCS#12 ตามที่ระบุไว้ในมาตรฐาน RFC 7292 ซึ่งเป็นชุดข้อมูลของ AuthenticatedSafe ที่ซ้อนกันอยู่รอบ ๆ โครงสร้างเข้ารหัสที่เก็บรักษาคีย์ส่วนตัว (private key) การอ่านค่าข้อมูลดังกล่าวหมายถึงการเข้าอ่านข้อมูลแบบ ASN.1 การแปลงรหัสผ่านเป็นคีย์ การถอดรหัส และการส่งต่อคีย์ RSA ที่กู้คืนได้ให้แก่โค้ดสำหรับสร้างข้อมูลลงนาม

ใน HotPDF ขั้นตอนเหล่านั้นจะเชื่อมโยงไปยังยูนิตที่แยกต่างหาก ตรรกะของคอนเทนเนอร์ PKCS#12 จะอยู่ใน HPDFPFX ข้อมูลแท็ก ความยาว และค่าทุกอย่างที่เรียกใช้จะถูกถอดรหัสโดยตัวอ่าน ASN.1 ใน HPDFASN1 ส่วนการดึงข้อมูลคีย์และการถอดรหัส PBES2 จะอยู่ใน HPDFCrypt เคียงคู่กับ PBKDF2HMACSHA256 เมื่อกู้คืนคีย์สำเร็จแล้ว ยูนิต HPDFRSA และระบบสร้าง CMS SignedData ใน HPDFCMS จะแปลงข้อมูลเหล่านั้นให้เป็นลายเซ็นดิจิทัลที่ฝังอยู่ในไฟล์ PDF จุดเข้าถึงสาธารณะที่ใช้ขับเคลื่อนห่วงโซ่การทำงานทั้งหมดนี้มีเพียงการเรียกใช้คำสั่งเดียว

// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature embedded
else
  // signing did not complete
;

ข้อมูลทุกไบต์ของไฟล์ signer.pfx จะต้องไหลผ่าน HPDFASN1 และ HPDFPFX ก่อนที่การทำงานรหัสลับใด ๆ จะเกิดขึ้น หากยูนิตทั้งสองตัวนี้ไม่ตรวจสอบข้อมูลที่ได้รับจากตัวไฟล์อย่างรอบคอบ ระบบเข้ารหัสถัดไปในลำดับถัดไปก็จะไม่มีโอกาสได้ช่วยป้องกันปัญหาใด ๆ เลย

ข้อบกพร่องที่หนึ่ง: ค่าความยาว ASN.1 ที่วนกลับจนข้ามผ่านตัวป้องกัน

รูปแบบการเข้ารหัส ASN.1 ในมาตรฐาน DER และ BER จะกำหนดทุกองค์ประกอบด้วยข้อมูลแท็ก ความยาว และจำนวนไบต์เนื้อหาตามความยาวนั้น ค่าความยาวคือฟิลด์ที่คุณจำเป็นต้องให้ความไว้วางใจแต่ในขณะเดียวกันก็ต้องตรวจสอบ เนื่องจากมันเป็นตัวบอกให้ตัววิเคราะห์ข้อมูลทราบว่าจะต้องอ่านไกลเท่าใด และค่านี้ถูกกำหนดโดยใครก็ตามที่เป็นคนสร้างไฟล์ มาตรฐาน X.690 §8.1.3 กำหนดการเข้ารหัสไว้สองรูปแบบ รูปแบบสั้นจะบรรจุขนาดความยาว 0 ถึง 127 ไบต์ไว้ในไบต์เดียว ส่วนรูปแบบยาวซึ่งใช้สำหรับข้อมูลที่มีขนาดใหญ่กว่า จะใช้ไบต์นำหน้าหนึ่งไบต์โดยใช้บิตล่างเจ็ดบิตระบุจำนวนไบต์ของขนาดความยาวที่จะตามมา จากนั้นจึงใช้ไบต์แบบ big-endian ในจำนวนเท่านั้นระบุค่าจริง ดังนั้น ไบต์ขนาดความยาวสี่ไบต์จึงสามารถระบุขนาดเนื้อหาข้อมูลที่เข้าใกล้สี่กิกะไบต์ได้เลย

หลังจากที่ถอดรหัสค่านั้นได้แล้ว ตัววิเคราะห์จะต้องตรวจสอบว่าเนื้อหาดังกล่าวยังคงอยู่ภายในพื้นที่จัดเก็บข้อมูล (buffer) จริง ๆ ก่อนที่จะอนุญาตให้ผ่านไป การตรวจสอบพื้นฐานคือการยืนยันว่าตำแหน่งปัจจุบันเมื่อรวมกับความยาวของเนื้อหาจะต้องไม่เกินขีดจำกัดสูงสุดของข้อมูล หากเขียนคำสั่งด้วยวิธีทั่วไป โดยกำหนดตำแหน่ง ความยาวเนื้อหา และขนาดทั้งหมดไว้ในจำนวนเต็มแบบมีเครื่องหมายขนาด 32 บิต ตัวป้องกันนี้จะเกิดข้อผิดพลาดขึ้น:

// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

ปัญหาไม่ได้อยู่ที่การเปรียบเทียบแต่อยู่ที่ตัวบวกต่างหาก เมื่อค่า ContentLen อยู่ใกล้กับค่า MaxInt (2147483647) ผลรวมของ Pos + ContentLen จะเกิดปัญหาหน่วยความจำล้น (overflow) ขีดจำกัดของจำนวนเต็มมีเครื่องหมายขนาด 32 บิตและวนกลับกลายเป็นค่าลบ ซึ่งผลรวมที่เป็นค่าลบจะไม่มีทางมีค่ามากกว่า Total ส่งผลให้ตัวป้องกันรายงานว่าทุกอย่างปกติดีและปล่อยให้ตัววิเคราะห์ทำงานต่อด้วยขนาดความยาวเนื้อหาจำลองประมาณสองกิกะไบต์ที่ไม่มีอยู่จริงในพื้นที่จัดเก็บข้อมูล สิ่งที่เกิดขึ้นตามมาคือความเสียหาย: ตัวอ่านจะจองพื้นที่จัดเก็บตามความยาวที่กล่าวอ้างและคัดลอกข้อมูลเข้าไป โดยมีคำสั่ง SetLength ตามด้วย Move เพื่ออ่านค่าข้อมูลจากแหล่งข้อมูล ซึ่งแท้จริงแล้วแหล่งข้อมูลมีข้อมูลเหลืออยู่เพียงไม่กี่ร้อยไบต์เท่านั้น ทำให้การคัดลอกข้อมูลเป็นการอ่านเกินขอบเขตอินพุตจริงไปมาก ซึ่งอย่างดีที่สุดจะทำให้ระบบหยุดทำงาน (crash) และอย่างร้ายที่สุดคือทำให้เกิดข้อมูลหน่วยความจำของกระบวนการข้างเคียงรั่วไหลเข้าไปในข้อมูลวิเคราะห์

แนวทางป้องกันที่ถูกต้องเพียงอย่างเดียวคือการเพิ่มขนาดการเก็บข้อมูลของผลรวมขั้นกลางก่อนทำการเปรียบเทียบ เพื่อไม่ให้การบวกเกิดปัญหาล้นประเภทข้อมูลที่ใช้คำนวณ การแก้ไขคือการขยายขนาดตัวดำเนินการทั้งสองตัวให้เป็นแบบ Int64:

// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

ประเภทข้อมูล Int64 สามารถเก็บผลรวมของค่าขนาด 32 บิตสองตัวได้โดยไม่มีการสูญเสียข้อมูล การเปรียบเทียบจึงมองเห็นตัวเลขจริงและปฏิเสธความยาวจำลองนั้นได้ ส่วนการตรวจสอบค่าไม่เป็นลบที่แยกเฉพาะสำหรับ ContentLen จะช่วยปิดกรณีที่ค่าจากการถอดรหัสส่งคืนค่าเป็นลบในตัวมันเองด้วย ใน HotPDF ตัวป้องกันนี้จะอยู่ใน HPDFASN1ParseNode ซึ่งเป็นฟังก์ชันที่สร้างโหนดสำหรับตัวช่วยตัวอื่น ๆ ทั้งหมด เนื่องจาก HPDFASN1Content กำหนดขนาดการเรียกใช้ SetLength และ Move จากความยาวเนื้อหาของโหนดโดยตรง โหนดใดที่ผ่านตัวป้องกันที่ผิดพลาดจึงอาจทำให้การอ่านค่าใด ๆ หลังจากนั้นเกิดข้อบกพร่องตามไปด้วย การแก้ไขขีดจำกัด ณ จุดถอดรหัสจึงช่วยให้ฟังก์ชันช่วยเหลือชั้นบนทั้งหมดทำงานได้อย่างปลอดภัย

ข้อบกพร่องที่สอง: การใช้จำนวนรอบซ้ำของ PBKDF2 เป็นอาวุธโจมตี

ข้อบกพร่องประการที่สองไม่ใช่ข้อผิดพลาดทางหน่วยความจำ แต่เป็นการที่ไฟล์เป็นผู้สั่งให้ CPU ของคุณทำงานหนักขึ้น โครงสร้าง PKCS#12 ปกป้องคีย์ของมันด้วย PBES2 ซึ่งเป็นรูปแบบการป้องกันด้วยรหัสผ่านจาก PKCS#5 ตามระบุใน RFC 8018 ฟังก์ชัน PBES2 จะรันฟังก์ชันดึงข้อมูลคีย์ ในกรณีนี้คือ PBKDF2 ร่วมกับ HMAC-SHA-256 จากนั้นใช้รหัสเข้ารหัสลับ (cipher) ในกรณีนี้คือ AES-256-CBC โดยที่ PBKDF2 จะรับจำนวนรอบซ้ำ (iteration count) ซึ่งถูกเก็บเป็นพารามิเตอร์ส่งมาพร้อมกับไฟล์ จุดประสงค์เดียวของจำนวนรอบคือต้องการให้การทำงานช้าลง: ยิ่งมีรอบการทำงานซ้ำมากเท่าใด การเดารหัสผ่านในแต่ละครั้งก็จะมีต้นทุนสูงขึ้นเท่านั้น ซึ่งเป็นสิ่งที่ดีสำหรับการป้องกันการโจมตีแบบออฟไลน์ ข้อกำหนด RFC 8018 §4.2 ระบุไว้ชัดเจนว่าการตั้งจำนวนรอบให้สูงส่งผลดีต่อความปลอดภัย และไม่ได้กำหนดขีดจำกัดเพดานสูงสุดไว้แต่อย่างใด

ความยืดหยุ่นในเรื่องนี้ไม่มีปัญหาใด ๆ เมื่อคุณเป็นผู้สร้างไฟล์ขึ้นมาเอง แต่มันจะกลายเป็นอาวุธทำลายระบบทันทีเมื่อผู้โจมตีเป็นคนกำหนดค่าเข้ามา จำนวนรอบการทำงานซ้ำกลายเป็นปัจจัยการควบคุมภาระงานโดยผู้โจมตี และการควบคุมภาระงานโดยผู้โจมตีจะส่งผลให้เกิดการปฏิเสธการให้บริการเนื่องจากความซับซ้อนของอัลกอริทึม (algorithmic-complexity denial of service) ไฟล์ .pfx ที่สร้างขึ้นอาจกำหนดจำนวนรอบไว้สูงเป็นหลักพันล้านรอบ ตัววิเคราะห์จะรับค่าข้อมูลตามหน้าที่และสั่งรันระบบ PBKDF2 ตามจำนวนรอบที่ระบุเพื่อใช้งาน HMAC-SHA-256 และส่งผลให้กระบวนการทำงานจมหายไปในลูปการทำงานที่ไม่ส่งคืนค่ากลับมาเลยเป็นเวลาหลายนาทีหรือหลายชั่วโมงเพียงเพราะไฟล์เดียวที่ได้รับมา บนเซิร์ฟเวอร์ลงนามเอกสารที่รับข้อมูลประจำตัวหนึ่งรายการต่อหนึ่งคำขอ การอัปโหลดไฟล์ที่ออกแบบมาเพียงไฟล์เดียวก็เพียงพอที่จะทำให้ระบบตัวประมวลผล (worker) ทั้งหมดค้างได้ทันที

จำนวนรอบดังกล่าวยังเพิ่มความรุนแรงของปัญหาการวนกลับของค่าตัวเลขก่อนที่จะสร้างความเสียหายให้ CPU ตัวแปรจำนวนรอบจะถูกเก็บไว้ในไฟล์ในรูปแบบ ASN.1 INTEGER ซึ่งไม่มีขนาดความกว้างคงที่ ในขณะที่ฟิลด์ข้อมูลที่ PBKDF2 ใช้งานจริงคือค่า Integer ขนาด 32 บิต หากถอดรหัส INTEGER เข้าสู่ฟิลด์นั้นโดยตรง ค่าที่สูงมากจะถูกตัดทอน (truncated) และค่าที่ถูกออกแบบมาให้ตรงกับบิตเครื่องหมาย (sign bit) จะถูกอ่านค่าออกมาเป็นค่าลบหรือตัวเลขขนาดเล็กอื่น ๆ ที่ไม่เกี่ยวข้อง ทำให้ภาระงานที่เกิดขึ้นจริงไม่ใช่ภาระงานที่ตัวไฟล์อ้างว่าร้องขออีกต่อไป การแก้ไขคือการอ่านค่าที่ความกว้างเต็มขนาดก่อนและจำกัดขอบเขตค่านั้นก่อนจะทำการลดขนาดข้อมูลลง:

// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node);          // returns Int64
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // safe: already bounded

ทำไมการแก้ไขทั้งสองประการจึงเป็นแนวทางเดียวกัน

ข้อบกพร่องทั้งสองจุดอาจดูแตกต่างกัน จุดหนึ่งเกิดปัญหาน้ำล้นพื้นที่จัดเก็บและอีกจุดทำให้กระบวนการค้างการทำงาน แต่แท้จริงแล้วมันคือข้อผิดพลาดประการเดียวกัน ในแต่ละกรณี ตัวเลขที่ได้มาจากไฟล์ที่ไม่น่าเชื่อถือถูกนำไปใช้งานกับประเภทข้อมูลความกว้างคงที่เร็วเกินไปหนึ่งขั้นตอน ก่อนที่จะผ่านการตรวจสอบความสมเหตุสมผลจริง ๆ ค่าความยาวถูกนำมาบวกกันในรูปแบบข้อมูล 32 บิตก่อนผ่านการตรวจสอบขอบเขต ส่วนจำนวนรอบถูกลดขนาดลงเหลือ 32 บิตก่อนการทดสอบช่วงค่าการทำงาน ทั้งคู่สามารถแก้ไขได้ด้วยหลักการแบบเดียวกัน: ถอดรหัสที่ความกว้างเต็มขนาด ตรวจสอบเทียบกับขีดจำกัดจริง และหลังจากนั้นจึงค่อยลดขนาดลง การใช้ตัวแปรทางผ่าน Int64 ไม่ใช่การเขียนโค้ดเพื่อความสวยงาม แต่มันเป็นความกว้างขนาดเดียวที่จะช่วยให้ตัวป้องกันมองเห็นค่าจริงที่ผู้โจมตีป้อนเข้ามาได้ ขอบเขตที่เกิดปัญหาน้ำล้นไม่ได้ช่วยป้องกันปัญหา และจำนวนนับที่ไม่มีเพดานจำกัดสูงสุดก็ไม่ใช่พารามิเตอร์ที่ดี แต่มันคือเครื่องมือสั่งการภายนอกที่พร้อมควบคุมทำลาย CPU ของคุณเอง

คำแนะนำเชิงปฏิบัติสำหรับระบบการลงนาม

บทเรียนโดยย่อคือต้องตรวจสอบข้อมูลอินพุตของใบรับรองที่ไม่น่าเชื่อถือในลักษณะเดียวกับที่คุณตรวจสอบข้อมูลอัปโหลดทั่วไป ควรกำหนดขีดจำกัดขนาดของไฟล์ .pfx ที่ยอมรับ เนื่องจากไฟล์ที่ใช้งานได้จริงมักมีขนาดเป็นกิโลไบต์ไม่ใช่เมกะไบต์ ให้จัดการความล้มเหลวในการวิเคราะห์ข้อมูลเป็นกรณีการปฏิเสธการรับเข้าข้อมูลทั่วไป ไม่ใช่ข้อผิดพลาดใหญ่โตที่ต้องแจ้งข้อมูลโครงสร้างการเรียกฟังก์ชัน (stack trace) ให้ผู้ใช้ทราบ หากคุณดำเนินการลงนามบนเซิร์ฟเวอร์ ให้รันขั้นตอนการนำเข้าใบรับรองในจุดที่หากตัวประมวลผลเกิดค้างขึ้นมาจะไม่ฉุดให้บริการทั้งหมดล่มไปด้วย และควรกำหนดช่วงเวลาหมดเวลา (timeout) สำหรับการดำเนินการ เพื่อช่วยกำจัดปัญหาไฟล์ที่สร้างภาระงานผิดปกติด้วยเวลาทำงานจริง (wall-clock) เคียงคู่กับการควบคุมเพดานจำนวนรอบทำงานซ้ำ

บทเรียนในมุมกว้างยังครอบคลุมไปถึงสิ่งอื่นนอกจากใบรับรอง การเพิ่มความปลอดภัยให้ตัววิเคราะห์ไม่ใช่เรื่องของการตรวจสอบยูนิตใดยูนิตหนึ่งเพียงครั้งเดียว แต่มันคือคุณลักษณะของทุกจุดที่ไลบรารีของคุณทำการอ่านค่าข้อมูลไบต์ที่ไม่ได้เขียนขึ้นมาเอง ไลบรารี PDF ต้องวิเคราะห์ข้อมูลจำนวนมากจากแหล่งข้อมูลที่ไม่น่าเชื่อถือ: ทั้งฟอนต์ที่ฝังอยู่ในเอกสาร รูปภาพในรูปแบบตัวแปลงสัญญาณหลากหลายชนิด ตัวกรองสตรีม และในฝั่งพาธการลงนามคือตัวใบรับรอง ข้อมูลแต่ละส่วนเหล่านี้ถือเป็นพื้นที่เสี่ยงต่อการถูกโจมตี และข้อมูลความยาวรวมถึงจำนวนนับทุกอย่างต่างควรได้รับความระมัดระวังอย่างเท่าเทียมกัน HotPDF สร้างพาธการนำเข้าและการลงนามบนยูนิตที่มีความปลอดภัยสูง ได้แก่ HPDFASN1, HPDFPFX, HPDFCrypt และ HPDFCMS ที่อธิบายไว้ที่นี่ เพื่อให้ข้อมูลประจำตัวที่คุณป้อนเข้าไป ไม่ว่าจะมาจากที่ใดก็ตาม จะถูกวิเคราะห์อย่างระมัดระวังเป็นพิเศษก่อนที่จะนำไปใช้งานจริง

ขั้นตอนการทำงานของการลงนามที่ระบบตรวจสอบเหล่านี้ช่วยปกป้องมีรายละเอียดครอบคลุมแบบตั้งแต่ต้นจนจบใน บทความแนะนำการใช้งานลายเซ็นดิจิทัล PAdES ใน Delphi ของเรา และแนวทางการตั้งรับแบบเดียวกันที่ใช้กับการเข้ารหัสเอกสาร รวมถึงพาธคีย์ AES-256 ที่ใช้ฐานโค้ดร่วมกันนี้ มีอธิบายไว้ใน บทความเกี่ยวกับการเข้ารหัสด้วย AES-256 และความปลอดภัย ทั้งหมดนี้พร้อมใช้งานในฐานะส่วนหนึ่งของ HotPDF Component สำหรับ Delphi และ C++Builder ร่วมกับ API สำหรับการโหลด การแก้ไข การเข้ารหัส และการลงนามที่อธิบายในบล็อกนี้