PDF 크로스 레퍼런스 테이블을 사용할 수 없을 때, 해결책은 그것을 완전히 무시하고 파일 본문으로부터 다시 만드는 것입니다. PDFlibPas 델파이 PDF 라이브러리는 눈에 보이는 모든 진짜 간접 객체 헤더를 기록하는 단일 패스 토큰 스캐너로 이를 수행한 다음, 트레일러 딕셔너리를 복구하고 재구성된 테이블을 일반 로더에 건넵니다
PDF가 손상되었을 때 가장 먼저 깨지는 것은 무엇인가
크로스 레퍼런스 테이블은 PDF에서 가장 취약한 부분입니다. 절대 바이트 오프셋을 저장하는 유일한 부분이기 때문입니다. ISO 32000-1 §7.5.4는 그 항목들을 파일 시작으로부터의 10자리 오프셋으로 정의하고, §7.5.5는 끝 부근에 그 테이블 자체를 가리키는 startxref 키워드를 둡니다. 바이트를 이동시키는 어떤 편집이든 그 숫자들을 하나하나 무효화합니다. 텍스트 모드로 실행되어 CRLF를 변환한 FTP 세션, 잘린 다운로드, 공유 드라이브에서 상한 섹터, 증분 업데이트를 제대로 작성하지 않고 덧붙인 배치 도구: 이 모두가 객체 데이터는 완벽하게 읽을 수 있는 채로 남기고 인덱스만 쓰레기를 가리키게 만듭니다
이것이 "파일이 손상되어 수리 중입니다"가 그토록 흔한 대화상자인 이유입니다. 바이트는 거의 항상 여전히 그곳에 있습니다. 사라진 것은 지도입니다. 그래서 재구축은 잃어버린 데이터의 법의학적 복구가 아니라 본문으로부터 유도할 수 있는 인덱스의 재구축이며, 비용이 큰 콘텐츠인 페이지 트리, 폰트, 이미지가 손대지 않은 채 남아 있기 때문에 사용자가 기대하는 것보다 훨씬 더 자주 성공합니다
N 0 obj를 스캔하면 거짓 일치를 찾는 이유는 무엇인가
순진한 재구축은 원시 바이트에서 "정수, 정수, obj" 패턴을 검색하고 모든 히트를 기록합니다. 이는 너무 많이 찾아냅니다. PDF는 컨테이너 포맷이며, 파일의 세 영역은 객체 문법에 대해 불투명합니다: 주석(§7.2), 문자열(§7.3.4), 스트림 데이터(§7.3.8)입니다. 이들 중 어느 것이든 객체 헤더처럼 정확히 읽히는 바이트를 담을 수 있으며, 어느 것도 객체 헤더가 아닙니다. 리터럴 문자열 안의 캡션, 남겨진 디버그 주석, 또는 2메가바이트의 Flate나 DCT 출력 모두 99 0 obj처럼 보이는 무언가를 기꺼이 만들어낼 것입니다
const
Trap: AnsiString =
'4 0 obj'#10 +
'(a caption that mentions 88 0 obj)'#10 + // literal string, not an object
'endobj'#10 +
'% 77 0 obj left over from a debug dump'#10 + // comment, not an object
'5 0 obj'#10 +
'<< /Length 2097152 >>'#10 +
'stream'#10 +
{ two MiB of compressed bytes that contain the byte sequence
99 0 obj and, further along, a complete endstream }
'endstream'#10 +
'endobj'#10;
모든 거짓 항목은 두 배로 비용이 듭니다. 존재하지 않는 객체 번호로 재구축된 테이블을 오염시키고, 파일 뒤쪽에 나타나는 같은 번호를 가진 진짜 객체를 가릴 수 있습니다. 그래서 PDFlibPas는 패턴 매칭을 전혀 하지 않습니다. 토큰화를 하는데, 이는 커서 아래의 바이트가 코드인지 페이로드인지 항상 알고 있다는 뜻이며, 페이로드는 결코 해석되지 않은 채 건너뛰어집니다
64 KiB 블록에 걸친 단일 패스 상태 기계
PDFlibPas는 ISO 32000-1 §7.2의 토큰 규칙과 §7.3.10의 간접 객체 구문 위에 세워진 상태 기계로 전체 파일을 정확히 한 번, 64 KiB 블록으로 스캔합니다. 토큰은 공백이나 구분자 문자 중 하나에서 끝나며, 객체 헤더는 양수 객체 번호, 음수가 아닌 세대 번호, 그리고 순수한 obj 키워드로 이루어진 완전한 시퀀스가 보였을 때만 기록됩니다. 기록되는 오프셋은 객체 번호 토큰의 시작이며, 이는 obj 키워드의 위치가 아니라 크로스 레퍼런스 항목이 가리켜야 하는 것입니다
function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
Result := (Value = 0) or (Value = 9) or (Value = 10) or
(Value = 12) or (Value = 13) or (Value = 32);
end;
function RebuildIsDelimiter(Value: Byte): Boolean;
begin
Result := (Value = Ord('(')) or (Value = Ord(')')) or
(Value = Ord('<')) or (Value = Ord('>')) or
(Value = Ord('[')) or (Value = Ord(']')) or
(Value = Ord('{')) or (Value = Ord('}')) or
(Value = Ord('/')) or (Value = Ord('%'));
end;
중요한 세부 사항은 토큰 상태와 문자열 상태가 블록 경계를 넘어 살아남는다는 것입니다. 65536바이트 선에 걸쳐 있는 헤더도 여전히 인식됩니다. 부분 토큰, 대기 중인 정수 쌍, 문자열 내부 플래그가 모두 다음 블록으로 이어지기 때문입니다. 버퍼는 고정되어 있습니다: 스캔에 64 KiB, 있을 수 있는 가장 긴 토큰에 32바이트이며, 파일과 함께 커지는 유일한 배열은 객체 번호, 세대 번호, 64비트 오프셋 목록으로, 파일 크기가 아니라 실제 객체 개수에 비례합니다. 실전에서 이 스캔은 문서 전체에 걸쳐 순차 읽기와 많아야 두 번의 명시적 탐색만 발행하며, 이것이 직접 접근 병합 및 분할에 관한 글에서 논의하는 수백 메가바이트급 입력에서도 실용적이게 만드는 요소입니다
스트림이 endstream에서 끝난다고 신뢰할 수 없는 이유는 무엇인가
스트림 데이터는 임의의 바이트이며, 임의의 바이트는 우연히 endstream을 철자할 수 있기 때문입니다. stream 키워드 뒤에서 시작하는 스트림은 진짜로 끝날 때까지 불투명한 데이터로 건너뛰어야 하지만, 종료 키워드의 첫 번째 등장은 후보일 뿐입니다. PDFlibPas는 확증을 요구함으로써 이를 해결합니다: endstream 토큰은 다음 비공백 토큰이 §7.3.8이 스트림 객체 주위에 요구하는 시퀀스인 독립된 endobj일 때만 스트림의 진짜 끝으로 받아들여집니다. 압축된 데이터 안의 우연한 히트는 거의 그런 후속을 갖지 않으므로, 스캐너는 스트림 안에 머물며 계속 진행합니다. 두 가지 더 작은 규칙도 그만큼 중요합니다. stream 키워드는 그것이 순수한 키워드일 때만 스트림 상태로 진입하므로, 딕셔너리 안의 /stream 같은 이름 객체는 결코 그것을 촉발하지 않습니다. 그리고 obj나 trailer 토큰은 그 토큰이 32바이트 상한을 넘치지 않았고 솔리더스로 시작하지 않았을 때만 받아들여집니다. 이 두 가드 없이는 잘못된 키 이름을 가진 리소스 딕셔너리만으로도 스캔을 탈선시킬 수 있으며, 이는 정확히 신뢰할 수 없는 PDF를 안전하게 파싱하기에 관한 노트에서 다루는 종류의 적대적 입력입니다
트레일러 딕셔너리의 진짜 끝 찾기
객체를 복구하는 것은 작업의 절반일 뿐입니다. 로더는 여전히 /Root를 찾기 위한 트레일러가 필요하기 때문입니다. PDFlibPas는 스캔 중에 발견된 마지막 64개의 trailer 키워드 위치를 기억하고 가장 최근 것부터 역순으로 검증하므로, 가장 최신의 사용 가능한 트레일러가 이기며, 딕셔너리가 뒤따르지 않는 우연한 키워드는 단순히 검증에 실패하고 이전 후보로 넘어갑니다. 각 후보는 1 MiB 상한으로 읽히며, 딕셔너리의 끝은 리터럴 문자열 이스케이프, 16진수 문자열, 주석과 함께 중첩된 <<와 >> 깊이를 추적함으로써 찾아집니다
// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
' /Custom << /Text (value >> preserved) >> >>'#10
깊이 추적은 학술적인 것이 아닙니다. /Encrypt를 잃어버리는 잘린 트레일러는 복구 가능했던 암호화 문서를 열 수 없는 문서로 바꿔놓으며, /Info나 사용자 정의 하위 딕셔너리를 잃어버리는 것은 하류 시스템이 의존할 수도 있는 메타데이터를 조용히 버립니다. 파일이 암호화되어 있다면 복구된 트레일러가 정상적인 자격 증명 경로를 실행하게 해주는 것이며, 재시도 의미론은 암호화된 문서 로딩에 관한 글에서 설명하는 것과 동일합니다
재구축이 돌려줄 수 없는 것은 무엇인가
재구축은 최선의 노력이며, 그 한계에 대해 정직한 것이 이를 출시하는 일의 일부입니다. 세 가지 경우는 완전히 실패합니다. 객체 스트림(§7.5.7) 안에 포장된 객체는 바이트 스캔으로는 개별적으로 보이지 않으므로, 컨테이너는 살아남았지만 그 크로스 레퍼런스 스트림(§7.5.8)이 그렇지 않다면, 그것이 담고 있는 객체는 재구축에 의해 인덱싱되지 않습니다. 단순히 잘못 인덱싱된 것이 아니라 실제로 본문이 손상된 파일은 내용이 더 이상 파싱되지 않는 헤더를 만들어낼 것입니다. 그리고 복구 가능한 trailer 키워드도 읽을 수 있는 카탈로그도 없는 파일은 얼마나 많은 객체 헤더가 발견되었든 문서 트리를 정박시킬 곳이 없습니다
중복 객체 번호는 흥미로운 중간 사례입니다. 증분 업데이트된 파일은 정당하게 같은 객체 번호의 여러 세대를 담고 있으며, 살아남은 크로스 레퍼런스 체인만이 어느 것이 현재였는지의 유일한 기록입니다. 재구축은 그 체인을 갖고 있지 않으므로, 보이는 모든 헤더를 파일 순서로 기록한 다음 나중에 객체 번호로 해석합니다. 보통은 더 나중의 리비전이 이기며, 이는 보통 옳지만, 업데이트된 다음 부분적으로 롤백된 문서는 원래의 xref가 설명했던 것과 미묘하게 다르게 돌아올 수 있습니다. 선형화된 파일은 반대 방향에서 같은 주의사항을 갖습니다: 인덱스가 재생성되면 첫 페이지 레이아웃과 힌트 테이블은 무의미해지므로, 수리된 파일은 평범한 비선형화 문서로 취급되어야 합니다
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
begin
if Pdf.GetDocumentRepaired = 1 then
LogWarning('xref was unusable; the table was reconstructed');
if Pdf.PageCount > 0 then
Pdf.SaveToFile('recovered-invoice.pdf'); // writes a clean xref
end;
finally
Pdf.Free;
end;
end;
폴백은 자동입니다: PDFlibPas는 크로스 레퍼런스 체인을 읽을 수 없을 때마다, 그리고 모든 사용 중 항목이 오프셋 0을 주장할 때도 원시 스캔을 실행합니다. 이는 작성되었지만 결코 채워지지 않은 테이블의 시그니처입니다. GetDocumentRepaired는 그 경로가 실행되었을 때 1을 반환하며, 이는 무시하기보다 로깅할 가치가 있습니다. 재구축을 통해 로드된 문서는 아무 일도 없었던 것처럼 파이프라인에 남겨지기보다는 깨끗한 파일로 다시 저장되어야 하기 때문입니다. 저장하면 새롭고 일관된 크로스 레퍼런스 테이블이 작성되며, 이는 모든 하류 소비자에게 가장 저렴한 가능한 수정입니다
여기서 보여드린 재구축 경로, GetDocumentRepaired 플래그, 스트리밍 로더는 이 블로그의 다른 곳에서 다루는 파싱, 렌더링, 서명 API와 함께 PDFlibPas 델파이 PDF 라이브러리의 일부입니다