xlsx 파일은 ZIP 아카이브이며, ZIP에는 단일한 권위 있는 목차가 없습니다. Delphi와 C++Builder용 HotXLS Excel Library는 이 모호함을 공격 표면으로 취급합니다. 이 라이브러리의 end of central directory 파서는 네 가지 독립적인 교차 검사가 모두 일치할 때만 후보 레코드를 받아들이므로, ZIP 주석에 숨겨진 위조 디렉터리는 절대 이기지 못합니다
이를 구체화하는 시나리오는 평범합니다. 서버가 고객으로부터 스프레드시트 업로드를 받습니다. 파일은 안티바이러스 검사를 통과하고 스풀 디렉터리에 기록되며, 여러분의 Delphi 서비스가 이를 열어 세 개의 열을 뽑아냅니다. 모든 것이 정상으로 보이지만, 스캐너와 여러분의 파서는 아카이브가 무엇을 담고 있는지에 대해 합의하지 못했습니다. 스캐너는 한 집합의 멤버를 열거했고, 여러분의 로더는 같은 바이트에서 다른 집합을 열거했습니다. 둘 다 일반적인 의미에서 버그가 있는 것은 아닙니다. 그들은 단지 ZIP 형식의 모호함을 서로 다른 두 방향으로 해소했을 뿐이며, 공격자는 바로 그렇게 되도록 바이트를 선택했습니다
ZIP 아카이브에 대한 진실은 실제로 어디에 있는가
그것은 맨 끝에, end of central directory 레코드라 불리는 22바이트 구조 안에 있습니다. ZIP 파일은 앞에서 뒤로 읽히지 않습니다. 모든 멤버는 압축된 데이터 바로 앞에 로컬 파일 헤더를 가지지만, 권위 있는 색인은 끝 근처에 있는 일련의 레코드인 중앙 디렉터리이며, 이는 모든 항목의 이름과 그 로컬 헤더의 오프셋을 알려줍니다. 중앙 디렉터리를 찾으려면 먼저 EOCD를 찾아야 하는데, EOCD가 디렉터리가 어디서 시작하고 몇 개의 레코드를 담고 있는지 말해주기 때문입니다. HotXLS는 이를 TEndOfCentralDirectoryRecord로 모델링하며, 필드들은 디스크상 레이아웃과 일대일로 대응합니다. 오프셋 4의 FDiskNumber, 6의 FStartDisk, 8의 FThisDiskEntries, 10의 FTotalEntries, 12의 FSizeOfCD, 16의 FOffsetOfStartCD, 20의 FCommentLen입니다. 그 합계가 생성자에서 4*3 + 5*2로 계산되는 FMinSize입니다. 그 뒤에는 최대 65535바이트의 임의 콘텐츠를 가질 수 있는 아카이브 주석이 오는데, 이는 FMaxSize를 65557로 만들며 이 레코드가 고정된 위치에 있지 않다는 뜻입니다. 찾으러 나서야 합니다
EOCD 서명을 뒤에서부터 스캔하는 것만으로는 왜 부족한가
여러분이 찾고 있는 네 바이트 PK\005\006는 아카이브 주석 안에, 압축된 데이터 안에, 또는 공격자가 일부러 덧붙인 두 번째 EOCD 안에 합법적으로 나타날 수 있기 때문입니다. 뒤에서부터 걸어가며 처음 만난 서명에서 멈추는 파서는 손쉽게 조종당합니다. 꼬리 근처에 미끼 EOCD를 배치하면 순진한 파서는 그것을 따라가고, 다른 순서로 스캔하거나 파일에서 마지막 서명을 정본으로 취급하는 파서는 진짜를 따라갑니다. 이것이 ZIP 모호성 공격군이며, 그 결과는 정확히 위에서 설명한 그 분열입니다. 스캔 엔진과 소비 애플리케이션이 하나의 파일에서 서로 다른 항목 집합을 보게 되는 것입니다
TEndOfCentralDirectoryRecord.Parse는 실제로 뒤에서부터 스캔합니다. startscan을 마지막 바이트로 설정하고, endscan을 lsize - FMaxSize나 0으로 클램프한 다음, 버퍼 경계에 걸친 서명이 절대 놓치지 않도록 3바이트씩 겹치는 256바이트 버퍼로 창을 걸어 이동합니다. 다른 점은 히트가 났을 때 일어나는 일입니다. 서명을 찾는 것은 Candidate 오프셋만 만들어낼 뿐입니다. HotXLS는 그다음 그 오프셋에서 22바이트를 읽고 ReadEOCD로 파싱한 다음, FOffsetEOCD가 배정되기 전에 결과 필드들이 그것들이 기술한다고 주장하는 파일과 내부적으로 일치하기를 요구합니다
Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
SetLength(RecordBuf, FMinSize);
inputstream.Position := Candidate;
if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
begin
ReadEOCD(RecordBuf[0], 0);
if (Candidate + FMinSize + FCommentLen = lsize) and
(FDiskNumber = 0) and (FStartDisk = 0) and
(FThisDiskEntries = FTotalEntries) and
(Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
begin
FOffsetEOCD := Candidate;
Result := FOffsetEOCD;
Exit;
end;
end;
end;
이 조건을 위조가 동시에 만족해야 하는 네 가지 개별 주장으로 읽어보십시오. Candidate + FMinSize + FCommentLen = lsize는 선언된 주석 길이가 정확히 파일 끝에 도달할 것을 요구하는데, 이것이 주석-내부-미끼 트릭을 무력화하는 부분입니다. 진짜 주석 안에 묻힌 가짜 EOCD는 그 자신 이후의 모든 바이트도 함께 설명할 수 없습니다. FDiskNumber = 0과 FStartDisk = 0은 어떤 xlsx도 정당하게 사용한 적 없고 조작된 아카이브에만 혼란을 주려고 존재하는 멀티디스크 분산 필드를 거부합니다. FThisDiskEntries = FTotalEntries는 한 파서는 한 필드에서, 다른 파서는 다른 필드에서 루프 크기를 정하게 만드는 분할 개수 트릭을 거부합니다. 그리고 Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate는 중앙 디렉터리가 정확히 EOCD가 시작하는 지점에서 끝날 것을 요구하므로, 디렉터리가 파일의 다른 곳에 있는 무관한 덩어리를 가리키게 만들 수 없습니다. 마지막 것의 Int64 캐스팅이 중요합니다. 두 피연산자 모두 32비트이며, 폭을 넓히지 않으면 조작된 쌍이 산술적으로 래핑되어 아무 데도 가리키지 않으면서도 검사를 통과할 수 있습니다
로컬 헤더는 중앙 디렉터리와 일치해야 한다
EOCD 검사는 어느 디렉터리가 권위 있는지 고정할 뿐, 아직 그 디렉터리가 개별 멤버에 대해 진실을 말하고 있는지는 보장하지 않습니다. ZIP 파일에서 모든 항목은 중앙에 한 번, 로컬 헤더에 한 번, 두 번 기술되며, 형식의 어떤 것도 두 기술이 일치하도록 강제하지 않습니다. 그래서 중앙 디렉터리를 신뢰하는 리더와 로컬 헤더를 신뢰하는 리더는 하나의 아카이브에서 다른 콘텐츠를 추출할 수 있습니다. TZipEntry.ParseLocalHeader는 FCdFile.LocalFileHeaderOffset에서 로컬 헤더를 파싱하고 두 사본을 필드별로 비교함으로써 이 틈을 닫습니다. 각 종류의 불일치에 대해 서로 다른 음수 코드를 반환합니다. 정규화된 항목 이름, 압축 방식, 일반 목적 비트 플래그, 그리고 데이터 디스크립터 플래그가 꺼져 있을 때는 CRC32와 두 크기입니다. 그 플래그가 켜져 있으면 진짜 값은 후행 디스크립터에 있으므로 로컬 사본은 0일 수 있지만, 0이 아닌 로컬 값은 여전히 일치해야 합니다. 마지막 검사는 파일 끝을 넘어서 실행될 데이터를 가진 항목을 거부하는데, Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize)를 inputstream.Size와 비교합니다. 실패는 TCentralDirectory.Parse에서 1이 아닌 결과로 전파되고, TZipArchive.OpenArchive는 반쯤 신뢰된 아카이브 객체를 넘겨주는 대신 이를 Can't open zip archive로 바꿉니다. 파일이 어떤 시트를 담고 있는지만 알면 될 때, 전체 파싱 전에 이 검증을 실행하는 비용은 저렴하며, 경량 시트 검사 경로는 셀 데이터를 실체화하지 않고 정확히 그것을 제공합니다
바이트 자체가 거짓말할 때는 어떻게 되는가
구조적 일치는 여전히 페이로드에 대해서는 아무것도 말해주지 않으므로, HotXLS는 모든 항목 스트림을 TZipVerifiedStream으로 감싸서 호출자가 읽는 동안 선언된 크기와 CRC32를 강제합니다. 이는 일부러 사후 검사가 아닙니다. 선언된 압축 해제 크기가 4KB이지만 실제로는 기가바이트로 팽창하는 압축 폭탄은 피해가 발생한 뒤가 아니라 4KB 지점에서 멈춥니다. 이 래퍼는 각 읽기를 남은 선언 바이트로 클램프하고, 소스가 일찍 고갈되면 ZIP entry ended before its declared size를 발생시키며, 완료 시 추가로 1바이트를 프로브해서 무언가 남아 있으면 ZIP entry exceeds its declared size를 발생시키고, 마지막으로 VerifyComplete에서 진행 중인 CRC32를 비교해서 ZIP entry uncompressed size mismatch나 ZIP entry CRC32 mismatch를 발생시킵니다
if Count > 0 then
begin
Result := FSource.Read(Buffer, Count);
if Result <= 0 then
raise Exception.Create('ZIP entry ended before its declared size');
FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
Inc(FPosition, Result);
end
else
Result := 0;
if FPosition = FExpectedSize then
begin
if FSource.Read(Probe, 1) <> 0 then
raise Exception.Create('ZIP entry exceeds its declared size');
VerifyComplete;
end;
계획해둘 만한 결과가 하나 있습니다. 이 스트림은 설계상 앞으로만 진행하며, 현재 위치가 아닌 다른 곳으로의 Seek는 ZIP entry stream is forward-only를 발생시킵니다. 다만 오프셋 0의 soEnd에 대해서는 크기 조회가 계속 작동하도록 예외를 하나 둡니다. 이것이 신뢰할 수 없는 입력에 대한 올바른 트레이드오프입니다. 되감을 수 있는 스트림은 CRC 회계를 무력화할 수 있는 스트림이기도 하기 때문입니다. 다만 이는 탐색 가능한 스트림을 기대하는 소비자 코드가 자기만의 버퍼를 필요로 한다는 뜻이기도 합니다. 같은 앞으로만 진행하는 원칙이 스트리밍 다이렉트 리더의 기반이며, 업로드된 워크북이 메모리에 전혀 상주시키고 싶지 않을 만큼 클 때 사용할 API입니다
할당 이후가 아니라 이전에 걸리는 리소스 한계
lxZipArchive의 상수 세 개가 단일 아카이브가 프로세스에게 요청할 수 있는 작업을 제한하며, TZipEntries.Add는 중앙 디렉터리가 아직 읽히는 동안, 즉 항목 데이터가 단 한 바이트도 건드려지기 전에 이를 적용합니다. ZipMaxEntryUncompressedSize는 단일 멤버를 1GiB로 제한하고, ZipMaxTotalUncompressedSize는 아카이브를 4GiB로 제한하며, 10000인 ZipMaxCompressionRatio는 선언된 팽창률이 만 배를 넘는 deflate된 항목을 거부합니다. 압축 크기가 0인데 압축 해제 크기가 0이 아닌 퇴화된 경우도 함께 거부합니다. 항목 이름은 같은 호출 안에서 CanonicalZipEntryName을 거치는데, 이는 내장된 NUL 문자, 콜론, .. 경로 세그먼트를 Invalid ZIP entry name으로 거부하고, 대소문자만 다르거나 중복된 구분자만 다른 두 멤버가 조용히 서로를 가리는 대신 Duplicate ZIP entry name으로 충돌하도록 세그먼트를 소문자화하고 정규화합니다
ZIP 계층 위의 심층 방어
ZIP 계층은 여러 단계 중 하나이며, 이 패턴은 HotXLS가 공격자가 통제하는 구조를 파싱하는 곳이면 어디든 반복됩니다. 가장 명확한 예는 BIFF 수식 파서에 있습니다. TXLSFormula.GetTranslated는 tMemFunc 토큰을 재귀적으로 순회하므로, 레거시 .xls 안의 조작된 rgce 토큰 스트림은 임의로 깊게 중첩되어 스택을 고갈시킬 수 있습니다. 그 게이트는 상수인 MaxTranslateDepth = 256이며, 추측이 아니라 알려진 상류 사실에 근거해 선택되었습니다. Excel은 수식 중첩을 64로 제한하므로 256은 네 배의 여유를 남기며, 실제 스프레드시트가 만들어낸 수식을 절대 거부할 수 없으면서도 악의적인 스트림을 스택이 고갈되기 훨씬 전에 종료시킬 수 있습니다
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
이 게이트가 예외를 발생시키는 대신 nil을 반환한다는 점에 유의하십시오. 진짜라고 하기에는 너무 깊은 수식은 구문 트리를 내지 못하고, 주변 파싱은 계속되며, 워크북은 여전히 로드됩니다. 이 비대칭성은 의도적이며 여러분 자신의 한계에도 그대로 베낄 가치가 있습니다. 리소스 고갈을 막기 위해 존재하는 상한은 자신이 만들 수 있는 가장 작은 단위를 저하시켜야지, 문서를 통째로 중단시켜서는 안 됩니다. 계산 계층을 확장할 때도 같은 논리가 적용됩니다. 수식 엔진 커스텀 함수 API를 통해 자체 핸들러를 등록한다면, 호출자가 이미 검사했다고 가정하는 대신 여러분만의 인자와 재귀 한계를 부여하십시오
이 검사들이 보장해주지 않는 것
경계에 대해 정확히 짚어두겠습니다. 네 가지 EOCD 교차 검사는 아카이브 색인을 모호하지 않게 만들어서, HotXLS와 그 밖의 준수하는 어떤 리더도 같은 파일을 같은 항목 집합으로 해석하게 합니다. 그 항목 집합이 무해한지에 대해서는 아무것도 말해주지 않습니다. 로컬 헤더 일치는 두 개의 시각(two-views) 트릭을 막을 뿐, 일관되게 기술된 악의적인 페이로드는 막지 못합니다. 검증된 스트림은 잘림, 오버플로, 손상을 막을 뿐, 여러분이 예상하지 못한 무언가를 인코딩한 완벽하게 형식이 맞는 XML 파트는 막지 못합니다. 그리고 이 중 어느 것도 매크로를 건드리지 않습니다. 구조적으로 흠잡을 데 없는 워크북 안의 VBA 프로젝트는 여전히 VBA 프로젝트이며, 이를 유지할지, 제거할지, 거부할지 결정하는 것은 ZIP 리더가 아니라 여러분의 정책 계층의 몫입니다
그 대가로 얻는 것은 깔끔한 실패 경계입니다. 신뢰할 수 없는 xlsx는 모호하지 않은 하나의 아카이브로 열리며, 그 멤버들은 선언된 크기와 체크섬과 일치하거나, 아니면 위반한 구체적인 불변조건을 명시하는 메시지와 함께 예외를 발생시킵니다. 여러분의 서비스는 추측하는 대신 그 예외를 근거로 격리할 수 있습니다. ZIP 리더와 그 위의 파서 계층은 Delphi와 C++Builder용 HotXLS Excel 컴포넌트의 일부로 제공되며, 이 컴포넌트는 파싱을 수행하는 머신에 Excel도 OLE 자동화도 필요로 하지 않습니다. 그 부재 자체가 업로드된 파일이 도달할 수 있는 범위를 의미 있게 줄여줍니다