Technical Article

Tăng cường độ an toàn cho PDFium VCL Binding: ABI và an toàn bộ nhớ

Một Pascal binding (liên kết Pascal) bọc trên một thư viện C trông giống như mã nguồn Pascal thông thường. Bạn gọi một phương thức, bạn nhận lại một bản ghi (record), bạn giải phóng những gì bạn đã cấp phát. Vấn đề là PDFium là một thư viện C và C++ với quy ước gọi hàm (calling convention), độ rộng số nguyên riêng, và các quy tắc riêng về việc ai sở hữu bộ nhớ và ai giải phóng nó. Không điều nào trong số đó tự động vượt qua ranh giới ngôn ngữ. Mỗi thỏa thuận đó đều phải được viết lại bằng tay trong các khai báo Pascal, và chỉ cần một từ sai sẽ biến một lệnh gọi trông sạch sẽ thành lỗi hỏng ngăn xếp (stack corruption), một độ lệch bị cắt ngắn (truncated offset), hoặc lỗi giải phóng bộ nhớ kép (double free). Một đợt kiểm tra phiên bản v1.61.0 đối với PDFium VCL binding đã phát hiện ra một lỗi thuộc từng loại kể trên. Chúng rất đáng để phân tích kỹ vì không chỉ riêng cho binding này. Chúng là những mối nguy hiểm thường trực khi bọc bất kỳ C API nào trong Delphi hoặc Lazarus.

cdecl là một phần của kiểu hàm, không phải một khai báo để trang trí

PDFium là mã nguồn C đã được biên dịch. Trên Win32, các hàm xuất và quan trọng hơn là các callback (hàm gọi ngược) mà nó gọi đều sử dụng quy ước gọi hàm cdecl. Theo cdecl, bên gọi sẽ dọn dẹp ngăn xếp sau khi lệnh gọi trả về. Giá trị mặc định gốc của Delphi là register, và chuẩn Win32 C cho các callback là stdcall trong một số thư viện, nơi bên được gọi sẽ thực hiện dọn dẹp. Khi một cấu trúc truyền cho PDFium một con trỏ hàm và bạn quên từ khóa cdecl trong kiểu của con trỏ đó, hai bên sẽ không thống nhất về việc ai là người điều chỉnh con trỏ ngăn xếp. Cả hai cùng sửa đổi hoặc không bên nào làm cả, và con trỏ ngăn xếp sẽ bị lệch một khoảng bằng kích thước của các đối số trong mỗi lần gọi.

Lý do khiến lỗi này khó tìm là vì hư hại xảy ra ở ngoài phạm vi cục bộ. Lệnh gọi bị hỏng trả về và trông vẫn bình thường. Sự sai lệch chỉ xuất hiện sau đó, trong một số hàm không liên quan khác mà khung (frame) của nó nằm trên một con trỏ ngăn xếp bị lệch vài byte, và nó biểu hiện dưới dạng lỗi đọc ngẫu nhiên (wild read), một địa chỉ trả về sai, hoặc một sự cố treo ứng dụng với dấu vết ngăn xếp (backtrace) không trỏ đến gần callback mà bạn thực sự đã viết sai. Điền biểu mẫu (Form-fill) là vị trí điển hình mà lỗi này gây hại, vì giao diện điền biểu mẫu là một bản ghi chứa đầy các callback mà PDFium gọi lại. Một trong số chúng, FFI_OpenFile, truyền cho PDFium một hàm nó sẽ gọi để mở một tệp bên ngoài, được khai báo là function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Từ khóa cdecl ở cuối là điểm quan trọng cần sao chép chính xác. Nếu bỏ nó đi, mã nguồn vẫn biên dịch, vẫn liên kết và vẫn chạy bình thường cho đến khi PDFium gọi hàm đó. Quy ước gọi thuộc về chính kiểu hàm. Nó không phải là phần tùy chọn để làm đẹp, và trình biên dịch sẽ không cảnh báo khi thiếu nó vì kiểu hàm cơ bản là một kiểu Pascal hợp lệ. Cách phòng ngự duy nhất là coi quy ước gọi hàm là một trường bắt buộc của mọi chữ ký hàm được nhập và mọi callback bạn truyền ra bên ngoài.

