Bài viết kỹ thuật

Code Delphi chỉ đúng nhờ may mắn: năm bug khi port sang FPC

PDF Library for Delphi tìm ra năm khiếm khuyết trong bộ giải mã khi đưa mã CCITT, TIFF, PNG, Flate và bộ đệm stream lên Free Pascal, và cả năm đều đã vượt qua toàn bộ bộ kiểm thử Delphi suốt nhiều năm. Không cái nào là bug của trình biên dịch. Tất cả đều là mã Pascal mà Delphi tình cờ thực thi đúng nhờ một chi tiết cài đặt: một tham số kết quả ẩn alias chính mảng của bên gọi, một nhánh ngoài khoảng chưa từng ai đọc tới, một buffer dài bằng không mà chốt chặn duy nhất là switch kiểm tra khoảng, một offset gốc 1 mà chỉ một nhánh mã từng truyền giá trị 1, và một khế ước TStream.Read mà stream trong bộ nhớ không bao giờ chạm tới. Đổi trình biên dịch, hoặc ném cho cùng đoạn mã đó một tệp hỏng, và sự tình cờ hết đứng vững

Phần tiếp theo mô tả hình dạng cụ thể của từng lỗi, cách sửa, và kỷ luật rút ra từ đó: cùng một mã nguồn giờ phải tạo ra cùng ngữ nghĩa tài liệu trên cả hai trình biên dịch, và một file include kiểm thử xác nhận đúng như vậy. Bài viết anh em về củng cố bộ phân tích PDF viết bằng Pascal trước tệp độc hại đã bàn về độ rộng số nguyên, độ sâu đệ quy và buffer chưa khởi tạo. Bài này nói về một nhóm lỗi khác: đoạn mã vốn sai từ đầu và có trình biên dịch lặng lẽ che cho nó

Vì sao một hàm trả về mảng động lại chạy được trên Delphi mà không cần SetLength?

Vì Delphi truyền chính biến của bên gọi vào làm tham số kết quả ẩn, nên một hàm không hề cấp phát kết quả vẫn ghi được vào mảng mà bên gọi đã cấp phát. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray là phép tra cứu dòng tham chiếu ở trung tâm của giải mã Group 3 và Group 4 hai chiều: cho vị trí hiện tại a0 và màu của run hiện tại, nó dò các changing element của scanline trước, tức b1 và b2 trong sơ đồ mã hóa hai chiều ITU-T T.4 và T.6, rồi trả về chúng dưới dạng mảng hai ô. Hàm gốc ghi Result[0] và Result[1] mà không hề gọi SetLength trên Result

Lẽ ra điều đó phải gây lỗi ngay ở lần ghi đầu tiên, và trên Free Pascal thì đúng là vậy. Trên Delphi thì chưa từng, vì cả hai chỗ gọi trong bộ giải mã đều trông như thế này: khai báo b: TCCITTIntegerArray, gọi SetLength(b, 2) một lần trước vòng lặp scanline, rồi trong vòng lặp gán b := GetNextChangingElement(a0, IsWhite) và đọc b[0] lẫn b[1]. Hướng dẫn ngôn ngữ Delphi ghi rõ rằng hàm có kết quả là long string, mảng động hay kiểu được quản lý khác sẽ nhận kết quả đó như một tham số var bổ sung, và trên thực tế trình biên dịch truyền địa chỉ của đích gán. Thành ra Result bên trong hàm chính là b, vốn đã dài hai phần tử, và mọi lệnh ghi đều rơi vào vùng nhớ mà bên gọi sở hữu. Free Pascal đưa cho hàm một mảng nil mới tinh rồi mới gán nó cho b, và đó mới là cách đọc đúng khế ước mà đoạn mã lẽ ra phải được viết theo ngay từ đầu

Khác biệt khi giải mã CCITT trong PDFlibPas: Delphi truyền mảng b của bên gọi làm tham số var Result ẩn của GetNextChangingElement nên các lệnh ghi rơi vào vùng nhớ bên gọi sở hữu và một lần dò trượt giữ lại giá trị cũ, còn Free Pascal đưa cho hàm một mảng nil mới mà chốt chặn Length phải cấp kích thước bằng SetLength trước lần ghi đầu tiên
Delphi alias mảng của bên gọi thành tham số Result ẩn nên những lệnh ghi không chốt chặn vẫn rơi vào vùng nhớ hợp lệ, còn Free Pascal đến với nil và chốt chặn một dòng biến lỗi thành hành vi đúng ý mà không đổi đường giải mã trên Delphi

