기술 문서

악성 파일로부터 Pascal PDF 파서 강화하기

PDF는 단순히 열어서 확인하는 읽기 전용 문서가 아닙니다. 가상 머신 상에서 실행되는 작은 컴퓨터 프로그램과 유사합니다. 임베디드 폰트는 문자 서식을 그리기 위해 실행 대기 중인 스택 기반 인터프리터이며, 이미지는 외부 파일이 전달한 가로세로 규격 및 색농도 매개변수를 기반으로 메모리를 할당받는 디코더이고, 압축 데이터 스트림 역시 조작된 압축 필터 정보에 싸여 유입됩니다. 이 데이터 수치값 중 어느 것도 개발자가 직접 설계한 것이 아닙니다. 고객사의 청구서 파일이나 출처를 알 수 없는 첨부 파일 등 외부 발송인이 임의로 주입한 바이트 수치들입니다. 이러한 로우 바이트 데이터를 픽셀 화면이나 글꼴 글리프로 변환하는 디코더가 가장 취약한 공격 표면(attack surface)이며, 이 정보를 무비판적으로 신뢰하는 파서는 단 한 장의 불량 조작 문서를 만나 크래시되거나 보안 경계가 무너지는 위협에 처합니다

PDF Library for Delphi는 폰트 프로그램(TrueType, Type1, CFF, CMap 테이블), 이미지 디코더(PNG, GIF, TIFF, JBIG2, CCITT 그룹 3 및 그룹 4), 스트림 필터(LZW, ASCII85, Flate 프리딕터) 전체에 걸쳐 디코드 경로를 적대적인 것으로 취급합니다. 이어지는 내용은 그 경로가 막아야 하는 다섯 가지 메모리 안전성 함정으로, 각각은 그 함정을 가능하게 만드는 Delphi 고유의 동작에 근거합니다. 그리고 같은 모양은 신뢰할 수 없는 입력을 파싱하는 어떤 Pascal 코드에서도 되살아납니다

PDF Library for Delphi: 폰트 인터프리터, 이미지 디코더, 스트림 필터로 데이터를 공급하는 Pascal PDF 파서의 공격 표면 맵, 다섯 가지 가드된 함정 클래스를 향해
너비, 깊이, 오프셋, 길이, 필터 플래그는 모두 생산자에게서 오므로, 폰트, 이미지, 필터 디코더가 파서가 적대적으로 취급해야 하는 공격 표면이 됩니다

메모리가 턱없이 부족한 버퍼를 배정받게 만드는 정수 오버플로

이미지 디코더에서 벌어지는 대표적인 메모리 오염 원인은, 차원 치수의 곱셈 값이 정수 상한을 초과하여 오버플로(wrap)되는 현상입니다. 디코더 엔진은 가로, 세로, 컴포넌트 수, 그리고 픽셀 색상 심도 정보 수치를 받아서 전부 곱한 값으로 버퍼 크기를 할당받고, 그 영역에 픽셀 데이터를 써서 이미지를 만듭니다. 이 곱셈 수식 계산이 32비트 기본 정수 연산 범위 내에서 처리되면, 각 개별 치수들은 정상 임계값 내에 존재하더라도 최종 곱한 결과가 32비트 최대치를 초과하여 랩어라운드되어 아주 작은 정수값으로 왜곡될 수 있습니다. 그 결과 버퍼 크기 할당 자체는 정상 작동하는 것처럼 보여도 터무니없이 작게 쪼개진 공간이 배정되며, 픽셀 쓰기가 시작되는 즉시 할당 영역 한계를 짓밟으며 넘치게 됩니다. 이것이 정수 오버플로(CWE-190) 취약점이며, 즉각적으로 힙 버퍼 영역 범위를 벗어난 메모리 쓰기(CWE-787) 오류로 번집니다

공통 이미지 경로에서 각 차원을 65535로 클램프하는 것만으로는, 독립 디코더가 자체 크기를 계산할 때 부족합니다. ByteCount * FHeight 같은 행 바이트 × 높이 식이나 FWidth * Components * BitDepth 같은 픽셀당 식은, 결과를 담을 변수가 아무리 넓어도 두 피연산자가 모두 32비트 정수면 Delphi에서 32비트 곱셈이 됩니다. 60000의 너비와 높이는 각각 큰 스캔 이미지로 그럴듯하지만, 바이트 단위 곱은 부호 있는 32비트 범위를 넘어서고 길이는 작게 나옵니다. BitsPerComponent * Colors * Columns 같은 프리딕터 스트라이드에도 같은 함정이 앉아 있습니다

