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

ป้องกันตัวเซ็น PDF ใน Delphi จาก PKCS#12 ที่มุ่งร้าย

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

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

แผนภาพของ HotPDF แสดงข้อบกพร่องเรื่องจำนวนรอบ PBKDF2 และทางแก้: จำนวนรอบปลอมระดับสี่พันล้านตรึง CPU ไว้จนกระทั่งขอบเขตล่างและบนใน HPDFCrypt ปฏิเสธมัน
หนึ่งร้อยล้านอยู่สูงกว่าไฟล์ที่ชอบธรรมทุกไฟล์ ขณะที่ยังจำกัดกรณีเลวร้ายที่สุดไว้ — ถอดรหัสด้วยความกว้างเต็ม แล้วจึงตีกรอบ แล้วจึงใช้งาน
HotPDF: ช่องก่อนและหลังของข้อบกพร่องการล้นค่าความยาว ASN.1 ที่ความยาวปลอมถูกบวกในขนาด 32 บิตแล้วเล็ดลอดการตรวจขอบเขตไปได้ จนกระทั่ง HPDFASN1ParseNode อ่านเป็น Int64 แล้วปฏิเสธมัน
ตัวป้องกันทำงานกับเงาที่ถูกตัดทอนของค่าจริง การอ่านด้วยความกว้างเต็มก่อนทำให้ความยาวปลอมปรากฏตัวก่อนที่ SetLength ใดจะเกิดขึ้น

ไบต์เหล่านั้นเดินทางไปไหนบ้าง

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

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

ไปป์ไลน์การนำเข้า .pfx ใน HotPDF: ไบต์ที่ผู้โจมตีมีอิทธิพลเหนือมันผ่าน HPDFPFX และ HPDFASN1 ก่อนถึงการอนุมานคีย์ใน HPDFCrypt และการเซ็นด้วย HPDFRSA และ HPDFCMS
ตัวอ่าน PKCS#12 คือพื้นผิวการโจมตี — วิทยาการเข้ารหัสที่อยู่ปลายน้ำไม่มีความหมายเลยถ้าตัวแยกวิเคราะห์สองตัวนั้นสะเพร่า
// ขับไปป์ไลน์ทั้งสาย: โหลด PDF ที่เตรียมช่องไว้ แยกวิเคราะห์ PFX
// อนุมานคีย์ สร้าง CMS SignedData แล้วเขียนไฟล์ที่เซ็นแล้วออกมา
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // ลายเซ็นถูกฝังแล้ว
else
  // การเซ็นไม่สำเร็จ
;

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

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

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

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

// กับดัก: เลขคณิตมีเครื่องหมายขนาด 32 บิต เมื่อ ContentLen ใกล้ MaxInt
// ค่า Pos + ContentLen ล้นกลายเป็นค่าติดลบ การเปรียบเทียบจึงเป็นเท็จ
// แล้วความยาวปลอมราว 2 GB ก็แล่นผ่านไปได้ตรง ๆ
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

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

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

// ถูกต้อง: ขยายตัวถูกดำเนินการทั้งสองเป็น Int64 ก่อนบวก ผลรวมจึง
// ล้นไม่ได้ ความยาวปลอมขนาด 2 GB ตกการตรวจขอบเขตแล้ว
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 แล้วจึงรันตัวเข้ารหัสลับ ในที่นี้คือ AES-256-CBC PBKDF2 รับจำนวนรอบเข้าไป และจำนวนรอบนั้นเป็นพารามิเตอร์ที่ติดมากับไฟล์ จุดประสงค์ทั้งหมดของมันคือให้ช้า: รอบยิ่งมาก การเดารหัสผ่านแต่ละครั้งยิ่งแพง ซึ่งเป็นเรื่องดีเมื่อเจอผู้โจมตีแบบออฟไลน์ RFC 8018 §4.2 ระบุชัดว่าจำนวนรอบที่มากกว่าย่อมปลอดภัยกว่า และจงใจไม่กำหนดเพดานไว้

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

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

// อ่านจำนวนรอบเป็น Int64 ก่อน แล้วบีบให้อยู่ในช่วงที่สมเหตุสมผล
// ก่อนที่มันจะถูกย่อลงฟิลด์ Iterations ขนาด 32 บิตที่ PBKDF2 ใช้
LIter := HPDFASN1ToInteger(Data, Node);          // คืนค่าเป็น 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);                    // ปลอดภัย: ตีกรอบไว้แล้ว

การอ่านลงใน Int64 แปลว่าค่าที่ถอดรหัสได้คือค่าจริง ไม่ใช่เงาที่ถูกตัดทอนของมัน ขอบเขตล่างปฏิเสธค่าศูนย์และค่าติดลบ ซึ่งไร้ความหมายสำหรับการอนุมานคีย์ ส่วนขอบเขตบนที่หนึ่งร้อยล้านนั้นอยู่สูงกว่าไฟล์ PKCS#12 ที่ชอบธรรมทุกไฟล์อยู่มาก เพราะทุกวันนี้ใช้กันในระดับหลักหมื่นถึงหลักแสนต้น ๆ ขณะเดียวกันก็จำกัดกรณีเลวร้ายที่สุดไว้ที่ปริมาณงานที่มีขอบเขตและรอดได้ ค่าจะถูกย่อลงฟิลด์ 32 บิตก็ต่อเมื่อผ่านช่วงนั้นมาแล้วเท่านั้น การตัดทอนจึงทำให้ใครประหลาดใจไม่ได้อีก ใน HotPDF การบีบค่านี้อยู่ใน ParsePBES2Params ซึ่งเป็นที่ที่พารามิเตอร์ของ PBKDF2 ถูกถอดรหัสระหว่างทางไปยัง PBKDF2HMACSHA256

เหตุใดทางแก้ทั้งสองจึงเป็นทางแก้เดียวกัน

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

แนวปฏิบัติสำหรับไปป์ไลน์การเซ็นเอกสาร

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

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

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