기술 문서

FPC에서 반복되는 PDF 암호화 키: PDFiumPas RNG 수정

버전 3.114.8 이전의 PDFiumPas는 비 Windows 대상에서 PDF 암호화 키 재료를 런타임 라이브러리의 Random 함수로 생성했고, 아무도 Randomize를 호출하지 않았기 때문에 모든 프로세스가 같은 바이트 시퀀스를 냈습니다. 그래서 Linux와 macOS의 Free Pascal 빌드는 실행할 때마다 동일한 파일 암호화 키, salt, CBC IV, AES-GCM nonce 접두사를 썼습니다. 버전 3.114.8은 대신 /dev/urandom을 읽고, 그럴 수 없을 때는 예외를 던집니다

결함 자체는 네 줄짜리 루프입니다. 더 유용한 교훈은 수백 개 문서를 AESV3와 AESV4로, PDF MAC을 붙이기도 하고 빼기도 하면서 암호화·복호화하던 테스트 스위트가 왜 끝까지 초록이었는가입니다. 프로세스마다 상수인 무작위성은 하나의 프로세스 안에서 도는 모든 테스트에 보이지 않고, 암호화 테스트는 대개 정확히 그렇게 짜여 있습니다

PDFiumPas는 어디서 난수 바이트가 필요할까요?

PDFiumPas 암호화 스택의 모든 난수 바이트는 단 하나의 프로시저, FPdfAes 유닛의 AesGenerateRandomBytes에서 나오므로, 나쁜 소스 하나가 전부를 오염시킵니다. ISO 32000-2 §7.6.4의 표준 보안 핸들러와 ISO/TS 32003의 AESV4 확장은 그 바이트들을 다음 자리에서 소비합니다:

  • DeriveEncryptionKeys가 문서마다 새로 생성하는 32바이트 파일 암호화 키, 이후 비밀번호 유도 키로 감싸 /UE와 /OE에 들어갑니다
  • 16바이트 salt 두 개. 하나는 /U 마지막 16바이트에, 하나는 /O 마지막 16바이트에 저장되며 각각 8바이트 validation salt와 8바이트 key salt로 나뉩니다
  • /Perms 뒤 평문의 12~15바이트. ISO 32000-2가 파일 키로 블록을 암호화하기 전에 난수 데이터로 채웁니다
  • AESV3 문서의 암호화된 문자열과 스트림마다 앞에 붙는 16바이트 CBC IV
  • AESV4 문서의 8바이트 nonce 접두사, 그 뒤를 따르는 0부터 시작하는 객체별 4바이트 카운터
  • EnableIntegrityProtection이 설정됐을 때의 32바이트 /KDFSalt와 MAC 키
PDFiumPas 암호화 스택의 모든 난수 바이트는 FPdfAes의 AesGenerateRandomBytes에서 여섯 소비자로 흐릅니다: /UE와 /OE로 랩핑되는 32바이트 파일 암호화 키, /U와 /O salt, /Perms 패딩 바이트, AESV3 CBC IV, AESV4 GCM nonce 접두사, 그리고 KDF salt와 MAC 키
하나의 공유 생성기는 나쁜 소스 하나가 키 재료를 한꺼번에 전부 오염시킨다는 뜻이고, 그래서 수정이 호출 지점마다가 아니라 단일 프로시저에 들어갔습니다

모든 프로세스가 왜 같은 키를 냈을까요?

AesGenerateRandomBytes는 Windows에서만 운영체제 생성기를 썼습니다. 다른 곳에서는 버퍼를 RTL 의사난수 생성기로 채웠고, 그 생성기는 프로그램이 Randomize를 호출하지 않으면 RandSeed = 0에서 출발합니다. 루프 위의 주석은 생성기가 GetTickCount64로 시드된다고 적어 두었습니다. 그런 코드는 한 줄도 없었고, 주석만이 시드가 존재하는 유일한 장소였습니다:

// 3.114.8 이전 AesGenerateRandomBytes의 비 Windows 분기
// (그 위의 주석은 결코 적용되지 않은 GetTickCount64 시드를 약속했다)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

