문서 수집 파이프라인은 낯선 사람이 작성한 파일을 허용합니다. 인보이스, 스캔, 웹 양식의 첨부 파일 등 각각은 자신이 PDF라고 주장하며 파서가 동작해야 할 수백 개의 숫자를 전달합니다. 스트림 길이, 이미지 크기, 바이트 오프셋, 객체 참조 등 모든 수치는 파일을 생성한 사람에 의해 선택되었으며, 잘린 업로드 파일이나 고의로 잘못 구성된 문서는 언젠가 이러한 숫자 중 하나를 손상을 입힐 수 있는 곳에 둘 것입니다. 이러한 파일에서 살아남는 파서와 충돌하거나 손상된 메모리로 계속 실행되는 파서의 차이는 특정 PDF 라이브러리에 의존하지 않는 몇 가지 작은 습관에 있습니다
이 습관들은 한 가지 전제를 공유합니다: 파일에서 읽은 값은 주장이지 측정값이 아닙니다. 이 값은 파서 자체가 측정한 것—파일의 실제 크기, 디코더가 생성한 실제 바이트 수, 재귀의 실제 깊이—과 비교 확인된 후에만 사용할 수 있게 됩니다. 다음은 문서 파서가 실제로 고장 나는 곳에 적용된 그 전제입니다
선언된 길이는 주장이지 측정값이 아닙니다
가장 단순한 불일치는 스트림 길이입니다. PDF 스트림 객체는 /Length 키에서 바이트 수를 선언하고, 실제 데이터는 stream과 endstream 키워드 사이에 위치합니다. 어느 것도 이 둘이 일치하도록 강제하지 않습니다. 잘린 파일은 선언된 수치보다 더 적은 실제 바이트를 보유하며, 고장 난 생성기에서 나온 파일은 파일의 끝을 넘거나 이웃 객체로 들어가는 길이를 선언할 수 있습니다. 선언된 값에서 할당하고 endstream까지 복사하면 버퍼 오버런이 발생합니다; 사용 가능성을 확인하지 않고 선언된 수치만큼 정확히 읽으면 파일의 끝을 벗어나게 됩니다. 데이터 끝까지 측정한 거리로 제한(clamp)한 후에만 선언된 값이 할당을 주도하도록 하고, 불일치를 결정 지점으로 취급하십시오 — endstream을 스캔하여 복구하거나 스트림을 거부하십시오 — 조용히 믿어버릴 대상이 아닙니다
할당한 것보다 더 큰 래스터를 설명하는 이미지 매개변수
서로 독립적인 두 세트의 숫자가 같은 픽셀을 설명하기 때문에 이미지 스트림은 위험성을 높입니다. 이미지 딕셔너리는 /Width와 /Height를 전달하며, 래스터 버퍼는 일반적으로 이에 맞춰 크기가 지정됩니다. 디코드 필터는 자체의 기하학적 정보를 가지고 있습니다: CCITTFaxDecode는 DecodeParms에서 /Columns, /Rows, /K를 가져오며, 여기서 /K는 Group 3 또는 Group 4 체계를 선택하고 디코더는 스캔라인당 (Columns + 7) div 8 바이트를 방출합니다. /Width 100을 선언하지만 필터에 기본값인 /Columns 1728을 넘겨주는 파일은 디코더가 행당 버퍼가 예상하는 것보다 16배 이상 많은 바이트를 생성하게 만들고, 그 오버플로는 할당된 것 바로 다음에 한 스캔라인씩 랜딩됩니다. /Rows가 없으면 데이터가 멈추라고 할 때까지 디코더가 실행되므로 행 수도 제한해야 합니다. DCTDecode도 같은 취약점이 있습니다: JPEG 데이터는 자체 SOF 마커에 자체 너비와 높이를 포함하며, 이들이 딕셔너리와 일치해야 할 의무는 없습니다
방어 규칙은 기계적입니다: 검증된 디코드 매개변수—CCITT의 경우 필터 자체의 /Columns와 /Rows, DCT의 경우 SOF 크기—로부터 예상되는 래스터 크기를 계산하고, 이를 제한값에 대해 확인한 후 이로부터 할당하고, 디코드 중에 출력이 할당을 초과하여 실행되지 않도록 확인하십시오. 딕셔너리와 필터가 기하학적 형태에 대해 불일치할 때 이들을 조정하거나 이미지를 거부하십시오. 파서가 절대 해서는 안 되는 일은 한 세트의 숫자로 버퍼 크기를 지정하고 디코더가 다른 세트로 실행되도록 두는 것입니다
Delphi 연산 및 할당의 함정
세 가지 Delphi 동작은 검증을 의도하는 파서조차 훼손합니다. 첫 번째는 32비트 곱셈입니다: Delphi는 대상의 너비와 관계없이 32비트에서 두 Integer 피연산자의 곱을 평가하므로, Width * Height * BytesPerPixel은 각 요소가 자체 정상(sanity) 검사를 통과하더라도 래핑(wrap)될 수 있습니다. 픽셀당 3바이트로 30000 곱하기 30000을 스캔하면 27억 바이트가 되며, 이는 부호 있는 32비트 연산에서 음수로 래핑됩니다; 약간 다른 인수들은 버퍼를 할당하고 작게 만드는 작은 양수 길이로 래핑됩니다. 첫 번째 피연산자를 형변환하여 전체 표현식을 넓게 강제하십시오 — Size := Int64(Width) * Height * BytesPerPixel — 그리고 어떤 것이든 SetLength에 도달하기 전에 명시적인 한도와 비교하십시오
두 번째는 범위 검사(range checking)입니다. Delphi의 기본 릴리스 구성은 범위 검사가 꺼진 상태로 제공되므로 파일 데이터에서 계산된 범위를 벗어난 인덱스는 예외를 발생시키지 않습니다 — 배열에 인접한 메모리를 읽거나 씁니다. 파일에서 파생된 값으로 인덱싱하는 모든 유닛의 상단에서 {$R+} (그리고 산술 오버플로를 위해 {$Q+})을 사용하여 이를 다시 켜십시오. 비용은 파서가 어차피 수행하는 I/O에 비해 측정할 수 없을 정도로 작으며, 이는 조용한 손상을 잡아낼 수 있는 ERangeError로 변환합니다
세 번째는 파일이 제공한 Int64를 사용하는 TMemoryStream.SetSize입니다. 현재 RTL에서는 파일이 요구한 크기를 그대로 할당하므로 4기가바이트를 요구하는 단일 스트림은 수집 도중에 메모리 부족 오류를 발생시킵니다. SetSize가 Longint를 사용하는 이전 RTL에서는 값이 먼저 말없이 좁혀집니다: 선언된 $100000010은 16이 되고 할당은 성공하지만 실제 데이터의 쓰기는 이를 훨씬 초과하여 실행됩니다. 어떤 할당 호출도 이를 보기 전에 측정한 소스 크기와 하드 리밋(hard cap)에 대해 모든 크기를 검증하십시오
파일 밖을 가리키는 오프셋
상호 참조 테이블은 객체 번호를 절대 바이트 오프셋에 매핑하며 파서는 그 위치로 찾기(seek)를 실행합니다. 손상되거나 악의적인 파일에서 이러한 오프셋은 파일의 끝을 지나거나 관련 없는 구조 내부에 안착합니다. TStream은 이러한 실패를 조용히 만듭니다: Size 너머로 Position을 설정하는 것은 오류가 아니며, 끝을 지나 단순하게 Read하면 요청한 것보다 적은 수의 바이트만 반환되므로 카운트 확인을 건너뛰는 코드는 이전 객체에서 가져온 낡은 바이트를 계속 파싱하게 됩니다. 이 방어책은 초크포인트(chokepoint)입니다 — 스트림이 이동하기 전에 파일에서 주도하는 모든 찾기 및 읽기가 통과하여 측정한 파일 크기에 대해 오프셋과 카운트를 검증하는 단일 헬퍼입니다
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // 어떤 단일 객체도 64MB를 초과할 수 없음
type
EPdfBoundsError = class(Exception);
// 모든 파일 주도 찾기 및 읽기는 여기를 거칩니다. Offset과 Count는
// 파일이 제공한 주장입니다; Source.Size는 이들이 맞아야 하는 측정값입니다.
procedure ReadBounded(Source: TStream; Offset, Count: Int64;
var Buffer: TBytes);
begin
if (Offset < 0) or (Count < 0) or (Count > MAX_OBJECT_BYTES) or
(Offset > Source.Size) or (Count > Source.Size - Offset) then
raise EPdfBoundsError.CreateFmt(
'객체 범위 %d+%d가 파일 크기 %d를 초과합니다',
[Offset, Count, Source.Size]);
SetLength(Buffer, Count);
if Count = 0 then
Exit;
Source.Position := Offset;
Source.ReadBuffer(Buffer[0], Count);
end;
상호 참조 오프셋, 스트림 범위, 포함된 파일 읽기를 이 헬퍼를 통해 라우팅하면, 잘못된 오프셋은 세 번 호출한 후의 액세스 위반이 아니라 숫자들의 이름을 지정하는 깨끗한 거부가 됩니다
객체 그래프의 주기와 깊이
PDF는 트리가 아닌 그래프입니다. 어떤 값이든 간접 참조일 수 있고, 참조는 다른 참조로 분석될 수 있으며 — /Length 12 0 R, 여기서 객체 12는 13 0 R을 포함 — 체인이 다시 자신에게 돌아가는 것을 막을 방법이 없습니다. 순진하게 참조를 따라가는 리졸버는 네이티브 스택이 소진될 때까지 재귀하며, 스택 소진은 잡을 수 있는 성질이 아닙니다; 프로세스를 종료시킵니다. 깊게 중첩된 배열과 딕셔너리는 주기가 전혀 없어도 동일한 결과를 낳습니다
두 개의 가드를 함께 사용하십시오: 명시적 깊이 카운터는 정당한 파일이 접근하지 않는 한도 내에서 정직하지만 깊은(honest-but-deep) 케이스를 제한하며, 방문된 집합은 두 번째 방문 시에 진정한 주기를 포착하여 제한(limit) 초과가 아니라 정확히 보고할 수 있는 오류로 바꿉니다
uses
System.SysUtils, System.Generics.Collections;
const
MAX_RESOLVE_DEPTH = 32; // 어떤 합법적인 참조 체인보다 훨씬 더 깊음
type
EPdfStructureError = class(Exception);
TPdfValueKind = (pvNull, pvNumber, pvName, pvString, pvArray,
pvDictionary, pvStream, pvReference);
TPdfValue = record
Kind: TPdfValueKind;
RefNumber: Integer; // Kind = pvReference 일 때 의미 있음
// ... 나머지 종류에 대한 페이로드 필드
end;
// LoadObject는 여러분 자신의 루틴입니다: ObjNumber에 대한 xref 오프셋을 찾고,
// ReadBounded로 객체를 읽고 파싱합니다.
function ResolveObject(ObjNumber, Depth: Integer;
Visited: TDictionary<Integer, Boolean>): TPdfValue;
begin
if Depth > MAX_RESOLVE_DEPTH then
raise EPdfStructureError.Create('참조 체인이 깊이 한도를 초과합니다');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'객체 %d를 통과하는 순환 참조', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // e.g. /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // 형제들이 합법적으로 이 객체를 공유할 수 있음
end;
end;
압축 해제는 증폭기입니다
몇 킬로바이트의 FlateDecode 입력은 수 기가바이트로 부풀어오를 수 있습니다; 일반 목적 압축은 반복적인 일반 텍스트에 유리하게 작용하며 공격자는 그것을 최대한 반복적으로 만들 수 있습니다. 각 스트림의 부풀려진 크기를 그 소비자가 그럴듯하게 필요로 하는 한도로 제한하고, 문서당 두 번째 예산을 유지하십시오: 스트림당 상한선 바로 밑에 있는 500개의 스트림은 하나의 거대한 스트림만큼 확실하게 메모리를 고갈시킵니다. 이 검사는 팽창 루프 내부에 위치하여 출력 바이트를 생성할 때마다 카운트하고 위반 시 중단시켜야 하며, 메모리가 이미 사용된 후인 루프 외부에 있어서는 안 됩니다. 합법적인 문서는 조작된 스트림이 도달하는 비율보다 훨씬 낮은 수준에 모여 있기 때문에, 문서 예산은 압축된 파일 크기의 배수로 표현하는 것이 효과적입니다
여러분 자신의 유닛을 넘어선 종심 방어(Defense in depth)
동일한 결함 종류가 라이브러리 내부에 존재합니다. 이 블로그에 있는 두 개의 사례 연구는 실제 인스턴스를 안내합니다: 악성 파일에 대항하는 Pascal PDF 파서 강화에서의 네이티브 Pascal 엔진 내부의 닫혀진 정수 랩핑, 끝없는 재귀 및 초기화되지 않은 버퍼, 그리고 PDFium Component 바인딩 강화에서의 C 엔진을 바인딩할 때의 호출 규약, 정수 너비 및 소유권 위험. 진정으로 신뢰할 수 없는 수집(공개 업로드 양식, 인증되지 않은 편지함)의 경우 별도의 낮은 권한 프로세스에서 파싱 및 디코드 작업을 실행하여, 모든 프로세스 내 가드를 통과하는 파일이 서비스 중단 대신 실패한 작업만 발생시키도록 하십시오
비행 전(preflight) 체크리스트
다음 빌드를 출시하기 전에 파서에 다음 목록을 확인하십시오: 모든 스트림 버퍼는 선언된 길이 대신 제한된 길이로 크기가 조정됩니다; 모든 래스터는 검증된 디코더 매개변수로부터 크기가 조정되고 디코더 출력에 대해 확인됩니다; 모든 차원 곱은 Int64에서 평가되고 명시적인 한도와 비교됩니다; 파일 파생 값으로 인덱싱하는 모든 단위에서 {$R+}가 활성화됩니다; 모든 탐색(seek)은 측정된 파일 크기에 대해 범위 확인이 됩니다; 모든 참조 해결은 깊이가 제한되고 주기가 확인됩니다; 모든 팽창 루프는 스트림별 및 문서별 예산에 대해 출력을 카운트합니다. 이러한 검사 중 어느 것도 합법적인 문서에서 측정 가능한 시간이 들지 않으며, 각 검사는 메모리 손상을 깨끗하고 기록 가능한 거부로 변환합니다
참고: losLab HotPDF Component, PDFlibPas Delphi PDF 라이브러리, 그리고 PDFium Component는 이러한 범위 검사, 깊이 제한, 팽창 상한을 내부적으로 적용하므로, 이들을 기반으로 구축된 수집 파이프라인은 강화된 기준선에서 시작합니다