Việc alias còn mang theo một ngữ nghĩa mà bộ giải mã dựa vào. Result[0] chỉ được gán khi phép dò tìm thấy phần tử lớn hơn a0, còn Result[1] chỉ khi có phần tử sau nó, nên khi dò trượt hai ô giữ nguyên thứ mà vòng lặp trước để lại trong b. Cách sửa hiển nhiên — cấp hai ô rồi xóa trắng mỗi lần gọi — sẽ phá hỏng phần giá trị giữ lại đó và làm thay đổi kết quả giải mã trên Delphi. Bản sửa được phát hành là một chốt chặn thay vì xóa: trên Delphi nó là mã chết và đường giải mã vẫn y nguyên từng byte, còn trên Free Pascal nó biến lỗi thành hành vi đúng ý. Sự bất đối xứng đó chính là điểm mấu chốt, vì bản sửa buộc phải là no-op trên trình biên dịch nơi đoạn mã vốn đã cho ra kết quả đã kiểm chứng

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi vào đây với mảng hai phần tử của bên gọi đã được
  // alias thành Result, nên ở đó đây là no-op. FPC vào với nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] vẫn chỉ được ghi khi dò trúng, nên khi
  // trượt thì giá trị của vòng lặp trước được giữ nguyên như cũ
End;

Một bộ đếm sống lâu hơn dữ liệu: entry thư mục TIFF

Khi bạn vô hiệu hóa một mảng, bạn phải vô hiệu hóa luôn bộ đếm của nó trong cùng một câu lệnh, nếu không thì bộ đếm vẫn được tin bởi những đoạn mã chẳng bao giờ nhìn thấy mảng. Một entry image file directory của TIFF (TIFF 6.0 §2, bố cục 12 byte gồm tag, type, count và value-hoặc-offset) mang theo bộ đếm 32 bit lấy thẳng từ tệp, và PDF Library for Delphi đọc từng entry qua PopDE: TTIFFEntry, một record gồm Tag, TagType, Length, Offset cùng hai mảng đã giải mã là IntegerValues và DoubleValues. Đoạn mã gốc kiểm tra xem Offset + TypeSize * Length có vượt quá cuối tệp không, và nếu có thì đặt cả hai mảng về độ dài không. Nó để nguyên Result.Length ở giá trị đọc từ tệp

Từ đó kéo theo hai vấn đề. Hàm kết thúc bằng một nhánh dự phòng với nội dung “nếu Length bằng không thì cho entry một phần tử mang giá trị không” để bên gọi luôn đọc được phần tử số không. Vì Length chưa bao giờ được xóa trên nhánh ngoài khoảng, nhánh dự phòng đó chưa từng kích hoạt đúng cho trường hợp duy nhất mà nó tồn tại. Còn bên gọi thì đọc phần tử số không vô điều kiện: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip và cả chục chỗ nữa lấy E.IntegerValues[0], còn các bảng strip thì làm Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), tức copy Length lần bốn byte từ một mảng rỗng. Một mảng đã xóa trắng nhưng bộ đếm còn sống nguy hiểm hơn hẳn một mảng không kiểm tra, vì mảng không kiểm tra ít ra còn giữ đúng số byte mà nó khai

Vấn đề thứ hai là thứ tự. Hai lệnh SetLength chạy trước phép kiểm tra khoảng, với kích thước lấy từ bộ đếm trong tệp, nên một entry thù địch có thể đòi cấp phát vài gigabyte trước khi có nổi một phép kiểm tra hợp lệ. Trên Delphi, exception sinh ra bị một handler ở tầng trên của đường nạp ảnh bắt lấy và tệp chỉ đơn giản là nạp thất bại, đó là lý do không ai để ý; còn thực tế đã xảy ra là một sự kiện hết bộ nhớ do chính tệp chọn. Bản sửa chuyển việc cấp phát xuống sau phép kiểm tra và buộc bộ đếm phải đi cùng dữ liệu