방어책은 피연산자 중 최소 하나를 Int64로 만들어 식 전체가 64비트에서 평가되게 하고, MaxInt와 비교한 다음, SetLength를 호출하러 좁혀 들어가기 전에 파일을 거부하는 것입니다

PDF Library for Delphi: 나란히 비교로 오버플로된 32비트 차원 곱이 과소 할당과 범위 밖 쓰기를 일으키는 경우와, Int64 확장 검사가 파일을 거부하는 경우
바이트 수 곱을 Int64로 평가하면 파서가 MaxInt와 비교하여 SetLength가 거짓 버퍼를 할당하기 전에 적대적 차원을 거부할 수 있습니다
// 기록한 뒤가 아니라 할당하기 전에 거부합니다.
// 32비트에서 오버플로되지 않도록 곱셈을 Int64로 계산합니다.
RowBytes := (Int64(FWidth) * Components * BitDepth + 7) div 8;
if (RowBytes <= 0) or (RowBytes * FHeight > MaxInt) then
  Exit;  // 악의적이거나 지원되지 않는 크기; 이미지를 거부합니다
SetLength(Buffer, RowBytes * FHeight);

이 취약점이 특히 Delphi 개발 환경에서 함정이 되는 이유는 묵시적 잘림(silent narrowing) 현상 때문입니다. 컴파일러는 범위 범위를 초과할 위험이 있는 정수 곱셈을 32비트 레지스터 변수에 대입하는 법적 코드 변환 시 경고 메시지를 노출하지 않으며, 범위 제한 검사(Range Checking) 역시 데이터 참조 전에는 오버플로 발생 여부를 감지하지 못합니다. 곱셈 단계를 32비트로 방관하면, 실제 데이터 기록 주기 시작 시 엉뚱한 쓰기 공간의 크기를 유효 크기로 판단해 버려 크래시를 자초하게 됩니다

한계 조건문 도달을 원천 차단해 버리는 비좁은 변수 자료형

TIFF 파일 포맷은 다음 디렉토리 물리 주소를 수반해 체인 연결되는 구조(Image File Directory, IFD)를 씁니다. 악성 해커는 이 인덱스 체인 루프 끝단을 자기 자신을 가리키는 순환 구조로 왜곡시킬 수 있으며, 한계 루프 회수 제한 조건이 없는 리더기는 조건 없이 무한 루프에 빠집니다. 이것이 사용자 변조 입력을 기반으로 가동되는 무한 루프 오류(CWE-835)이며, 일반적인 방지책은 정상 파일에서는 결코 도달하지 않을 일정 수준 이상의 반복 회수 도달 시 루프를 탈출할 카운터 계측기를 다는 것입니다

이제 페이지 카운터를 Delphi에서 0부터 65535까지 담는 Word로 선언하고, 루프에 "페이지 수가 65535를 넘으면 중단" 형태의 종료 가드를 달아 보세요. 피연산자와 임계값이 상한을 공유한다는 사실을 눈치채기 전까지는 올바르게 읽힙니다. Word는 65535보다 커질 수 없으므로 비교는 구조적으로 항상 거짓입니다. 카운터가 65535에 도달하면 다음 증가가 0으로 되감기고, 가드는 한계 위의 값을 한 번도 보지 못하며, 순환하는 IFD 체인이 리더를 계속 돌립니다

치료법은 필드를 넓혀 가드가 카운터가 실제로 담을 수 있는 값을 표현할 수 있게 하는 것입니다. TPDFTIFF.FPageCount는 Integer로 선언되어 있으므로 FPageCount > 65535 비교는 도달 가능하고 루프는 종료됩니다. 경계 검사가 Value > MaxValueOfType(Value) 모양이고 피연산자가 이미 정확히 그 최댓값으로 타이핑되어 있다면, 그 조건은 상수 거짓입니다. 타입을 넓히거나, 발동할 수 있도록 최댓값과의 동등 비교로 검사하세요

동작 가속화 경로에서 차단 해제되어 버린 범위 한계 검사

