Technical Article

악성 PKCS#12로부터 Delphi PDF 서명기 보호하기

PDF에 서명할 때 서명 키는 일반적으로 본인이 제어하는 대상이라고 생각합니다. 사용자가 선택한 비밀번호로 보호되고 생성한 .pfx 파일 내에 존재하기 때문입니다. 해당 파일을 읽는 코드는 경계선이 아니라 단순한 통로처럼 느껴집니다. 하지만 인증서가 본인의 소유가 아니게 되는 순간 이 직관은 틀리게 됩니다. 사용자가 임의의 .pfx를 선택할 수 있게 하는 데스크톱 도구, 업로드된 자격 증명을 수락하는 서버, 네트워크를 통해 인증서를 공급받는 배치 서명기 등은 단 하나의 서명 바이트가 생성되기도 전에 공격자가 변조할 수 있는 바이트를 파서에 넘겨주게 됩니다. PKCS#12 리더는 이미지 디코더나 폰트 로더와 마찬가지로 공격 표면(attack surface)입니다.

이 아티클에서는 서명 자격 증명을 가져오는 경로에 존재했던 리더 내부의 두 가지 실제 결함을 분석합니다. 둘 다 특이한 오류는 아닙니다. 두 오류 모두 고정 너비 정수를 사용하는 언어로 작성된 거의 모든 이진 파서에 나타나는 동일한 근본 원인에서 비롯됩니다. 즉, 파일의 길이나 개수 정보를 적정 수준 이상으로 신뢰했기 때문입니다. 하나는 범위를 벗어난 읽기(out-of-bounds read)로 이어지고, 다른 하나는 사용자가 수동으로 프로세스를 종료할 때까지 프로세스가 멈추는 현상을 유발합니다.

바이트가 이동하는 경로

문서에 서명하기 위해 .pfx를 가져오는 것은 단일 작업이 아니라 짧은 파이프라인이며, 각 단계는 공격자가 작성했을 가능성이 있는 데이터를 구문 분석합니다. 컨테이너는 RFC 7292에 정의된 PKCS#12 구조이며, 개인 키를 보유한 암호화된 슈라우드(shroud)를 감싸고 있는 AuthenticatedSafe 백(bag)의 중첩 구조입니다. 이 컨테이너를 읽으려면 ASN.1을 탐색하고, 비밀번호에서 키를 유도하며, 암호를 해독한 다음, 복구된 RSA 키를 서명을 작성하는 코드에 전달해야 합니다.

HotPDF에서 이러한 단계들은 개별 유닛으로 매핑됩니다. PKCS#12 컨테이너 로직은 HPDFPFX에 상주합니다. 처리하는 모든 태그, 길이 및 값은 HPDFASN1의 ASN.1 리더에 의해 디코딩됩니다. 키 유도 및 PBES2 암호 해독은 PBKDF2HMACSHA256과 함께 HPDFCrypt에 위치합니다. 키가 복구되면 HPDFRSAHPDFCMS의 CMS SignedData 빌더가 이를 PDF에 임베드된 분리형 서명(detached signature)으로 변환합니다. 전체 체인을 실행하는 공용 진입점은 단 하나의 호출입니다.

// Drives the full pipeline: load the placeholder PDF, parse the PFX,
// derive the key, build CMS SignedData, write the signed output.
if THotPDF.SignPDFWithPFX('Prepared.pdf', 'Signed.pdf',
     'signer.pfx', 'p@ssw0rd') then
  // signature embedded
else
  // signing did not complete
;

signer.pfx의 모든 바이트는 암호화 프로세스가 시작되기 전에 HPDFASN1HPDFPFX를 통과합니다. 만약 이 두 유닛이 파일이 선언한 길이나 형식에 주의하지 않으면, 다운스트림의 암호화 단계가 실행되기도 전에 비정상적으로 종료될 것입니다.

결함 1: 보호막을 넘어 랩어라운드되는 ASN.1 길이

