Bài viết kỹ thuật

Khóa mã hóa PDF lặp lại trên FPC: bản vá RNG của PDFiumPas

Trước phiên bản 3.114.8, PDFiumPas sinh vật liệu khóa mã hóa PDF trên các nền tảng non-Windows bằng hàm Random của runtime library, và vì chẳng ai gọi Randomize, mỗi process đều cho ra cùng một chuỗi byte. Các bản build Free Pascal trên Linux và macOS vì thế ghi khóa mã hóa file, salt, CBC IV và tiền tố nonce AES-GCM giống hệt nhau từ lần chạy này sang lần chạy khác. Phiên bản 3.114.8 đọc /dev/urandom để thay thế và raise exception khi không đọc được

Bản thân khiếm khuyết chỉ là một vòng lặp bốn dòng. Bài học hữu dụng hơn là vì sao một bộ test mã hóa rồi giải mã hàng trăm tài liệu, với AESV3 và AESV4, có và không có PDF MAC, lại vẫn xanh suốt cả thời gian đó. Tính ngẫu nhiên mà hằng định theo process là vô hình với mọi test chạy bên trong một process, và các test mã hóa thường được viết đúng theo kiểu đó

PDFiumPas cần byte ngẫu nhiên ở những đâu?

Mọi byte ngẫu nhiên trong stack mã hóa của PDFiumPas đều đến từ đúng một procedure, AesGenerateRandomBytes trong unit FPdfAes, nên một nguồn hỏng làm ô nhiễm tất cả. Security handler chuẩn trong ISO 32000-2 §7.6.4 và phần mở rộng AESV4 trong ISO/TS 32003 tiêu thụ những byte đó ở các chỗ sau:

  • Khóa mã hóa file 32 byte, được DeriveEncryptionKeys sinh mới cho từng tài liệu rồi bọc vào /UE và /OE dưới các khóa dẫn xuất từ mật khẩu
  • Hai salt 16 byte, một nằm trong 16 byte cuối của /U và một trong 16 byte cuối của /O, mỗi cái tách thành một salt validation 8 byte và một salt key 8 byte
  • Các byte 12 đến 15 của plaintext phía sau /Perms, thứ mà ISO 32000-2 đổ dữ liệu ngẫu nhiên vào trước khi khối được mã hóa dưới file key
  • Một CBC IV 16 byte được gắn trước mọi string và stream được mã hóa trong một tài liệu AESV3
  • Một tiền tố nonce 8 byte cho các tài liệu AESV4, theo sau là một bộ đếm per-object 4 byte khởi đầu từ 0
  • /KDFSalt 32 byte và MAC key khi EnableIntegrityProtection được bật
Mọi byte ngẫu nhiên trong stack mã hóa của PDFiumPas chảy từ AesGenerateRandomBytes trong FPdfAes tới sáu bên tiêu thụ: khóa mã hóa file 32 byte được bọc vào /UE và /OE, các salt /U và /O, các byte đệm /Perms, CBC IV của AESV3, tiền tố nonce GCM của AESV4, và KDF salt cùng MAC key
Một generator dùng chung nghĩa là một nguồn hỏng làm ô nhiễm vật liệu khóa ở mọi nơi cùng một lúc, đó là lý do bản vá nằm trong đúng một procedure chứ không phải tại từng call site

Vì sao mọi process cho ra cùng một key?

AesGenerateRandomBytes chỉ dùng generator của hệ điều hành trên Windows; ở mọi nơi khác nó đổ buffer từ generator giả ngẫu nhiên của RTL, và generator đó khởi động từ RandSeed = 0 trừ khi chương trình gọi Randomize. Comment phía trên vòng lặp bảo generator được seed từ GetTickCount64. Chẳng một dòng code nào làm điều đó, khiến comment trở thành nơi duy nhất cái seed từng tồn tại:

// Nhánh non-Windows của AesGenerateRandomBytes trước 3.114.8
// (comment phía trên hứa một seed GetTickCount64 chưa bao giờ được áp dụng)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Chuỗi khởi động lại theo mỗi process và đi tiếp bên trong nó, nên tài liệu đầu tiên mà bất kỳ process nào mã hóa dùng chung file key với tài liệu đầu tiên của mọi process khác đang chạy cùng một bản build, tài liệu thứ hai với tài liệu thứ hai, và cứ thế. File key trong R5, R6 và R7 chẳng phụ thuộc gì vào mật khẩu, vì mật khẩu chỉ bọc nó lại, nghĩa là bất kỳ ai tái hiện được chuỗi đều nắm key mà không cần biết mật khẩu. AESV4 thêm một lần thất bại thứ hai: cùng key với cùng tiền tố 8 byte và một bộ đếm khởi động lại từ 0 làm lặp lại GCM nonce, điều mà NIST SP 800-38D §8 cấm trắng trợn. Một GCM nonce lặp lại dưới một key làm lộ XOR của hai plaintext và phơi bày authentication subkey, nên các tag mà mã hóa AESV4-GCM và token PDF MAC dựa vào ngừng mang ý nghĩa. Bảo mật và toàn vẹn mất cùng một lúc

Generator RTL chưa seed trong PDFiumPas trên bản build FPC non-Windows: với RandSeed 0 mọi process phát ra cùng một chuỗi, nên tài liệu một ở process A mang cùng file key với tài liệu một ở process B, và AESV4 lặp lại GCM nonce vì cùng key gặp cùng tiền tố với bộ đếm khởi động lại từ 0
Vì file key chẳng bao giờ phụ thuộc mật khẩu, bất kỳ ai tái hiện được chuỗi đều nắm trọn key, còn GCM nonce lặp lại phá hủy bảo mật và toàn vẹn cùng một lúc

Phạm vi hẹp hơn những gì đoạn trên gợi ý. Các bản build Windows chưa bao giờ bị ảnh hưởng, vì nhánh Windows luôn gọi CryptGenRandom qua advapi32 với CRYPT_VERIFYCONTEXT và raise khi nó thất bại. Thứ bị phơi bày là output từ các bản build non-Windows trước 3.114.8, mà trong thực tế nghĩa là các ứng dụng Lazarus và Free Pascal trên Linux và macOS, thêm một mục vào danh sách các cạm bẫy Delphi so với FPC trong bản build PDFium

Vì sao Randomize chưa bao giờ là cách sửa đúng?

Gọi Randomize sẽ che được triệu chứng mà không sửa được nguồn, vì RandSeed là một giá trị 32-bit và Randomize suy ra nó từ đồng hồ. Điều đó chặn số lượng key stream khả dĩ ở mức 2^32, và việc biết đại khái một file được ghi khi nào cắt không gian tìm kiếm xuống xa thấp hơn nữa, thứ chẳng là gì so với một khóa AES 256-bit. Vật liệu khóa phải đến từ bể entropy của kernel, nên AesGenerateRandomBytes trong 3.114.8 đọc /dev/urandom, lặp qua các lần đọc ngắn, và raise nếu bể không giao nổi mọi byte được yêu cầu:

Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
  Remaining := Count;
  while Remaining > 0 do
  begin
    Got := FileRead(Handle, P^, Remaining);
    if Got <= 0 then
      Break;               // thất bại hoặc stream kết thúc bất ngờ
    Inc(P, Got);
    Dec(Remaining, Got);
  end;
  if Remaining = 0 then
    Exit;
finally
  FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');

Việc từ chối là có chủ ý, và nó khớp với điều nhánh Windows vẫn làm mỗi khi CryptGenRandom không có mặt. Một lần lưu mã hóa thất bại là một sự cố bạn để ý ngay trong ngày; một lần lưu thành công với các key đoán trước được là sự cố bạn nghe tin từ người khác. Hai hệ quả thực dụng theo sau. Một container hay chroot tối giản không có /dev được cấp phát giờ thất bại ở bước mã hóa thay vì suy thoái lặng lẽ, nên hãy mount nó. Và vì exception lan ra khỏi TPdf.SaveAsEncrypted sau khi file đích đã được mở bằng fmCreate, một file output rỗng bị bỏ lại cho error handler của bạn dọn