Củng cố entry thư mục TIFF trong PDFlibPas: entry 12 byte mang bộ đếm do tệp cung cấp, thứ tự sai cấp phát mảng theo bộ đếm đó trước phép kiểm tra khoảng rồi để Result.Length còn sống sau khi xóa mảng, còn thứ tự đã sửa kiểm tra số học Int64 với độ dài tệp trước nên bộ đếm được xóa cùng lúc với các mảng
Cấp phát trước phép kiểm tra khoảng cho phép một bộ đếm thù địch đòi hàng gigabyte và để lại bộ đếm còn sống trên mảng đã rỗng, nên bản sửa kiểm tra offset trước rồi xóa Result.Length cùng câu lệnh với các mảng
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // bộ đếm đi cùng các giá trị
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // chỉ đến lúc này
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... về sau, nhánh dự phòng sẵn có mới chạm đúng trường hợp nó sinh ra để lo:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Bản sửa này không có gì đặc thù cho trình biên dịch, và chính điều đó khiến nó thuộc về danh sách này. Khiếm khuyết vốn ẩn trên Delphi vì đúng lý do nó ẩn trên Free Pascal: không tệp kiểm thử nào có entry thư mục trỏ ra ngoài cuối tệp. Việc port không phơi ra nó. Chính câu hỏi “Delphi đang làm hộ tôi cái gì ở đây mà tôi không tự làm” khi đọc mã mới phơi ra nó

Điều gì xảy ra khi IHDR của PNG khai một color type mà định dạng không định nghĩa?

PDF Library for Delphi giờ từ chối ảnh trước khi các bộ lọc dòng chạy; trước v3.539.2 nó tính ra một scanline dài không byte rồi đưa cho vòng lặp unfilter một buffer rỗng. ISO 15948 §11.2.2 định nghĩa chunk IHDR và Bảng 11.1 liệt kê sáu tổ hợp color type với bit depth hợp lệ: grayscale ở 1, 2, 4, 8 hoặc 16 bit, indexed color ở 1, 2, 4 hoặc 8, cùng truecolor, grayscale kèm alpha và truecolor kèm alpha ở 8 hoặc 16. TPNGReader có kiểm tra trường compression method và filter method của IHDR nhưng để FColorType cùng bit depth đi qua nguyên vẹn

Mã lọc dòng tính mọi kích thước từ một Case FColorType Of ánh xạ mỗi color type sang số thành phần. Một color type nằm ngoài sáu giá trị sẽ rơi vào nhánh Else, nơi SourceComponents bằng 0, kéo theo ScanlineByteCount bằng 0, nên ngay sau SetLength(PreviousScanline, 0) là FillChar(PreviousScanline[0], ScanlineByteCount, 0). Đánh chỉ số phần tử số không của một mảng động rỗng là một địa chỉ tính từ nil. Với range checking tắt, một lệnh fill không byte qua địa chỉ đó là no-op im lặng và bộ giải mã cứ thế chạy tiếp qua những dòng không tồn tại; với range checking bật thì đó là ERangeError ngay ảnh đầu tiên; còn những lệnh Move theo sau chỉ cách một access violation đúng một bước. Bạn nhận được cái nào phụ thuộc vào trình biên dịch và các switch build chứ không phụ thuộc vào bất cứ điều gì bộ giải mã đã quyết, và đó chính là dấu hiệu cho thấy bộ giải mã chưa từng quyết gì cả

Bản sửa chính là bảng từ đặc tả, áp vào đúng chỗ các trường IHDR khác vốn đã được kiểm: COLOR_GRAYSCALE nhận FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE nhận [1, 2, 4, 8], còn COLOR_RGB, COLOR_GRAYSCALEALPHA và COLOR_RGBALPHA nhận [8, 16]; mọi giá trị khác sẽ xóa ValidImage và ảnh bị từ chối nhưng vẫn giữ nguyên width lẫn height để chẩn đoán. Một chunk pHYs ngắn hơn chín byte cũng được xử lý trong cùng lượt, vì bộ đọc DPI đã đánh chỉ số S[1] tới S[8] trên một chuỗi mà chunk ngắn đó để rỗng

Một offset gốc 1 bị đối xử như con trỏ gốc 0

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString nhận StartPos gốc 1, vì đầu vào của nó là AnsiString và bản cài đặt Delphi đánh địa chỉ vùng vào của zlib bằng @Input[StartPos]. Bản cài đặt Free Pascal, viết dựa trên paszlib để cả hai target Windows đều link nén tĩnh, lại đặt next_in thành PAnsiChar(Input) + StartPos và avail_in thành Length(Input) - StartPos. Đó là số học con trỏ, và nó gốc 0. Truyền 1 — đúng nghĩa “bắt đầu từ đầu” của hàm này — thì bản FPC bắt đầu inflate từ byte thứ hai và dừng trước cuối một byte

