기술 문서

PDFium 컴포넌트 바인딩 강화: ABI 및 메모리 안전성

C 라이브러리 기반의 Pascal 바인딩은 일반적인 Pascal 코드처럼 읽힙니다. 메서드를 호출하고, 레코드를 반환받으며, 할당한 메모리를 해제합니다. 문제는 PDFium이 자체적인 호출 규칙, 고유한 정수 너비, 그리고 메모리 소유권 및 해제에 대한 자체 규칙을 가진 C 및 C++ 라이브러리라는 점입니다. 이러한 규칙 중 어느 것도 언어 경계를 넘어 자동으로 적용되지 않습니다. 이러한 모든 계약은 Pascal 선언에서 수동으로 다시 명시해야 하며, 단어 하나만 틀려도 깔끔해 보이던 호출이 스택 손상, 잘린 오프셋 또는 이중 해제(double free)로 바뀔 수 있습니다. PDFium 컴포넌트 바인딩의 v1.61.0 감사(audit)에서는 각각의 유형에 해당하는 결함이 발견되었습니다. 이러한 결함들은 이 바인딩에만 국한된 것이 아니므로 살펴볼 가치가 있습니다. 이는 Delphi 또는 Lazarus에서 C API를 래핑할 때 항상 존재하는 위험 요소입니다

cdecl은 장식이 아니라 함수 유형의 일부입니다

PDFium은 컴파일된 C 라이브러리입니다. Win32에서 익스포트(export) 기능과 더 중요하게는 호출하는 콜백은 cdecl 호출 규칙을 사용합니다. cdecl에서는 호출이 반환된 후 호출자(caller)가 스택을 정리합니다. Delphi의 기본 설정은 register이고, 콜백에 대한 Win32 C 표준은 일부 라이브러리에서 호출되는 쪽(callee)이 스택을 정리하는 stdcall입니다. 구조체가 PDFium에 함수 포인터를 전달할 때 해당 포인터의 유형에서 cdecl을 잊어버리면, 양측은 누가 스택 포인터를 조정할지에 대해 의견이 일치하지 않게 됩니다. 둘 다 조정하거나 둘 다 조정하지 않게 되며, 호출할 때마다 인수 크기만큼 스택 포인터가 어긋나게 됩니다

이러한 결함을 찾기 어려운 이유는 손상이 지역적(non-local)이지 않기 때문입니다. 손상된 호출이 정상적으로 반환된 것처럼 보입니다. 그러나 나중에 몇 바이트 벗어난 스택 포인터에 프레임이 위치한 완전히 무관한 함수에서 정렬 오류가 나타나며, 이는 잘못된 읽기, 잘못된 반환 주소 또는 실수한 콜백과 전혀 관련 없는 백트레이스와 함께 발생하는 크래시(crash)로 나타납니다. 양식 채우기(form-fill)가 이러한 문제를 겪는 대표적인 영역인데, 양식 채우기 인터페이스가 PDFium이 다시 호출할 콜백으로 가득 찬 레코드이기 때문입니다. 그중 하나인 FFI_OpenFile은 PDFium이 외부 파일을 열기 위해 호출할 함수를 전달하며, function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl로 선언됩니다. 끝에 있는 cdecl이 중요한 부분입니다. 이를 빼먹어도 코드는 여전히 컴파일되고, 링크되며, PDFium이 해당 함수를 호출하기 직전까지 정상적으로 실행됩니다. 호출 규칙은 함수 유형 자체에 속합니다. 선택적인 부가 요소가 아니며, 일반적인 함수 유형은 완벽하게 합법적인 Pascal 유형이기 때문에 누락되더라도 컴파일러는 경고하지 않습니다. 유일한 방어책은 호출 규칙을 모든 임포트된 서명과 외부로 전달하는 모든 콜백의 필수 필드로 취급하는 것입니다

size_t는 포인터 너비이며, FPC Win64에서는 64비트를 의미합니다

