v3.126.2 이전의 PDFium Component 빌드에서 TPdf.AnalyzeSignatureRevisions는 진짜 페이지 콘텐츠 편집을 DocMDP P=3 아래에서 허용되는 어노테이션 변경으로 등급 매길 수 있었습니다. 리비전 역할 그래프가 서명 위젯의 페이지에 대한 /P 역참조를 소유권으로 취급했기 때문입니다. v3.126.2부터 PDFium Component는 탐색 에지와 소유 페이로드 에지를 분리하므로, 페이지 콘텐츠는 페이지 콘텐츠로 남습니다. 이 수정 뒤에 있는 버그 리포트는 종이 위에서는 무해해 보입니다. 인증된 계약서는 어노테이션을 허용하고, 상대방이 증분 저장 하나를 추가하고, 분석기는 이후 모든 변경이 허용된다고 말합니다. 그러다 누군가 렌더링된 페이지를 diff해 보면 2페이지의 결제 금액이 다릅니다
이 글은 서명 후 리비전 변경 분석 개관의 공격자 시점 후속편이므로, 리비전 재구축과 DocMDP 등급 매기기의 기초는 건너뛰고 오브젝트 그래프로 곧장 갑니다. 소유권이 어떻게 모델링됐는지, 에지의 방향이 왜 보안 판정을 결정하는지, v3.126.2에서 무엇이 바뀌었는지, 그리고 자기 수용 로직을 어떻게 감사하는지요
페이지 편집은 왜 DocMDP P=3 아래에서 어노테이션 변경으로 통과했을까?
페이지 편집이 통과한 이유는 옛 역할 그래프가 딕셔너리의 모든 간접 참조를 참조된 오브젝트가 참조하는 쪽에 속한 것처럼 따라갔고, 서명 위젯이 자기 페이지를 역으로 가리키기 때문입니다. 어노테이션 딕셔너리는 자기가 앉은 페이지 오브젝트에 대한 간접 참조인 /P를 실고 있습니다(ISO 32000-1 §12.5.2). 그 엔트리는 탐색 힌트입니다. 위젯은 페이지를 소유하지 않습니다. 페이지가 자기 /Annots 배열을 통해 위젯을 소유합니다
분석기는 이후 변경을 등급 매기기 전에 각 오브젝트에게 역할 비트 집합을 부여합니다. 페이지, 어노테이션, 폼, 검증 자료입니다. 루트 오브젝트는 자기 딕셔너리에서 역할을 받고, 그 역할이 참조하는 모든 것으로 퍼집니다. 옛 전파에서 사슬은 이렇게 흘렀습니다:
- 서명 위젯은
/FT /Sig를 가진/Subtype /Widget이므로 어노테이션 역할을 받습니다 - 위젯의
/P가 어노테이션 역할을 페이지 딕셔너리에 밀어 넣는데, 그 딕셔너리는 이미 페이지 역할을 갖고 있습니다 - 페이지는 두 역할을
/Contents와/Resources로, 그리고/Parent를 통해 Pages 트리 위로, 형제 페이지 전부에 걸쳐 밀어 넣습니다 << /Length 812 >>같은 콘텐츠 스트림 딕셔너리는/Type이 없으므로, 분류기는 역할 비트로 폴백해 페이지 역할보다 어노테이션 역할을 먼저 검사했습니다
그리하여 수정된 콘텐츠 스트림은 prckAnnotation으로 나왔습니다. ISO 32000-1 §12.8.2.2 아래에서 DocMDP P=3은 어노테이션 변경을 허용하므로, 결정은 prdAllowed였고 리포트는 prasAllowed로 말아 올려졌습니다. P=2 아래의 같은 파일은 거부됐지만 우연히 그랬을 뿐입니다. P=2는 어노테이션 변경을 금지하므로 잘못 라벨 붙은 스트림이 잘못된 이유로 거부된 것이죠. 고정된 4패스 전파 루프는 두 번째 약점을 더했습니다. 간접 배열을 통해, 또는 오브젝트 번호가 거꾸로 도는 긴 사슬을 통해 닿는 페이로드는 역할을 아예 받지 못할 수 있었습니다
서명 검증기는 왜 오브젝트를 누가 소유하는지 물어야 할까?
서명 검증기가 오브젝트를 누가 소유하는지 물어야 하는 이유는 PDF 증분 업데이트(ISO 32000-1 §7.5.6)가 누구나 기존 오브젝트 번호를 재정의하는 리비전을 덧붙이게 해 주고, 재정의된 본문은 자기가 무엇인지 알리지 않기 때문입니다. 서명은 여전히 검증됩니다. 자기 리비전의 바이트만 커버하니까요. 서명 후 조작에 대한 모든 방어는 따라서 변경된 각 오브젝트를 그것을 쓰는 구조로 사상한 뒤, 서명자가 그 구조의 변경을 허용했는지 묻는 데 기댑니다
발표된 몇몇 공격 클래스가 정확히 그 틈에서 작동합니다. Incremental saving 공격은 페이지 콘텐츠를 바꿔치기하는 리비전을 덧붙이고 검증기가 서명된 바이트 범위만 검사하기를 기대합니다. Shadow 공격은 서명 전에 숨은 콘텐츠를 심고 서명 뒤에 작고 무해해 보이는 변경으로 활성화합니다. 인증된 문서에 대한 공격은 P=2와 P=3이 일부 이후 편집을 명시적으로 허용한다는 사실을 남용해 금지된 편집을 허용된 것으로 꾸밉니다. /Type /Annot 같은 라벨로, 또는 우연히 닿는 어떤 참조 경로로 오브젝트를 분류하는 검증기는 세 번째 클래스에 노출됩니다. 공격자에게 필요한 것은 금지된 구조에 닿을 수 있는 허용된 구조 하나뿐입니다
그래서 질문은 어떤 오브젝트가 바뀌었는지가 아니라 그것을 누가 소유하는지입니다. 페이지에서 /Contents를 통해 닿는 콘텐츠 스트림은 무엇이 그것을 가리키든 페이지 콘텐츠입니다. /P로 페이지를 역으로 가리키는 어노테이션은 어노테이션이 어디에 사는지를 말할 뿐, 무엇을 소유하는지는 말하지 않습니다
PDFium Component v3.126.2는 소유권을 어떻게 모델링할까?
PDFium Component v3.126.2는 역참조를 탐색으로 취급해 역할 전파에서 제외하고, 어떤 키가 탐색으로 셈인지는 키 이름만이 아니라 그 키를 담은 딕셔너리의 구조적 역할에서 정합니다. 표는 더 이상 소유권을 나르지 않는 탐색 키를 요약합니다
| 소유 딕셔너리 | 탐색으로 취급되는 키 | 명세 참조 |
|---|---|---|
| 페이지 또는 Pages 노드 | /Parent, /Kids, /Annots | ISO 32000-1 §7.7.3 |
| 어노테이션 또는 위젯 | /P | ISO 32000-1 §12.5.2 |
| 위젯 또는 필드 딕셔너리 | /Parent | ISO 32000-1 §12.7.3 |
키 이름으로 전역 필터링했다면 새 구멍이 생겼을 겁니다. 폰트나 XObject 리소스는 합법적으로 /P, /Parent, /Annots라는 이름을 가질 수 있고, 자기 /P 엔트리를 전파에서 떼어 버리는 /Resources 딕셔너리는 공격자가 무해한 리소스 이름 뒤에 페이지 소유 XObject를 숨기게 해 줍니다. v3.126.2에서 탐색 필터는 소유 딕셔너리가 실제로 페이지, Pages 노드, 어노테이션, 위젯, 필드일 때만 적용됩니다. 그런 딕셔너리가 중복된 탐색 키를 지니고 있다면, 위젯에 /P 엔트리가 둘인 것처럼, 분석기는 뷰어가 어느 사본을 쓸지 추측하지 않습니다. 역할 빌드가 실패하고 서명은 Indeterminate가 됩니다
추가 규칙 몇 가지가 남은 재라벨링 경로를 닫습니다:
- Pages 노드는 자격을 갖춘 페이지 역할 루트이므로, Pages 트리에서 물려받은 리소스(ISO 32000-1 §7.7.3.4)는 자식 페이지에서
/Parent를 걸어 가는 대신 진짜 소유권을 통해 페이지 문맥에 들어옵니다 - 카탈로그, Pages 노드, 페이지, 어노테이션, 필드 딕셔너리에 도달한 어노테이션이나 폼 역할은 거기서 멈춥니다. 그 구조 오브젝트들은 자기 역할을 확립하며 들어오는 페이로드 역할이 그것들을 덮어써서는 안 되기 때문입니다
- 분류 중에는 페이지 역할이 최종 권위입니다. 페이지 소유 오브젝트는 이후 리비전이 위조된
/FT나/Type /Annot라벨로 다시 쓰거나 어피어런스 스트림과 공유하더라도prckPageContent입니다 - 필드나 어노테이션 어피어런스로만 쓰이는 Form XObject는 자기 폼 또는 어노테이션 범주를 유지하므로, 폼 작성 뒤의 평범한 어피어런스 재생성은 여전히 정상 권한 규칙 아래에서 등급 매겨집니다
- 자기
/FT가 없는 위젯은/Parent사슬을 통해 물려받은 필드 타입을 해석하고, 해석되지 않는 사슬은 어노테이션 기본값 대신 역할 빌드를 실패시킵니다 - 모든 이후 리비전의 역할 비트는 커버된 리비전의 역할로 병합되므로, 이후 업데이트가 스트림을 먼저 떼어 낸 뒤 편집하는 방식으로 이전의 페이지 소유 관계를 지울 수 없습니다
고정 패스 수 대신 고정점
v3.126.2의 역할 도달 가능성은 어떤 오브젝트도 새 역할 비트를 얻지 않을 때까지 반복하는 작업 큐로 돕니다. 사슬 깊이나 오브젝트 번호 매기기와 무관한 진짜 고정점입니다. 자기 오브젝트로 저장된 /Contents 배열 같은 간접 배열도 함께 걸어 갑니다. 각 오브젝트는 서로 다른 역할 비트를 최대 네 개까지 얻을 수 있으므로 큐는 오브젝트 번호당 엔트리 넷으로 제한되며, 그 예산을 넘으면 prrResourceLimitExceeded를 일으킵니다. 프리 오브젝트 참조, 세대 불일치, 깨진 오브젝트 헤더는 prrMalformedRevisionChain을, 압축 오브젝트 스트림 안의 페이로드는 prrCompressedObjectUnresolved를 일으킵니다. 이 실패들은 모두 prasIndeterminate로 끝나지, 결코 허용 판정으로 끝나지 않으며, 커버된 리비전의 역할을 빌드하다 실패하면 서명은 Changes를 아예 보고하지 않습니다
다음 루틴은 이 분석에서 살아남는 페이지 콘텐츠 편집을 나열합니다. prckPageContent 변경은 결코 prdAllowed로 등급 매겨지지 않습니다. DocMDP P=1, 2, 3은 그것을 prdDisallowed로 만들고, DocMDP 없는 서명은 prdSuspicious로 등급 매깁니다:
uses
SysUtils, TypInfo, PDFium, FPdfPades;
procedure ListPageContentEdits(const FileName: string);
const
ShadowTag: array[Boolean] of string = (' (unreferenced shadow)', '');
var
Pdf: TPdf;
Report: TPadesRevisionAnalysisReport;
Sig: TPadesSignatureRevisionAnalysis;
Change: TPadesRevisionObjectChange;
i, j: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
Report := Pdf.AnalyzeSignatureRevisions; // 레코드, 해제할 것 없음
for i := 0 to High(Report.Signatures) do
begin
Sig := Report.Signatures[i];
if not (prrPageContentChanged in Sig.Risks) then
Continue;
Writeln(Format('Signature %d (DocMDP P=%d): page content changed',
[Sig.SignatureIndex, Sig.DocMdpPermission]));
for j := 0 to High(Sig.Changes) do
begin
Change := Sig.Changes[j];
if Change.Kind <> prckPageContent then
Continue;
Writeln(Format(' revision %d object %d %d R %s%s',
[Change.RevisionIndex, Change.ObjectNumber, Change.Generation,
GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Change.Decision)),
ShadowTag[Change.IsAuthoritative]]));
end;
end;
finally
Pdf.Free;
end;
end;
FieldMDP와 어노테이션이 하나의 오브젝트를 공유하면?
필드의 /V와 어노테이션의 /Contents가 같은 간접 오브젝트를 가리킬 때, v3.126.2는 변경이 어노테이션 편집으로 분류되더라도 FieldMDP 잠금을 계속 유효하게 유지합니다. 시나리오는 손으로 만들기 쉽습니다. 서명자가 FieldMDP로 Total 필드를 잠그고(ISO 32000-1 §12.8.2.4), 공격자가 텍스트 어노테이션의 /Contents가 필드 값을 담은 문자열 오브젝트를 참조하게 만듭니다. P=3 아래에서 어노테이션 편집은 허용되므로, 수정 전에는 그 공유 문자열을 다시 쓰는 것으로 허용 판정과 함께 잠긴 필드 값을 바꿀 수 있었습니다
이제 그 오브젝트는 어노테이션과 폼 역할을 둘 다 지니고, 서명이 FieldMDP 변환을 가지고 있으면 어노테이션 결정은 폼 쪽을 다시 검사합니다:
- P=2 아래에서는 어노테이션 변경이 전면 금지됩니다. 전과 정확히 같습니다
- FieldMDP
All에서는 모든 필드가 잠기므로, 공유 변경은prdDisallowed입니다 - FieldMDP
Include나Exclude에서는 분석기가 공유 스칼라를 하나의 필드 이름으로 역추적할 수 없으므로, 결정은 추측 대신prdIndeterminate입니다 - FieldMDP가 없으면 P=3 어노테이션 규칙이 적용되고 변경은 허용된 채로 남습니다
게이트 코드에는 보고 세부 하나가 중요합니다. 공유 사례는 Kind = prckAnnotation에 Decision = prdIndeterminate로 보고되고, prrFieldMdpUnresolved는 폼 필드로 분류된 변경에만 위험 집합에 추가됩니다. prrFieldMdpUnresolved만 검색하고 Status를 무시하는 게이트는 이 사례를 완전히 놓칩니다
Delphi 코드는 리비전 분석에서 어떻게 fail closed해야 할까?
Delphi 코드는 분석 상태가 prasNoLaterChanges나 prasAllowed이고 구조적 위험이 없을 때만 서명된 문서를 받아들여야 하며, prasIndeterminate와 prasSuspicious는 기록하고 통과시킬 경고가 아니라 신뢰할 수 없는 것으로 다뤄야 합니다. Indeterminate는 분석기가 이후 리비전이 허용됐다고 증명하지 못했다는 뜻입니다. 공격자 입장에서, 여러분의 코드가 통과시켜 준다면 안정적으로 Indeterminate를 만들어 내는 입력은 Allowed를 만드는 입력만큼 유용합니다. 전역 AnalyzePadesSignatureRevisions는 어떤 TStream이든 받아 위치 0부터 읽으므로, 문서를 렌더링할 필요가 없는 업로드 핸들러에 어울립니다
uses
Classes, SysUtils, TypInfo, FPdfPades;
const
// 중복 정의는 Status를 낮추지 않고 기록됨
BlockingRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
prrSignatureObjectRedefined, prrCompressedObjectUnresolved,
prrResourceLimitExceeded];
function SignedRevisionsAcceptable(const FileName: string;
out Reason: string): Boolean;
var
Source: TFileStream;
Report: TPadesRevisionAnalysisReport;
begin
Result := False;
Reason := '';
Source := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Report := AnalyzePadesSignatureRevisions(Source);
finally
Source.Free;
end;
if Report.SignatureCount = 0 then
begin
Reason := 'no signature anchors the analysis';
Exit;
end;
if Report.Risks * BlockingRisks <> [] then
begin
Reason := 'structural risk in the revision chain';
Exit;
end;
case Report.Status of
prasNoLaterChanges, prasAllowed:
Result := True;
else
// prasIndeterminate와 prasSuspicious는 경고가 아니라 거절임
Reason := 'revision status ' +
GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus), Ord(Report.Status));
end;
end;
두 경계는 똑똑히 말할 가치가 있습니다. TPadesRevisionAnalysisReport는 CMS 무결성이나 인증서 신뢰에 대해 아무것도 말하지 않으므로, 이 게이트는 암호학적 검증과 신뢰 검증 곁에 있지 그 자리를 대신하지 않습니다. 그리고 올바른 소유권 그래프가 모든 워크플로에서 P=3을 안전하게 만들지는 않습니다. P=3은 어노테이션을 진짜로 허용하며, 불투명한 어피어런스를 가진 어노테이션은 콘텐츠 스트림 하나 건드리지 않고 서명된 텍스트를 덮을 수 있습니다. 인증된 문서가 검토 사본이 아니라 계약서라면, P=2로 인증하거나 허용된 어노테이션 변경을 사람에게 돌리세요. 다음 헬퍼처럼요:
function AllowedAnnotationEditsUnderP3(
const Report: TPadesRevisionAnalysisReport): Integer;
var
i, j: Integer;
begin
Result := 0;
for i := 0 to High(Report.Signatures) do
if Report.Signatures[i].DocMdpPermission = 3 then
for j := 0 to High(Report.Signatures[i].Changes) do
if (Report.Signatures[i].Changes[j].Kind = prckAnnotation) and
(Report.Signatures[i].Changes[j].Decision = prdAllowed) then
Inc(Result);
end;
서명 리비전 감사 체크리스트
이 목록으로 검증 파이프라인이 노출됐는지, 이제 fail closed하는지 확인하세요:
- v3.126.2 이전의 PDFium Component 빌드는 DocMDP P=3 문서의 페이지 콘텐츠 편집에
prasAllowed를 보고할 수 있었습니다. 구형 빌드가 받아들인 인증된 P=3 파일에TPdf.AnalyzeSignatureRevisions를 다시 돌리세요 - 필드 값과 어노테이션이 간접 오브젝트를 공유할 수 있는 FieldMDP 잠금이 있는 P=3 문서를 다시 검사하세요
prasNoLaterChanges와prasAllowed만 받아들일 것.prasIndeterminate와prasSuspicious는 신뢰할 수 없는 것으로 다룰 것Report.Status만이 아니라Report.Risks도 검사할 것.prrDuplicateObjectDefinition은 그 자체로는 상태를 바꾸지 않습니다- 서명 상태가 Indeterminate일 때 빈
Changes배열을 깨끗한 결과로 읽지 말 것. 실패한 역할 빌드는 변경을 전혀 보고하지 않습니다 - FieldMDP 문제를 잡는 데
prrFieldMdpUnresolved만 의존하지 말 것. 공유 어노테이션 사례는 결정과 상태로만 표면화됩니다 - 워크플로에서 P=3 아래의 허용된 어노테이션 변경에 사람 검토가 필요한지 정할 것
- 원본 파일 바이트를 분석할 것.
SaveAs로 다시 쓰인 문서는 리비전 사슬을 더 이상 담고 있지 않습니다
리비전 분석은 서명 검사의 한 층입니다. 딕셔너리와 베이스라인 레벨에는 PDF 디지털 서명과 PAdES 레벨 검사하기와, JavaScript, launch 액션, 임베디드 파일에는 더 넓은 PDF 보안 위험 감사와 짝지으세요. TPdf.AnalyzeSignatureRevisions, AnalyzePadesSignatureRevisions와 여기서 기술한 소유권 인식 역할 그래프는 Delphi, C++Builder, Lazarus용 PDFium Component에 실려 나갑니다