DER 및 BER의 ASN.1은 모든 요소를 태그, 길이 및 콘텐츠 바이트 수의 순서로 인코딩합니다. 길이는 파서에 어디까지 읽어야 하는지 알려주고 파일 생성자가 임의로 설정할 수 있으므로, 반드시 신뢰성을 확인해야 하는 필드입니다. X.690 §8.1.3은 두 가지 인코딩 방식을 정의합니다. 숏 폼(short form)은 0~127의 길이를 단일 바이트로 압축합니다. 더 큰 값에 사용되는 롱 폼(long form)은 하위 7비트가 뒤따르는 길이 바이트의 개수를 나타내는 첫 리드 바이트를 사용하고, 그 개수만큼의 빅엔디안 바이트가 실제 값을 전달합니다. 따라서 4개의 길이 바이트는 거의 4GB에 달하는 콘텐츠 크기를 선언할 수 있습니다.

이러한 값을 디코딩한 후, 파서는 데이터를 신뢰하기 전에 콘텐츠가 실제로 버퍼 내에 맞는지 확인해야 합니다. 일반적인 확인 절차는 현재 위치에 콘텐츠 길이를 더한 값이 데이터의 끝을 넘어가지 않는지 확인하는 것입니다. 위치, 콘텐츠 길이, 전체 크기가 모두 부호 있는 32비트 정수(signed 32-bit integer)로 처리되는 일반적인 방식으로 작성하면 이 보호 로직이 작동하지 않게 됩니다.

// The trap: signed 32-bit arithmetic. With ContentLen near MaxInt,
// Pos + ContentLen overflows to a NEGATIVE value, so the comparison
// is false and a forged ~2 GB length sails straight through.
if Pos + ContentLen > Total then
  raise EHPDFASN1Error.Create('content overruns buffer');

문제는 비교 연산이 아니라 덧셈 연산 자체에 있습니다. ContentLenMaxInt(2147483647)에 근접할 때, Pos + ContentLen은 부호 있는 32비트 범위를 초과(overflow)하여 음수로 오버플로(wrap around)됩니다. 음수 합계는 결코 Total보다 클 수 없으므로, 검사기는 정상적인 상태로 판단하고 실제 버퍼에는 존재하지 않는 약 2GB의 콘텐츠 길이를 파서가 신뢰하도록 허용합니다. 그 후에 문제가 발생합니다. 리더는 요청된 길이에 맞게 버퍼를 할당하고 복사 작업을 수행합니다. 즉, SetLength가 호출된 후 소스에서 읽어오는 Move가 실행됩니다. 그러나 실제 소스에는 수백 바이트만 남아 있으므로, 복사 프로세스는 입력의 끝을 한참 지나쳐서 읽게 되며, 이는 최선의 경우 크래시를 유발하고 최악의 경우 인접한 프로세스 메모리를 파서로 유출하는 범위를 벗어난 읽기(out-of-bounds read) 취약점이 됩니다.

올바른 보호막은 비교를 수행하기 전에 중간 합계의 자료형을 확장하여, 더한 값의 자료형이 오버플로를 방지할 수 있도록 보장해야 합니다. 해결 방법은 두 피연산자를 모두 Int64로 캐스팅하는 것입니다.

// Correct: both operands widened to Int64 before the add, so the sum
// cannot wrap. A forged 2 GB length now fails the bounds check.
if ContentLen < 0 then
  raise EHPDFASN1Error.Create('negative content length after decoding.');
if Int64(Pos) + Int64(ContentLen) > Int64(Total) then
  raise EHPDFASN1Error.Create('content overruns buffer');

Int64는 값의 왜곡 없이 두 32비트 값의 합을 온전히 담아내므로, 비교 조건문은 실제 값을 인식하고 조작된 거짓 길이를 걸러낼 수 있습니다. ContentLen에 대한 별도의 음수 여부 검사는 디코딩된 값 자체가 음수로 변하는 경우를 사전에 차단합니다. HotPDF에서 이 검사 코드는 다른 모든 헬퍼가 기반으로 삼는 노드를 생성하는 함수인 HPDFASN1ParseNode에 위치합니다. HPDFASN1Content가 노드의 콘텐츠 길이에 기반해 SetLengthMove 크기를 지정하므로, 이 보호막을 잘못 통과한 노드는 그것으로부터 읽는 모든 작업을 감염시켰을 것입니다. 디코딩 시점에서 경계를 수정하는 것이 상위 단계의 헬퍼들을 안전하게 유지하는 핵심입니다.

