PDF Library for Delphi는 CCITT, TIFF, PNG, Flate, 스트림 버퍼 코드를 Free Pascal로 올리면서 디코더 결함 다섯 개를 찾아냈습니다. 그런데 다섯 개 모두 수년간 Delphi 전체 테스트 스위트를 통과해 온 코드였습니다. 컴파일러 버그는 하나도 아니었습니다. 각각은 Delphi가 구현 세부 사항 덕분에 우연히 올바르게 실행해 준 Pascal이었습니다. 호출자의 배열을 그대로 별칭으로 받아 가는 숨은 결과 파라미터, 아무도 끝까지 읽지 않던 범위 밖 분기, 유일한 방어가 range check 스위치뿐이던 길이 0 버퍼, 한 코드 경로만 1로 넘기던 1 기반 오프셋, 그리고 메모리 스트림에서는 결코 드러나지 않는 TStream.Read 계약입니다. 컴파일러를 바꾸거나 같은 코드에 손상된 파일을 먹이면 그 우연은 더 이상 유지되지 않습니다
이 글에서는 결함 각각의 구체적인 형태와 수정 내용, 그리고 거기서 나온 원칙을 다룹니다. 이제 같은 소스가 두 컴파일러에서 동일한 문서 의미를 만들어 내야 하고, 그것을 검증하는 테스트 include가 함께 들어갑니다. 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의 디코딩 출력까지 바꿨을 것입니다. 실제로 배포된 수정은 초기화가 아니라 가드입니다. 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값 원소 하나를 넣어 준다"는 fallback이 있어서 호출자가 항상 0번 원소를 읽을 수 있게 되어 있습니다. Length가 범위 초과 경로에서 한 번도 초기화되지 않았기 때문에, 그 fallback은 존재 이유인 바로 그 경우에 결코 발동하지 않았습니다. 게다가 호출자들은 정말로 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;
// ... 뒤에서, 기존 fallback이 마침내 존재 이유였던 경우에 도달합니다:
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는 이제 행 필터를 돌리기 전에 이미지를 거부합니다. v3.539.2 이전에는 0바이트 스캔라인을 계산해서 unfilter 루프에 빈 버퍼를 넘겨줬습니다. ISO 15948 §11.2.2가 IHDR 청크를 정의하고, 표 11.1에 컬러 타입과 비트 깊이의 합법적인 조합 여섯 가지가 나옵니다. 그레이스케일 1, 2, 4, 8, 16비트, 인덱스 컬러 1, 2, 4, 8비트, 그리고 트루컬러·알파 포함 그레이스케일·알파 포함 트루컬러 8, 16비트입니다. TPNGReader는 IHDR의 압축 방식과 필터 방식 필드는 검증했지만 FColorType과 비트 깊이는 손대지 않고 그대로 통과시켰습니다
행 필터 코드는 컬러 타입마다 컴포넌트 수를 매핑하는 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 한 발짝 앞입니다. 어느 쪽이 나올지는 디코더가 결정한 무엇이 아니라 컴파일러와 빌드 스위치에 달려 있고, 그게 바로 디코더가 아무것도 결정하지 않았다는 증거입니다
수정은 규격의 표를 다른 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 청크도 같은 작업에서 막았습니다. 짧은 청크가 빈 문자열로 남겨 둔 S[1]부터 S[8]까지를 DPI 리더가 인덱싱하고 있었기 때문입니다
1 기반 오프셋을 0 기반 포인터로 취급
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString는 1 기반 StartPos를 받습니다. 입력이 AnsiString이고 Delphi 구현이 zlib 입력 주소를 @Input[StartPos]로 잡기 때문입니다. 두 Windows 타깃이 압축을 정적으로 링크하도록 paszlib 기준으로 작성된 Free Pascal 구현은 next_in을 PAnsiChar(Input) + StartPos로, avail_in을 Length(Input) - StartPos로 설정했습니다. 이건 포인터 연산이고, 0 기반입니다. 이 함수에서 "처음부터 시작"을 뜻하는 값인 1을 넘기면 FPC 빌드는 두 번째 바이트부터 압축을 풀기 시작해 끝에서 한 바이트 전에 멈춥니다
이 버그가 살아남은 이유는 대부분의 테스트가 닿는 유일한 호출자가 0을 넘기는 InflateStr이기 때문입니다. 0은 우연히도 올바른 0 기반 오프셋이라, 평범한 InflateStr 호출과 그것을 거치는 모든 테스트에서 두 빌드가 일치했습니다. 단일 FlateDecode 스트림을 읽을 수 있는 형태로 풀어 주는 TPDFDocument.DecodeAllStreams는 1을 넘깁니다. 이 루틴은 SaveQDFToFile과 ConvertFileToQDF가 사용합니다. FPC 빌드에서는 건너뛴 zlib 헤더 때문에 압축 해제가 실패했지만, zlib 스트림은 자기가 훑은 바이트에 대해 여전히 0이 아닌 Consumed를 보고했습니다. 그래서 DecodeAllStreams는 빈 페이로드를 성공적인 디코딩으로 받아들이고 모든 콘텐츠 스트림을 빈 문자열로 바꿔 버렸습니다. 그렇게 나온 QDF는 페이지 수는 맞고 구조도 유효하지만 페이지 콘텐츠가 없었습니다. 모든 뷰어에서 오류 없이 열리면서 아무것도 보여 주지 않는 파일입니다
// v3.539.16 이후의 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.DistanceToByte, v3.539.6 이후의 루프입니다.
// 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에 대한 증거일 뿐 소스에 대한 증거는 아니라는 것입니다. v3.539.16부터 Delphi DUnitX 스위트와 Free Pascal 콘솔 스위트가 같은 Tests\CrossCompilerSemantics.inc를 포함합니다. RunCrossCompilerFileSemantics라는 단일 루틴이 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에 포함되어 있으며, 여기서는 같은 소스가 어느 한 컴파일러의 덕이 아니라 자기가 대상으로 삼은 모든 컴파일러에서 같은 결과를 스스로 얻어내야 합니다