Lý do nó sống sót là vì bên gọi duy nhất mà phần lớn bài kiểm thử chạm tới là InflateStr, vốn truyền 0. Số không tình cờ lại đúng là offset gốc 0, nên hai bản build khớp nhau ở mọi lệnh gọi InflateStr thường và mọi bài kiểm thử đi qua nó. TPDFDocument.DecodeAllStreams, thủ tục mà SaveQDFToFile và ConvertFileToQDF dùng để giãn các stream FlateDecode đơn thành dạng đọc được, lại truyền 1. Trên bản FPC, header zlib bị bỏ qua làm phép inflate thất bại, nhưng stream zlib vẫn báo Consumed khác không cho số byte nó đã xem xét, nên DecodeAllStreams coi payload rỗng là giải mã thành công và thay mọi content stream bằng chuỗi rỗng. Tệp QDF tạo ra có đúng số trang, cấu trúc hợp lệ, và không có nội dung trang — một tệp mở ra không báo lỗi trong mọi trình xem và chẳng hiển thị gì

// Nhánh FPC của InflateStrFromPosition, sau v3.539.16.
// StartPos gốc 1 giống nhánh Delphi; kẹp nó lại, rồi đổi sang
// offset con trỏ gốc 0 đúng một lần, tại ranh giới.
If (StartPos < 1) Then
  StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
  Exit;
...
strm.next_in  := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;

Bài kiểm thử hồi quy canh chừng lỗi này nhỏ nhất có thể: deflate một payload, inflate nó từ vị trí 0 và từ vị trí 1, rồi khẳng định cả hai trả về cùng payload và cùng báo Consumed bằng đúng độ dài stream. Một stream theo RFC 1950 có header hai byte và trailer Adler-32 bốn byte, nên lệch một đơn vị ở đầu này hay đầu kia không phải là hỏng dữ liệu tinh vi, mà là một stream hoặc không khởi động được hoặc không kết thúc được. Bài học nằm ở ranh giới chứ không ở zlib: khi tham số của hàm được định nghĩa theo một gốc chỉ số còn bản cài đặt bên dưới dùng gốc kia, việc chuyển đổi phải nằm đúng một dòng, và bài kiểm thử phải gọi hàm với giá trị phân biệt được hai gốc đó

Vì sao một lần TStream.Read ngắn không phải là hết stream?

Vì TStream.Read được phép trả về ít byte hơn yêu cầu vì bất cứ lý do gì nó muốn, và chỉ khi trả về 0 mới có nghĩa là hết dữ liệu. TMemoryStream và TFileStream trên đĩa nội bộ gần như luôn lấp đầy yêu cầu, đó là lý do đoạn mã coi “trả về ít hơn số tôi xin” là hết tệp vẫn vượt qua mọi bài kiểm thử dùng chúng. Stream chạy qua mạng, stream giải nén, và bất kỳ lớp con TStream nào khách hàng tự viết đều có thể trả về hai byte khi được xin sáu mươi tư nghìn byte mà phía sau vẫn còn hàng gigabyte

TPLBuffer là bộ đọc mà mọi parser trong PDF Library for Delphi đều đi qua, và nó có thể bọc một AnsiString, một con trỏ, một mảng byte hoặc một TStream. Bốn truy vấn quét của nó — DistanceToByte, DistanceToOtherByte, DistanceToAnyByte và DistanceToOtherBytes, đều trả về Int64 — đọc nguồn theo từng khối 64 KB để tìm dấu phân cách và cho biết nó cách bao xa mà không dịch chuyển vị trí logic. Mỗi vòng lặp kết thúc bằng Until ReadCount < BlockSize. Với ba nguồn trong bộ nhớ thì như vậy là đúng, vì ReadIntoBuffer luôn giao đủ khối cho tới khối cuối cùng. Với nguồn là stream thì điều đó có nghĩa là phép quét bỏ cuộc ngay ở lần đọc ngắn đầu tiên, báo rằng không có dấu phân cách, và bộ tokenizer bên trên quyết định object kết thúc ở chỗ nó không hề kết thúc

Xử lý lần đọc ngắn trong buffer stream của PDFlibPas: DistanceToByte quét từng khối 64 KB, vòng lặp cũ coi Until ReadCount < BlockSize là hết dữ liệu nên bỏ cuộc ngay ở lần đọc ngắn đầu tiên, còn vòng lặp đã sửa chạy tới khi ReadCount bằng không, tìm ra dấu phân cách và khôi phục vị trí trong khối finally
Một stream có thể trả về hai byte khi được xin sáu mươi tư nghìn byte, nên số không là tín hiệu hết dữ liệu duy nhất mà phép quét được phép tin, còn mệnh đề finally khôi phục vị trí logic khi tìm thấy dấu phân cách và vòng lặp thoát sớm
// TPLBuffer.DistanceToByte, vòng lặp sau v3.539.6.
// Số không là tín hiệu hết dữ liệu duy nhất mà TStream.Read định nghĩa.
TempPosition := FPosition;
Try
  Repeat
    ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
    For TestPos := 0 To ReadCount - 1 Do
      If TempBuffer[TestPos] = Value Then
      Begin
        Result := TotalSkipped + TestPos;
        Exit;
      End;
    Inc(TotalSkipped, ReadCount);
  Until ReadCount = 0;
