Technical Article

Tăng cường bảo mật cho công cụ ký số PDF bằng Delphi trước các tệp PKCS#12 độc hại

Khi bạn ký một tài liệu PDF, bạn thường nghĩ khóa ký là thứ bạn kiểm soát hoàn toàn. Nó tồn tại trong một tệp .pfx bạn đã tự tạo và bảo vệ bằng mật khẩu bạn đã chọn. Mã nguồn đọc tệp đó cho cảm giác giống như đường ống dẫn thông tin thông thường hơn là một ranh giới bảo mật. Trực giác đó sẽ sai ngay khi chứng chỉ không còn thuộc về bạn. Một công cụ máy tính cho phép người dùng chọn bất kỳ tệp .pfx nào, một máy chủ chấp nhận thông tin xác thực tải lên, một hệ thống ký số hàng loạt nhận chứng chỉ qua mạng, tất cả đều chuyển các byte bị tin tặc can thiệp đến một trình phân tích cú pháp trước khi một byte chữ ký số duy nhất được tạo ra. Trình đọc PKCS#12 chính là một bề mặt tấn công (attack surface), theo cùng nghĩa như một bộ giải mã hình ảnh hoặc một bộ tải font chữ.

Bài viết này đi sâu vào phân tích hai lỗi thực tế tồn tại trong trình đọc đó, cả hai đều nằm trên đường dẫn nhập chứng chỉ ký số. Không có lỗi nào là xa lạ. Cả hai đều có cùng một nguyên nhân gốc rễ vốn ảnh hưởng đến hầu hết các trình phân tích cú pháp nhị phân được viết bằng một ngôn ngữ có các kiểu số nguyên có độ rộng cố định: một độ dài hoặc một số đếm từ tệp được tin tưởng nhiều hơn mức cần thiết. Một lỗi dẫn đến việc đọc ngoài vùng biên (out-of-bounds read), lỗi còn lại dẫn đến tiến trình bị treo cho đến khi bị buộc tắt.

Nơi các byte dữ liệu đi qua

Việc nhập một tệp .pfx để ký một tài liệu không phải là một thao tác đơn lẻ mà là một đường ống quy trình ngắn, và mỗi giai đoạn đều phân tích cú pháp thứ gì đó có thể do kẻ tấn công viết ra. Vỏ bọc chứa là một cấu trúc PKCS#12 được định nghĩa trong RFC 7292, một tổ hợp các phân đoạn AuthenticatedSafe bọc quanh một lớp mã hóa bảo vệ chứa khóa riêng tư. Việc đọc nó đồng nghĩa với duyệt qua cấu trúc ASN.1, rút trích khóa từ mật khẩu, giải mã, sau đó chuyển khóa RSA đã phục hồi cho mã nguồn xây dựng chữ ký số.

Trong HotPDF, các giai đoạn đó được ánh xạ tới các đơn vị (units) riêng biệt. Logic vùng chứa PKCS#12 nằm trong HPDFPFX. Mỗi thẻ (tag), độ dài và giá trị mà nó tương tác đều được giải mã bởi trình đọc ASN.1 trong HPDFASN1. Quá trình dẫn xuất khóa và giải mã PBES2 nằm trong HPDFCrypt bên cạnh PBKDF2HMACSHA256. Khi khóa được phục hồi, HPDFRSA và bộ dựng CMS SignedData trong HPDFCMS sẽ chuyển đổi nó thành chữ ký số tách rời (detached signature) được nhúng trong PDF. Điểm vào công khai điều khiển toàn bộ chuỗi này chỉ là một lệnh gọi.

// 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
;

Mọi byte của tệp signer.pfx đều đi qua HPDFASN1 and HPDFPFX trước khi bất kỳ hoạt động mã hóa nào diễn ra. Nếu hai đơn vị này không kiểm soát cẩn thận những gì tệp tin cung cấp, các hoạt động mật mã hóa phía sau sẽ không bao giờ có cơ hội hoạt động.

Lỗi thứ nhất: độ dài ASN.1 bị xoay vòng vượt qua chốt bảo vệ

