Technical Article

Gia cố bảo mật trình phân tích cú pháp PDF bằng Pascal trước các tệp độc hại

Một tệp PDF không phải là một tài liệu bạn mở ra để đọc. Nó là một chương trình nhỏ bạn thực thi. Mỗi font chữ nhúng là một bộ thông dịch dựa trên ngăn xếp (stack-based interpreter) chờ đợi các chuỗi ký tự (charstrings), mỗi hình ảnh là một bộ giải mã được cung cấp các trường chiều rộng, chiều cao và độ sâu bit do chính tệp tin lựa chọn, và mỗi luồng dữ liệu đi tới đều được bọc trong các bộ lọc mà tham số của chúng do tệp xác lập. Không con số nào trong số đó thuộc quyền kiểm soát của bạn. Chúng đến từ bất kỳ ai đã tạo ra tệp tin, vốn trên thực tế có thể là một hóa đơn của khách hàng hoặc một tệp đính kèm từ người gửi không xác định. Các bộ giải mã chuyển đổi các byte đó thành các pixel và ký tự chính là bề mặt tấn công, và một trình phân tích cú pháp tin cậy các thông tin đầu vào đó có thể bị treo chương trình hoặc tệ hơn chỉ bởi một tệp tin sai định dạng.

PDFlibPas đã trải qua một đợt gia cố bảo mật vốn coi toàn bộ đường dẫn giải mã là thù địch, trên khắp các chương trình font chữ (TrueType, Type1, CFF, và các bảng CMap), các bộ giải mã hình ảnh (PNG, GIF, TIFF, JBIG2, và CCITT Nhóm 3 và Nhóm 4), và các bộ lọc luồng (LZW, ASCII85, và các bộ dự đoán Flate). Dưới đây là năm nhóm lỗi mà nó đã đóng lại, mỗi nhóm bắt nguồn từ hành vi cụ thể của Delphi đã tạo điều kiện cho lỗi xảy ra. Chúng đã được sửa đổi trong các phiên bản phát hành hiện tại, và các cấu trúc lỗi tương tự thường lặp lại trong bất kỳ mã nguồn Pascal nào phân tích cú pháp dữ liệu đầu vào không đáng tin cậy.

Một lỗi tràn số nguyên cung cấp cho bạn một bộ đệm thiếu kích thước

Lỗi an toàn bộ nhớ kinh điển trong một bộ giải mã hình ảnh là tích số của các kích thước bị tràn số và xoay vòng. Một bộ giải mã đọc chiều rộng, chiều cao, số lượng thành phần, và độ sâu bit, nhân chúng lại để xác định kích thước đầu ra, cấp phát ngần ấy byte, rồi ghi hình ảnh ở kích thước thực tế của nó. Nếu phép nhân được thực hiện bằng số học 32-bit, tích số có thể xoay vòng thành một giá trị nhỏ ngay cả khi từng hệ số riêng lẻ nằm trong phạm vi hợp lý, do đó việc cấp phát bộ đệm thành công nhưng dung lượng nhận về lại quá nhỏ, và hoạt động giải mã sẽ ghi đè ra ngoài vùng biên của nó. Đây là lỗi tràn số nguyên (CWE-190), dẫn đến lỗi ghi ngoài vùng biên đống (heap out-of-bounds write - CWE-787) ở bước tiếp theo.

Đường dẫn hình ảnh dùng chung đã giới hạn mỗi chiều ở mức 65535; các bộ giải mã độc lập không phải tất cả đều kế thừa giới hạn đó. Một biểu thức byte-hàng-nhân-chiều-cao như ByteCount * FHeight, hoặc một biểu thức cho mỗi pixel như FWidth * Components * BitDepth, là một tích số 32-bit trong Delphi khi cả hai toán hạng là các số nguyên 32-bit, bất kể biến bạn gán kết quả có độ rộng lớn thế nào. Một chiều rộng và một chiều cao 60000 đều hợp lý đối với một bản quét lớn, nhưng tích số của chúng theo byte vượt quá dải số có dấu 32-bit và độ dài trả về ở mức nhỏ. Bẫy tương tự cũng tồn tại trong bước dự đoán ZLib (ZLib predictor stride), BitsPerComponent * Colors * Columns.