Vì sao các test round-trip chẳng bao giờ tóm được nó?

Một test round-trip không thể thấy tính ngẫu nhiên hằng định, vì giải mã thu hồi đúng cái file key mà mã hóa đã chọn. Test mã hóa một tài liệu, mở lại bằng mật khẩu, bung key từ /UE, rồi giải mã mọi object; một key đoán trước được bung và giải mã ngon lành như một key ngẫu nhiên, và các tag GCM xác thực được vì chúng được tính bằng đúng key đó. Đến một test mã hóa hai lần rồi khẳng định hai output khác nhau cũng qua, vì lần gọi thứ hai trong cùng một process rút tiếp những byte kế của chuỗi. Thuộc tính quan trọng, một key khác nhau ở mỗi process, chỉ quan sát được bằng cách so output giữa các process. Bất cứ khi nào cùng một code vừa sinh vừa tiêu thụ một giá trị, các test mù trước cả một lớp khiếm khuyết, và tính ngẫu nhiên là ví dụ tinh khiết nhất

Test tính ngẫu nhiên của key qua các process bằng cách nào?

Chạy một probe nhỏ hai lần dưới dạng các process riêng rẽ trên nền tảng đích rồi so output. Probe bên dưới gọi DeriveEncryptionKeys và in salt nằm ở các byte 32 đến 47 của entry /U. Giá trị đó được ghi trắng trợn vào mọi file được mã hóa, nên in nó ra log CI không lộ gì cả, trong khi nó đến từ cùng generator với file key:

Test tính ngẫu nhiên qua các process cho PDFiumPas: chương trình SaltProbe gọi DeriveEncryptionKeys và in hex của các byte /U 32 đến 47, job chạy nó hai lần dưới dạng các process riêng và thất bại khi hai dòng khớp nhau, còn các PDF đã xuất xưởng được so bằng 16 byte cuối của chuỗi /U
Tính ngẫu nhiên hằng định vô hình bên trong một process vì giải mã thu hồi đúng key mà mã hóa chọn, nên thuộc tính quan trọng chỉ quan sát được bằng cách so output giữa các process
program SaltProbe;
{$mode delphi}
uses
  SysUtils, FPdfEncrypt;
var
  Opts: TPdfEncryptOptions;
  Keys: TPdfEncryptionKeys;
  I: Integer;
  Hex: string;
begin
  Opts := TPdfEncryptOptions.Default;
  Opts.UserPassword := 'probe';
  Opts.Revision := erR6;
  DeriveEncryptionKeys(Opts, Keys);
  Hex := '';
  for I := 32 to 47 do          // /U = hash 32 byte + salt 16 byte
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // phải khác nhau ở mỗi lần chạy
end.

Nối probe vào bản build cho mọi nền tảng non-Windows: chạy nó hai lần, fail job nếu hai dòng khớp nhau. Phép so sánh tương tự dùng được trên các file đang lưu hành. Lấy hai PDF được mã hóa do hai lần chạy khác nhau của cùng một ứng dụng ghi ra, đọc các chuỗi /U từ Encrypt dictionary của chúng, rồi so 16 byte cuối; các salt giống hệt nhau nhận diện một bản build bị ảnh hưởng, và các tài liệu nên được mã hóa lại từ plaintext của chúng bằng 3.114.8 trở lên để mỗi cái nhận một file key mới. Thói quen tổng quát là chạy các đường code non-Windows trên chính nền tảng đó thay vì tin vào lượt chạy Windows, cùng lý lẽ đằng sau backend timestamp libcurl cho các bản build non-Windows

PDFiumPas là một PDF component cho Delphi và Lazarus dựng trên engine PDFium, với AES-256, AES-GCM và token PDF MAC được cài đặt nguyên bản bằng Pascal và vật liệu khóa rút từ generator của hệ điều hành trên mọi nền tảng. Chi tiết và bản tải về nằm trên trang PDFium Delphi component