Finally
  FPosition := TempPosition;   // thao tác peek không được dịch chuyển bộ đọc
End;

Bài kiểm thử chốt lại hành vi này là một lớp con của TMemoryStream có Read ghi đè giới hạn mọi yêu cầu ở hai byte. Bọc chuỗi aaaaaX trong đó, đặt vị trí buffer về 1, và cả bốn truy vấn phải báo khoảng cách 4 tới X, giữ nguyên vị trí 1 sau đó, và trả về -1 với một byte không hề tồn tại. Trước bản sửa, truy vấn đầu tiên thấy hai byte, kết luận stream đã cạn, và trả về -1. Khối finally quan trọng ngang với điều kiện vòng lặp: một Exit từ bên trong phép quét là đường thành công bình thường, và vị trí logic cũng phải được khôi phục trên đường đó, không chỉ khi vòng lặp chạy hết

Một mã nguồn, hai trình biên dịch, một bộ khẳng định

Kỷ luật rút ra từ năm lỗi này là “bản build Delphi chạy được” chỉ là bằng chứng về Delphi, không phải về mã nguồn. Từ v3.539.16, bộ kiểm thử DUnitX của Delphi và bộ kiểm thử console của Free Pascal đều include cùng một tệp Tests\CrossCompilerSemantics.inc, một thủ tục duy nhất tên RunCrossCompilerFileSemantics, thủ tục này dựng một tài liệu hai trang có nội dung nén qua TPDFlib, lưu nó, lưu lại lần nữa dưới dạng QDF qua SaveQDFToFile, sửa chữa QDF bằng RepairQDFFile, mã hóa tệp thường bằng AES-128 qua EncryptFile cùng một mặt nạ quyền từ EncodePermissions, rồi nạp lại mọi artifact và khẳng định đúng những điều như nhau trên cả hai trình biên dịch: số trang là 2, tiêu đề còn nguyên, văn bản trang hai trích ra nguyên vẹn từ tệp thường, tệp đã sửa chữa và tệp đã mã hóa, mật khẩu sai bị từ chối với LastErrorCode khác không, EncryptionStrength là 128, EncryptionAlgorithm là 2, và từng bit quyền riêng lẻ từ GetUserPermissions quay về đúng như đã mã hóa

Phép so sánh được chuẩn hóa có chủ đích chứ không so từng byte. Mã hóa lấy salt ngẫu nhiên và bộ ghi tự gán document identifier, nên hai bản build không được kỳ vọng phát ra tệp giống hệt nhau; chúng được kỳ vọng phát ra những tệp mang cùng ý nghĩa, và các khẳng định được viết ở đúng mức đó. Nhánh QDF có mặt chính vì lỗi offset: một QDF hai trang mà không có nội dung vẫn qua được phép kiểm tra số trang nhưng trượt phép kiểm tra trích văn bản, và ma trận khẳng định vế thứ hai. Mọi bản sửa trong tương lai mà no-op trên một trình biên dịch và đổi hành vi trên trình biên dịch kia — mô tả đúng bốn trong năm lỗi trên — giờ đều phải vượt qua cùng bộ khẳng định đó hai lần trước khi phát hành

Nửa còn lại của cùng cuộc port nằm ở khâu link, tức việc làm cho object OMF của Delphi và kỳ vọng COFF của Free Pascal ăn khớp với nhau, được kể riêng trong liên kết object OMF sang COFF trên FPC Win32, còn phần củng cố cấu trúc của chính bộ đọc TIFF trước BigTIFF và tệp dạng tiled nằm trong ghi chú về bộ giải mã TIFF tích hợp. Những bộ giải mã trong bài này, cùng bài kiểm thử liên trình biên dịch giờ nằm bên dưới chúng, đều có trong PDF Library for Delphi dành cho Delphi, C++Builder và Free Pascal, nơi cùng một mã nguồn được kỳ vọng tự giành lấy cùng một kết quả trên mọi trình biên dịch nó nhắm tới thay vì được một trình biên dịch ban cho