Giải pháp khắc phục là làm cho ít nhất một toán hạng thuộc kiểu Int64 để toàn bộ biểu thức được tính toán ở dạng 64-bit, sau đó so sánh với MaxInt và từ chối tệp trước khi thu hẹp trở lại để gọi 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);

Điều khiến đây trở thành vấn đề của Delphi chứ không phải lỗi chung là sự thu hẹp kiểu dữ liệu âm thầm (silent narrowing). Việc gán một biểu thức quá rộng vào một điểm đích 32-bit là một chuyển đổi hợp lệ mà trình biên dịch sẽ không cảnh báo theo mặc định, và tính năng kiểm tra phạm vi (range checking) không bắt được lỗi xoay vòng xảy ra trước khi giá trị được sử dụng làm chỉ mục. Để tích số ở dạng 32-bit và ngôn ngữ sẽ âm thầm cung cấp cho bạn một độ dài sai lệch so với dung lượng bộ nhớ thực tế hoạt động giải mã sắp tương tác.

Một kiểu trường dữ liệu làm cho chốt bảo vệ không bao giờ có thể kích hoạt

Một tệp TIFF là một chuỗi các thư mục tệp hình ảnh (image file directories - IFD), mỗi thư mục mang độ lệch byte của thư mục tiếp theo. Một tệp độc hại có thể trỏ chuỗi đó quay ngược lại chính nó, và một trình đọc duyệt qua nó mà không có điều kiện dừng sẽ chạy mãi mãi. Đó là lỗi vòng lặp vô hạn (CWE-835) do dữ liệu đầu vào của kẻ tấn công điều khiển, và giải pháp phòng thủ là một bộ đếm dừng lại một khi nó vượt quá giới hạn mà không tệp hợp lệ nào đạt tới.

Bộ đếm trang được khai báo là kiểu Word, trong Delphi chứa các giá trị từ 0 đến 65535. Vòng lặp mang một chốt bảo vệ kết thúc có dạng "dừng lại khi số đếm trang vượt quá 65535," trông có vẻ đúng cho đến khi bạn nhận thấy toán hạng và ngưỡng chia sẻ cùng một giới hạn trên. Một biến kiểu Word không bao giờ có thể lớn hơn 65535, vì vậy phép so sánh về mặt cấu trúc luôn luôn sai: khi bộ đếm đạt 65535, lượt tăng tiếp theo sẽ đưa nó quay về 0, chốt bảo vệ không bao giờ nhìn thấy giá trị vượt quá trần, và một chuỗi IFD lặp vòng sẽ giữ cho trình đọc quay vô hạn.

Giải pháp khắc phục là mở rộng kiểu trường dữ liệu để chốt bảo vệ có thể biểu diễn một giá trị mà bộ đếm thực sự có thể chứa. Với việc TPDFTIFF.FPageCount được khai báo là Integer, phép so sánh FPageCount > 65535 tương tự sẽ trở nên khả thi, vòng lặp chấm dứt, và thuộc tính công khai PageCount thay đổi kiểu dữ liệu để khớp mà không làm hỏng bất kỳ mã nguồn gọi nào. Bất cứ khi nào một phép kiểm tra giới hạn có cấu trúc Value > MaxValueOfType(Value) và toán hạng đã có kiểu dữ liệu ở chính xác giá trị cực đại đó, điều kiện đó là một hằng số sai: hãy mở rộng kiểu dữ liệu, hoặc kiểm tra tính bằng nhau so với giá trị cực đại để nó có thể kích hoạt.

Tính năng kiểm tra phạm vi bị tắt trên một đường dẫn nóng (hot path)

Khi bật tính năng kiểm tra phạm vi, Delphi chèn một lệnh kiểm tra giới hạn biên trên mọi chỉ mục mảng và chuỗi, đây là sự khác biệt giữa việc một chỉ mục nằm ngoài phạm vi gây ra lỗi ERangeError có thể bắt được và việc chính chỉ mục đó đọc hoặc ghi vào bộ nhớ không thuộc về cấu trúc dữ liệu. Các đường dẫn nóng (hot paths) đôi khi vô hiệu hóa tính năng này bằng một chỉ thị cục bộ {$R-}, điều này có thể chấp nhận được cho đến khi các chỉ mục không còn đáng tin cậy.