결함 2: 공격 도구로 오용될 수 있는 PBKDF2 반복 횟수

두 번째 결함은 메모리 손상 오류가 아니라, 파일이 CPU에 연산 강도를 강제하는 형태입니다. PKCS#12는 RFC 8018에 정의된 PKCS#5의 패스워드 기반 체계인 PBES2를 통해 키 데이터를 보호합니다. PBES2는 키 유도 함수(여기서는 HMAC-SHA-256 기반의 PBKDF2)를 실행한 다음, 대칭키 암호화(여기서는 AES-256-CBC)를 실행합니다. PBKDF2는 반복 횟수를 입력받는데, 이 횟수는 파일에 매개변수로 기록되어 있습니다. 이것의 존재 의의는 연산을 의도적으로 느리게 만드는 것입니다. 즉, 반복 횟수가 많아질수록 비밀번호 추측 연산 비용이 증가하여 오프라인 공격자를 막는 데 효과적입니다. RFC 8018 §4.2는 보안을 위해 반복 횟수가 클수록 좋다고 명시하고 있으며, 의도적으로 상한선을 두지 않았습니다.

이러한 열린 구조는 사용자가 직접 파일을 생성할 때는 유용하지만, 공격자가 작성한 파일일 때는 치명적인 무기가 됩니다. 반복 횟수가 공격자에 의해 제어 가능한 작업 요소(work factor)가 되어, 알고리즘 복잡도를 이용한 서비스 거부(DoS) 공격으로 변질되기 때문입니다. 조작된 .pfx 파일에 수십억 번의 반복 횟수를 기록하면, 파서는 이를 읽고 그대로 받아들여 그 횟수만큼 HMAC-SHA-256을 활용하는 PBKDF2를 호출합니다. 그 결과 프로세스는 단 하나의 파일 때문에 몇 분 또는 몇 시간 동안 반환되지 않는 루프에 빠지게 됩니다. 요청당 하나의 자격 증명을 처리하는 서명 서버에서, 이러한 악성 파일이 한 번만 업로드되어도 특정 작업 프로세스가 완전히 멈추게 됩니다.

반복 횟수가 CPU를 가동시키기 전에 먼저 값의 잘림 현상이 발생하여 상황을 왜곡시킵니다. 반복 횟수 값은 파일 내부에서 크기가 고정되지 않은 ASN.1 INTEGER로 저장되지만, PBKDF2가 최종적으로 처리하는 필드는 32비트 Integer입니다. INTEGER 값을 이 필드에 직접 디코딩하면 큰 값은 절단되며, 부호 비트(sign bit)에 걸치도록 조작된 값은 음수나 무관한 작은 수로 변형될 수 있습니다. 이로 인해 실제 수행되는 연산 규모가 파일이 요구했던 바와 완전히 달라질 수 있습니다. 해결 방법은 값을 전체 너비로 먼저 읽고 검증을 수행한 후에 축소하는 것입니다.

// Read the iteration count as Int64 first, then clamp to a sane band
// BEFORE it is narrowed into the 32-bit Iterations field PBKDF2 uses.
LIter := HPDFASN1ToInteger(Data, Node);          // returns Int64
if (LIter < 1) or (LIter > 100000000) then
  raise EHPDFPFXError.CreateFmt(
    'PBKDF2 iteration count %d is outside the accepted range 1..100000000',
    [LIter]);
Iterations := Integer(LIter);                    // safe: already bounded

