OOM killer가 나설 때까지 서비스 프로세스를 붙잡아두는 20KB짜리 PDF는 여러분 코드의 버그가 아니라 압축 폭탄입니다. Delphi와 C++Builder용 네이티브 VCL PDF 컴포넌트인 HotPDF는 DecodeBudgetBytes로 이를 제한합니다. 기본값이 268435456바이트인 필터 체인당 상한이며, 모든 디코드 단계를 하나의 공유 예산에 청구합니다
워커 프로세스를 집어삼킨 20KB 파일
이런 사고의 모양은 항상 같습니다. 썸네일을 렌더링하는 큐 워커가 업로드 파일을 집어들고, 상주 메모리가 2초 만에 12GB를 넘어서더니 프로세스가 스택 트레이스도 없이 사라집니다. 파일은 20KB입니다. 페이지 하나, 콘텐츠 스트림 하나, 그리고 다섯 개 항목을 가진 /Filter 배열 하나가 전부입니다. 그 배열의 모든 이름은 명세가 정의한 필터이고, 모든 단계는 오류 없이 디코드되며, 파일 안에 잘못된 부분은 하나도 없습니다. 이 유형을 까다롭게 만드는 게 바로 그 지점입니다. 거부할 손상된 바이트가 없다는 것
이는 단일 필터를 올바르게 디코딩하는 문제와는 다릅니다. LZWDecode와 /DecodeParms predictor를 제대로 처리하는 것 자체가 불러온 문서에서의 LZW, predictor, DecodeParms 안내에서 다루는 별개의 주제입니다. 여기서는 모든 디코더가 이미 올바릅니다. 실패는, 다섯 개를 연달아 돌리는데 아무도 총량을 세지 않을 때 올바른 디코더들이 벌이는 일에서 옵니다. ISO 32000-1 §7.4는 /Filter가 단일 이름이거나 이름 배열일 수 있고, 배열은 순서대로 첫 항목부터 적용된다고 명시합니다. 하지만 한 단계가 입력을 얼마나 늘릴 수 있는지, 그리고 체인 전체의 합산에 대해서는 아무 말이 없습니다. ASCIIHexDecode 단계는 입력을 대략 절반으로 줄이니 무해해 보입니다. 0바이트가 연속된 구간에 대한 FlateDecode 단계는 수천 배의 비율에 도달합니다. 이를 체인으로 연결하면 산술은 곱셈이 됩니다. 20KB는 20MB가 되고, 20MB는 20GB가 되며, 각 개별 단계는 합법적인 스트림에 대한 규격 준수 디코드입니다
필터별 제한은 왜 디코드 폭탄을 막지 못하는가
필터별 제한은 /Filter 배열의 요소마다 다시 무장되기 때문입니다. 단계별로 256MiB 상한을 둔 5단계 체인은 1.25GiB를 허용하며, 마지막 단계도 앞선 네 단계가 무엇을 만들어냈든 상관없이 완전히 새로운 허용치로 시작합니다. 이 제한은 정직하게 강제되지만 정작 중요한 것은 아무것도 제약하지 못합니다. HotPDF는 v2.447.0 이전에 정확히 이런 형태였고, 나란히 두 번째 구멍도 있었습니다. LZW 압축 해제기는 MaxOutputBytes 상한을 가지고 있었고 이미지 predictor 경로는 자신의 행 수를 스스로 관리했으므로, 이 둘은 국지적으로는 제한되어 있었습니다. FlateDecode, ASCIIHexDecode, ASCII85Decode, RunLengthDecode는 상한이 전혀 없었습니다. 각각 입력이 소진되거나 할당자가 포기할 때까지 TMemoryStream에 계속 써넣었습니다. 그래서 악의적인 체인은 두 가지 통로를 가졌습니다. 완전히 무방비 필터를 쓰거나, 아니면 방비된 필터를 쓰되 그냥 더 많이 이어붙이면 됐습니다
순진한 수정으로는 놓치는 세 번째 세부 사항이 있습니다. 여러분이 신경 써야 할 숫자는 최종 디코딩 출력의 크기가 아닙니다. 그것은 피크이며, 그 피크는 대개 중간 버퍼에 있습니다. 최종적으로 4MB짜리 소박한 콘텐츠 스트림으로 끝나는 체인이 3단계에서 8GB를 할당해놓고도 완전히 합리적으로 보이는 결과를 돌려줄 수 있습니다. 결과의 길이를 사후에 확인해봐야 프로세스를 죽인 그 할당에 대해서는 아무것도 알 수 없습니다
필터 체인당 예산 추적기 하나
HotPDF v2.447.0의 수정은 회계를 단계가 아니라 체인 전체로 확장하는 것입니다. 각 필터 체인은 THPDFDecodeBudgetTracker 하나를 구성하고, 모든 디코더는 실제 대상을 감싸는 THPDFBudgetWriteStream을 통해 기록합니다. 이 래퍼는 단 한 바이트라도 전달하기 전에 Budget.Consume(Count)를 호출하므로, 거부는 대상 스트림이 아직 예전 크기일 때 일어납니다. 이 순서가 요점 전부입니다. 버퍼가 이미 커진 뒤에 수행되는 검사는 방어가 아니라 진단일 뿐입니다
// Simplified from the HotPDF chain decoder: one tracker for the whole
// /Filter array, one bounded wrapper stream per stage
Budget := THPDFDecodeBudgetTracker.Create(Doc.DecodeBudgetBytes);
try
for I := 0 to FilterCount - 1 do
begin
if I = 0 then
InputStream := StreamObj.Stream // read the source, do not copy it
else
InputStream := CurrentStream;
NextStream := TMemoryStream.Create;
InputStream.Position := 0;
// BeginFilter names the stage and bumps FilterCount; the wrapper
// stream calls Budget.Consume before writing into NextStream
Doc.LoadUnFlateLZW(InputStream, NextStream, Filters[I], True, Budget);
CurrentStream.Free;
CurrentStream := NextStream;
end;
finally
Budget.FinishFilter;
Budget.Free;
end;
국지적 상한들이 사라진 게 아니라 공유 예산의 투영이 되었습니다. LZW 단계는 이제 Decoder.MaxOutputBytes := Budget.RemainingBytes를 설정하므로, 그 자체의 상한은 독립적인 허용치가 아니라 체인에 남아 있는 양이 됩니다. 이미지 predictor 단계는 BeginFilter로 시작하며 할당하기 전에 자신의 행 요구량을 Consume을 통해 청구하는데, 이는 predictor 출력이 그것을 먹여 살린 일반 필터들과 같은 예산에 청구된다는 뜻입니다. 이는 필터 체인과 predictor가 하나의 연산을 이루는 두 반쪽인 이미지 경로에서 특히 중요하며, 디코드 필터를 통해 불러온 문서에서 이미지 추출하기에서 다룹니다
예산이 거부할 때 호출자는 무엇을 보는가
스택 맨 아래에서 거부는 EHPDFDecodeBudgetError를 일으킵니다. 그 위에서는 답이 호출 API가 이미 가지고 있던 계약에 따라 달라집니다. False나 nil로 실패를 알리던 상위 수준 읽기 메서드는 그대로 그렇게 동작을 유지하는데, 문서화된 Boolean 결과를 예외로 바꾸면 이미 잘못된 입력을 올바르게 처리하고 있던 호출자를 깨뜨리기 때문입니다. 불러온 페이지 콘텐츠 경로는 의도적인 예외입니다. 잘린 콘텐츠 스트림이 단순히 비어 있는 것처럼 페이지로 렌더링되도록 두는 대신 EHPDFDecodeBudgetError를 다시 발생시킵니다. 이 설계는 단순한 False만으로는 그 자체로 애매하다는 뜻이므로, 예산은 그 옆에 진단 레코드를 함께 공개합니다. THotPDF.GetLastDecodeBudgetInfo는 해당 인스턴스가 가장 최근에 디코드한 체인의 상태를 반환합니다
type
THPDFDecodeBudgetInfo = record
LimitBytes: Int64;
DecodedBytes: Int64;
PeakStageBytes: Int64;
FilterCount: Integer;
Exceeded: Boolean;
ExceededFilter: AnsiString;
end;
var
Pdf: THotPDF;
Info: THPDFDecodeBudgetInfo;
PageText: UnicodeString;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.DecodeBudgetBytes := 64 * 1024 * 1024; // tighter than the default
Pdf.LoadFromFile('untrusted.pdf');
if not Pdf.ExtractLoadedPageText(0, PageText) then
if Pdf.GetLastDecodeBudgetInfo(Info) and Info.Exceeded then
LogWarning(Format(
'decode refused in %s after %d bytes, peak stage %d, %d filters',
[String(Info.ExceededFilter), Info.DecodedBytes,
Info.PeakStageBytes, Info.FilterCount]));
finally
Pdf.Free;
end;
end;
이 필드들을 함께 읽으면 두 가지 공격 형태를 구분할 수 있습니다. PeakStageBytes가 DecodedBytes에 가까우면 한 단계가 모든 피해를 냈다는 뜻이고, 고배율 필터 하나를 보고 있는 것입니다. PeakStageBytes가 DecodedBytes의 아주 작은 일부이고 FilterCount가 높으면, 개별 단계는 특별히 과하지 않았고 체인이 누적되어 상한을 넘어선 것입니다. 이것이 바로 필터별 제한이 볼 수 없는 경우입니다. 핸들러에 적어둘 만한 주의점 하나: GetLastDecodeBudgetInfo는 인스턴스가 필터를 하나도 디코드하기 전까지는 False를 반환하므로, 여기서 온 False가 문서가 깨끗했다는 증거는 아닙니다
예산이 리셋되는 지점, 그리고 0이 정직한 답인 경우
DecodeBudgetBytes는 문서 하나가 아니라 스트림 체인 하나를 제한하며, 이 경계는 의도적이지만 오해하기 쉽습니다. 모든 콘텐츠 스트림, 모든 첨부 파일, 모든 상호 참조 스트림, 모든 객체 스트림은 256MiB로 새로 시작합니다. 그러므로 4,000페이지짜리 문서는 전체 상한을 소비할 4,000번의 독립적인 기회를 가지며, 객체 스트림은 그 개수를 더 늘립니다. 각 객체 스트림 자체가 여러 객체를 담은 압축 컨테이너이기 때문입니다. 객체 스트림과 증분 업데이트에 관한 노트에서 설명합니다. 실제 요구사항이 전체 프로세스 메모리에 대한 제한이라면, 이 속성은 그 목표를 위한 하나의 입력일 뿐 전부가 아니며, 작업 수준이나 컨테이너 수준의 상한 뒤에 놓여야 합니다
0은 무제한을 의미하며, 이는 우회 수단이 아니라 정당한 설정입니다. 입력을 여러분이 소유하는 경우, 즉 여러분 시스템이 만든 문서를 재처리하는 아카이브 파이프라인이나, 600dpi 컬러 스캔 체인 하나가 여러분이 하드코딩하고 싶은 어떤 상한보다도 정말로 더 많이 필요한 래스터화 단계라면 이렇게 설정하십시오. 음수 값은 ERangeError로 즉시 거부되는데, 음수 예산에는 일관된 의미가 없고 이를 조용히 클램프하면 설정 오류를 감추게 되기 때문입니다
// Trusted archive pipeline: state the intent rather than guess a ceiling
ArchivePdf.DecodeBudgetBytes := 0; // explicit unlimited
// Untrusted upload: size the ceiling from what your corpus actually needs
IngestPdf.DecodeBudgetBytes := 96 * 1024 * 1024;
// Configuration mistakes fail loudly instead of clamping
try
IngestPdf.DecodeBudgetBytes := -1;
except
on E: ERangeError do
LogWarning('DecodeBudgetBytes cannot be negative');
end;
숫자를 정하는 일은 보통 받는 것보다 더 많은 주의를 받을 자격이 있습니다. 너무 낮게 설정된 예산은 자초한 장애이기 때문입니다. 기존 코퍼스를 기본값으로 돌려보고, 모든 체인에 대해 PeakStageBytes와 DecodedBytes를 기록한 다음, 관측된 최댓값보다 실질적인 여유를 두고 상한을 설정하십시오. 안전해 보인다는 이유만으로 고른 어중간한 숫자는 최악의 순간에 정상적인 대형 스캔을 거부할 것이고, 그 실패는 로그에서 정확히 공격처럼 보일 것입니다
더 이상 일어나지 않는 복사
모든 단계를 예산 래퍼로 통과시킨 결과, 체인은 오히려 더 비싸지기는커녕 더 저렴해졌습니다. 스트림에 필터가 있을 때 첫 단계는 이제 인코딩된 바이트를 스크래치 버퍼로 먼저 복사하는 대신 소스 스트림을 직접 읽으며, 그 이후로는 현재 입력과 기록 중인 단계 출력, 딱 두 개의 버퍼만 동시에 살아 있습니다. 원시 복사는 필요한 두 경우에만 남습니다. 필터가 전혀 없는 스트림과, 호출자가 마지막 인코딩을 그대로 유지하고 싶어하는 이미지의 경우인데, 둘 다 호출자가 소유하고 독립적으로 탐색할 수 있는 스트림을 돌려주기 때문입니다. 방비되지 않은 예전 버전 코드는 더 많이 할당하고 더 적게 제한했는데, 이것이 그 둘 사이의 일반적인 관계입니다. 다만 분명히 짚을 게 하나 있습니다. 이 어느 것도 임의의 PDF를 로드하기 안전하게 만들어주지는 않습니다. 이는 매우 저렴한 서비스 거부 공격 벡터 하나, 즉 작은 파일이 중첩 필터를 통해 큰 할당을 사들이는 경우를 막을 뿐입니다. 바이트 회계에서의 정수 오버플로는 별도로 방비되어 있으며, 내부 오프셋을 신뢰하지 않고 악의적인 문서를 파싱한다는 더 넓은 질문은 다른 분야입니다. 디코드 예산은 여러 한계 가운데 하나이며, 그 가치는 파일을 건드리기도 전에 속성 하나로 설정할 수 있다는 데 있습니다
체인당 예산과 그 진단 레코드, 그리고 그것이 보호하는 불러온 문서 디코드 경로는 외부 압축 해제 종속성을 설정하거나 패치할 필요 없이 컴포넌트 자체의 일부로 제공됩니다. Delphi나 C++Builder 서비스 안에서 신뢰할 수 없는 PDF 입력을 어떻게 제한할지 검토 중이라면, HotPDF Delphi PDF 컴포넌트 페이지에 이 한계들이 적용되는 불러온 문서 툴킷이 정리되어 있습니다