CCITT, TIFF, PNG, Flate, 스트림 버퍼 코드를 Free Pascal로 올리는 일은 우연히 Delphi에서만 작동하는 Pascal을 찾아내는 확실한 방법입니다. 아래 다섯 패턴 어느 것도 컴파일러 버그가 아닙니다. 각각은 구현 디테일 덕분에 Delphi가 우연히 올바르게 실행해 주는 코드입니다. 호출자의 배열을 별칭으로 받아 가는 숨은 결과 파라미터, 아무도 끝까지 읽지 않던 범위 밖 분기, 유일한 방어가 range check 스위치뿐인 길이 0 버퍼, 한 코드 경로만 1로 넘기던 1 기반 오프셋, 그리고 메모리 스트림에서는 결코 드러나지 않는 TStream.Read 계약. 컴파일러를 바꾸거나 같은 코드에 손상된 파일을 먹이면 그 우연은 더 이상 유지되지 않습니다
이어지는 내용은 함정 하나하나의 구체적인 형태, PDF Library for Delphi가 그것을 다루는 방식, 그 이면의 규율입니다. 같은 소스가 두 컴파일러에서 같은 문서 의미를 만들어 내야 하고, 그것을 아는 유일한 방법은 양쪽에서 확인하는 것입니다. 자매 글인 Pascal PDF 파서를 악성 파일로부터 강화하는 방법에서는 정수 폭, 재귀 깊이, 초기화되지 않은 버퍼를 다뤘습니다. 이 글은 다른 실패 부류입니다. 처음부터 잘못되어 있었는데 컴파일러가 조용히 덮어 주던 코드의 이야기입니다
Delphi에서 동적 배열을 반환하는 함수가 SetLength 없이도 동작하는 이유는?
Delphi가 호출자의 변수 자체를 숨은 결과 파라미터로 넘기기 때문입니다. 결과를 전혀 할당하지 않는 함수도 호출자가 만들어 둔 배열에 쓸 수 있습니다. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray는 2차원 Group 3, Group 4 디코딩의 핵심인 참조 라인 조회 루틴입니다. 현재 위치 a0와 현재 런의 색을 받아, ITU-T T.4와 T.6 2차원 부호화 방식의 b1, b2에 해당하는 이전 스캔라인의 changing element를 찾아 두 칸짜리 배열로 돌려줍니다. 유혹적인 구현은 Result[0]과 Result[1]에 쓰면서 Result에 SetLength를 아예 호출하지 않는 것입니다
첫 쓰기에서 오류가 나야 정상이고, Free Pascal에서는 실제로 그렇습니다. Delphi에서는 호출 지점이 이렇게 생겼는한 그렇지 않습니다. b: TCCITTIntegerArray를 선언하고 스캔라인 루프 전에 SetLength(b, 2)를 한 번 실행한 다음, 루프 안에서 b := GetNextChangingElement(a0, IsWhite)로 대입하고 b[0]과 b[1]을 읽습니다. Delphi 언어 가이드에 따르면 결과가 long string, 동적 배열 등 관리형 타입인 함수는 그 결과를 추가 var 파라미터로 받으며, 실제로 컴파일러는 대입 대상의 주소를 넘깁니다. 그래서 함수 안의 Result는 이미 두 칸짜리인 b 그 자체이고, 모든 쓰기가 호출자 소유의 메모리에 떨어집니다. Free Pascal은 함수에 새 nil 배열을 건네주고 나중에 그것을 b에 대입하는데, 애초에 이 코드가 기준으로 삼았어야 할 계약 해석이 바로 이쪽입니다
별칭 처리에는 디코더가 의존하는 의미도 실려 있습니다. Result[0]은 스캔이 a0보다 큰 원소를 찾았을 때만 대입되고, Result[1]은 그 뒤에 원소가 있을 때만 대입됩니다. 그래서 못 찾으면 두 칸은 이전 반복이 b에 남겨 둔 값을 유지합니다. 뻔한 수정, 매 호출마다 두 칸을 할당하고 0으로 채우는 방식은 그 이월을 없애고 Delphi의 디코딩 출력까지 바꿉니다. PDF Library for Delphi는 초기화 대신 가드를 씁니다. Delphi에서는 죽은 코드라 디코딩 경로가 바이트 단위로 그대로고, Free Pascal에서는 오류를 의도된 동작으로 바꿉니다. 이 비대칭이 핵심입니다. 가드는 이미 검증된 출력을 내는 컴파일러에서 no-op이어야 하니까요
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi는 호출자의 2칸 배열이 Result로 별칭된 채 들어오므로
// 여기서는 no-op입니다. FPC는 nil로 들어옵니다.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1]은 여전히 hit일 때만 기록되므로 miss면
// 이전 반복의 값을 그대로 유지합니다
End;
데이터보다 오래 살아남은 카운트: TIFF 디렉터리 엔트리
배열을 무효화할 때는 같은 문장에서 그 카운트도 무효화해야 합니다. 그러지 않으면 배열을 보지도 못하는 코드가 카운트를 믿어 버립니다. TIFF 이미지 파일 디렉터리 엔트리(TIFF 6.0 §2, tag, type, count, value-or-offset의 12바이트 레이아웃)는 파일에서 읽은 32비트 카운트를 그대로 들고 있고, PDF Library for Delphi는 각 엔트리를 PopDE: TTIFFEntry로 읽습니다. 이 레코드는 Tag, TagType, Length, Offset, 그리고 디코딩된 IntegerValues와 DoubleValues 배열로 이루어집니다. 순진한 범위 검사는 Offset + TypeSize * Length가 파일 끝을 넘는지 검사하고, 넘으면 두 배열을 길이 0으로 만들면서 Result.Length는 파일에서 온 값 그대로 둡니다
여기서 두 가지가 어긋납니다. 함수 끝에는 "Length가 0이면 0값 원소 하나를 넣어 준다"는 폴백이 있어서 호출자가 언제나 0번 원소를 읽을 수 있게 되어 있습니다. 범위 초과 경로에서 Length가 비워지지 않으면, 그 폴백은 존재 이유인 바로 그 경우에 발동하지 않습니다. 그리고 호출자들은 정말로 0번 원소를 무조건 읽습니다. Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip 등 십여 개가 E.IntegerValues[0]을 꺼내 쓰고, 스트립 테이블은 Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4)로 원소가 하나도 없는 배열에서 Length 곱하기 4바이트를 복사합니다. 비워진 배열에 살아 있는 카운트가 붙은 쪽이 검사조차 없는 배열보다 엄밀히 더 위험합니다. 검사 없는 배열은 그래도 자기가 주장하는 바이트를 실제로 들고 있으니까요
두 번째 함정은 순서입니다. 범위 검사 전에 파일의 카운트로 두 SetLength 호출을 실행하면, 적대적인 엔트리가 유효성 검사 한 번 거치기 전에 기가바이트 단위 할당을 요구할 수 있습니다. Delphi에서는 그 예외가 이미지 로딩 경로 위쪽의 핸들러에 잡혀 파일이 그냥 로드에 실패하는데, 그래서 문제를 놓치기 쉽습니다. 실제로 일어나는 일은 파일이 고른 out-of-memory 이벤트입니다. 올바른 순서는 검사 뒤에 할당하고 카운트가 데이터와 함께 움직이게 만드는 것입니다
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // 카운트는 값과 함께 사라집니다
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // 이제서야
SetLength(Result.DoubleValues, Result.Length);
End;
// ... 뒤에서, 폴백이 존재 이유인 경우에 도달합니다:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
이것엔 컴파일러별로 다른 부분이 하나도 없습니다. 그래서 이 목록에 들어갑니다. 이 함정이 Delphi에서도 Free Pascal에서도 잠복해 있는 이유는 같습니다. 파일 끝을 넘어가는 디렉터리 엔트리가 필요한데, 평범한 테스트 파일에는 그런 게 없습니다. 이식 작업이 드러낸 게 아닙니다. "여기서 Delphi가 나 대신 해 주는 일이 뭘까"라는 질문을 들고 코드를 읽은 것이 드러냈습니다
PNG IHDR이 규격에 없는 컬러 타입을 주장하면 어떻게 될까요?
PDF Library for Delphi는 행 필터를 돌리기 전에 이미지를 거부합니다. ISO 15948 §11.2.2가 IHDR 청크를 정의하고 표 11.1에는 합법적인 컬러 타입·비트 깊이 조합 여섯 가지가 나옵니다. 1, 2, 4, 8, 16비트 그레이스케일, 1, 2, 4, 8비트 인덱스 컬러, 그리고 8 또는 16비트의 트루컬러, 알파 포함 그레이스케일, 알파 포함 트루컬러입니다. 함정은 IHDR의 압축 방식과 필터 방식 필드는 검증하면서 FColorType과 비트 깊이는 손대지 않고 통과시키는 리더입니다. 불법 컬러 타입이 들어오면 0바이트 스캔라인이 계산되고 unfilter 루프는 빈 버퍼를 받습니다
행 필터 코드는 컬러 타입마다 컴포넌트 수를 매핑하는 Case FColorType Of 하나로 모든 크기를 계산합니다. 여섯 가지에 들지 않는 컬러 타입은 Else 분기로 빠지는데, 거기서 SourceComponents가 0이니 ScanlineByteCount도 0이고, SetLength(PreviousScanline, 0) 바로 다음에 FillChar(PreviousScanline[0], ScanlineByteCount, 0)이 실행됩니다. 빈 동적 배열의 0번 원소를 인덱싱한다는 것은 nil에서 계산한 주소를 쓰겠다는 뜻입니다. range check를 끄면 그 주소를 통한 0바이트 채우기가 조용한 no-op이 되고 디코더는 존재하지도 않는 행들을 계속 훑어갑니다. range check를 켜면 첫 이미지에서 ERangeError가 납니다. 그 뒤따르는 Move 호출들은 access violation 한 발짝 앞입니다. 어느 쪽이 나올지는 디코더가 결정한 무엇이 아니라 컴파일러와 빌드 스위치에 달려 있고, 그게 바로 디코더가 아무것도 결정하지 않았다는 증거입니다
PDF Library for Delphi는 규격의 표를 다른 IHDR 필드를 검사하던 자리에 적용합니다. COLOR_GRAYSCALE은 FSourceBitDepth in [1, 2, 4, 8, 16]을 받고, COLOR_PALETTE는 [1, 2, 4, 8]을, COLOR_RGB, COLOR_GRAYSCALEALPHA, COLOR_RGBALPHA는 [8, 16]을 받습니다. 그 밖의 값은 ValidImage를 지우고, 진단을 위해 너비와 높이는 그대로 둔 채 이미지를 거부합니다. 9바이트보다 짧은 pHYs 청크도 같은 자리에서 검사하므로, DPI 리더가 짧은 청크가 빈 채로 남긴 문자열의 S[1]부터 S[8]까지를 인덱싱하는 일은 없습니다
1 기반 오프셋을 0 기반 포인터로 취급
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString는 1 기반 StartPos를 받습니다. 입력이 AnsiString이고 Delphi 구현이 zlib 입력을 @Input[StartPos]로 주소하기 때문입니다. 두 Windows 타깃이 압축을 정적으로 링크하도록 paszlib 기준으로 작성된 Free Pascal 구현은 같은 입력을 포인터 연산으로 주소해야 하는데, 포인터 연산은 0 기반입니다. next_in을 PAnsiChar(Input) + StartPos로, avail_in을 Length(Input) - StartPos로 두고, 이 함수에서 "처음부터 시작"을 뜻하는 1을 넘기면, FPC 빌드는 두 번째 바이트부터 압축을 풀기 시작해 끝에서 한 바이트 전에 멈춥니다
이것은 쉽게 숨습니다. 대부분의 테스트가 닿는 호출자는 0을 넘기는 InflateStr이고, 0은 우연히도 올바른 0 기반 오프셋이라 평범한 InflateStr 호출과 그것을 거치는 모든 테스트에서 두 빌드가 일치합니다. 단일 FlateDecode 스트림을 읽을 수 있는 형태로 풀어 주는 TPDFDocument.DecodeAllStreams, 즉 SaveQDFToFile과 ConvertFileToQDF가 쓰는 루틴은 1을 넘깁니다. 하나씩 어긋난 상태에서는 건너뛴 zlib 헤더 때문에 inflate가 실패하지만, zlib 스트림은 자기가 훑은 바이트에 대해 여전히 0이 아닌 Consumed를 보고합니다. Consumed를 믿는 호출자는 빈 페이로드를 성공적인 디코딩으로 받아들이고 모든 콘텐츠 스트림을 빈 문자열로 바꿉니다. 그렇게 나온 QDF는 페이지 수도 맞고 구조도 유효한데 페이지 콘텐츠가 없습니다. 모든 뷰어에서 오류 없이 열리면서 아무것도 보여 주지 않는 파일입니다
// InflateStrFromPosition의 FPC 분기.
// StartPos는 Delphi 분기와 마찬가지로 1 기반이므로 값을 보정한 뒤
// 경계 지점에서 딱 한 번 0 기반 포인터 오프셋으로 변환합니다.
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;
이걸 잡는 검사는 가능한 한 가장 작은 것입니다. 페이로드를 deflate하고 위치 0과 위치 1에서 각각 inflate한 뒤, 둘이 같은 페이로드를 돌려주고 Consumed가 전체 스트림 길이와 같다고 확인합니다. RFC 1950 스트림은 2바이트 헤더와 4바이트 Adler-32 트레일러를 가지므로, 어느 쪽 끝이든 하나씩 어긋나면 미묘한 손상이 아니라 시작조차 못 하거나 끝맺지 못하는 스트림입니다. 교훈은 zlib가 아니라 경계에 관한 것입니다. 함수 파라미터가 한 인덱스 기준으로 정의되어 있는데 그 아래 구현이 다른 기준을 쓴다면, 변환은 정확히 한 줄에 있어야 하고, 테스트는 두 기준을 구분해 주는 값으로 그 함수를 호출해야 합니다
TStream.Read가 짧게 반환했다고 스트림 끝이 아닌 이유는?
TStream.Read는 요청한 바이트보다 적게 반환해도 되고 그 이유는 무엇이든 상관없기 때문입니다. 0을 반환한 경우에만 더 읽을 것이 없다는 뜻입니다. 로컬 디스크의 TMemoryStream과 TFileStream은 요청한 크기를 거의 항상 다 채워 주기 때문에, "요청한 것보다 적게 왔다"를 파일 끝으로 취급하는 코드가 그 스트림을 쓰는 모든 테스트를 통과합니다. 네트워크 기반 스트림, 압축 해제 스트림, 고객이 직접 작성한 TStream 하위 클래스는 64,000바이트를 요청받고도 2바이트만 돌려주면서 뒤에 기가바이트를 남겨 두고 있을 수 있습니다
TPLBuffer는 PDF Library for Delphi의 모든 파서가 거치는 리더로, AnsiString, 포인터, 바이트 배열, TStream을 감쌀 수 있습니다. Int64를 반환하는 네 스캔 쿼리 DistanceToByte, DistanceToOtherByte, DistanceToAnyByte, DistanceToOtherBytes는 소스를 64 KB 블록으로 읽어 구분자를 찾고, 논리적 위치를 움직이지 않은 채 얼마나 떨어져 있는지 알려 줍니다. 그런 루프를 끝내는 자연스러운 방법은 Until ReadCount < BlockSize입니다. 메모리 기반 소스 세 가지에는 이게 맞습니다. ReadIntoBuffer가 마지막 블록 전까지는 항상 전체 블록을 채워 주기 때문입니다. 스트림 소스에서는 첫 번째 짧은 읽기에서 스캔이 포기하고 구분자가 없다고 보고하며, 그 위의 토크나이저는 객체가 실제로 끝나지 않는 지점에서 끝났다고 판단합니다. 그래서 TPLBuffer는 읽기가 0을 반환할 때까지 루프합니다
// TPLBuffer.DistanceToByte 스캔 루프.
// 0은 TStream.Read가 정의하는 유일한 데이터 끝 신호입니다.
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; // 훑어보기는 리더를 움직이면 안 됩니다
End;
이걸 고정하는 방법은 Read를 재정의해 모든 요청을 2바이트로 제한하는 TMemoryStream 하위 클래스입니다. 문자열 aaaaaX를 감싸고 버퍼 위치를 1로 두면, 네 쿼리 모두 X까지의 거리로 4를 보고하고 그 뒤에도 위치를 1로 남겨 두며, 없는 바이트에 대해서는 -1을 보고해야 합니다. 첫 짧은 읽기에서 멈추는 스캔은 2바이트를 보고 스트림이 고갈됐다고 결론 내리고 -1을 반환합니다. finally는 루프 조건만큼 중요합니다. 스캔 안에서의 Exit는 정상적인 성공 경로이고, 논리적 위치는 루프가 끝까지 돌 때뿐 아니라 그 경로에서도 되돌려져야 합니다
하나의 소스, 두 개의 컴파일러, 하나의 검증
다섯 가지 이면의 규율은 이것입니다. "Delphi 빌드가 통과한다"는 것은 Delphi에 대한 증거이지 소스에 대한 증거가 아닙니다. 갖고 둘 가치가 있는 크로스 컴파일러 검사는 하나의 루틴을 두 컴파일러로 돌립니다. TPDFlib으로 압축된 콘텐츠가 들어간 2페이지 문서를 만들어 저장하고, SaveQDFToFile로 QDF로 다시 저장하고, RepairQDFFile로 QDF를 복구하고, EncryptFile과 EncodePermissions의 권한 마스크로 평문 파일을 AES-128로 암호화한 다음, 모든 산출물을 다시 읽어 두 컴파일러에서 같은 것을 확인합니다. 페이지 수는 2, 제목은 유지, 2페이지 텍스트는 평문·복구본·암호화 파일 모두에서 온전히 추출, 잘못된 비밀번호는 0이 아닌 LastErrorCode로 거부, EncryptionStrength는 128, EncryptionAlgorithm은 2, GetUserPermissions의 개별 권한 비트는 인코딩한 그대로 복원
비교는 바이트가 아니라 정규화된 의미로 합니다. 암호화는 무작위 salt를 뽑고 라이터는 문서 식별자를 부여하므로, 두 빌드가 동일한 파일을 내놓으리라고 기대하지 않습니다. 같은 의미의 파일을 내놓으리라고 기대하는 것이고, 검사도 그 수준에서 서술해야 합니다. QDF 구간이 들어간 이유는 오프셋 함정입니다. 2페이지에 콘텐츠가 없는 QDF는 페이지 수 검사는 통과하고 텍스트 추출 검사는 실패하므로, 후자를 검사하세요. 한 컴파일러에서 no-op이고 다른 컴파일러에서 동작 변경인 수정, 위 다섯 중 넷이 여기 해당합니다. 그런 수정은 양쪽에서 같은 검사를 통과해야 합니다
같은 이식의 링크 타임 절반, 그러니까 Delphi의 OMF 오브젝트와 Free Pascal의 COFF 기대치를 맞추는 이야기는 FPC Win32 OMF to COFF 오브젝트 링킹에 따로 있고, 같은 TIFF 리더를 BigTIFF와 타일 파일에 대비해 구조적으로 강화한 내용은 내장 TIFF 디코더 노트에 있습니다. 이 글의 디코더들은 Delphi, C++Builder, Free Pascal용 PDF Library for Delphi에 들어 있습니다. 같은 소스가 어느 한 컴파일러의 덕이 아니라 자기가 대상으로 삼은 모든 컴파일러에서 같은 결과를 스스로 얻어내는 곳입니다