ASN.1 trong DER và BER mã hóa mọi phần tử dưới dạng một thẻ (tag), một độ dài (length) và một lượng byte nội dung tương ứng. Độ dài là trường thông tin bạn bắt buộc phải kiểm chứng, vì nó cho trình phân tích cú pháp biết cần đọc bao xa, và nó được viết bởi bất kỳ ai đã tạo ra tệp tin đó. X.690 §8.1.3 định nghĩa hai kiểu mã hóa. Dạng ngắn đóng gói độ dài từ 0 đến 127 vào một byte đơn lẻ. Dạng dài, được sử dụng cho bất kỳ giá trị nào lớn hơn, sử dụng một byte dẫn đường mà bảy bit thấp của nó cho biết số lượng byte độ dài theo sau, sau đó là lượng byte định dạng big-endian tương ứng mang giá trị thực tế. Bốn byte độ dài do đó có thể khai báo kích thước nội dung lên tới gần bốn gigabyte.

Sau khi giải mã một giá trị như vậy, trình phân tích cú pháp phải kiểm tra xem nội dung có thực sự khớp bên trong bộ đệm hay không trước khi tin cậy nó. Phép kiểm tra thông thường là xác nhận rằng vị trí hiện tại cộng với độ dài nội dung không vượt quá phần cuối của dữ liệu. Nếu được viết theo cách thông thường, với vị trí, độ dài nội dung và tổng dung lượng đều được lưu trữ trong các số nguyên có dấu 32-bit, chốt bảo vệ đó sẽ bị hỏng:

// 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');

Vấn đề nằm ở phép cộng chứ không phải phép so sánh. Khi ContentLen ở gần giá trị MaxInt (2147483647), biểu thức Pos + ContentLen sẽ tràn dải số có dấu 32-bit và quay vòng trở lại thành một số âm. Một tổng số âm thì không bao giờ lớn hơn Total, do đó chốt bảo vệ báo cáo rằng mọi thứ đều ổn và cho phép trình phân tích cú pháp tiếp tục với độ dài nội dung khoảng hai gigabyte mà bộ đệm thực tế không hề chứa. Hậu quả xảy ra sau đó là sự cố nghiêm trọng: trình đọc cấp phát một bộ đệm cho độ dài đã khai báo đó và sao chép vào đó, lệnh SetLength theo sau bởi một lệnh Move đọc từ nguồn. Nguồn chỉ còn vài trăm byte, do đó việc sao chép sẽ đọc vượt xa phần cuối của đầu vào, một lỗi đọc ngoài vùng biên vốn nhẹ thì làm treo chương trình, nặng thì làm rò rỉ bộ nhớ tiến trình lân cận vào trình phân tích.

Chốt bảo vệ chính xác duy nhất là mở rộng tổng trung gian trước khi thực hiện so sánh, để phép cộng không thể bị tràn kiểu dữ liệu mà nó được tính toán. Giải pháp khắc phục là nâng cả hai toán hạng lên kiểu 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');

Kiểu Int64 chứa tổng của hai giá trị 32-bit mà không bị mất mát dữ liệu, do đó phép so sánh sẽ nhìn thấy con số thực tế và từ chối độ dài giả mạo. Việc kiểm tra giá trị không âm riêng biệt đối với ContentLen giải quyết trường hợp giá trị được giải mã tự động chuyển thành số âm. Trong HotPDF, chốt bảo vệ này nằm trong HPDFASN1ParseNode, hàm tạo ra nút (node) mà mọi hàm trợ giúp khác dựa vào. Bởi vì HPDFASN1Content xác định kích thước của SetLengthMove trực tiếp từ độ dài nội dung của nút, một nút vượt qua một chốt bảo vệ bị lỗi sẽ làm hỏng mọi thao tác đọc lấy từ nó. Việc sửa lỗi giới hạn biên tại thời điểm giải mã là điều làm cho các hàm trợ giúp phía trên nó trở nên an toàn.

Lỗi thứ hai: số lượt lặp PBKDF2 bị sử dụng làm vũ khí tấn công