Bộ truy cập danh sách mà trình thông dịch font chữ dựa vào, TPDFlibStringList.Get, chính là một đường dẫn như vậy. Trên Windows, nó được biên dịch khi tắt tính năng kiểm tra phạm vi và lập chỉ mục trực tiếp vào kho lưu trữ hỗ trợ của nó, do đó một chỉ mục nằm ngoài phạm vi không phải là một lỗi mà là một thao tác truy cập bộ nhớ thô. Điều đó vẫn ổn khi chỉ mục luôn hợp lệ, và sẽ không còn ổn bên trong một bộ thông dịch charstring CFF hoặc Type2, nơi chỉ mục có thể đến từ tệp tin. Một charstring lấy một toán hạng ra khỏi một ngăn xếp trống sẽ tạo ra một chỉ mục bằng âm một; một định danh ký tự (glyph identifier) bị lệch một giá trị so với số lượng ký tự sẽ lập chỉ mục vượt quá phần cuối một vị trí. Khi tắt tính năng kiểm tra phạm vi, cả hai đều trở thành một truy cập ngoài vùng biên thực tế thay vì một ngoại lệ có thể bắt được, và bởi vì các vị trí chứa các giá trị chuỗi AnsiString được đếm tham chiếu, một lượt đọc sai hướng cũng có thể làm hỏng số lượng tham chiếu của chuỗi.

Quá trình gia cố bảo mật không bật lại tính năng kiểm tra phạm vi cho đường dẫn nóng. Nó làm cho các chỉ mục được xác thực hợp lệ trước tiên: trước khi lấy phần tử đầu của ngăn xếp toán hạng, trình thông dịch kiểm tra xem ngăn xếp có trống hay không, và mọi chốt bảo vệ chỉ mục được viết dưới dạng so sánh nhỏ hơn nghiêm ngặt đối với số lượng phần tử thay vì so sánh nhỏ hơn hoặc bằng vốn chấp nhận lỗi lệch một đơn vị (off-by-one). Chỉ thị này chuyển trách nhiệm quản lý giới hạn biên từ trình biên dịch sang bạn, và các kiểm tra xác thực mà nó đã loại bỏ phải được đưa lại bằng tay tại mỗi điểm vào.

Lỗi đệ quy vô hạn trong trình thông dịch charstring

Một charstring Type2 có thể gọi một chương trình con (subroutine), và một chương trình con tự nó là một charstring có thể gọi một chương trình con khác, do đó các toán tử gọi chương trình con cục bộ và toàn cục cho phép tệp tin quyết định mức độ sâu của cuộc gọi. Một chương trình con tự gọi chính nó, trực tiếp hoặc thông qua một chu trình, sẽ đệ quy không giới hạn cho đến khi ngăn xếp hệ thống bị cạn kiệt và tiến trình bị sập. Đó là lỗi đệ quy không kiểm soát (CWE-674).

