PDF 이름 키는 접두사(prefixes)를 공유하므로 Pos('/Length', Dict)와 같은 부분 문자열(substring) 검색 방식은 PDF 사전을 읽을 때 적합하지 않은 도구입니다: 예를 들어 /Length는 /Length1의 접두사이며, /Encrypt는 /EncryptMetadata의 접두사입니다. ISO 32000-1 §7.3.5는 이름(name)을 구분 기호(delimiter) 또는 공백(whitespace)으로만 끝나는 토큰으로 정의합니다. 즉, 이름 키 뒤에 이어지는 바이트가 이들 문자 중 하나일 때만 키를 발견한 것으로 인정해야 합니다. 이 유효성 검사를 누락한 사전 리더는 완벽히 유효한 파일에서도 결국 잘못된 값을 읽게 될 것입니다
우리에게 교훈을 준 버그는 겉보기에는 어휘 분석기(lexer)의 문제처럼 보이지 않았습니다. 한 적합성 스캔 프로그램이 FontFile 스트림이 손상되었다고 보고하기 시작했습니다: 압축 해제된 글꼴 프로그램이 데이터 테이블 중간에서 잘린 파편 형태로 로드된 것입니다. 파일 자체는 모든 PDF 뷰어에서 정상적으로 열렸습니다. 디스크의 스트림 데이터도 온전했습니다. 근본적인 원인은 공용 사전 리더 코드의 단 한 줄에 있었습니다: Pos('/Length', ...) 코드가 ISO 32000-1 Table 127에 따라 FontFile 스트림 사전이 포함하는 표준 키인 /Length1과 매핑되었고, 리더 모듈이 /Length1 뒤에 오는 정수 값을 스트림 길이로 간주해 읽어 들였습니다. §7.3.7에 따라 사전의 엔트리들은 순서가 없으므로 해당 파일을 작성한 프로그램은 자유롭게 /Length1을 /Length보다 앞부분에 기록했던 것입니다. 결국 스트림이 엉뚱한 바이트 수로 절사되었고, 이 데이터를 사용하는 모든 다운스트림 유효성 검사가 조용히 오류를 무시한 채 오작동했습니다
부분 문자열 매칭이 PDF 사전 파싱을 손상시키는 이유는 무엇입니까?
부분 문자열 매칭이 실패하는 이유는 PDF 이름 공간에 접두사를 공유하는 명명 체계가 널리 쓰이기 때문이며, 사전 내 엔트리들의 순서가 보장되지 않기 때문입니다. ISO 32000-1 Table 127은 내장 글꼴 스트림에 대해 /Length1, /Length2, /Length3을 정의하고 있으며 이들은 동일한 사전에서 /Length와 공존합니다. 암호화 사전의 경우 트레일러(trailer)에 있는 /Encrypt가 내부의 /EncryptMetadata와 공존합니다. 짧은 길이의 키의 경우는 상황이 더 나쁩니다: Key = 'N'에 대해 Pos('/' + Key, ...) 형태로 조회를 수행하면 /Name이나 /Nums와 같은 무관한 키에 쉽게 엉뚱하게 오매칭될 수 있습니다. 이러한 충돌 상황은 손상된 파일에서만 일어나는 예외가 아닙니다. /Length1을 /Length보다 앞에 기록하는 파일 작성 프로그램은 완전하게 규격을 준수하는 정상 프로그램이므로, 이 부분 문자열 버그는 손상된 입력값에 대한 예외 처리 미흡이 아니라 정상 입력값에 대한 설계적 오류에 해당합니다
이 결함의 오류 유형도 조용히 오작동을 유발하는 성격을 띱니다. 잘못된 /Length 값은 예외를 발생시키지 않으며, 단지 스트림이 차지하는 실제 크기와 다른 더 짧거나 긴 바이트 조각을 반환할 뿐입니다. 이 데이터 조각이 글꼴 서브셋 검사, CMap 파싱 또는 메타데이터 스캔 모듈로 들어가면 소비자는 무의미한 데이터를 읽게 되며, 손상된 zlib 스트림은 압축 해제에 실패하고 코드 처리가 그냥 넘어가기 때문에 대개 아무런 오류도 외부로 보고되지 않습니다. 우리는 정확히 이러한 결함을 겪은 후, ISO 32000-1 §7.2–§7.3 규정을 한 줄 한 줄 대조 검토하여 모든 Pos 형태의 키 조회를 수정 대상으로 식별하고 공용 리더 라이브러리의 v2.14.3 버전에 해당 패치를 적용했습니다
ISO 32000-1 §7.3.5가 규정한 이름(name)의 정의
7.3.5절은 간결하고 명확합니다: 이름 객체는 슬래시(/) 기호로 시작되고 일반 문자열이 이어지며, 첫 번째 구분 기호 또는 공백 문자를 만났을 때 토큰이 종료됩니다. 구분 기호는 8가지 괄호 문자 및 슬래시와 퍼센트 기호로 구성됩니다 — ( ) < > [ ] { } / % — 그리고 공백은 null, 탭, 줄바꿈, 폼 피드, 캐리지 리턴 및 빈칸(space)입니다(§7.2.2–§7.2.3). 그 종료 규칙이 규격 구조의 핵심입니다. /Length1은 "/Length 뒤에 1이 붙은 것"이 아닙니다; 이는 Pascal 코드 상에서 LengthOne과 Length가 다른 식별자인 것처럼 독립적으로 분리된 단일 토큰입니다. 원시 바이트 검색을 고수해 키를 찾는 리더 프로그램은 종료 규칙 사양을 누락한 채 어휘 분석기를 억지로 구현한 셈입니다
오류 코드의 핵심 형상은 다음과 같습니다. 이 버전은 컴파일이 가능하고 /Length가 앞에 오는 파일들에 대해서는 테스트를 성공적으로 패스하지만, 순서가 다른 파일들에 대해서는 스트림 데이터를 파괴하고 맙니다
// WRONG: matches /Length1, /Length2, /Length3 as well
function ReadStreamLength(const Dict: AnsiString): Integer;
var
P: Integer;
begin
Result := -1;
P := Pos('/Length', Dict);
if P > 0 then
Result := ReadIntAt(Dict, P + Length('/Length'));
end;
전체 토큰 매칭: 키 뒤에 오는 바이트 유효성 확인
올바른 판별 기준은 §7.3.5 규정으로부터 직접 도출됩니다: 검색된 대상 문자 바로 뒤에 오는 문자가 구분 기호, 공백이거나 버퍼의 끝일 때만 올바른 키로 인정해야 합니다. 이외의 문자가 오는 경우는 접두사만 일치할 뿐 다른 더 긴 이름을 나타내므로 조회를 즉시 중단하지 않고 계속 이어서 탐색해야 합니다. 우리 리더 라이브러리의 수정 사항은 모든 원시 Pos 조회 코드를 이 규칙 기반의 단일 공통 함수로 교체하는 것이었습니다
function IsPdfDelimOrWs(C: AnsiChar): Boolean;
begin
Result := C in [#0, #9, #10, #12, #13, ' ',
'(', ')', '<', '>', '[', ']', '{', '}', '/', '%'];
end;
// Correct: whole-token match per ISO 32000-1 §7.3.5
function FindDictKey(const Dict, Key: AnsiString): Integer;
var
P, After: Integer;
begin
Result := 0;
P := Pos(Key, Dict);
while P > 0 do
begin
After := P + Length(Key);
if (After > Length(Dict)) or IsPdfDelimOrWs(Dict[After]) then
Exit(P); // token ends here: genuine key
P := PosEx(Key, Dict, P + 1); // prefix of a longer name: keep looking
end;
end;
해당 루프 코드 내에 주목할 만한 세부 특징이 있습니다. 첫째, /Length1 120 /Length 4076과 같이 유효한 선언 순서가 정해져 있어 접두사 매칭 실패가 나더라도 즉시 종료하지 않고 탐색을 계속 이어가 실제 타겟 키를 발견하도록 제어합니다. 둘째, 사전 데이터 조각이 이름 선언 직후 끝나는 경우도 정상적이므로 버퍼의 끝도 종료 기호로 인식하게 배려합니다. 자체 코드를 검토할 때 감사할 세부 사항: 검색할 키 단어에 슬래시(/) 기호가 명시되지 않은 경우 왼쪽 경계선에도 동일한 규칙을 적용해야 합니다. 그렇지 않으면 Pos('Length', ...) 코드가 /PieceLength 내부의 단어에 매칭될 우려가 있습니다. 위 코드와 같이 검색 문자열에 슬래시 기호를 붙여 탐색을 시작하면, 슬래시 문자 자체가 앞선 토큰의 종료 구획선 역할을 수행하므로 왼쪽 경계 처리가 자연스럽게 완결됩니다
악의적인 PDF가 파서 버그를 수 기가바이트의 메모리 할당 오류로 변환시키는 시나리오
사전의 정수 값들은 대개 메모리 할당 크기를 결정짓는 변수로 투입되므로, 손상되었거나 악의적인 파일은 이러한 사소한 파싱 오류를 자원 고갈(resource exhaustion) 장애로 확대시킵니다. 우리는 객체 스트림 확장 프로세스에서 정확히 이러한 형태의 오류 체인을 발견했습니다. ObjStm 사전의 /N 엔트리는 스트림에 포함된 압축 객체 수를 지시하며, 압축을 해제하는 코드는 이 크기에 맞춰 배열의 SetLength를 호출했습니다. 그러나 정수 파서 모듈이 파싱 실패 시 출력 매개변수를 초기화하지 않고 그대로 반환하여, 수치 데이터가 아닌 /N 값이 전달될 때 SetLength에 초기화되지 않은 스택 쓰레기 값이 전달되었습니다. 이로 인해 단 몇 바이트의 손상된 입력값만으로도 신뢰 여부가 검증되지 않은 문서를 단순 스캔하는 과정에서 수 기가바이트의 메모리 할당 요청이 유발되어 메모리가 소진되는 결과가 발생했습니다
수정 조치는 두 가지 개별적인 작업으로 수행되었으며 둘 다 표준화되었습니다. 파서는 파싱 실패 시 항상 명시적인 0을 반환하도록 개선되었으며 결코 스택 내 쓰레기 값을 전달하지 않습니다. 그리고 데이터 소비 측에서도 /N 값을 아무 검증 없이 신뢰하지 않습니다: /First 앞의 ObjStm 헤더 영역은 압축된 각 객체에 대해 객체 번호와 오프셋이라는 정수 쌍을 저장하며, 각 정수 쌍은 구분 기호를 포함해 최소 4바이트의 공간을 차지합니다. 따라서 선언된 헤더 크기에 비추어 볼 때 FirstVal div 4 + 1을 초과하는 /N 값은 물리적으로 불가능하므로 메모리 할당이 일어나기 전에 차단됩니다. 이 경계 검사는 단 한 번의 대조 연산만 유발하며 기존 데이터를 활용해 수행되므로 매우 효과적입니다: 즉 임의의 상수가 아니라 파일 데이터가 증명하는 경계 천장 값을 기준으로 안전 장치를 거는 것입니다
// /N is attacker-controlled; bound it by what /First can hold
if not TryReadDictInt(Dict, '/N', NVal) then
NVal := 0; // explicit zero, never stack garbage
if (NVal <= 0) or (NVal > FirstVal div 4 + 1) then
Exit; // header cannot contain that many pairs
// /Length can never exceed the file that contains the stream
if (LenVal < 0) or (LenVal > SourceSize) then
Exit; // refuse before allocating the buffer
v2.12.0에 출시된 두 가지 추가 경계 장치가 리더 모듈의 방어선을 완성합니다. 스트림 리더 모듈은 결과물 버퍼를 메모리에 생성하기 전에 전체 파일 크기보다 큰 /Length 선언을 거부합니다 — 스트림은 그것이 속한 컨테이너 파일보다 클 수 없으므로, 이 체크 동작은 오진의 우려가 전혀 없습니다. 또한 인플레이트(inflate) 경로 상에서 압축 해제 용량을 256 MiB로 제한하여 단 몇 킬로바이트의 입력값이 무제한으로 팽창하는 전형적인 zlib 폭탄 공격을 원천 봉쇄합니다; 이 한계값은 일반적인 PDF 스트림 파일들에 비해서는 충분히 넉넉하면서 최악의 경우에도 시스템이 폭사하지 않도록 지켜줍니다. 이 세 가지 검사 모두 일관된 목적을 갖습니다: 즉 파일이 선언한 크기는 주장(claim)일 뿐이며, 파서는 메모리를 할당하기 전에 스스로 검증 가능한 지표를 통해 이를 크로스체크해야 합니다. 이러한 보안 검토 자세는 바인딩 경계 레이어에도 그대로 적용되며, 이에 대해서는 Delphi에서의 PDFium ABI 및 메모리 안전성 강화 기사에서 설명합니다
전체 토큰 규칙만으로는 해결되지 않는 영역
기술 사양의 한계에 대해 명확히 기술해 둡니다. 전체 토큰 매칭은 키 식별 문제를 해결해 주지만, 사전 영역 전체에 대해 플랫한 바이트 검색을 진행할 때는 해당 매칭 지점이 중첩 사전 내부인지 문자열 리터럴 또는 주석(comment) 영역 내부인지 구분하지 못합니다 — 예를 들어 탐색 범위를 너무 넓게 설정하면 페이지 객체 상에서 수행한 FindDictKey가 /Resources 하위 사전 내부의 키와 오매칭될 위험이 있습니다. 우리 리더 라이브러리는 먼저 대상 영역을 단일 객체 바디로 제한하고 문자열 및 주석 영역의 예외 처리를 연관 이슈로 남겨 처리하고 있습니다. 부분 문자열 안전장치는 보안의 사다리 중 한 칸일 뿐 전체가 될 수는 없습니다: 상호 참조(cross-reference) 일관성 검증도 중요한 연구 영역에 속하며 이에 대해서는 객체 및 xref 스트림 검증 기사에서 설명하며, 직접 빌드하지 않은 문서를 로드할 때 고려할 기타 보안 이슈들은 PDF 보안 위험 감사 문서에서 정리합니다
Delphi 또는 Lazarus에서 직접 구현한 사전 리더기를 관리 중이라면 이 사고에서 도출되는 체크리스트는 명확합니다. 소스 코드 상에서 Pos('/ 구문을 모두 검색하여 전체 토큰 매칭 도우미 함수를 사용하도록 수정하십시오. 사전 키가 유발하는 접두사 충돌 가능 목록을 작성해 점검해 보십시오 — 실제 파일들에서는 /Length, /Encrypt, /N, 그리고 /Type 대 /Type1 등의 대립이 일어납니다. 또한 SetLength, GetMem 혹은 복사 루프로 들어가는 모든 정수 값에 대해 어떤 경계 조건(파일 크기, 헤더 기반 천장 등)이 성립하는지 확인하십시오. 여기에 설명된 파싱 레이어는 바이트 단위 리더와 렌더링 엔진이 로드된 모든 문서에 대해 상호 검증하는 PDFium Component의 기본 토대를 이룹니다