문서 접수 파이프라인은 낯선 사람이 쓴 파일을 받아들입니다. 청구서, 스캔본, 웹 양식에서 온 첨부 파일. 저마다 PDF라고 주장하며, 여러분의 파서가 그에 따라 움직이리라 기대되는 숫자를 수백 개 지고 옵니다. 스트림 길이, 이미지 치수, 바이트 오프셋, 객체 참조 — 하나하나가 그 파일을 만든 자가 고른 것이고, 잘려 올라온 업로드나 일부러 망가뜨린 문서는 언젠가 그 숫자 하나를 해를 끼치는 자리에 놓습니다. 그 파일을 견디는 파서와, 충돌하거나 망가진 메모리를 안은 채 계속 도는 파서를 가르는 것은 어떤 특정 PDF 라이브러리에도 기대지 않는 작은 습관 묶음입니다
그 습관들은 전제를 하나 나눠 가집니다. 파일에서 읽은 값은 측정치가 아니라 주장이라는 것입니다. 그것은 파서가 스스로 측정한 무엇인가와 맞춰 본 뒤에야 쓸 수 있게 됩니다. 파일의 실제 크기, 디코더가 실제로 만들어 낸 바이트 수, 재귀의 실제 깊이 같은 것들 말입니다. 이제부터는 그 전제를 문서 파서가 실제로 무너지는 자리마다 적용한 것입니다
선언된 길이는 측정치가 아니라 주장입니다
가장 단순한 어긋남은 스트림 길이입니다. PDF 스트림 객체는 /Length 키에 자기 바이트 수를 선언하고, 실제 데이터는 stream과 endstream 키워드 사이에 앉아 있습니다. 둘이 일치하도록 강제하는 것은 아무것도 없습니다. 잘린 파일은 선언된 수보다 적은 실제 바이트를 지니고, 망가진 생성기에서 나온 파일은 파일 끝을 넘어서거나 이웃 객체 안까지 닿는 길이를 선언할 수 있습니다. 선언된 값으로 할당하고 endstream까지 복사하면 버퍼를 넘어섭니다. 사용 가능 여부를 확인하지 않고 선언된 수만큼 정확히 읽으면 파일 끝 밖으로 걸어 나갑니다. 선언된 값이 할당을 이끌게 하되, 데이터 끝까지의 측정된 거리에 대고 눌러 자른 뒤에만 그렇게 하고, 불일치는 조용히 믿을 것이 아니라 판단 지점으로 다루십시오. endstream을 훑어 고치거나, 스트림을 거부하는 것입니다
할당한 것보다 큰 래스터를 설명하는 이미지 매개변수
이미지 스트림은 판돈을 올립니다. 같은 픽셀을 서로 독립적인 숫자 묶음 둘이 설명하기 때문입니다. 이미지 사전은 /Width와 /Height를 지니고, 래스터 버퍼는 대개 그것으로 크기를 잡습니다. 디코드 필터는 자기 나름의 기하를 지니고 있습니다. CCITTFaxDecode는 DecodeParms에서 /Columns, /Rows, /K를 받는데, /K가 그룹 3 또는 그룹 4 방식을 고르고 디코더는 스캔라인마다 (Columns + 7) div 8 바이트를 내놓습니다. /Width 100을 선언해 놓고 필터에는 기본값인 /Columns 1728을 건네는 파일은 디코더가 버퍼가 기대하는 행당 바이트의 열여섯 배가 넘게 만들어 내게 하고, 넘친 부분은 할당 뒤에 놓인 무엇이든 위에 스캔라인 단위로 떨어집니다. /Rows가 없으면 디코더는 데이터가 멈추라고 할 때까지 도니, 행 수도 한정하십시오. DCTDecode에도 같은 이음매가 있습니다. JPEG 데이터는 SOF 표지에 자기 너비와 높이를 지니고 있고, 그것이 사전과 맞아야 할 의무는 없습니다
방어 규칙은 기계적입니다. 검증된 디코드 매개변수 — CCITT라면 필터 자신의 /Columns와 /Rows, DCT라면 SOF 치수 — 에서 기대 래스터 크기를 계산하고, 그것을 여러분의 한계와 맞춰 보고, 그것으로 할당하고, 디코드 중에 출력이 할당을 넘어 달리지 않는지 확인하십시오. 사전과 필터가 기하를 두고 어긋나면 조정하거나 이미지를 거부하십시오. 파서가 결코 해서는 안 되는 일은 한쪽 숫자로 버퍼 크기를 잡고 디코더는 다른 쪽으로 돌게 두는 것입니다
델파이의 산술과 할당 함정
델파이의 동작 셋이 검증할 뜻이 있는 파서마저 허물어뜨립니다. 첫째는 32비트 곱셈입니다. 델파이는 두 Integer 피연산자의 곱을 대상의 폭과 무관하게 32비트로 평가하므로, 모든 인자가 저마다의 온전성 검사를 통과해도 Width * Height * BytesPerPixel이 넘칠 수 있습니다. 픽셀당 3바이트인 30000 곱하기 30000 스캔은 27억 바이트이고, 부호 있는 32비트 산술에서 음수로 감깁니다. 인자가 조금만 달라지면 작은 양수 길이로 감겨 할당은 성공하면서 버퍼는 모자라게 됩니다. 첫 피연산자를 캐스팅해 식 전체를 넓게 만들고 — Size := Int64(Width) * Height * BytesPerPixel — 무엇이든 SetLength에 닿기 전에 명시적 상한과 견주십시오
둘째는 범위 검사입니다. 델파이의 기본 릴리스 구성은 그것을 끈 채로 출하되므로, 파일 데이터에서 계산된 범위 밖 인덱스는 예외를 일으키지 않고 배열 옆의 메모리를 읽거나 씁니다. 파일에서 나온 값으로 인덱싱하는 모든 유닛 맨 위에서 {$R+}(그리고 산술 넘침에는 {$Q+})로 다시 켜십시오. 그 값은 파서가 어차피 하는 I/O 옆에서 잴 수도 없을 만큼 작고, 조용한 손상을 잡아낼 수 있는 ERangeError로 바꿔 줍니다
셋째는 파일이 공급한 Int64를 받는 TMemoryStream.SetSize입니다. 요즘 RTL에서는 파일이 요구한 만큼 할당하므로, 4기가바이트를 주장하는 스트림 하나가 접수 도중의 메모리 부족 실패가 됩니다. SetSize가 Longint를 받는 옛 RTL에서는 값이 먼저 조용히 좁혀집니다. 선언된 $100000010이 16이 되고, 할당은 성공하고, 실제 데이터의 쓰기가 그것을 한참 지나쳐 달립니다. 어떤 할당 호출이 보기 전에 모든 크기를 측정된 원본 크기와 굳은 상한에 대고 검증하십시오
파일 바깥을 가리키는 오프셋
상호 참조 표는 객체 번호를 절대 바이트 오프셋에 대응시키고, 파서는 그것이 가리키는 곳으로 탐색합니다. 손상되었거나 적대적인 파일에서 그 오프셋은 파일 끝 너머나 무관한 구조 안에 떨어집니다. TStream은 그 실패를 조용하게 만듭니다. Position을 Size 너머로 설정하는 것은 오류가 아니고, 끝을 지난 평범한 Read는 그저 요청한 것보다 적은 바이트를 돌려주므로, 개수 검사를 건너뛴 코드는 앞선 객체에서 남은 바이트를 계속 구문 분석합니다. 방어는 좁은 길목입니다. 파일이 이끄는 모든 탐색과 읽기가 지나가는 도우미 하나를 두어, 스트림이 움직이기 전에 오프셋과 개수를 측정된 파일 크기에 대고 검증하는 것입니다
uses
System.SysUtils, System.Classes;
const
MAX_OBJECT_BYTES = 64 * 1024 * 1024; // 어떤 객체도 64 MB를 넘을 수 없습니다
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(
'object extent %d+%d exceeds file size %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을 담고 있는 식으로 — 사슬이 자기 자신으로 되돌아 닫히는 것을 막는 것은 아무것도 없습니다. 참조를 순진하게 따라가는 해결기는 네이티브 스택이 바닥날 때까지 재귀하고, 스택 고갈은 잡을 수 있는 것이 아닙니다. 프로세스를 끝냅니다. 깊게 중첩된 배열과 사전은 순환이 전혀 없어도 같은 끝에 이릅니다
방어 둘을 함께 쓰십시오. 명시적 깊이 계수기가 정직하지만 깊은 경우를 어떤 정당한 파일도 다가가지 않는 한계에서 한정하고, 방문 집합이 진짜 순환을 두 번째 방문에서 잡아내어 한계 초과가 아니라 정확하고 보고할 수 있는 오류로 바꿉니다
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('reference chain exceeds depth limit');
if Visited.ContainsKey(ObjNumber) then
raise EPdfStructureError.CreateFmt(
'circular reference through object %d', [ObjNumber]);
Visited.Add(ObjNumber, True);
try
Result := LoadObject(ObjNumber);
if Result.Kind = pvReference then // 예: /Length 12 0 R
Result := ResolveObject(Result.RefNumber, Depth + 1, Visited);
finally
Visited.Remove(ObjNumber); // 형제들이 이 객체를 합법적으로 나눠 쓸 수 있습니다
end;
end;
압축 해제는 증폭기입니다
몇 킬로바이트의 FlateDecode 입력이 기가바이트로 부풀 수 있습니다. 범용 압축은 되풀이되는 평문에 보답하고, 공격자는 그것을 최대한 되풀이되게 만들 수 있습니다. 스트림마다 부푼 크기를 그 소비자가 그럴듯하게 필요로 할 만한 선에서 막고, 문서마다 두 번째 예산을 따로 두십시오. 스트림당 상한 바로 아래인 스트림 오백 개는 거대한 스트림 하나만큼 확실하게 메모리를 고갈시킵니다. 그 검사는 부풀리기 루프 안에, 만들어지는 대로 출력 바이트를 세면서 어기는 순간 중단하는 자리에 속하지, 이미 메모리를 다 쓴 뒤인 루프 다음에 속하지 않습니다. 압축된 파일 크기의 배수로 표현한 문서 예산이 잘 통하는데, 정당한 문서는 조작된 스트림이 이르는 비율보다 한참 아래에 모여 있기 때문입니다
여러분의 유닛 너머의 다층 방어
같은 결함 부류가 라이브러리 안에도 삽니다. 이 블로그의 사례 연구 둘이 실제 사례를 짚어 갑니다. 네이티브 Pascal 엔진에서 막은 정수 감김, 한정 없는 재귀, 초기화되지 않은 버퍼는 악성 파일에 맞서 Pascal PDF 파서 굳히기에, C 엔진을 묶을 때의 호출 규약과 정수 폭과 소유권 위험은 PDFium 컴포넌트 바인딩 굳히기에 있습니다. 정말로 믿을 수 없는 접수 — 공개 업로드 양식, 인증 없는 사서함 — 에서는 구문 분석과 디코드 작업을 별도의 낮은 권한 프로세스에서도 돌리십시오. 그러면 프로세스 안의 모든 방어를 뚫는 파일이 서비스 중단이 아니라 실패한 작업 하나의 값으로 끝납니다
비행 전 점검표
다음 빌드를 출하하기 전에 이 목록에 대고 파서를 훑으십시오. 모든 스트림 버퍼가 선언된 길이가 아니라 눌러 자른 길이로 크기를 잡는가. 모든 래스터가 검증된 디코더 매개변수로 크기를 잡고 디코더 출력과 맞춰 보는가. 모든 치수 곱이 Int64로 평가되고 명시적 상한과 견주어지는가. 파일에서 나온 값으로 인덱싱하는 모든 유닛에 {$R+}가 살아 있는가. 모든 탐색이 측정된 파일 크기에 대고 경계 검사를 받는가. 모든 참조 해결이 깊이 제한과 순환 검사를 받는가. 모든 부풀리기 루프가 스트림당 예산과 문서당 예산에 대고 출력을 세는가. 이 검사 중 어느 것도 정당한 문서에서 잴 만한 시간을 쓰지 않으며, 하나하나가 메모리 손상을 말끔하고 기록할 수 있는 거부로 바꿉니다
참고: losLab의 HotPDF Delphi Component, PDF Library for Delphi 델파이 PDF 라이브러리, PDFium Component는 이 경계 검사와 깊이 제한과 팽창 상한을 내부에 적용하므로, 그 위에 지은 접수 파이프라인은 굳혀진 기준선에서 출발합니다