범위 한계 검사(Range Checking)가 켜져 있으면, Delphi는 컴파일 시점에 스트링이나 어레이 배열의 인덱스 한계를 검사하는 코드를 자동 삽입하여, 인덱스가 한계 범위를 벗어날 경우 메모리 오염으로 번지지 않도록 ERangeError 예외 처리를 유발해 제어권을 지킵니다. 하지만 빈번하게 사용되는 가속화 성능 경로(Hot path)에서는 {$R-} 지시문을 써서 성능상의 이유로 이 체크 동작을 강제 해제해 버리곤 합니다. 그러나 인덱스로 전송될 인수가 해커에 의해 조작되는 즉시 엄청난 취약점으로 돌변합니다

폰트 인터프리터가 의존하는 리스트 접근자 TPDFlibStringList.Get이 바로 그런 경로입니다. Windows에서는 범위 검사를 끈 채 컴파일되고 백킹 스토어를 직접 인덱싱하므로, 범위 밖 인덱스는 오류가 아니라 날 메모리 접근이 됩니다. 인덱스가 항상 유효할 때는 문제없지만, 인덱스가 파일에서 올 수 있는 CFF나 Type2 charstring 인터프리터 안에서는 세심함을 요구합니다. 빈 스택에서 피연산자를 pop하는 charstring은 -1 인덱스를 만들어 내고, 글리프 수에 비해 하나 어긋난 글리프 식별자는 끝 바로 다음 슬롯을 인덱싱합니다. 검사하지 않고 놔두면 둘 다 잡을 수 있는 예외가 아니라 진짜 범위 밖 접근이 되고, 슬롯이 참조 카운트된 AnsiString 값을 담고 있으므로 엉뚱한 읽기 하나가 문자열의 참조 카운트도 망가뜨릴 수 있습니다

PDF Library for Delphi는 그 핫 경로에서 범위 검사를 끈 채 두는 대신 인덱스가 증명 가능하게 유효하도록 만듭니다. 피연산자 스택의 꼭대기를 가져오기 전에 인터프리터가 스택이 비어 있지 않은지 확인하고, 모든 인덱스 가드는 off-by-one을 용납하는 이하가 아니라 개수에 대한 엄격한 미만입니다. 지시문은 경계에 대한 책임을 컴파일러에서 여러분에게로 옮기며, 그것이 걷어낸 검증은 모든 진입점에서 손으로 되돌려 넣어야 합니다

글꼴 서식 해석 루틴의 끝없는 재귀 함수 순회 결함

Type2 글꼴 정보 포맷은 내부 서브루틴을 호출할 수 있는 기능을 가집니다. 그리고 이 서브루틴은 내부에서 또 다른 서브루틴을 꼬리 물어 호출할 수 있어, 악의적인 파일은 시스템이 허용하는 스택 한계를 뛰어넘어 무한히 함수를 파고 내려가도록 순환 참조 서식을 구성해 둘 수 있습니다. 서브루틴이 자신을 다시 호출하거나 순환 궤도를 그리며 무제한 재귀 호출이 작동하면, 프로세스의 실제 스택 메모리가 고갈(Stack Overflow)되어 강제 사멸합니다. 이것이 통제되지 않은 재귀(CWE-674) 취약점입니다

Type1 인터프리터는 호출 깊이 카운터와 상한 PLType1MaxCallDepth로 이를 방어하고, 그 한계를 넘어 내려가기를 거부합니다. 이는 Type1 명세 자체가 이름 붙인 깊이 한계를 반영한 것입니다. 함정은 나중에 작성된 구조적으로 유사한 인터프리터, 이를테면 Type2 쪽이 그 가드를 저절로 물려받지는 않는다는 것이며, 자기 번호를 호출하는 서브루틴을 담은 손으로 만든 폰트는 빠진 검사를 곧장 통과해 스택 오버플로로 들어갑니다

// 두 charstring 인터프리터 모두에게 필요한 깊이 가드의 모양입니다.
// 중첩 호출 전반의 깊이를 추적하고 그 한계를 넘는 재귀를 거부합니다.
Inc(CallDepth);
if CallDepth > PLType1MaxCallDepth then
  Exit;  // 악의적인 자기 참조 서브루틴; 하강을 멈춥니다