Lỗi thứ hai không phải là lỗi bộ nhớ, mà là việc tệp tin yêu cầu CPU của bạn phải làm việc nặng đến mức nào. PKCS#12 bảo vệ dữ liệu khóa của nó bằng PBES2, cơ chế dựa trên mật khẩu từ PKCS#5, được đặc tả trong RFC 8018. PBES2 chạy một hàm dẫn xuất khóa, ở đây là PBKDF2 với HMAC-SHA-256, sau đó là một thuật toán mã hóa, ở đây là AES-256-CBC. PBKDF2 nhận một số lượng lượt lặp (iteration count), và số lượng đó là một tham số được mang trong tệp tin. Mục đích chính của nó là để chạy chậm: nhiều lượt lặp hơn có nghĩa là mỗi lần đoán mật khẩu sẽ tốn nhiều chi phí hơn, điều này có lợi trong việc chống lại kẻ tấn công ngoại tuyến (offline). RFC 8018 §4.2 nêu rõ rằng số lượng lớn hơn sẽ tốt hơn cho bảo mật, và cố tình không đặt ra giới hạn trần nào.

Sự cởi mở đó là bình thường khi bạn tự tạo ra tệp. Nhưng nó sẽ là một vũ khí khi kẻ tấn công làm điều đó. Số lượt lặp là một hệ số làm việc do kẻ tấn công kiểm soát, và một hệ số làm việc do kẻ tấn công kiểm soát chính là một cuộc tấn công từ chối dịch vụ vào độ phức tạp thuật toán (algorithmic-complexity denial of service). Một tệp .pfx giả mạo có thể mã hóa một số lượng lượt lặp lên đến hàng tỷ; trình phân tích cú pháp sẽ đọc nó một cách máy móc và gọi PBKDF2 cho ngần ấy vòng lặp của HMAC-SHA-256, làm tiến trình rơi vào một vòng lặp không thể kết thúc trong nhiều phút hoặc nhiều giờ chỉ với một tệp được cung cấp. Trên một máy chủ ký số xử lý một thông tin xác thực cho mỗi yêu cầu, một lượt tải lên được tinh chỉnh độc hại duy nhất có thể làm tê liệt một tiến trình xử lý.

Số lượng này làm cho lỗi xoay vòng trở nên tồi tệ hơn trước khi nó làm cho CPU quay cuồng. Giá trị lượt lặp tồn tại trong tệp dưới dạng một số nguyên ASN.1 INTEGER, vốn không có độ rộng cố định, trong khi trường dữ liệu mà PBKDF2 tiêu thụ cuối cùng là một số nguyên 32-bit Integer. Nếu giải mã thẳng giá trị INTEGER vào trường đó, một giá trị lớn sẽ bị cắt ngắn, và một giá trị được cố ý tạo ra để rơi vào bit dấu sẽ trở thành số âm hoặc một con số nhỏ không liên quan, do đó ngay cả khối lượng công việc cũng không còn như những gì tệp tin dường như yêu cầu. Giải pháp khắc phục là đọc giá trị ở độ rộng đầy đủ và giới hạn biên nó trước khi thu hẹp:

// 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

Đọc dữ liệu vào một biến kiểu Int64 giúp đảm bảo giá trị giải mã được là giá trị thực tế, chứ không phải một giá trị ảo bị cắt ngắn. Giới hạn dưới loại bỏ các số đếm bằng không và số đếm âm, vốn không có ý nghĩa đối với quá trình dẫn xuất khóa. Giới hạn trên, một trăm triệu, nằm cao hơn nhiều so với bất kỳ tệp PKCS#12 hợp lệ nào vốn hiện nay sử dụng từ hàng chục đến vài trăm nghìn lượt lặp, đồng thời khống chế trường hợp xấu nhất ở một mức độ công việc có giới hạn và có thể chịu đựng được. Chỉ sau khi giá trị đã vượt qua dải kiểm tra đó, nó mới được thu hẹp thành trường dữ liệu 32-bit, do đó việc cắt ngắn không còn có thể gây ra sự cố bất ngờ. Trong HotPDF, chốt chặn này nằm ở ParsePBES2Params, nơi các tham số PBKDF2 được giải mã trên đường đến PBKDF2HMACSHA256.

Tại sao cả hai cách sửa lỗi đều là cùng một phương pháp