두 번째 결함은 특정 타겟에서만 나타나는 정수 너비 불일치입니다. C의 size_t는 모든 객체 크기를 담을 수 있을 만큼 넓게 정의되며, 64비트 플랫폼에서는 64비트 부호 없는 정수(unsigned integer)를 의미합니다. PDFium의 점진적 로딩(progressive-loading) 인터페이스는 size_t 바이트 오프셋 단위로 작동합니다. 가용성 제공자의 FX_FILEAVAIL 레코드에는 PDFium이 오프셋과 크기를 인수로 전달하여 호출하는 IsDataAvail 콜백이 포함되어 있으며, FX_DOWNLOADHINTS 레코드의 AddSegment 콜백도 동일한 인수를 받습니다. 두 매개변수 모두 size_t입니다

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

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

이러한 오프셋을 32비트 유형으로 선언하면, 이 바인딩은 Win32 및 Delphi Win64에서는 정상적으로 작동하다가 FPC 및 Lazarus Win64에서는 조용히 깨집니다. 그 원인은 미묘합니다. FPC Win64에서 NativeUInt는 진정한 포인터 너비의 64비트 유형이며, size_t는 이에 별칭(alias)으로 지정되어 있습니다. 바인딩의 타입 섹션에는 FPC에서 NativeUInt를 섀도잉(shadowing)하지 말라는 경고 주석이 정확히 명시되어 있습니다. 만약 이를 32비트 별칭으로 재정의하면 size_t가 강제로 32비트가 되고, 라이브러리에 전달되거나 기록되는 모든 size_t 매개변수가 손상되기 때문입니다. 64비트 오프셋이 32비트 매개변수에 전달되면 상위 절반이 잘려 나갑니다. 파일이 작을 때는 모든 오프셋이 32비트 안에 들어가므로 아무런 문제가 없습니다. 하지만 대용량 파일의 경우, 오프셋이 4기가바이트를 넘어서는 순간 잘린 값이 완전히 다른 곳을 가리키게 되고, PDFium은 잘못된 바이트 범위의 가용 여부를 묻게 되어 점진적 로딩이 중단되거나 쓰레기 값을 읽어오게 됩니다. 이 결함은 파일 크기가 충분히 크고 size_t가 실제로 확장된 타겟 환경에서만 가시적으로 나타납니다

Pascal 예외는 절대로 C 프레임을 통해 언와인드(unwind)되어서는 안 됩니다

세 번째 유형은 예외 모델에 관한 것으로, C에는 없는 개념입니다. PDFium이 콜백 중 하나를 호출할 때, Pascal 코드는 Delphi의 예외 처리 메커니즘에 대해 전혀 모르는 C 및 C++ 프레임 스택 내부에서 실행됩니다. 콜백에서 예외를 발생시키고 그 예외가 전파되도록 놔두면, 원래 언와인드(unwind)되도록 만들어지지 않은 프레임들을 통해 언와인드가 일어납니다. 이로 인해 PDFium 자체의 정리 작업이 실행되지 않고, 내부 상태(invariants)가 절반만 업데이트된 채로 남게 되며, 프로세스는 라이브러리가 전혀 예상하지 못한 상태에 빠지게 됩니다. 이러한 콜백에 대한 계약은 예외가 아니라 반환 코드(return code)입니다

두 개의 콜백이 이를 구체적으로 보여줍니다. FPDF_FILEWRITE는 PDFium이 저장된 문서를 기록하는 싱크(sink)이고, FPDF_FILEACCESS는 PDFium이 입력 문서를 읽어오는 소스(source)입니다. 이 바인딩에서 두 콜백 모두 Delphi TStream을 기반으로 구현되며, 디스크가 가득 차거나 스트림이 닫히거나 읽기가 끝을 지나치는 등 스트림에서 발생할 수 있는 모든 일반적인 오류를 겪을 수 있습니다. 쓰기 콜백은 스트림 쓰기 작업을 감싸고, 실패가 외부로 빠져나가게 하는 대신 어떠한 실패든 PDFium의 실패 코드로 변환합니다

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;

