PDF는 단순히 열어서 확인하는 읽기 전용 문서가 아닙니다. 가상 머신 상에서 실행되는 작은 컴퓨터 프로그램과 유사합니다. 임베디드 폰트는 문자 서식을 그리기 위해 실행 대기 중인 스택 기반 인터프리터이며, 이미지는 외부 파일이 전달한 가로세로 규격 및 색농도 매개변수를 기반으로 메모리를 할당받는 디코더이고, 압축 데이터 스트림 역시 조작된 압축 필터 정보에 싸여 유입됩니다. 이 데이터 수치값 중 어느 것도 개발자가 직접 설계한 것이 아닙니다. 고객사의 청구서 파일이나 출처를 알 수 없는 첨부 파일 등 외부 발송인이 임의로 주입한 바이트 수치들입니다. 이러한 로우 바이트 데이터를 픽셀 화면이나 글꼴 글리프로 변환하는 디코더가 가장 취약한 공격 표면(attack surface)이며, 이 정보를 무비판적으로 신뢰하는 파서는 단 한 장의 불량 조작 문서를 만나 크래시되거나 보안 경계가 무너지는 위협에 처합니다.
PDFlibPas는 글꼴 해석기(TrueType, Type1, CFF, CMap 테이블), 이미지 디코더(PNG, GIF, TIFF, JBIG2, CCITT 그룹 3 및 그룹 4), 압축 스트림 필터(LZW, ASCII85, Flate) 전 영역에 걸쳐 외부 유입 데이터를 악성 데이터로 가정하고 사전 방어하도록 전면적인 보안 강화(hardening) 패스를 마쳤습니다. 본 문서에서는 실제 수정 조치된 다섯 가지 취약점 범주를 설명하며, 개별 취약점들이 Delphi 고유 컴파일 방식 하에서 어떻게 침투할 수 있었는지 대조해 서술합니다. 최신 릴리스 제품군에는 모두 대책이 반영되었으며, 외부 파일을 직접 가공해야 하는 모든 종류의 Pascal 파서 설계 시 상기해 봐야 할 부분들입니다.
메모리가 턱없이 부족한 버퍼를 배정받게 만드는 정수 오버플로
이미지 디코더에서 벌어지는 대표적인 메모리 오염 원인은, 차원 치수의 곱셈 값이 정수 상한을 초과하여 오버플로(wrap)되는 현상입니다. 디코더 엔진은 가로, 세로, 컴포넌트 수, 그리고 픽셀 색상 심도 정보 수치를 받아서 전부 곱한 값으로 버퍼 크기를 할당받고, 그 영역에 픽셀 데이터를 써서 이미지를 만듭니다. 이 곱셈 수식 계산이 32비트 기본 정수 연산 범위 내에서 처리되면, 각 개별 치수들은 정상 임계값 내에 존재하더라도 최종 곱한 결과가 32비트 최대치를 초과하여 랩어라운드되어 아주 작은 정수값으로 왜곡될 수 있습니다. 그 결과 버퍼 크기 할당 자체는 정상 작동하는 것처럼 보여도 터무니없이 작게 쪼개진 공간이 배정되며, 픽셀 쓰기가 시작되는 즉시 할당 영역 한계를 짓밟으며 넘치게 됩니다. 이것이 정수 오버플로(CWE-190) 취약점이며, 즉각적으로 힙 버퍼 영역 범위를 벗어난 메모리 쓰기(CWE-787) 오류로 번집니다.
공통 이미지 처리 경로에는 이미 개별 축 치수를 65535 이내로 클램프 제한하는 논리가 등재되어 있었으나, 일부 독립 이미지 디코더들이 이 보호 코드를 누락하고 있었습니다. ByteCount * FHeight 같은 줄 단위 바이트 연산이나 FWidth * Components * BitDepth 같은 픽셀당 곱셈 계산식은, 비록 결과를 저장할 변수가 64비트로 선언되어 있더라도 두 피연산자 모두가 32비트 정수형이면 Delphi 내부 연산 과정에서는 32비트 곱셈으로만 가동됩니다. 가로세로 60,000 수준의 고해상도 지도는 그 자체로는 정상 범위이지만, 둘을 곱하는 와중에 부호 있는 32비트 최댓값을 가뿐히 넘어서며 아주 좁은 크기의 결과로 축소됩니다. ZLib 압축 보정 영역인 BitsPerComponent * Colors * Columns 연산식도 동일한 맹점을 지니고 있었습니다.
// Reject before allocating, not after writing.
// Evaluate the product in Int64 so it cannot wrap at 32 bits.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
Exit; // hostile or unsupportable dimensions; refuse the image
SetLength(Buffer, RowBytes * FHeight);
이 취약점이 특히 Delphi 개발 환경에서 함정이 되는 이유는 묵시적 잘림(silent narrowing) 현상 때문입니다. 컴파일러는 범위 범위를 초과할 위험이 있는 정수 곱셈을 32비트 레지스터 변수에 대입하는 법적 코드 변환 시 경고 메시지를 노출하지 않으며, 범위 제한 검사(Range Checking) 역시 데이터 참조 전에는 오버플로 발생 여부를 감지하지 못합니다. 곱셈 단계를 32비트로 방관하면, 실제 데이터 기록 주기 시작 시 엉뚱한 쓰기 공간의 크기를 유효 크기로 판단해 버려 크래시를 자초하게 됩니다.
한계 조건문 도달을 원천 차단해 버리는 비좁은 변수 자료형
TIFF 파일 포맷은 다음 디렉토리 물리 주소를 수반해 체인 연결되는 구조(Image File Directory, IFD)를 씁니다. 악성 해커는 이 인덱스 체인 루프 끝단을 자기 자신을 가리키는 순환 구조로 왜곡시킬 수 있으며, 한계 루프 회수 제한 조건이 없는 리더기는 조건 없이 무한 루프에 빠집니다. 이것이 사용자 변조 입력을 기반으로 가동되는 무한 루프 오류(CWE-835)이며, 일반적인 방지책은 정상 파일에서는 결코 도달하지 않을 일정 수준 이상의 반복 회수 도달 시 루프를 탈출할 카운터 계측기를 다는 것입니다.
과거의 페이지 계측 변수는 0~65535 범위만 보관할 수 있는 Word 형식으로 기재되어 있었습니다. 그리고 제어 조건문 루프에는 "반복 횟수가 65535를 넘어서면 탈출"하도록 작성되어 있었습니다. 하지만 이 구조는 함정을 가집니다. Word 변수는 65535보다 큰 정수를 가질 수 없어, 이 조건문의 대소 비교식 결과는 항상 거짓(false)으로 고정되기 때문입니다. 카운터 변수가 65535에 도달한 뒤 1이 더 증가하면 변수는 0으로 회귀(wrap)해 버려, 조건문 검사기는 한계 상한선을 돌파했음을 영원히 인지하지 못하고, 악성 도면의 순환 참조 고리 위에서 계속 회전하게 됩니다.
해결 대책은 변수를 충분히 큰 정수 공간으로 넓혀 조건식이 정상 동작하도록 유도하는 것입니다. TPDFTIFF.FPageCount를 32비트인 Integer 형태로 선언을 수정하면, FPageCount > 65535 조건 도달 판단이 올바르게 동작해 무한 루프를 이탈하고, 기존 컴파일 버전과의 호환성을 깨뜨리지 않고 공개 PageCount 속성 정보도 함께 교체됩니다. 변수 > 변수자료형의최댓값 형태의 검사식을 마주할 때는 조건이 무조건 false가 됨을 주의해야 합니다. 자료형 크기를 넉넉하게 확장하거나 최댓값 도달 여부를 동등 연산자(==) 조건식으로 판별해 대처해야 합니다.
동작 가속화 경로에서 차단 해제되어 버린 범위 한계 검사
범위 한계 검사(Range Checking)가 켜져 있으면, Delphi는 컴파일 시점에 스트링이나 어레이 배열의 인덱스 한계를 검사하는 코드를 자동 삽입하여, 인덱스가 한계 범위를 벗어날 경우 메모리 오염으로 번지지 않도록 ERangeError 예외 처리를 유발해 제어권을 지킵니다. 하지만 빈번하게 사용되는 가속화 성능 경로(Hot path)에서는 {$R-} 지시문을 써서 성능상의 이유로 이 체크 동작을 강제 해제해 버리곤 합니다. 그러나 인덱스로 전송될 인수가 해커에 의해 조작되는 즉시 엄청난 취약점으로 돌변합니다.
글꼴 데이터 해석기가 활용하는 내부 리스트 검색 함수 TPDFlibStringList.Get가 바로 지시문으로 범위 검사가 꺼져 있던 경로였습니다. Windows 환경에서 범위 검사 없이 어레이 메모리에 바로 접근하도록 설정되어 있어, 인덱스 범위를 초과했을 때 예외가 발생하는 대신 로우 메모리 공간에 직접 쓰거나 읽게 됩니다. 정교하게 제어된 정상 동작 시에는 실익이 있지만, 글꼴 데이터를 파싱하는 주기에 유효하지 않은 외부 데이터가 들어오는 순간 시스템이 파괴됩니다. 비어 있는 계산 스택 메모리에서 강제로 데이터를 꺼내려 시도할 시 -1 번지 주소가 유도될 수 있고, 글리프 개수 오차로 인해 할당 공간 경계선 바로 바깥 영역을 찌르게 될 수 있습니다. 범위 검사가 꺼져 있는 파서 엔진은 예외를 감지하지 못하고 로우 메모리를 무단 침범해 기록을 시도하며, 그 영역에 보관되어 있던 AnsiString 같은 참조 방식 정렬 문자열 데이터들의 참조 횟수 구조를 짓밟아 유휴 메모리를 오염시킵니다.
가속화 경로를 위해 컴파일러 범위 검사를 다시 켜지는 않았습니다. 그 대신 인덱스에 사용할 수치가 유효 범위 내의 값인지 사전에 엄격히 수동 검사하도록 수정했습니다. 스택 메모리에서 연산 정보를 취득하기 전에 비어 있지 않은지 더블 체크하고, 오차 범위를 허용할 위험이 있는 이하(<=) 조건식 대신 엄격한 미만(<) 판별식으로 어레이 인덱스 경계 한계를 가두었습니다. 범위 검사 비활성 지시문을 선언하는 순간 컴파일러가 대신 해 주던 감시 책임이 개발자에게 이전되는 것이며, 생략된 검증 코드는 진입점 함수마다 수동으로 꼼꼼히 채워야만 안심할 수 있습니다.
글꼴 서식 해석 루틴의 끝없는 재귀 함수 순회 결함
Type2 글꼴 정보 포맷은 내부 서브루틴을 호출할 수 있는 기능을 가집니다. 그리고 이 서브루틴은 내부에서 또 다른 서브루틴을 꼬리 물어 호출할 수 있어, 악의적인 파일은 시스템이 허용하는 스택 한계를 뛰어넘어 무한히 함수를 파고 내려가도록 순환 참조 서식을 구성해 둘 수 있습니다. 서브루틴이 자신을 다시 호출하거나 순환 궤도를 그리며 무제한 재귀 호출이 작동하면, 프로세스의 실제 스택 메모리가 고갈(Stack Overflow)되어 강제 사멸합니다. 이것이 통제되지 않은 재귀(CWE-674) 취약점입니다.
이전의 Type1 글꼴 해석기에는 이미 이와 유사한 위협에 대비해 계측 카운터와 최대 제한값 상한인 PLType1MaxCallDepth 검사 논리가 정립되어 있어, Type1 표준 스펙에서 정의하는 임계 깊이를 초과하는 호출을 거부하고 차단했습니다. 그러나 나중에 추가된 유사 구조의 Type2 글꼴 해석기에서는 이 조건 감시가 누락되어 있었고, 무한 호출을 유도하도록 악성 조작된 글꼴 데이터 유입 시 스택 오버플로 크래시를 무방비로 마주했던 것입니다.
// The shape of the Type1 guard the Type2 path was missing.
// Track depth across nested calls and refuse to recurse past it.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
Exit; // hostile self-referential subroutine; stop descending
// ... interpret the subroutine, then Dec(CallDepth) on the way out
해결 대책은 Type2 경로에도 Type1 해석기 개발 시 활용했던 검사 제한 한계 로직을 장착해 재귀 호출 깊이를 감금하는 것입니다. 외부 공격자의 통제 영역에 있는 중첩 글꼴 서브루틴, 순환 매핑 어레이 배열, 체인 연동 구조 등을 파싱할 때는, 사용자가 수치 조작으로 늘릴 수 없는 영구적인 최대 진입 깊이 상한 가드레일을 구축해 두어야 안전합니다.
출력 데이터 버퍼의 빈 여백을 채우는 미초기화 메모리 정보 유출
가장 감지하기 어렵고 미묘했던 결함은, 암호화가 풀린 결과 데이터 스트림 끝부분에 힙(heap) 영역의 잔존 메모리 데이터가 그대로 노출되는 취약점이었으며, 원인은 SetLength 동작 방식의 함정 때문입니다. SetLength를 사용해 AnsiString 크기를 확장하면, Delphi는 메모리 주소 영역을 즉시 배정하지만 배정된 메모리 내부를 0으로 초기화해 주는 작업을 수행하지 않습니다. 따라서 새로 잡힌 메모리 영역에는 이전에 다른 프로그램이 힙 메모리에 사용하고 버렸던 미처 해제되지 않은 유휴 바이트 데이터들이 그대로 잔존해 있습니다. 할당 직후 전체 영역에 실 데이터를 빈틈없이 채워 쓰면 이 잔재가 덮어씌워져 문제가 없지만, 예외 상황 등으로 일부 잔여 여백 공간이 빈 채로 반환되면, 그 영역에 남아 있던 이전 메모리 사본 정보가 출력 파일 형태로 외부에 방출되어 버립니다. 이것이 초기화되지 않은 메모리 사용(CWE-457) 취약점이며, 보안 경계를 넘어 외부 파일로 전달될 시 심각한 개인 정보 및 서버 메모리 정보 유출 사고로 직결됩니다.
AES-CBC 복호화 모듈 실행 경로가 바로 이러한 맹점에 노출되어 있었습니다. 출력 버퍼 크기를 SetLength로 확보하고 암호문을 16바이트 블록 단위로 복호화해 채워 나가는 구조였습니다. 암호문의 최종 데이터 크기가 정확히 16의 배수 크기로 맞아떨어지지 않는 불량 패킷이 임의 유입되면, 블록 크기 단위를 채우지 못한 마지막 끝부분 여백 공간은 복호화 데이터가 채워지지 않은 채 공백으로 방치되었습니다. 이로 인해 SetLength가 초기화 없이 할당했던 과거의 메모리 파편 데이터가 소멸하지 않고 문서 암호가 풀린 평문 뒷부분에 그대로 합쳐져 유출되었던 것입니다. 해결 대책은 두 가지 안전 장치를 병렬 구성하는 것이며 하나만으로는 충분치 않습니다. 첫째, 진입점 함수에서 블록 배수 크기를 충족하지 못하는 악성 불량 데이터 진입 시 즉각 수신 거부 처리를 작동시킵니다. 둘째, 어떠한 오류 상황에서도 쓰이지 않은 여백 공간이 잔존 메모리를 노출하지 않도록 버퍼 할당 직후 FillChar를 사용해 영역 전체를 0으로 강제 소독(zeroing)해 정화합니다.
보안 감사 활동이 남긴 엔지니어링 지침
다섯 가지 결함은 외견상 상이하지만 일맥상통하는 부분이 있습니다. 연산 결과가 랩어라운드되는 아슬아슬한 정수 변수 크기 지정, 조건식을 영구 작동 불능으로 격리하는 너무 좁은 대소 검사용 변수 자료형, 동작 향상을 위해 Local 범위 검사 장치를 차단한 구문 분석기, 제동 장치가 없던 재귀 호출 설계, 그리고 메모리 소독을 지원하지 않는 기본 할당 규칙 등입니다. Delphi는 자신이 설계 규칙으로 제시한 대로 작동했을 뿐입니다. 32비트 연산의 오버플로를 허용하고, 캐스팅 시 수치 잘단을 말없이 진행하며, 로컬 범위 검사 비활성 지시문을 지원하고, 재귀 깊이를 자동 제어해 주지 않으며, 할당된 주소 영역 내부를 개발자 대신 정화해 주지 않는 정립된 사양대로 작동한 것입니다. 이것이 Delphi 언어의 생리이며, Pascal 파서를 설계하는 개발자는 외부 침입 경로를 차단하기 위해 다음 네 가지 수치 관리를 수동으로 꼼꼼히 챙겨 대응해야만 안전을 지킬 수 있습니다. 즉, 정수 연산 자료형 크기 통제, 배열 인덱스 유효성 수동 체크, 재귀 순회 최대 한계 깊이 가두기, 그리고 할당 버퍼 영역의 0 초기화 생활화입니다.
소개한 모든 취약점들은 Delphi 및 C++Builder용 라이브러리 엔진인 PDFlibPas의 최신 릴리스 버전에서 완벽히 패치되었습니다. 파일이 내세우는 암호 방어 성능의 기술적 실체를 분석하고 싶다면, 암호 및 접근 권한 분석 기법 문서와 PDF/A 및 PDF/UA 사전 유효성 검사 안내 가이드를 참고할 수 있으며, 이 모든 분석 기능은 Delphi PDF 로딩, 렌더링, 서명 API와 함께 PDFlibPas Delphi PDF Library 제품에 통합되어 제공됩니다.