Two defects look different, one a buffer overrun and one a hung process, but they are the same mistake. Trong mỗi trường hợp, một con số từ một tệp tin không đáng tin cậy đã được chuyển vào một kiểu dữ liệu có độ rộng cố định quá sớm một bước, trước khi nó được kiểm tra so với thực tế. Độ dài được cộng ở dạng 32-bit trước khi kiểm tra giới hạn biên; số lượt lặp được thu hẹp về 32-bit trước khi kiểm tra phạm vi. Cả hai lỗi đều được giải quyết bằng cùng một nguyên lý: giải mã ở độ rộng đầy đủ, kiểm tra so với giới hạn thực tế, và chỉ sau đó mới thu hẹp kiểu dữ liệu. Kiểu trung gian Int64 không phải là một sự lựa chọn về phong cách lập trình, nó là độ rộng duy nhất mà chốt bảo vệ có thể nhìn thấy giá trị thực tế mà kẻ tấn công đã viết. Một giới hạn bị tràn thì không còn là một giới hạn, và một số đếm không có trần thì không còn là một tham số nữa, nó là một van điều tiết từ xa đối với chính CPU của bạn.

Hướng dẫn thực tế cho một quy trình ký số

Bài học cụ thể là hãy xác thực đầu vào chứng chỉ không tin cậy theo cách bạn xác thực bất kỳ tệp tải lên không đáng tin cậy nào khác. Hãy giới hạn kích thước của tệp .pfx mà bạn chấp nhận, vì một tệp hợp lệ thường chỉ nặng vài kilobyte, chứ không phải megabyte. Hãy coi một lỗi phân tích cú pháp là một sự từ chối đầu vào thông thường, chứ không phải là một lỗi đáng hiển thị toàn bộ dấu vết ngăn xếp (stack trace) cho người dùng. Nếu bạn thực hiện ký số trên máy chủ, hãy chạy quy trình nhập ở nơi mà một tiến trình bị dừng không thể làm sập toàn bộ dịch vụ, và đặt thời gian chờ (timeout) xung quanh thao tác đó để một tệp nặng bất thường sẽ bị giới hạn bởi thời gian thực tế cũng như bởi giới hạn số lượt lặp.

Bài học rộng lớn hơn vượt ra ngoài các chứng chỉ. Việc tăng cường bảo mật trình phân tích cú pháp không phải là đợt kiểm tra một lần duy nhất đối với một đơn vị (unit), mà nó là thuộc tính tại mọi vị trí thư viện của bạn đọc các byte mà nó không tự ghi ra. Một thư viện PDF phân tích rất nhiều thứ từ các nguồn không tin cậy: các font chữ nhúng trong tài liệu, các hình ảnh thuộc nửa tá bộ giải mã, các bộ lọc luồng (stream filters), và trên đường dẫn ký số là các chứng chỉ. Mỗi thành phần đó đều là một bề mặt tấn công, và mỗi thành phần đều xứng đáng nhận được sự nghi ngờ tương tự đối với mọi độ dài và mọi số đếm. HotPDF xây dựng đường dẫn nhập và ký số dựa trên các đơn vị đã được gia cố bảo mật HPDFASN1, HPDFPFX, HPDFCrypt, và HPDFCMS được mô tả ở đây, để thông tin xác thực bạn cung cấp cho nó, dù đến từ bất kỳ đâu, đều được phân tích cú pháp một cách phòng vệ trước khi được tin cậy.

Quy trình ký số mà các kiểm tra này bảo vệ được trình bày chi tiết từ đầu đến cuối trong hướng dẫn của chúng tôi về chữ ký số PAdES trong Delphi, và tư thế phòng thủ tương tự áp dụng cho việc mã hóa tài liệu, bao gồm cả đường dẫn khóa AES-256 dùng chung cơ sở mã nguồn này, được mô tả trong bài viết về mã hóa và bảo mật AES-256. Tất cả những tính năng này đều được phân phối kèm theo HotPDF Component dành cho Delphi và C++Builder, bên cạnh các API tải, chỉnh sửa, mã hóa và ký số được đề cập ở những nơi khác trên blog này.