size_t có độ rộng con trỏ, và trên FPC Win64 điều đó có nghĩa là 64-bit

Lỗi thứ hai là sự không khớp về độ rộng số nguyên vốn chỉ xuất hiện trên một nền tảng mục tiêu. Kiểu size_t của C được định nghĩa đủ rộng để chứa bất kỳ kích thước đối tượng nào, trên nền tảng 64-bit nghĩa là một số nguyên không dấu 64-bit. Giao diện tải lũy tiến (progressive-loading) của PDFium giao tiếp bằng các độ lệch byte kiểu size_t. Bản ghi FX_FILEAVAIL của nhà cung cấp tính khả dụng mang một callback IsDataAvail mà PDFium gọi với một độ lệch và một kích thước, và callback AddSegment của bản ghi FX_DOWNLOADHINTS cũng nhận dữ liệu tương tự. Cả hai tham số đều thuộc kiểu size_t.

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

Nếu bạn khai báo các độ lệch đó dưới dạng kiểu 32-bit, liên kết sẽ hoạt động trên Win32 và trên Delphi Win64, sau đó âm thầm bị lỗi trên FPC và Lazarus Win64. Nguyên nhân rất tinh vi. Trên FPC Win64, NativeUInt là một kiểu 64-bit có độ rộng con trỏ thực sự, và size_t là một bí danh (alias) của nó. Liên kết có một chú thích trong phần khai báo kiểu cảnh báo chính xác về việc che bóng (shadowing) NativeUInt trên FPC, vì việc xác định lại nó thành một bí danh 32-bit tại đó sẽ ép buộc size_t thành 32-bit và làm hỏng mọi tham số size_t được truyền tới hoặc ghi bởi thư viện. Một độ lệch 64-bit đi vào một tham số 32-bit sẽ bị mất một nửa cao của nó. Đối với một tệp nhỏ, mọi độ lệch đều vừa vặn trong 32-bit và không có gì sai sót xảy ra. Đối với một tệp lớn, ngay khi một độ lệch vượt qua ranh giới bốn gigabyte, giá trị bị cắt ngắn sẽ trỏ đến một nơi hoàn toàn khác, PDFium hỏi xem phạm vi byte sai có khả dụng không, và quá trình tải lũy tiến sẽ bị treo hoặc đọc dữ liệu rác. Lỗi này vô hình cho đến khi tệp đủ lớn và mục tiêu là nền tảng mà size_t thực sự được mở rộng.

Một ngoại lệ Pascal không bao giờ được phép tháo gỡ qua một khung C

Nhóm lỗi thứ ba liên quan đến mô hình ngoại lệ, điều mà ngôn ngữ C không có. Khi PDFium gọi một trong các callback của bạn, mã nguồn Pascal của bạn sẽ chạy bên trong một ngăn xếp các khung C và C++ vốn không biết gì về cơ chế xử lý ngoại lệ của Delphi. Nếu callback của bạn gây ra lỗi và để ngoại lệ đó lan truyền, nó sẽ tháo gỡ (unwind) qua các khung chưa bao giờ được xây dựng để hỗ trợ cơ chế tháo gỡ ngoại lệ của Pascal. Các thao tác tự dọn dẹp của chính PDFium sẽ không chạy, các bất biến nội bộ của nó chỉ được cập nhật một nửa, và tiến trình hiện rơi vào trạng thái mà thư viện chưa bao giờ lường trước. Thỏa thuận cho các callback này là một mã trả về, không phải một ngoại lệ.

Hai callback làm cho điều này trở nên cụ thể. FPDF_FILEWRITE là bộ tiếp nhận dữ liệu (sink) mà PDFium ghi tài liệu đã lưu vào, và FPDF_FILEACCESS là nguồn mà nó đọc tài liệu đầu vào từ đó. Cả hai đều được triển khai ở đây dựa trên một lớp TStream của Delphi, và cả hai đều có thể thất bại theo cách bất kỳ luồng nào thất bại: đĩa bị đầy, luồng bị đóng dưới quyền kiểm soát của bạn, hoặc thao tác đọc vượt quá phần cuối. Callback ghi bọc thao tác ghi luồng của nó và chuyển đổi bất kỳ thất bại nào thành mã lỗi của PDFium thay vì để nó thoát ra ngoài.

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium treats any non-1 return as a write failure. A Pascal exception
  // must not unwind through this cdecl/C++ frame, so trap it and report
  // failure instead.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