Int64로 데이터를 읽으면 잘려 나간 값이 아닌 디코딩된 실제 유효 값이 됩니다. 하한선 검사를 통해 키 유도에 무의미한 0이나 음수 횟수를 차단합니다. 1억 번의 상한선은 오늘날 수만에서 수십만 번 수준을 사용하는 일반적인 PKCS#12 파일의 기준을 충분히 만족하면서도, 최악의 경우를 감당할 수 있는 수준의 작업으로 제한합니다. 값이 이러한 유효 범위를 통과한 후에만 32비트 필드로 축소되므로, 값의 절단으로 인한 왜곡이 일어나지 않습니다. HotPDF에서 이 상한 필터는 PBKDF2HMACSHA256으로 가기 전에 PBKDF2 매개변수를 디코딩하는 ParsePBES2Params에 구현되어 있습니다.

두 가지 해결 방법이 근본적으로 일치하는 이유

두 결함은 각각 버퍼 오버런과 프로세스 정지라는 서로 다른 증상으로 나타나지만, 실상은 동일한 원인에서 비롯됩니다. 둘 다 신뢰할 수 없는 파일로부터 전달된 숫자 값을 실제 범위 검증 없이 너무 일찍 고정 너비 자료형으로 주입했기 때문입니다. 길이는 범위 검증 전에 32비트로 연산되었고, 반복 횟수 역시 유효 범위 검사 전에 32비트로 축소되었습니다. 해결 철학은 같습니다. 값을 최대 자료형 너비로 온전히 읽고, 실제 한계값과 비교 검증한 후에 필요 크기로 줄이는 것입니다. 중간 단계로 Int64를 도입하는 것은 개발 방식의 취향이 아니라, 공격자가 기록한 실제 위협 값을 검사기가 정확하게 포착하기 위한 필수적인 방어선입니다. 오버플로를 감지하지 못하는 경계는 무용지물이며, 한계가 없는 변수가 매개변수가 아니라 CPU 자원을 소모하도록 열어둔 제어기일 뿐입니다.

서명 파이프라인 구축을 위한 실무 조언

여기서 얻을 수 있는 직접적인 교훈은 신뢰할 수 없는 인증서 입력을 일반적인 업로드 파일과 동일하게 철저히 검증해야 한다는 것입니다. 정상적인 .pfx 파일은 수 메가바이트가 아니라 수 킬로바이트 수준이므로 허용 파일 크기를 명확히 제안하십시오. 구문 분석 실패는 평범한 유효하지 않은 입력의 거부로 취급하고, 사용자에게 전체 스택 트레이스(stack trace)를 유출하지 않아야 합니다. 서버에서 서명 작업을 수행한다면 특정 프로세스의 정지가 전체 서비스 마비로 번지지 않도록 격리된 환경에서 실행하고, 비정상적으로 긴 연산을 차단할 타임아웃을 적용하십시오.

보다 포괄적인 교훈은 인증서 보안 영역에만 국한되지 않습니다. 파서 보호는 일회성 검사로 끝나는 것이 아니라, 라이브러리가 직접 쓰지 않은 모든 외부 바이트 데이터를 읽어 들이는 과정 전반에 걸쳐 요구되는 영구적인 아키텍처 속성입니다. PDF 라이브러리는 문서 내부의 임베디드 폰트, 수많은 형식의 이미지 디코더, 스트림 필터 및 서명 단계의 인증서 등 수많은 유입 데이터를 구문 분석합니다. 이 모든 것이 공격 표면이므로 수치와 길이를 의심해 검사해야 합니다. HotPDF는 유입 지점을 사전에 모니터링하여 방어적으로 분석한 후 키를 신뢰할 수 있도록 HPDFASN1, HPDFPFX, HPDFCrypt, HPDFCMS 유닛을 기반으로 가져오기 및 서명 파이프라인을 설계했습니다.

이러한 검증이 보호하는 서명 워크플로는 Delphi의 PAdES 디지털 서명 구현 가이드에서 포괄적으로 다루며, 본 코드베이스를 공유하는 AES-256 키 경로를 포함하여 문서 암호화에 적용된 동일한 방어 대책은 AES-256 암호화 및 보안 아티클에서 확인할 수 있습니다. 이 모든 기능은 본 블로그의 다른 주제인 로드, 편집, 암호화 및 서명 API와 함께 Delphi 및 C++Builder용 HotPDF Component의 일부로 제공됩니다.