HotPDF는 가장 위험한 세 가지 PDF 이미지 필터인 DCTDecode, JPXDecode, JBIG2Decode를 애플리케이션 내부가 아니라 별도의 단기 워커 프로세스 안에서 디코드할 수 있습니다. 이를 켜는 속성이 CodecIsolationMode이며, 실질적인 효과는 예전 같으면 VCL 애플리케이션을 다운시켰을 손상된 JPEG 2000 코드스트림이 이제는 일회용 자식 프로세스만 죽이고, 호스트는 상태 코드를 보고한 뒤 계속 실행된다는 것입니다
이 차이는 PDF가 실제로 유입되는 지점, 즉 업로드 양식, 메일 게이트웨이, 스캔 장비, 파트너 FTP 드롭에서 가장 중요합니다. 그 바이트들을 여러분이 통제할 수 없으며, 역사적으로 피해가 발생한 곳이 바로 이미지 코덱입니다
이미지 하나가 잘못됐을 뿐인데 왜 애플리케이션 전체가 다운되는가?
이미지 코덱은 PDF 리더에서 공격자가 통제하는 데이터에 대해 복잡한 상태 기계를 돌리면서도 기댈 만한 구조적 검사가 거의 남아 있지 않은 유일한 부분이기 때문입니다. 바이트가 JPEG 2000이나 JBIG2 디코더에 도달할 즈음에는 상호 참조 테이블이 이미 파싱되었고, 객체는 이미 해석되었으며, 필터 체인도 이미 풀려서, 남은 것은 타일 수, 컴포넌트 수, 샘플당 비트 수를 알려주는 순수한 코드스트림뿐입니다. 여기서 숫자가 잘못되면 그것은 파싱 오류가 아닙니다. 조밀한 디코드 루프 안에서 벌어지는 잘못된 할당 크기이거나 범위를 벗어난 인덱스입니다
예산 제한은 도움이 되며, 이미 갖추고 있어야 합니다. HotPDF는 DecodeBudgetBytes와 DocumentDecodeBudgetBytes로 팽창을 제한하고, DecodeFilterLimit과 DecodePipelineDepthLimit로 필터 체인을 제한합니다. 이 상한들의 근거는 중첩 필터와 PDF 폭탄에 대한 제한된 디코딩에서 다룹니다. 그러나 바이트 예산은 단 하나의 질문, 즉 출력이 얼마나 허용되는가에만 답합니다. 디코더가 출력을 만들어내기도 전에 오류를 일으킬 때 무슨 일이 일어나는지는 답하지 못합니다. 디코드 루프 안의 액세스 위반은 여러분이 거부할 수 있는 정책 위반이 아니라 프로세스 수준의 사건이며, 프로세스 수준 사건을 신뢰성 있게 봉쇄하는 유일한 방법은 다른 프로세스뿐입니다
HotPDF가 격리하는 것과 격리하지 않는 것
HotPDF가 격리하는 코덱 종류는 정확히 세 가지이며, HPDFCodecIsolation 유닛에서 hckDCT, hckJPX, hckJBIG2로 열거됩니다. 그 외의 Flate, LZW, RunLength, ASCII85, CCITT는 모두 프로세스 내부에 남는데, 이 디코더들은 예산으로 충분히 제한할 수 있을 만큼 단순하고, 흥미로운 실패가 발생하는 곳도 아니기 때문입니다
전송 방식은 의도적으로 좁게 설계되어 있습니다. 호스트는 크기가 제한된 공유 메모리 매핑을 하나 할당하고, 고정된 THPDFCodecSharedHeader와 압축된 입력, 그리고 JBIG2 전역 세그먼트가 있다면 그것까지 기록한 뒤 워커를 실행하고 대기합니다. 워커는 디코드된 픽셀을 같은 매핑에 다시 기록하고 상태 워드를 설정합니다. 동기가 어긋날 파이프 프로토콜도 없고, 퍼징 대상이 될 직렬화 형식도 없으며, 헤더에는 매직 값과 버전이 담겨 있어 버전이 맞지 않는 워커 바이너리는 잘못 해석되는 대신 거부됩니다
uses
HPDFDoc, HPDFCodecIsolation;
var
Pdf: THotPDF;
Info: THPDFCodecWorkerInfo;
Bmp: TBitmap;
begin
Pdf := THotPDF.Create(nil);
try
// 페일 클로즈드: 이 코덱들은 절대 프로세스 내부에서 디코드하지 않음
Pdf.CodecIsolationMode := cimRequired;
Pdf.CodecWorkerExecutable := 'HotPDFCodecWorker.exe';
Pdf.CodecWorkerTimeoutMilliseconds := 5000; // 1..600000
Pdf.CodecWorkerMemoryLimitBytes := 268435456; // 0 또는 64MiB 이상
Pdf.DecodeBudgetBytes := 134217728;
if Pdf.LoadFromFile('untrusted-upload.pdf') = 1 then
if Pdf.GetLoadedImageCount > 0 then
begin
Bmp := Pdf.ExtractLoadedImage(0);
try
if Pdf.GetLastCodecWorkerInfo(Info) then
LogCodecOutcome(Info);
finally
Bmp.Free;
end;
end;
finally
Pdf.Free;
end;
end;
CodecWorkerExecutable을 비워두면 HotPDF는 ParamStr(0) 디렉터리 안에서 여러분의 실행 파일 옆에 있는 HotPDFCodecWorker.exe를 워커로 찾습니다. 배포 환경에서 워커를 다른 위치에 두었다면 이 값을 명시적으로 설정하십시오. 이 값은 ExpandFileName을 거쳐 확장되므로, 상대 경로는 애플리케이션 디렉터리가 아니라 현재 디렉터리를 기준으로 해석되는데, 이는 서비스에서는 좀처럼 원하는 동작이 아닙니다
자동인가 필수인가: 어떤 실패 방식을 선택할 것인가?
THPDFCodecIsolationMode의 세 값은 워커가 아예 실행되지 못할 때 어떤 일이 일어나야 하는가라는 하나의 질문에 대한 세 가지 다른 답을 담고 있습니다. cimDisabled는 격리를 완전히 건너뛰고 프로세스 내부에서 디코드하는, 3.x 이전의 동작입니다. 기본값인 cimAutomatic은 워커를 시도하고, 워커 실행 파일이 없거나 실행되지 않으면 조용히 프로세스 내부 디코딩으로 폴백하며, 이는 cwsUnavailable 상태로 보고됩니다. cimRequired는 그 폴백을 거부합니다. 워커를 사용할 수 없으면 디코드는 처리되었지만 실패한 것으로 표시되므로, 신뢰할 수 없는 코드스트림이 여러분의 주소 공간에 도달하는 일은 결코 없습니다
편의가 아니라 위협 모델을 기준으로 선택하십시오. 사용자가 이미 디스크에 가지고 있는 문서를 여는 데스크톱 뷰어라면 cimAutomatic으로 충분합니다. 워커가 없어도 제품이 깨지는 대신 예전 동작으로 저하될 뿐이기 때문입니다. 인터넷에서 온 파일을 파싱하는 수집 서비스는 cimRequired를 실행해야 합니다. 격리 계층을 조용히 빠뜨리는 배포 실수는 문제가 될 때까지 아무도 알아채지 못하는 바로 그런 종류의 회귀이기 때문입니다. 비대칭성에 유의하십시오. 폴백을 유발하는 것은 오직 cwsUnavailable뿐입니다. 실행된 뒤 크래시하거나, 타임아웃되거나, 한계에 부딪힌 워커는 두 모드 모두에서 디코드 실패일 뿐, 프로세스 내부로 조용히 재시도되는 일은 없습니다
THPDFCodecWorkerStatus에서 판정 결과 읽기
GetLastCodecWorkerInfo는 가장 최근에 격리 디코드를 수행한 결과를 반환하며, 상태 열거형은 "이미지 실패"라는 일반적인 로그 한 줄보다 실제 운영 의사결정을 이끌어낼 수 있을 만큼 구체적입니다. 값은 cwsNotRun, cwsSucceeded, cwsUnavailable, cwsLaunchFailed, cwsTimedOut, cwsCrashed, cwsDecodeFailed, cwsProtocolError, cwsOutputLimit입니다
이들을 세 그룹으로 취급하십시오. 배포 문제는 cwsUnavailable과 cwsLaunchFailed입니다. 누군가 워커 없이 배포했거나, 백신 제품이 프로세스 생성을 차단하는 경우입니다. 문서 문제는 cwsDecodeFailed와 cwsOutputLimit입니다. 파일이 잘못되었거나 정책이 허용하는 크기보다 커서 거부하는 것이 올바른 답인 경우입니다. 흥미로운 그룹은 cwsTimedOut과 cwsCrashed인데, 예전이라면 호스트 프로세스를 멈추게 하거나 죽였을 사건들이기 때문입니다. 이런 일이 벌어지면 함께 제공되는 ProcessId, ExitCode, ElapsedMilliseconds 필드만으로도 Windows 오류 보고 항목과 대조하여 특정 고객 파일이 병적인 것인지 아니면 누군가 여러분을 떠보고 있는 것인지 판단하기에 충분합니다
procedure LogCodecOutcome(const Info: THPDFCodecWorkerInfo);
begin
case Info.Status of
cwsSucceeded:
; // 보고할 내용 없음
cwsUnavailable, cwsLaunchFailed:
Alert('Codec worker not deployed: ' + Info.ErrorMessage);
cwsTimedOut, cwsCrashed:
Quarantine(Format('pid %d exit %d after %d ms',
[Info.ProcessId, Info.ExitCode, Info.ElapsedMilliseconds]));
else
RejectDocument(Info.ErrorMessage);
end;
end;
실제로 작동하는 한계들
모든 격리 디코드에는 세 가지 별개의 상한이 적용되며, 어느 것이 발동했는지 아는 것만으로 오후 내내 추측하는 수고를 덜 수 있습니다. CodecWorkerTimeoutMilliseconds는 기본값이 10,000이고 1에서 600,000 범위로 검증되며, 범위를 벗어난 값은 조용히 클램프되는 대신 예외를 일으킵니다. CodecWorkerMemoryLimitBytes는 기본값이 536,870,912바이트이고, 0(무제한을 의미) 또는 최소 67,108,864바이트여야 합니다. 그보다 작은 상한은 현실적인 디코더 작업 집합을 담을 수 없어 모든 문서를 실패시키기 때문입니다. 메모리 상한은 kill-on-close 방식의 Windows 작업 개체(Job Object)로 강제되므로, 호스트가 갑자기 종료되더라도 워커는 그 작업과 함께 소멸합니다
세 번째 상한은 출력 한계이며, 이는 설정되는 값이 아니라 유도되는 값입니다. HotPDF는 요청된 영역이나 예상 이미지 크기로부터, 즉 24비트 출력이라면 너비×높이×3으로 필요한 바이트 수를 계산한 다음, 예산이 설정되어 있으면 그 값을 DecodeBudgetBytes까지 클램프합니다. 그럴듯한 헤더를 보고하고서 기하 정보가 허용하는 것보다 훨씬 많은 픽셀을 내보내려는 디코더는 매핑 자체에 의해 저지되고, 호스트는 cwsOutputLimit을 보게 됩니다. 이것이 격리 계층과 디코드 예산이 서로를 보완하는 이유입니다. 예산은 이미지가 얼마나 커질 수 있는지를 정의하고, 격리 경계는 그 크기에 대한 거짓말이 여러분 프로세스 안의 범위를 벗어난 쓰기로 이어지지 않도록 보장합니다
강화된 수집 경로에서 이것이 차지하는 위치
프로세스 격리는 훨씬 이전부터 시작되는 방어 체인의 가장 바깥 계층입니다. 구조적 제한은 파싱 시점에 그럴듯하지 않은 문서를 거부합니다. 필터 예산은 팽창을 제한합니다. 격리는 이 두 단계를 모두 통과한 것을 봉쇄합니다. 이미지 계층에 도달한 문서에 대해서는 실제로 어떤 코덱을 다루고 있는지 아는 것이 중요합니다. JPXDecode 처리와 JBIG2 심볼 딕셔너리는 실패 양상이 매우 다르며, 특히 JBIG2는 소박한 이미지별 샌드박스라면 깨져버릴 페이지 간 전역 세그먼트를 담고 있습니다
비용은 솔직하게 밝힐 가치가 있습니다. 격리된 이미지마다 프로세스를 실행하면 밀리초 단위의 지연이 추가되고, 수백 페이지짜리 스캔 문서에서는 이를 체감하게 됩니다. 그 대가로 무엇을 얻는지와 비교해 보십시오. 밤새 무인으로 돌아가는 배치 변환기라면 처리량 손실은 눈에 띄지 않고 크래시 봉쇄가 핵심 그 자체입니다. 사용자가 이미 신뢰하는 문서를 여는 대화형 뷰어라면 cimDisabled나 cimAutomatic이 합리적인 기본값입니다. 이 모드는 평범한 속성이므로 실행 시점에 문서 종류별로 선택하지 못할 이유가 없습니다
HotPDF는 격리 계층, 디코드 예산, 구조적 파서 제한을 Delphi와 C++Builder용 네이티브 VCL 컴포넌트 하나에 담아 제공하며, 워커 실행 파일 자체를 제외하면 별도로 배포할 외부 런타임이 없습니다. 전체 API 문서와 평가판은 HotPDF Delphi PDF 컴포넌트 페이지에서 확인할 수 있습니다