// ... 서브루틴을 해석한 뒤, 빠져나오면서 Dec(CallDepth)를 수행합니다

PDF Library for Delphi는 Type2 경로에도 Type1 형제와 같은 유한 깊이를 부여합니다. 폰트 서브루틴이든, 중첩 배열이든, 크로스 레퍼런스 체인이든, 공격자 제어 구조 위의 재귀 하강에는 입력이 들어올릴 수 없는 깊이 천장이 필요합니다

출력 데이터 버퍼의 빈 여백을 채우는 미초기화 메모리 정보 유출

가장 미묘한 함정은 힙 내용을 복호화 출력으로 새는 것이고, 원인은 잊기 쉬운 SetLength의 한 가지 속성입니다. SetLength로 AnsiString을 늘리면 Delphi는 바이트를 할당하되 0으로 채우지 않으므로, 새 영역에는 그 힙 메모리에 이전에 있던 것이 그대로 들어 있습니다. 이후 모든 바이트를 쓴다면 아무 문제도 없습니다. 어떤 경로가 버퍼의 일부를 쓰지 않은 채 데이터로 반환하면, 그 오래된 바이트들이 결과에 실려 나갑니다. 이것이 CWE-457, 초기화되지 않은 메모리 사용이며, 결과가 신뢰 경계를 넘으면 정보 유출이 됩니다

AES-CBC 복호화 경로가 바로 이것이 물리는 지점입니다. 출력 버퍼를 SetLength로 잡고, 암호문을 한 번에 16바이트 블록씩 처리하는데, 암호문 길이가 16의 배수가 아니면, 공격자가 고를 수 있는 길이이므로, 뒤따르는 부분 블록은 쓰이지 않습니다. 그 마지막 바이트들은 SetLength가 남겨둔 힙 내용을 그대로 지니고, 버퍼는 문서 객체의 복호화된 평문으로 돌아갑니다. 해결책은 두 가드이며, 하나만으로는 충분하지 않습니다. PDF Library for Delphi의 복호화 진입점은 길이가 블록 크기의 배수가 아닌 암호문을 거부하고, 만일의 대비로 출력은 사용 전에 FillChar로 지워서, 영역 쓰기에 실패한 어떤 경로도 힙 잔여물이 아니라 0을 반환하게 합니다

PDF Library for Delphi 다이어그램: AES-CBC 복호화에서 항목 길이 검사와 FillChar 버퍼 클리어가 경로를 보호하지 않으면 종료되지 않은 후행 블록이 초기화되지 않은 힙 바이트를 남김
두 개의 독립적인 가드가 누출을 막습니다. 16바이트 경계에 맞지 않는 암호문은 거부하고, 출력을 미리 0으로 지워 누락된 쓰기조차 힙 잔여물 대신 0을 내놓게 합니다

다섯 함정의 공통점

다섯 함정은 서로 다른 버그지만 운율이 같습니다. 곱을 랩하는 정수 너비, 가드를 상수 거짓에 못 박는 필드 타입, 인덱스가 더 이상 안전하지 않은 곳에서 꺼진 범위 검사, 바닥 없는 재귀, 언어가 0으로 채기를 거부한 버퍼. 각각에서 Delphi는 자신이 정의한 것을 정확히 수행합니다. 이 언어는 랩하는 산술, 조용한 좁히기, 끌 수 있는 범위 검사, 내장 한계 없는 재귀, 초기화하지 않는 할당을 주기 때문입니다. 그것이 계약이며, Pascal 파서는 파일이 통제하는 모든 경계에서 네 가지를 손으로 소유함으로써 그 계약을 이행합니다. 정수 너비, 범위 검사, 재귀 깊이, 버퍼 초기화입니다

Delphi 및 C++Builder용 엔진인 PDF Library for Delphi는 디코드 경로를 다섯 함정 모두에서 방어합니다. 작업이 파일이 자신의 보호 방식을 주장하는 방법까지 닿는다면, 암호화 및 권한 감사 노트와 PDF/A 및 PDF/UA 프리플라이트 노트가 같은 파서의 분석 쪽을 다룹니다. 이 모든 것은 이 블로그의 다른 글에서 다루는 로딩, 렌더링, 서명 API와 함께 PDF Library for Delphi Delphi PDF Library 안에 들어 있으며 함께 제공됩니다