7-Zip opens far more than .7z and .zip. It recognizes dozens of container and filesystem formats by content, ext2/3/4 disk images among them, and it parses one the moment you list or extract it. We found a heap out-of-bounds read in that ext handler: a single attacker-controlled field in a crafted image’s superblock walks the inode-bitmap scan off the end of its buffer. Opening the file is enough.
IMPORTANT
Who’s exposed. You’re affected if you use 7-Zip 26.01 or earlier to open, or even just list, a file an attacker controls: a downloaded image, a mail attachment, anything recognized as an ext2/3/4 filesystem by its content rather than its name. Extracting a specific entry is not required; the parse runs during l (list) as well as x (extract). One malformed image crashes the process. The over-read is read-only and never reaches output, so the impact is denial of service, not disclosure. Fixed in 7-Zip 26.02.
The bug, traced in CHandler::Open2
When 7-Zip identifies a file as ext2/3/4, it scans the inode bitmap to decide which inodes are in use. The scan is driven by numNodes, taken from the superblock’s inode count, and it indexes a fixed-size bitmap buffer, nodesMap, allocated at exactly block_size bytes (so block_size * 8 bits).
The validation that exists bounds numNodes against the inode count, never against the bitmap buffer’s capacity:
// ExtHandler.cpp: CHandler::Open2 (traced at git 8c63d71)
numNodes = InodesPerGroup; // from s_inodes_per_group (superblock @ +0x28)
if (numNodes > NumInodes) numNodes = NumInodes; // clamp vs inode COUNT, not buffer capacity
...
blockSize = 1 << BlockBits; // 1024 .. 65536 bytes
nodesMap.Alloc(blockSize); // bitmap buffer: blockSize bytes = blockSize*8 bits
SeekAndRead(..., nodesMap, blockSize); // only blockSize bytes are ever read in
for (n = 0; n < numNodes && globalNodeIndex < NumInodes; n++) {
if ((nodesMap[n >> 3] & (1 << (n & 7))) == 0) // n>>3 runs up to numNodes/8, past blockSize
continue;
const Byte *p = nodesData + n * InodeSize; // sibling buffer: sized numNodes*InodeSize, in bounds
...
}
CByteBuffer::operator[] is an unchecked raw index, so nodesMap[n >> 3] for any n >= blockSize * 8 reads (n >> 3) - blockSize bytes past the allocation. With a single block group, s_inodes_per_group equals the whole image’s inode count, so that one attacker-controlled superblock field is the entire control over how far the over-read runs.
The tell is the sibling buffer three lines down. nodesData is allocated numNodes * InodeSize behind an explicit multiply-overflow check, so the inode parse at n * InodeSize stays in bounds. The same loop counter indexes two buffers sized by two different quantities, and only one of them was bounded for. The inode table got the check. The bitmap that gates it did not.
What we observed
The proof image is a single-block-group ext2 filesystem with 1 KiB blocks, so nodesMap is 1024 bytes, or 8192 bits. Setting s_inodes_per_group to 10000 pushes numNodes past 8192, and the bitmap index runs off the end on the very first pass:
==ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 1 at 0x... (0 bytes after the 1024-byte nodesMap region)
#0 CHandler::Open2 ExtHandler.cpp:1267
The same crash fires on both 7zz l and 7zz x, because Open2 runs for each. The negative control, the identical image with an inode count of 8000 that fits inside the 8192-bit bitmap, parses clean. On a non-sanitized build, a multi-million inode count over a sparse image drives the over-read across an unmapped page and the process takes a hard SIGSEGV.
Impact
The out-of-bounds byte gates a continue: it only decides whether a given inode slot is treated as allocated, and its contents never flow into the extracted output. There is no path from this bug to reading adjacent heap memory back out. What an attacker gets is a controllable-length invalid read and a process crash. For a tool routinely pointed at untrusted archives, in mail gateways, malware pipelines, upload scanners, and file managers with preview, that is a reliable denial of service on the parse of a single file.
The scoring reflects that shape: CVSS 3.1 AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H, 6.5 (Moderate). The file can arrive over the network, but it needs a user or an automated pipeline to open or list it.
Remediation
If you maintain the code. Clamp numNodes to the bitmap capacity before the scan, immediately after the existing clamp:
if (numNodes > (UInt32)blockSize * 8)
numNodes = (UInt32)blockSize * 8;
blockSize is computed a few lines below the current clamp, so hoist it above, or inline ((size_t)1 << BlockBits) * 8. This has been fixed in 7-Zip 26.02.
If you operate it. Upgrade to 7-Zip 26.02 or later. 7-Zip ships as source and binaries with no per-format switch to disable a single handler, so until you upgrade the mitigation is procedural: do not hand untrusted disk images to an older 7-Zip, and in automated pipelines pin the 7-Zip version and gate on file type before extraction. The image is recognized as ext by its content, so renaming it does not change whether it is parsed.
Disclosure
- Software: 7-Zip 26.01 and earlier (ext2/3/4 handler,
ExtHandler.cpp); reproduced at git8c63d71. - Class: CWE-125, heap out-of-bounds read.
- Severity: Moderate, CVSS 6.5.
- CVE: pending.
- Advisory: the fix shipped in 7-Zip 26.02 (2026-06-25), whose changelog notes that vulnerabilities were fixed without naming them, per 7-Zip’s convention.
- Fix: 26.02. Reported to the 7-Zip maintainer (Igor Pavlov).
- Reported by: Cipher / Causal Security.
All testing was against a self-contained lab image we generated; the triggering file is synthetic.
Why we look at the sibling buffer
When a length is validated once and then reused, the check is written with one consumer in mind. Here numNodes is a count of inodes, and read as a count of inodes it is validated correctly: it is clamped to the number of inodes that exist, and the inode table it indexes is sized to match. The bug is that the same counter also indexes a second buffer, the inode bitmap, whose capacity is set by an unrelated quantity, the block size, and nothing re-checked the bound against that smaller buffer. We call it being bounded against the wrong buffer: when one index walks two allocations, the bound has to hold for the smaller of them, not the one the variable happens to be named after. The method is not subtle, and that is the point. Find every buffer a loop counter touches, ask which is smallest, and check whether the bound was written for it.
This work is part of our ongoing vulnerability research into the file-parsing and archive code that everyone else builds on.