Phía đọc cũng làm tương tự: một lượt đọc thất bại sẽ báo cáo số không để khớp với thỏa thuận FPDF_FILEACCESS thay vì phát sinh lỗi qua ranh giới. Một khối lệnh except rỗng không có lệnh re-raise trông có vẻ sai đối với một lập trình viên Pascal vốn được đào tạo để không bao giờ nuốt trọn các ngoại lệ, và trong mã nguồn Pascal thông thường thì nó sai thật. Nhưng tại một ranh giới ABI, nó lại là cấu trúc chính xác, vì giá trị an toàn duy nhất để truyền ngược lại cho bên gọi C là một mã trạng thái mà nó biết cách diễn giải. Sự thất bại vẫn được lan truyền, chỉ là thông qua giá trị trả về, và mã gọi phía trên thư viện sẽ hiển thị nó dưới dạng EPdfError một khi quyền kiểm soát quay trở lại phía Pascal.

Lỗi giải phóng bộ nhớ kép ẩn nấp trên đường dẫn xử lý lỗi

Lỗi thứ tư liên quan đến quyền sở hữu. Một tay cầm (handle) tài liệu PDFium được mở bởi thư viện và phải được đóng chính xác một lần duy nhất bằng FPDF_CloseDocument. Mối nguy hiểm là một đường dẫn xử lý lỗi giải phóng một tay cầm mà một tiến trình dọn dẹp thứ hai cũng đang sở hữu. Hãy hình dung một thủ tục tạo ra một đối tượng bao bọc (wrapper object), gán một tay cầm tài liệu mới mở cho nó, và sau đó thực hiện nhiều cấu hình hơn vốn có thể thất bại. Nếu việc cấu hình phát sinh lỗi, một trình xử lý trả về sớm gọi FPDF_CloseDocument trên tay cầm gốc sẽ đóng nó lại, và sau đó bộ hủy (destructor) của chính đối tượng bao bọc sẽ đóng nó lại lần nữa khi đối tượng được giải phóng. Tay cầm bị giải phóng hai lần, đó là hành vi không xác định (undefined behavior) và dễ gây ra lỗi treo chương trình.

Đợt kiểm tra đã phát hiện ra điều này trên một đường dẫn nhập kiểu áp trang (imposition-style import path) vốn xây dựng một lớp TPdf xung quanh một tay cầm đã được mở sẵn. Giải pháp là làm cho việc chuyển giao quyền sở hữu trở thành nguồn chân lý duy nhất. Một khi tay cầm được gán cho trường dữ liệu của lớp bao bọc, lớp bao bọc sẽ sở hữu nó, và việc dọn dẹp duy nhất trên đường dẫn xử lý lỗi là giải phóng lớp bao bọc đó. Bộ hủy của lớp bao bọc sẽ gọi FPDF_CloseDocument cho bạn, do đó lệnh đóng tường minh thứ hai sẽ giải phóng kép cùng một tài liệu. Trình xử lý lỗi được sửa đổi sẽ giải phóng đối tượng và ném lại lỗi (re-raises), và chỉ có chính xác một đường dẫn dẫn đến việc đóng tài liệu.

Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result now owns the handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
  // here would double-free the same PDFium document.
  Result.Free;
  raise;
end;

Cả bản ghi được quản lý và thư viện chứa đầy hàm xuất đều cần dọn dẹp tường minh

Nhóm lỗi cuối cùng liên quan đến bộ nhớ mà trình biên dịch quản lý thay bạn, điều mà thói quen lập trình C sẽ âm thầm phá hỏng. Nhiều hàm trợ giúp của binding này trả về một bản ghi chứa một chuỗi WideString hoặc một mảng động. Đó là các trường đếm tham chiếu (reference-counted fields), và trình biên dịch sẽ tạo ra các thao tác ẩn để duy trì số lượng tham chiếu của chúng. Bản năng mang từ C sang là dọn dẹp một bản ghi mới bằng lệnh FillChar(Result, SizeOf(Result), 0). Việc đó ghi đè các số không lên tham chiếu được quản lý bên trong bản ghi mà không giảm số đếm tham chiếu của nó trước. Trình biên dịch tái sử dụng một biến tạm thời ẩn cho kết quả hàm qua các vòng lặp, do đó ở vòng lặp thứ hai, lệnh FillChar sẽ ghi đè lên một con trỏ chuỗi đang hoạt động chưa bao giờ được giải phóng, và chuỗi mà nó trỏ tới sẽ bị rò rỉ. Gọi hàm đó trong một vòng lặp qua một nghìn chú thích và bạn sẽ làm rò rỉ một nghìn chuỗi.