시퀀스는 프로세스마다 다시 시작되고 그 안에서만 전진하므로, 어떤 프로세스가 암호화하는 첫 문서는 같은 빌드를 도는 다른 모든 프로세스의 첫 문서와 파일 키를 공유하고, 두 번째는 두 번째끼리 공유합니다. R5, R6, R7의 파일 키는 비밀번호에 전혀 의존하지 않습니다. 비밀번호는 키를 감쌀 뿐이기 때문에, 시퀀스를 재현할 수 있는 사람은 비밀번호를 몰라도 키를 쥡니다. AESV4는 두 번째 실패를 얹습니다. 같은 키에 같은 8바이트 접두사, 0에서 다시 시작하는 카운터면 GCM nonce가 반복되는데, NIST SP 800-38D §8이 이를 명백히 금지합니다. 한 키에서 반복된 GCM nonce는 두 평문의 XOR을 드러내고 인증 서브키를 노출하므로, AESV4-GCM 암호화와 PDF MAC 토큰이 의지하는 태그는 아무 의미도 잃습니다. 기밀성과 무결성이 동시에 날아갑니다

비 Windows FPC 빌드 PDFiumPas의 시드 없는 RTL 생성기: RandSeed 0에서 모든 프로세스가 같은 시퀀스를 내므로 프로세스 A의 첫 문서는 프로세스 B의 첫 문서와 같은 파일 키를 지니고, AESV4는 같은 키가 같은 접두사를 만나 카운터가 0부터 다시 시작하면서 GCM nonce를 반복합니다
파일 키는 비밀번호에 의존하지 않으므로 시퀀스를 재현할 수 있는 사람은 키를 통째로 쥐고, 반복된 GCM nonce는 기밀성과 무결성을 함께 파괴합니다

범위는 앞 문단이 암시하는 것보다 좁습니다. Windows 빌드는 한 번도 영향받지 않았습니다. Windows 분기는 언제나 advapi32를 통해 CRYPT_VERIFYCONTEXT로 CryptGenRandom을 호출했고 실패하면 예외를 던졌습니다. 노출된 것은 3.114.8 이전 비 Windows 빌드의 출력이고, 실전에서는 Linux와 macOS의 Lazarus와 Free Pascal 애플리케이션을 뜻하며, PDFium 빌드의 Delphi 대 FPC 함정 목록에 한 줄 더 추가되는 셈입니다

Randomize는 왜 한번도 올바른 수정이 아니었을까요?

Randomize를 호출했다면 근원은 고치지 않은 채 증상만 가렸을 것입니다. RandSeed는 32비트 값이고 Randomize는 그것을 시계에서 유도하기 때문입니다. 그러면 가능한 키 스트림의 수가 2^32로 갑히고, 파일이 대략 언제 쓰였는지 알면 탐색 공간은 그보다 훨씬 줄어듭니다. 256비트 AES 키 옆에서는 아무것도 아닌 셈입니다. 키 재료는 커널 엔트로피 풀에서 와야 하므로, 3.114.8의 AesGenerateRandomBytes는 /dev/urandom을 읽고, 짧은 읽기를 루프로 처리하며, 풀이 요청된 바이트를 전부 주지 못하면 예외를 던집니다:

Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
  Remaining := Count;
  while Remaining > 0 do
  begin
    Got := FileRead(Handle, P^, Remaining);
    if Got <= 0 then
      Break;               // 실패 또는 예상 밖의 스트림 끝
    Inc(P, Got);
    Dec(Remaining, Got);
  end;
  if Remaining = 0 then
    Exit;
finally
  FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');

거부하는 것은 의도적이며, CryptGenRandom을 쓸 수 없을 때 Windows 분기가 언제나 해온 것과 같습니다. 실패한 암호화 저장은 당일에 알아차리는 사건이고, 예측 가능한 키로 성공한 저장은 남이 알려 줘야 아는 사건입니다. 실무적 귀결이 둘 따라옵니다. /dev가 채워지지 않은 미니멀 컨테이너나 chroot는 이제 조용히 열화되는 대신 암호화에 실패하니, 마운트하세요. 그리고 예외가 대상 파일을 fmCreate로 연 뒤 TPdf.SaveAsEncrypted 밖으로 전파되므로, 빈 출력 파일이 남고 여러분의 오류 핸들러가 지우게 됩니다