읽기 측도 마찬가지로 작동합니다. 즉, 읽기 실패 시 경계를 넘어 예외를 발생시키는 대신, FPDF_FILEACCESS 계약에 맞춰 0을 반환하여 보고합니다. 예외를 무시하지 말라고 훈련받은 Pascal 프로그래머에게는 예외를 다시 발생(re-raise)시키지 않는 단순한 except 구문이 잘못된 것처럼 보일 수 있으며, 일반적인 Pascal에서는 실제로 잘못된 것입니다. 그러나 ABI 경계에서는 이것이 올바른 형태입니다. C 호출자(caller)에게 전달할 수 있는 유일하게 안전한 값은 호출자가 해석할 줄 아는 상태 코드뿐이기 때문입니다. 실패는 여전히 전파되지만 반환 값을 통해 이루어지며, 제어권이 Pascal 쪽으로 돌아오면 라이브러리 위의 호출 코드가 이를 EPdfError로 표면화합니다

오류 경로에 숨어있는 이중 해제(Double free)

네 번째 결함은 소유권에 관한 것입니다. PDFium 문서 핸들은 라이브러리에 의해 열리고, FPDF_CloseDocument에 의해 정확히 한 번만 닫혀야 합니다. 위험한 것은 두 번째 정리 작업도 소유권을 갖는 핸들을 오류 경로(error path)에서 해제할 때 발생합니다. 래퍼(wrapper) 객체를 생성하고, 새로 열린 문서 핸들을 그 객체에 할당한 후, 실패할 수도 있는 추가 설정 작업을 수행하는 루틴을 상상해 보십시오. 설정 작업에서 예외가 발생하면, 원시(raw) 핸들에 FPDF_CloseDocument를 호출하는 조기 반환(early-return) 핸들러가 해당 핸들을 닫을 것이고, 그 후 객체가 해제될 때 래퍼 객체 자체의 소멸자가 이를 다시 닫게 됩니다. 핸들이 두 번 해제되는 것은 정의되지 않은 동작(undefined behavior)이며 크래시를 유발할 가능성이 높습니다

이러한 결함은 이미 열려있는 핸들을 감싸 TPdf를 생성하는 임포지션(imposition) 스타일의 임포트 경로에 대한 감사 중에 발견되었습니다. 수정 방법은 소유권 이전을 유일한 진실 공급원(single source of truth)으로 만드는 것입니다. 핸들이 래퍼의 필드에 할당되고 나면 래퍼가 그 소유권을 가지며, 오류 경로에서의 유일한 정리 작업은 래퍼를 해제하는 것뿐입니다. 래퍼의 소멸자가 자동으로 FPDF_CloseDocument를 호출하므로, 명시적으로 다시 닫게 되면 동일한 PDFium 문서에 대해 이중 해제가 발생하게 됩니다. 수정된 오류 핸들러는 객체를 해제하고 예외를 다시 발생시키며, 닫기(close) 작업으로 가는 경로는 단 하나만 존재하게 됩니다

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;

관리형 레코드(Managed records)와 수많은 익스포트(exports)로 가득 찬 라이브러리 모두 명시적인 정리가 필요합니다

마지막 유형은 컴파일러가 대신 관리해 주는 메모리에 관한 것으로, C의 습관이 조용히 이를 손상시킬 수 있습니다. 이 바인딩의 헬퍼(helper) 함수 중 다수는 WideString 또는 동적 배열을 포함하는 레코드를 반환합니다. 이들은 참조 횟수가 계산(reference-counted)되는 필드이며, 컴파일러는 그 횟수를 유지하기 위해 숨겨진 북키핑(bookkeeping) 코드를 생성합니다. C에서 넘어온 본능적인 습관은 FillChar(Result, SizeOf(Result), 0)를 사용하여 새로운 레코드를 초기화하는 것입니다. 이 코드는 레코드 내부의 관리되는 참조에 대해 참조 횟수를 먼저 감소시키지 않고 0으로 덮어씁니다. 컴파일러는 루프 반복 전체에 걸쳐 함수 결과로 숨겨진 임시 변수 하나를 재사용하므로, 두 번째 반복에서 FillChar는 한 번도 해제되지 않은 유효한 문자열 포인터를 덮어쓰고, 결과적으로 그 포인터가 가리키던 문자열은 메모리 누수(leak)를 일으킵니다. 천 개의 어노테이션(annotations)에 대해 루프를 돌며 함수를 호출하면 천 개의 문자열이 누수됩니다