Giải pháp khắc phục là hãy để ngôn ngữ tự dọn dẹp bản ghi theo cách nó biết, bằng lệnh Default(T), lệnh này giải phóng bất kỳ trường được quản lý nào trước khi đưa nó về giá trị không.

// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);

Một vấn đề sở hữu liên quan tồn tại ở ranh giới tải thư viện. Binding này giải quyết hàng trăm con trỏ hàm ra khỏi tệp PDFium DLL bằng lệnh GetProcAddress sau lệnh LoadLibrary. Nếu thiếu một hàm xuất bắt buộc, trạng thái liên kết một phần sẽ rất nguy hiểm: hàng tá con trỏ hợp lệ, số còn lại là nil hoặc lỗi thời, và bất kỳ lệnh gọi nào sau đó thông qua một trong số chúng sẽ nhảy vào một mô-đun có thể đã bị dỡ tải (unloaded). Binding xử lý việc này bằng cách dỡ tải thư viện và chạy một hàm ClearAllBindings đầy đủ để đặt lại mọi con trỏ đã nhập về nil bất cứ khi nào một hàm xuất bắt buộc không thể giải quyết. Sau đó, không con trỏ hàm nào bị treo lơ lửng vào một mô-đun đã bị dỡ tải, và một lệnh gọi sau đó sẽ thất bại một cách sạch sẽ với một phép kiểm tra con trỏ nil thay vì nhảy vào mã nguồn đã được giải phóng.

Lớp bao bọc là nơi bốn thỏa thuận được viết lại bằng tay

Không có lỗi nào trong số năm lỗi này là xa lạ. Chúng là các kịch bản thất bại có thể dự đoán trước của một lớp Pascal mỏng bọc trên một C API, và chúng tập trung lại vì lớp đó chính xác là nơi bốn thỏa thuận riêng biệt phải được khai báo lại. Quy ước gọi hàm phải được viết là cdecl trên mỗi callback. Độ rộng số nguyên phải khớp với size_t trên nền tảng mục tiêu nơi nó thực sự được mở rộng. Mô hình ngoại lệ phải được chuyển đổi thành các mã trả về tại mỗi callback đi ra ngoài Pascal. Quyền sở hữu của mọi tay cầm và mọi trường được quản lý phải được quy định một lần và tuân thủ trên mọi đường dẫn, bao gồm cả các đường dẫn xử lý lỗi mà không ai chạy thử cho đến khi lên môi trường sản xuất. Bỏ sót bất kỳ điều nào và bạn sẽ nhận lại một lỗi có triệu chứng xuất hiện rất xa nguyên nhân thực sự của nó, đây chính là điều làm cho nhóm lỗi này trở nên tốn kém thời gian. Giá trị của đợt kiểm tra không nằm ở bất kỳ cách sửa lỗi đơn lẻ nào mà nằm ở việc coi mỗi điều này là một kỷ luật riêng cần kiểm tra trên toàn bộ liên kết.

Nếu bạn muốn thấy liên kết hoạt động thực tế thay vì chỉ bảo vệ các cạnh của nó, các kỹ thuật zoom và render-cache trong ghi chú của chúng tôi về hiệu năng render-cache và zoom sẽ trình bày đường dẫn kết xuất, và hướng dẫn biên dịch chéo trong xây dựng trình xem Lazarus và FPC là nơi hành vi size_t trên Win64 mô tả ở đây thực sự phát huy vai trò. Cả hai đều dựa trên cùng một công trình về an toàn bộ nhớ và ABI được phân phối trong PDFium Component dành cho Delphi, Lazarus và C++Builder, cùng các API kết xuất, trích xuất văn bản và biểu mẫu được đề cập ở những nơi khác trên blog này.