왕복 테스트는 왜 한번도 못 잡았을까요?

왕복 테스트는 상수인 무작위성을 볼 수 없습니다. 복호화는 암호화가 고른 파일 키가 무엇이든 회복해 내기 때문입니다. 테스트는 문서를 암호화하고, 비밀번호로 다시 열고, /UE에서 키를 풀어내고, 모든 객체를 복호화합니다. 예측 가능한 키도 무작위 키만큼이나 잘 풀리고 복호화되며, GCM 태그는 그 같은 키로 계산됐으니 검증을 통과합니다. 두 번 암호화해서 두 출력이 다르다고 단언하는 테스트조차 통과합니다. 같은 프로세스의 두 번째 호출은 시퀀스의 다음 바이트를 뽑을 뿐이기 때문입니다. 중요한 성질, 즉 프로세스마다 다른 키는 출력을 프로세스 간에 비교해야만 관측됩니다. 같은 코드가 어떤 값을 만들고 소비하는 순간, 테스트는 결함 부류 전체에 눈멀어지고 무작위성은 그 가장 순수한 예입니다

키 무작위성을 프로세스 간에 어떻게 테스트할까요?

작은 probe를 대상 플랫폼에서 별개 프로세스로 두 번 돌리고 출력을 비교하세요. 아래 probe는 DeriveEncryptionKeys를 호출하고 /U 항목의 32~47바이트에 저장된 salt를 출력합니다. 그 값은 모든 암호화 파일에 그대로 드러나게 쓰이므로 CI 로그에 찍어도 아무것도 유출되지 않지만, 파일 키와 같은 생성기에서 나옵니다:

PDFiumPas의 프로세스 간 무작위성 테스트: SaltProbe 프로그램이 DeriveEncryptionKeys를 호출해 /U의 32~47바이트를 16진수로 출력하고, job이 그것을 별개 프로세스로 두 번 돌려 두 줄이 같으면 실패하며, 배포된 PDF는 /U 문자열의 마지막 16바이트로 비교합니다
상수인 무작위성은 하나의 프로세스 안에서 보이지 않습니다. 복호화는 암호화가 고른 키가 무엇이든 회복하니, 중요한 성질은 출력을 프로세스 간에 비교해야만 관측됩니다
program SaltProbe;
{$mode delphi}
uses
  SysUtils, FPdfEncrypt;
var
  Opts: TPdfEncryptOptions;
  Keys: TPdfEncryptionKeys;
  I: Integer;
  Hex: string;
begin
  Opts := TPdfEncryptOptions.Default;
  Opts.UserPassword := 'probe';
  Opts.Revision := erR6;
  DeriveEncryptionKeys(Opts, Keys);
  Hex := '';
  for I := 32 to 47 do          // /U = 32바이트 해시 + 16바이트 salt
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // 실행마다 달라야 함
end.

비 Windows 대상마다 probe를 빌드에 연결하세요. 두 번 돌리고 두 줄이 같으면 job을 실패 처리합니다. 같은 비교는 이미 유통 중인 파일에도 통합니다. 같은 애플리케이션의 서로 다른 실행이 쓴 암호화 PDF 두 개를 골라 Encrypt 딕셔너리에서 /U 문자열을 읽고 마지막 16바이트를 비교하세요. 동일한 salt는 영향받은 빌드를 지목하고, 그 문서들은 평문에서 3.114.8 이상으로 다시 암호화해 각자 새 파일 키를 받아야 합니다. 일반적인 습관은 Windows 실행을 믿기보다 비 Windows 코드 경로를 그 플랫폼 자체에서 돌려 보는 것이고, 비 Windows 빌드용 libcurl 타임스탬프 백엔드가 같은 논리입니다

PDFiumPas는 PDFium 엔진 위에 세워진 Delphi 및 Lazarus PDF 컴포넌트로, AES-256과 AES-GCM, PDF MAC 토큰을 Pascal로 네이티브 구현하고 모든 플랫폼에서 키 재료를 운영체제 생성기에서 가져옵니다. 자세한 내용과 다운로드는 PDFium Delphi 컴포넌트 페이지에 있습니다