수정 방법은 관리되는 필드를 0으로 초기화하기 전에 먼저 해제해 주는 Default(T)를 사용하여, 언어가 스스로 알고 있는 방식으로 레코드를 초기화하도록 맡기는 것입니다

// 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);

이와 관련된 소유권 문제는 라이브러리 로딩 경계에 존재합니다. 이 바인딩은 LoadLibrary 이후에 GetProcAddress를 사용하여 PDFium DLL에서 수백 개의 함수 포인터를 확인합니다. 필수 익스포트 항목 중 하나라도 누락되면, 부분적으로만 바인딩된 상태는 위험합니다. 수십 개의 포인터는 유효하지만 나머지는 nil이거나 오래된 상태일 수 있으며, 그중 하나를 통해 나중에 호출을 수행하면 이미 언로드된(unloaded) 모듈로 점프하게 될 수 있습니다. 바인딩은 필수 익스포트를 찾지 못했을 때 라이브러리를 언로드하고 모든 임포트된 포인터를 다시 nil로 재설정하는 완전한 ClearAllBindings를 실행하여 이 문제를 처리합니다. 이렇게 하면 언로드된 모듈을 가리키는 함수 포인터가 남아 있지 않게 되고, 나중에 호출하더라도 해제된 코드로 분기하는 대신 nil 포인터 검사를 통해 깔끔하게 실패하게 됩니다

래퍼(wrapper)는 네 가지 계약이 수동 문장으로 다시 명시되는 곳입니다

이 다섯 가지 결함 중 어느 것도 특별하거나 특이하지 않습니다. 이들은 C API 위의 얇은 Pascal 계층에서 예측할 수 있는 실패 유형(failure modes)이며, 네 가지 별개의 계약을 모두 해당 계층에서 다시 선언해야 하기 때문에 집중적으로 발생합니다. 호출 규칙은 모든 콜백에서 cdecl로 명시되어야 합니다. 정수 너비는 실제로 확장되는 유일한 타겟의 size_t와 일치해야 합니다. 예외 모델은 Pascal을 벗어나는 모든 콜백에서 반환 코드(return codes)로 변환되어야 합니다. 모든 핸들과 모든 관리되는 필드의 소유권은 한 번 명시되어야 하며, 실제 프로덕션 환경에 가기 전까지 아무도 테스트하지 않는 오류 경로를 포함한 모든 경로에서 준수되어야 합니다. 단 하나라도 놓치면 그 원인과 동떨어진 곳에서 증상이 나타나는 결함을 얻게 되며, 바로 이 때문에 이러한 유형의 결함은 해결 비용이 많이 듭니다. 이번 감사의 가치는 개별적인 단일 수정보다는, 이러한 각각의 요소들을 전체 바인딩에 걸쳐 검증해야 하는 하나의 규율(discipline)로 다루었다는 데에 있습니다

가장자리에서 방어만 하는 것이 아니라 실제로 작업을 수행하는 바인딩을 보고 싶다면, 렌더 캐시 및 확대/축소 성능에 대한 노트에서 다룬 렌더 캐시 및 확대/축소 기술에서 렌더링 경로를 확인할 수 있으며, Lazarus 및 FPC 뷰어 빌드하기의 크로스 컴파일러 연습에서는 여기서 설명한 Win64 size_t 동작이 실제로 왜 중요한지 보여줍니다. 두 내용 모두 Delphi, Lazarus 및 C++Builder를 위한 PDFium 컴포넌트에서 제공되는 동일한 메모리 안전성 및 ABI 작업에 기반을 두고 있으며, 이 블로그의 다른 곳에서 다루는 렌더링, 텍스트 추출 및 양식 API와 함께 제공됩니다