Trình thông dịch Type1 đã bảo vệ chống lại điều này. Nó mang một bộ đếm độ sâu cuộc gọi và một trần giới hạn, PLType1MaxCallDepth, và từ chối đi sâu hơn giới hạn đó, phản ánh giới hạn độ sâu mà chính đặc tả Type1 quy định. Trình thông dịch Type2, được thêm vào sau và có cấu trúc tương tự, đã không mang chốt bảo vệ này, và một font chữ được xây dựng thủ công với một chương trình con tự gọi số hiệu của chính nó sẽ đi thẳng qua phép kiểm tra bị thiếu dẫn đến lỗi tràn ngăn xếp (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

Giải pháp khắc phục là cung cấp cho đường dẫn Type2 cùng một giới hạn độ sâu mà nhánh Type1 của nó đã có. Bất kỳ lượt duyệt đệ quy nào trên một cấu trúc do kẻ tấn công kiểm soát, cho dù là chương trình con font chữ, một mảng lồng nhau, hay một chuỗi tham chiếu chéo, đều cần một trần độ sâu mà dữ liệu đầu vào không thể nâng lên.

Bộ nhớ chưa được khởi tạo bị rò rỉ vào kết quả đầu ra

Lỗi tinh vi nhất là rò rỉ nội dung đống (heap contents) vào đầu ra đã giải mã, và nguyên nhân là một thuộc tính của SetLength rất dễ bị lãng quên. Khi bạn tăng chiều dài một chuỗi AnsiString bằng SetLength, Delphi cấp phát các byte nhưng không đưa chúng về không, do đó vùng nhớ mới sẽ chứa bất kỳ dữ liệu nào đã tồn tại trước đó trong phân đoạn đống đó. Nếu mọi byte sau đó đều được ghi đè, điều này không gây ảnh hưởng; nhưng nếu một đường dẫn để lại một phần bộ đệm không được ghi và sau đó trả lại nó dưới dạng dữ liệu, các byte cũ đó sẽ đi ra cùng kết quả. Đó là lỗi sử dụng bộ nhớ chưa khởi tạo (CWE-457), và khi kết quả vượt qua ranh giới tin cậy, nó sẽ trở thành một lỗi rò rỉ thông tin.

Đường dẫn giải mã AES-CBC đã gặp chính xác lỗi này. Bộ đệm đầu ra được định kích thước bằng SetLength và bộ giải mã xử lý bản mã (ciphertext) tuần tự từng khối 16-byte một. Khi độ dài bản mã không phải là bội số của 16, một độ dài mà kẻ tấn công có thể chọn, khối một phần ở cuối không bao giờ được ghi, do đó các byte cuối cùng đó giữ nguyên nội dung đống mà lệnh SetLength để lại và bộ đệm được trả lại dưới dạng văn bản rõ đã giải mã của một đối tượng tài liệu. Biện pháp khắc phục là hai chốt bảo vệ, và không có chốt nào đứng riêng lẻ là đủ: điểm vào giải mã hiện từ chối bất kỳ bản mã nào có độ dài không phải là bội số của kích thước khối, và như một biện pháp dự phòng, đầu ra được dọn dẹp bằng lệnh FillChar trước khi sử dụng để bất kỳ đường dẫn nào không ghi được một vùng nhớ sẽ trả về các số không thay vì dữ liệu đống còn sót lại.

Những gì đợt kiểm tra để lại cho bạn

Năm lỗi trên là các lỗi khác nhau, nhưng chúng có cấu trúc tương tự. Một độ rộng số nguyên làm tràn tích số, một kiểu trường giữ chốt bảo vệ ở một hằng số sai, một tính năng kiểm tra phạm vi bị vô hiệu hóa khi các chỉ mục không còn an toàn, một đệ quy không có đáy, và một bộ đệm mà ngôn ngữ từ chối đưa về không. Trong mỗi lỗi, Delphi đã thực hiện chính xác những gì nó định nghĩa, vì ngôn ngữ cung cấp cho bạn số học có thể tràn, sự thu hẹp kiểu diễn ra âm thầm, kiểm tra phạm vi bạn có thể tắt đi, đệ quy không có giới hạn tích hợp, và sự cấp phát không khởi tạo giá trị. Đó là thỏa thuận của ngôn ngữ, và một trình phân tích cú pháp Pascal đáp ứng nó bằng cách tự quản lý thủ công bốn thứ tại mọi ranh giới mà tệp kiểm soát: độ rộng số nguyên, kiểm tra phạm vi, độ sâu đệ quy, và khởi tạo bộ đệm.

Các lỗi này đã được đóng lại trong các phiên bản phát hành PDFlibPas hiện tại, công cụ dành cho Delphi và C++Builder. Nếu công việc của bạn cũng liên quan đến cách một tệp tin khai báo được bảo vệ, các ghi chú đi kèm về kiểm tra mã hóa và phân quyền và về tiền kiểm PDF/A và PDF/UA sẽ đề cập đến khía cạnh phân tích của cùng một trình phân tích cú pháp, và tất cả các tính năng này được phân phối bên trong PDFlibPas Delphi PDF Library bên cạnh các API tải, hiển thị và ký số được đề cập ở những nơi khác trên blog này.