คุณสร้างเวิร์กบุ๊ก เข้ารหัสด้วยรหัสผ่าน ส่งไฟล์ให้เพื่อนร่วมงาน และเพื่อนร่วมงานเปิดไฟล์นั้นใน Excel ตัว Excel จะถามหารหัสผ่าน เพื่อนร่วมงานพิมพ์รหัสผ่านลงไป และ Excel ยอมรับรหัสผ่านนั้น จนถึงจุดนี้การเข้ารหัสดูเหมือนจะถูกต้องดี แต่หลังจากนั้น Excel จะแสดงกล่องข้อความที่ระบุว่าไฟล์เสียหายและไม่สามารถเปิดได้ หรือเปิดขึ้นมาพร้อมค่าตารางของช่องเซลล์ที่ไม่มีความหมายใด ๆ รหัสผ่านนั้นถูกต้อง แต่ไฟล์ก็ยังคงเสียหายอยู่ดี นี่คือสภาวะความล้มเหลวที่สร้างความสับสนที่สุดสำหรับการเข้ารหัสตระกูล Office เนื่องจากส่วนที่ทำหน้าที่ยืนยันความถูกต้องของรหัสผ่านและส่วนที่ทำหน้าที่จัดเก็บข้อมูลจริงของคุณได้รับการปกป้องด้วยขั้นตอนที่แตกต่างกันสองขั้นตอน และการจัดการส่วนใดส่วนหนึ่งให้ถูกต้องก็ไม่ได้ช่วยรับประกันความถูกต้องของอีกส่วนหนึ่งเลย
บั๊กทั้งสองประเด็นที่อธิบายในบทความนี้มีลักษณะโครงสร้างปัญหาแบบเดียวกันนี้ทุกประการ โดยในแต่ละกรณี ตัวทดสอบความถูกต้อง (verifier) ประมวลผลผ่านแต่ส่วนเนื้อหาข้อมูลหลัก (body) ประมวลผลไม่ผ่าน ซึ่งอาจทำให้คุณหลงไปตามหาจุดบกพร่องในระบบตรวจสอบรหัสผ่านหรือพาธการดึงคีย์ที่จริง ๆ แล้วทำงานถูกต้องสมบูรณ์ดี ข้อผิดพลาดที่แท้จริงอยู่ปลายทางถัดไปในขั้นตอนที่ข้อมูลไบต์ของแพ็กเกจถูกแปลงค่า ข้อผิดพลาดทั้งสองตัวเป็นอิสระต่อกัน ตัวหนึ่งอยู่ในพาธ AES และอีกตัวอยู่ในพาธ RC4 แต่แชร์ปัญหาในการวิเคราะห์ร่วมกัน จึงคุ้มค่าที่เราจะทำความเข้าใจว่าทำไมผลลัพธ์ที่ถูกต้องเพียงครึ่งเดียวจึงเป็นกรณีที่ตรวจแก้ได้ยากที่สุด
ทำไมรหัสผ่านที่ถูกต้องจึงไม่ได้พิสูจน์อะไรเกี่ยวกับเนื้อหาข้อมูลหลักเลย
รูปแบบการเข้ารหัสที่ไฟล์ XLSX ยุคปัจจุบันเลือกใช้งานคือ ECMA-376 Standard Encryption ซึ่งจะจัดเก็บอ็อบเจกต์ที่เข้ารหัสสองชิ้นคู่ขนานกันไป ชิ้นแรกคือ EncryptionVerifier: ซึ่งเป็นบล็อกขนาดเล็กที่จัดเก็บค่าสุ่มและค่าแฮช (hash) ของค่าสุ่มนั้น โดยเข้ารหัสด้วยคีย์ที่ดึงมาจากรหัสผ่าน ชิ้นที่สองคือ EncryptedPackage: ซึ่งเป็นคอนเทนเนอร์ zip ของเวิร์กบุ๊กทั้งหมดที่ถูกเข้ารหัสด้วยคีย์เดียวกัน ตัวตรวจสอบ (verifier) นี้มีขึ้นมาเพื่อให้โปรแกรมอ่านสามารถยืนยันความถูกต้องของรหัสผ่านได้ล่วงหน้าก่อนที่จะต้องเสียเวลาประมวลผลถอดรหัสเนื้อหาข้อมูลหลักที่มีขนาดหลายเมกะไบต์ การถอดรหัสตัวตรวจสอบ การหาค่าแฮชของค่าสุ่ม และการเปรียบเทียบกับค่าแฮชที่บันทึกไว้ หากมีค่าตรงกันจะถือว่ารหัสผ่านมีความถูกต้อง
กับดักคือตัวตรวจสอบและตัวแพ็กเกจจะถูกเข้ารหัสผ่านคำสั่งเรียกแยกเฉพาะคนละตัวบนพื้นที่จัดเก็บที่แยกจากกัน คีย์ที่ถูกสร้างขึ้นอย่างถูกต้องย่อมสามารถถอดรหัสตัวตรวจสอบได้อย่างถูกต้องสมบูรณ์โดยไม่สนใจว่าผลลัพธ์ของแพ็กเกจหลังจากนั้นจะเป็นอย่างไร ดังนั้นหากขั้นตอนการแปลงคีย์ของคุณถูกต้องแต่กระบวนการแปลงแพ็กเกจผิดพลาด Excel จะยังคงกู้คืนรหัสผ่านจากตัวตรวจสอบได้สำเร็จและแสดงรายงานล้มเหลวที่เนื้อหาหลัก อาการแสดงจะถูกระบุว่า "รหัสผ่านถูกต้อง แต่ไฟล์เสียหาย" ซึ่งเบี่ยงเบนการสืบค้นไปที่พาธจัดการรหัสผ่านซึ่งเป็นจุดที่ไม่เคยมีข้อผิดพลาดเลย การแยกส่วนทำงานในลักษณะเดียวกันนี้ยังคุมโครงสร้างระบบ RC4 รุ่นเก่าด้วยเช่นกัน: โดยค่าแฮชตัวตรวจสอบจะได้รับการตรวจสอบเป็นจุดแรก และถึงแม้เนื้อหาหลักจะเบี่ยงเบนคลาดเคลื่อนไป ตัวขั้นตอนการตรวจสอบนี้ก็ยังคงรายงานผลสำเร็จอยู่
บั๊กที่หนึ่ง: การเรียกใช้ AES ในระบบ ECB แทนที่จะเป็น CBC
เอกสารข้อกำหนด [MS-OFFCRYPTO] §2.3.4.15 ระบุว่าระบบ Standard Encryption จะเข้ารหัสแพ็กเกจด้วย AES ในโหมด Electronic Codebook (ECB) บล็อกข้อมูลขนาด 16 ไบต์ของแพ็กเกจที่เติมเต็ม (padded) แต่ละบล็อกจะถูกเข้ารหัสแยกกันอย่างเป็นอิสระด้วยคีย์เดียวกัน ไม่มีการเชื่อมโยงระหว่างบล็อก (chaining) และไม่มีการระบุตัวแปลงค่าเริ่มต้น (initialization vector: IV) นี่เป็นตัวเลือกที่แปลกเมื่อเทียบกับมาตรฐานในปัจจุบันซึ่งมักจะหลีกเลี่ยงโหมด ECB เสมอ แต่ในการทำงานร่วมกันระหว่างระบบ (interoperability) เราไม่มีสิทธิ์ตั้งข้อสงสัยในข้อกำหนด Excel จะถอดรหัสแพ็กเกจในโหมด ECB เสนอ ดังนั้นตัวสร้างไฟล์จึงต้องเข้ารหัสด้วยโหมด ECB เท่านั้นเพื่อให้ทั้งสองระบบเข้าใจตรงกัน
ข้อผิดพลาดที่เกิดขึ้นคือแพ็กเกจถูกเข้ารหัสด้วย AES ในโหมด CBC โดยใช้พารามิเตอร์ IV เป็นศูนย์ทั้งหมด นี่เป็นสาเหตุที่ทำไมคำสั่งดังกล่าวจึงทำงานเกือบสำเร็จ และคำว่าเกือบคือจุดที่เลวร้ายที่สุดในการตรวจสอบแก้ไข ในโหมด CBC บล็อกข้อความดิบตัวแรกจะถูกรัน XOR ร่วมกับ IV ก่อนขั้นตอนการเข้ารหัส เมื่อ IV มีค่าเป็นศูนย์ทั้งหมด การรัน XOR จะไม่มีการเปลี่ยนแปลงใด ๆ ส่งผลให้บล็อกแรกของโหมด CBC-with-zero-IV ได้ผลลัพธ์ข้อความเข้ารหัสลับแบบเดียวกับโหมด ECB ทุกประการ แต่เมื่อเริ่มบล็อกที่สองเป็นต้นไป โหมด CBC จะดึงข้อความเข้ารหัสลับของบล็อกก่อนหน้ามาใช้คำนวณบล็อกถัดไป บล็อกทุกตัวถัดจากบล็อกแรกจึงเบี่ยงเบนออกไปจากโหมด ECB ทั้งหมด
ตอนนี้เมื่อนำเงื่อนไขนี้มาซ้อนทับกับโครงสร้าง เลย์เอาต์ของตัวแพ็กเกจจะวางข้อมูลขนาดความยาว 8 ไบต์แบบ little-endian ไว้ที่ส่วนเริ่มต้นสุดของไฟล์ ดังนั้นส่วนประกอบของไฟล์ที่ Excel ตรวจสอบเร็วที่สุดจะอยู่ในบล็อกแรกหรือบล็อกที่สอง การที่บล็อกแรกทำงานได้สอดคล้องกันพอดีหมายความว่าขั้นตอนการวิเคราะห์ด่านแรกสุดจะผ่านไปได้ด้วยดี ในขณะที่บล็อกถัดจากนั้นทั้งหมดจะถูกถอดรหัสออกมาเป็นสัญญาณรบกวนขยะทั้งหมด การแก้ไขจึงตรงไปตรงมาเมื่อระบุประเภทโหมดได้ถูกต้อง: คือการเข้ารหัสบล็อกข้อมูลขนาด 16 ไบต์ด้วยโหมด ECB และสั่งปิดการเชื่อมโยงข้ามบล็อก ในส่วนเอ็นจิ้น ฟังก์ชัน XlsEncryptStdPackage จะไล่อ่านตารางพื้นที่จัดเก็บขนาด 16 ไบต์ทีละบล็อกและเรียกใช้ AESEncryptECB128Block ในแต่ละบล็อก ซึ่งเป็นสถาปัตยกรรมตัวเดียวกับที่ใช้ประมวลผลบล็อกตัวตรวจสอบ ในโค้ดจะมีคำอธิบายระบุไว้ในลูปอย่างชัดเจนว่า: โหมด CBC ร่วมกับ IV ขนาดศูนย์จะให้ผลลัพธ์ตรงกับโหมด ECB เฉพาะบล็อกแรกเท่านั้น ส่วนที่เหลือของแพ็กเกจจะถูกถอดรหัสออกมาเป็นขยะและ Excel จะปฏิเสธไฟล์นั้นทันที
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
Book.Open('report.xlsx');
// SaveAsEncrypted serializes the workbook, then runs the
// ECMA-376 Standard Encryption pipeline: AES-128 ECB over the
// package per [MS-OFFCRYPTO] 2.3.4.15. Returns 1 on success.
if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
raise Exception.Create('Encryption failed');
finally
Book.Free;
end;
end;
บั๊กที่สอง: การเปลี่ยนคีย์ของ RC4 คลาดเคลื่อนผิดจังหวะ
พาธไฟล์ .xls รุ่นเก่าจะใช้งานระบบการเข้ารหัส RC4 CryptoAPI ซึ่งมีเงื่อนไขต่างออกไป เอกสารข้อกำหนด [MS-OFFCRYPTO] §2.3.6 ระบุว่าระบบการเข้ารหัสจะทำการเปลี่ยนคีย์ใหม่ (re-keyed) ในทุก ๆ ขอบเขตบล็อกขนาด 1024 ไบต์ ตัวสตรีมจะถูกซอยย่อยเป็นบล็อกขนาด 1024 ไบต์ คีย์ RC4 ชุดใหม่จะถูกสร้างขึ้นสำหรับบล็อกหมายเลข 0, 1, 2 เรียงลำดับไป และภายใต้แต่ละบล็อกข้อมูลการไหลของคีย์ (keystream) จะถูกเรียกใช้งานอย่างต่อเนื่องแบบไบต์ต่อไบต์ มีตัวแปรคงที่สองตัวที่ต้องสอดคล้องตรงกันเสมอ: คือการเปลี่ยนคีย์ใหม่บนทุกขอบเขตและการประมวลผลการไหลของคีย์โดยไม่มีช่องว่างภายในบล็อก เนื่องจาก RC4 เป็นระบบรหัสเข้ารหัสลับแบบสตรีม (stream cipher) การไหลของคีย์จึงเป็นลำดับที่จัดเรียงต่อเนื่องกันอย่างชัดเจน ข้อมูลไบต์ลำดับที่ n ที่คุณดึงมาใช้งานจะถูกกำหนดโดยจำนวนไบต์ที่คุณเคยดึงมาก่อนหน้านั้นทั้งหมด ขั้นตอนถอดรหัสจะคำนวณด้วยการ XOR แบบเดียวกันเทียบกับลำดับเดียวกัน ซึ่งหมายความว่าฝั่งผู้ผลิตและผู้บริโภคข้อมูลจำเป็นต้องดึงข้อมูลไบต์เดียวกัน ณ ตำแหน่งพิกัดเดียวกันอย่างไม่มีข้อยกเว้น
นี่คือความท้าทายทั้งหมดของกระบวนการ ระบบรหัสเข้ารหัสลับแบบสตรีมไม่มีกลไกปรับประสานพิกัดเวลาใหม่ (resynchronization) หากคุณปล่อยให้ข้อมูลคีย์รั่วไหลไปเพียงหนึ่งไบต์ ข้อมูลไบต์ถัดจากนั้นทั้งหมดจะถูกนำไป XOR ร่วมกับค่าคีย์ที่ผิดตำแหน่ง และข้อผิดพลาดจะไม่มีทางแก้ไขตัวเองได้สำเร็จ มันจะแผ่ความเสียหายลามยาวไปจนถึงจุดสิ้นสุดของบล็อก และเมื่อตำแหน่งการทำงานผิดเพี้ยนไปแล้ว มันจะลามไปยังบล็อกถัดไปทั้งหมดด้วย บั๊กที่พบในส่วนนี้ทำงานเช่นนั้นทุกประการ ตัวแปรนับบล็อกเริ่มต้นด้วยค่า sentinel เป็นลบหนึ่ง และรูทีนการข้ามคิดเอาเองว่าตัวนับนั้นสอดคล้องกับบล็อกปัจจุบันอยู่แล้ว เมื่อเริ่มประมวลผลจากค่า sentinel นั้น มันจะสั่งเปลี่ยนคีย์และประมวลผลคีย์เต็มขนาด 1024 ไบต์ซึ่งแท้จริงแล้วไม่ควรถูกนำมาใช้งานเลย และทำให้จำนวนที่เหลือเกิดมีค่าติดลบ ตั้งแต่นั้นเป็นต้นไป ตัวถอดรหัสจะทำงานผิดจังหวะ (out of phase) ไปหนึ่งบล็อกเต็ม ตัวตรวจสอบซึ่งผ่านขั้นตอนตรวจวิเคราะห์ก่อนหน้านี้จะยังคงผ่านฉลุย รหัสผ่านจึงดูถูกต้องดีในขณะที่ช่องเซลล์ข้อมูลจริงถอดรหัสออกมาเป็นขยะทั้งหมด
ตรรกะที่ได้รับการแก้ไขแล้วจะบันทึกอยู่ในฟังก์ชัน TXLSDecrypterRC4 ทั้งเมธอด Skip และ Decrypt จะประมวลผลภายใต้ลูปเดียวกันคือ: จะเปลี่ยนคีย์ใหม่เฉพาะเมื่อตำแหน่งพิกัดการทำงานวิ่งข้ามเข้าสู่บล็อกใหม่เท่านั้น โดยที่ดัชนีบล็อกคือตำแหน่งปัจจุบันหารด้วย REKEY_BLOCK_SIZE (1024) จากนั้นประมวลผลข้อมูลไปจนถึงตำแหน่งสิ้นสุดของบล็อกปัจจุบันและไม่มีการคำนวณเกินกว่านั้น ฟังก์ชัน MakeKey จะถูกเรียกใช้พร้อมระบุดัชนีบล็อกเสมอ และไม่มีการส่งค่าล้าสมัยหรือค่าตัวตรวจสอบจำลองเข้ามา และตำแหน่งพิกัดการประมวลผลจะขยับตามจำนวนไบต์ที่ใช้งานจริงอย่างแม่นยำ เพื่อให้ Skip และ Decrypt ยังคงทำงานสอดคล้องตรงจังหวะกับผู้สร้างไฟล์ บทเรียนสำคัญอยู่ในระดับหน่วยที่เล็กที่สุด: ข้อมูลที่เพี้ยนไปเพียงหนึ่งไบต์ในระบบสตรีมไม่ใช่ข้อผิดพลาดเล็กน้อย แต่มันคือความล้มเหลวโดยสมบูรณ์ของระบบประมวลผลถัดไปทั้งหมด
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
// CanReadEncrypted checks the Compound File (OLE2) signature so
// you can branch before attempting a normal Open. OpenEncrypted
// routes plain files to Open and handles the encrypted container.
if Book.CanReadEncrypted('legacy.xls') then
Book.OpenEncrypted('legacy.xls', 'S3cret!')
else
Book.Open('legacy.xls');
// read cells here
finally
Book.Free;
end;
end;
การทำงานร่วมกันภายใต้ข้อกำหนดที่ตายตัวคือความแม่นยำระดับไบต์
ข้อบกพร่องทั้งสองตัวชี้ไปยังหลักการต้นตอประการเดียวกัน และเป็นหลักการที่คุ้มค่าแก่การหยิบยกมาเน้นย้ำเนื่องจากมันส่งผลต่อวิธีที่คุณใช้ประเมินทางเลือกการออกแบบ เมื่อปลายทางผู้รับเอาต์พุตของคุณคือโปรแกรมภายนอกที่ตายตัวซึ่งคุณไม่มีสิทธิ์ไปแก้ไขใด ๆ ตัวโหมดการเข้ารหัสและขั้นตอนการเปลี่ยนคีย์ใหม่จะไม่ใช่รายละเอียดเชิงปฏิบัติที่คุณสามารถนำไปปรับแต่งเพิ่มประสิทธิภาพหรือทำให้ง่ายลงตามใจชอบได้ แต่มันคือข้อตกลงการรับส่งข้อมูล (wire contract) ที่ละเมิดไม่ได้ Excel จะสั่งถอดรหัสด้วยโหมด ECB และเปลี่ยนคีย์บนขอบเขต 1024 ไบต์เสมอไม่ว่าพฤติกรรมนี้จะตรงใจคุณหรือไม่ และหน้าที่เดียวของคุณคือการผลิตข้อมูลไบต์ให้สามารถถอดรหัสออกมาตรงตามไฟล์เดิมภายใต้กระบวนการนั้นพอดิบพอดี โหมดที่ทันสมัยกว่า IV ที่ดูเหมือนไม่มีอันตราย หรือตัวนับจำนวนที่เริ่มต้นตรงตำแหน่งที่สะดวก สิ่งเหล่านี้จะกลายเป็นข้อบกพร่องทันทีหากคลาดเคลื่อนไปจากที่เครื่องอ่านข้อมูลปลายทางคาดหวัง การเขียนโค้ดรองรับมาตรฐานข้อกำหนดที่ตายตัวจะไม่มีคำว่าประมาณการ แต่มันต้องแม่นยำระดับไบต์เท่านั้น
นี่เป็นเหตุผลที่การตรวจสอบด้วยตัวตรวจสอบเพียงอย่างเดียวถือเป็นการทดสอบเบื้องต้น (smoke test) ที่ไม่มีประสิทธิภาพเพียงพอ มันบอกแค่ว่าการแปลงคีย์ทำงานสำเร็จ ซึ่งเป็นสิ่งจำเป็นแต่ยังไม่เพียงพอที่จะใช้เป็นตัวตัดสิน การทดสอบที่ทำเพียงแค่เปิดไฟล์เข้ารหัสและยืนยันว่าป้อนรหัสผ่านผ่านจะรายงานผลสำเร็จทั้ง ๆ ที่เนื้อหาภายในทั้งหมดไม่สามารถอ่านได้ การทดสอบที่แท้จริงต้องทำการถอดรหัสแพ็กเกจและเปรียบเทียบข้อมูลไบต์ที่ได้เทียบกับอินพุตดั้งเดิม หรือทำการเปิดบันทึกเวิร์กบุ๊กวนซ้ำผ่านระบบเข้ารหัสและถอดรหัสเพื่ออ่านค่าเซลล์กลับมาตรวจสอบ ตัวตรวจสอบพิสูจน์รหัสผ่าน และมีเพียงเนื้อหาหลักเท่านั้นที่ช่วยยืนยันความสมบูรณ์ของการเข้ารหัส
วิธีการเขียนและอ่านเวิร์กบุ๊กที่ได้รับการปกป้องที่รองรับอย่างเป็นทางการ
ข้อมูลฝั่งภายนอกมีขนาดกระชับ ในการบันทึกเวิร์กบุ๊กยุคใหม่ที่ปกป้องด้วยรหัสผ่าน ให้กรอกข้อมูลหรือเปิด TXLSXWorkbook และเรียกใช้เมธอด SaveAsEncrypted พร้อมระบุชื่อไฟล์และรหัสผ่าน มันจะแปลงข้อมูลเวิร์กบุ๊กเป็นข้อมูลอนุกรมและรันขั้นตอนการเข้ารหัส Standard Encryption ที่ได้รับการแก้ไขแล้ว โดยจะส่งคืนค่า 1 เมื่อทำงานสำเร็จ ในส่วนขั้นตอนการอ่านให้เรียก CanReadEncrypted เพื่อตรวจสอบว่าไฟล์นั้นเป็นคอนเทนเนอร์ไฟล์ร่วมแบบเข้ารหัส (encrypted Compound File container) หรือไม่ จากนั้นจึงแยกขั้นตอนการประมวลผล: OpenEncrypted จะจัดการพาธไฟล์เข้ารหัสและสลับกลับมาใช้ Open สำหรับไฟล์ทั่วไป และ Open แบบระบุรหัสผ่านพร้อมใช้งานโดยตรง ขั้นตอนการจัดการโหมดและลูปเปลี่ยนคีย์ที่ระบุไว้ข้างต้นทำงานอยู่เบื้องหลังการเรียกใช้เหล่านี้ คุณมีหน้าที่ระบุรหัสผ่านและชื่อไฟล์ และตัวเอ็นจิ้นจะจัดการทุกอย่างตามข้อกำหนดมาตรฐานให้เอง
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
Book.Open('quarterly.xlsx');
Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
// Reopen on the consumer side
Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
finally
Book.Free;
end;
end;
โครงสร้างของผลลัพธ์ที่ได้รับการปกป้อง สตรีม EncryptionInfo บล็อกตัวตรวจสอบ และเลย์เอาต์ของตัวแพ็กเกจ มีรายละเอียดใน บทความการนำเสนอข้อมูลเอาต์พุต XLSX ที่เข้ารหัสด้วย AES ของเรา และสำหรับประเด็นการล็อกในระดับแผ่นงาน (sheet-level locking) และการทำงานของระบบป้องกันที่เกี่ยวเนื่องกับการตั้งค่าหน้ากระดาษและการสั่งพิมพ์ สามารถศึกษาต่อได้ใน บทความการป้องกัน การตั้งค่าหน้ากระดาษ และการสั่งพิมพ์ ทั้งหมดนี้พัฒนาขึ้นบนพาธเข้ารหัสที่อธิบายไว้ในบทความนี้ ซึ่งมีมาพร้อมเป็นส่วนหนึ่งของ HotXLS spreadsheet component สำหรับ Delphi และ C++Builder ร่วมกับ API การอ่าน การเขียน และการแสดงผลที่มีระบุไว้ในบล็อกนี้