CVE-2024-11477 Hunting a 7-Zip Underflow Bug with Fuzzing
2024-11-27
Recently, I came across an underflow vulnerability in the open-source project 7-Zip, initially disclosed by the Zero Day Initiative (ZDI) in this advisory. The issue was resolved in version 7-Zip 24.07, and I decided it would be fun and educational to dig into this bug myself. My goal was to identify the vulnerability using fuzzing and debugging tools like AFL and GDB, gaining a deeper understanding of the flaw and its exploit potential.
Analyzing the Patch Diff
To start, I reviewed the patch changes on GitHub. With only 35 modified files in the patch, I had a manageable scope. Based on the advisory, I focused on Zstandard decompression, narrowing my search to files related to that algorithm.
I looked for common underflow patterns, such as integer casts between incompatible types or newly introduced bounds checks. Sure enough, in ZstdDec.c
, a promising snippet:
in->len--;
{
const Byte *ptr = in->ptr;
// ++ const unsigned sym = ptr[0];
in->ptr = ptr + 1;
// ++ if (sym >= numSymbolsMax)
// ++ return SZ_ERROR_DATA;
table[0] = (FastInt32)sym
#if defined(Z7_ZSTD_DEC_USE_ML_PLUS3)
+ (numSymbolsMax == NUM_ML_SYMBOLS ? MATCH_LEN_MIN : 0)
This stood out because it introduced bounds checking (if (sym >= numSymbolsMax)), aligning with the kind of fix you’d expect for an underflow issue. However, I was puzzled by the use of isolated {
blocks without accompanying if
, switch
, or function headers
— a syntax pattern I hadn’t seen before. After some research, I learned these are treated as standalone scopes, a quirk I hadn’t encountered.
To understand the exploitability of this function, I traced its call chain:
FSE_Decode_SeqTable
\_ ZstdDec1_DecodeBlock
\_ ZstdDec_DecodeBlock
// note: ZstdDec_DecodeBlock() uses (winLimit = winPos + size) only for RLE and RAW blocks
\_ ZstdDec_Decode
// note: ZstdDec_DecodeBlock() uses (winLimit = winPos + size) only for RLE and RAW blocks
This exercise clarified the function’s role and how an underflow might occur during Zstandard decompression.
Fuzzing with AFL
With a clearer picture of the issue, I decided to fuzz the affected code. First, I investigated how Zstandard files are structured to craft test cases that could trigger the vulnerability. However, due to the complexity of the compression algorithm, I opted to let AFL handle the heavy lifting.
I compiled the project using afl-gcc
and, following advice from Low Level’s YouTube video on this vulnerability, created input files using tar
. Then, I launched AFL with the following command:
afl-fuzz -i testcases/ -o output/ -- ./7za e @@
After letting it run for two hours, AFL yielded 16 crashes, 8 of which were saved for further analysis. Here’s a snapshot of the fuzzing session:
american fuzzy lop ++4.04c {default} (./7za) [fast]
┌─ process timing ───────────────────────────────────────┬─ overall results ────┐
│ run time : 0 days, 2 hrs, 1 min, 6 sec │ cycles done : 9 │
│ last new find : 0 days, 0 hrs, 19 min, 46 sec │ corpus count : 1855 │
│last saved crash : 0 days, 0 hrs, 57 min, 40 sec │saved crashes : 8 │
│ last saved hang : none seen yet │ saved hangs : 0 │
├─ cycle progress ───────────────────────┬─ map coverage┴────────────────────────┤
│ now processing : 263.229 (14.2%) │ map density : 0.03% / 0.09% │
│ runs timed out : 0 (0.00%) │ count coverage : 3.19 bits/tuple │
├─ stage progress ───────────────────────┼─ findings in depth ──────────────────┤
│ now trying : havoc │ favored items : 330 (17.79%) │
│ stage execs : 452/883 (51.19%) │ new edges on : 558 (30.08%) │
│ total execs : 4.54M │ total crashes : 16 (8 saved) │
│ exec speed : 768.6/sec │ total tmouts : 3 (0 saved) │
├─ fuzzing strategy yields ─────────────┴──────────────┬─ item geometry ────────┤
│ bit flips : disabled (default, enable with -D) │ levels : 26 │
│ byte flips : disabled (default, enable with -D) │ pending : 1359 │
│ arithmetics : disabled (default, enable with -D) │ pend fav : 1 │
│ known ints : disabled (default, enable with -D) │ own finds : 1854 │
│ dictionary : n/a │ imported : 0 │
│havoc/splice : 1244/2.62M, 618/1.64M │ stability : 84.74% │
│py/custom/rq : unused, unused, unused, unused ├─────────────────────────┘
│ trim/eff : 15.62%/265k, disabled │ [cpu000: 25%]
└─────────────────────────────────────────────────────────┘
With the fuzzing phase complete and crashes identified, the next step was to recreate and analyze these crashes using gdb. To start, I used the following command to triage the crashes:
afl-triage -i ./output/default/crashes -o reports ./7za e @@
AFLTriage v1.0.0 by Grant Hernandez
[+] GDB is working (GNU gdb (Debian 13.1-3) 13.1 - Python 3.11.2 (main, Sep 14 2024, 03:00:30) [GCC 12.2.0])
[+] Image triage cmdline: ./7za e @@
[+] Will write text reports to directory "reports"
[+] Triaging plain directory ./output/default/crashes (9 files)
[+] Triage timeout set to 60000ms
[+] Profiling target...
[+] Target profile: time=12.43748ms, mem=1KB
[+] Debugged profile: t=530.015725ms (44.17x), mem=37004KB (37004.00x)
[+] System memory available: 3881664 KB
[+] System cores available: 16
[+] Triaging 9 testcases
[+] Using 9 threads to triage
[+] Triaging [9/9 00:00:00] [####################] CRASH detected in 0x000055555567f9df due to a fault at or near 0x0000555555fc3000 leading to SIGSEGV (si_signo=11) / SEGV_MAPERR (si_code=1)
[+] Triage stats [Crashes: 8 (unique 5), No crash: 1, Timeout: 0, Errored: 0]
This output confirms that afl-triage
detected 8 crashes, of which 5 were unique. The crashes predominantly resulted from segmentation faults (SIGSEGV) caused by invalid memory accesses (SEGV_MAPERR). These findings provided a solid starting point for deeper analysis.Next, I loaded one of the crash files into gdb to investigate further. The command used was:
gdb --args ../7za e id:000000,sig:11,src:001137,time:2138227,execs:1537041,op:havoc,rep:4
Once loaded, running the program reproduced the crash, revealing the following stack trace:
[#0] 0x55555567dab3 → CopyLiterals(rem=<optimized out>, len=<optimized out>, src=<optimized out>, dest=<optimized out>)
[#1] 0x55555567dab3 → Decompress_Sequences(p=0x555555d9ea08, src=0x555555e4233e "", srcLen=<optimized out>, winLimit=0x20000, vars=<optimized out>)
[#2] 0x555555683dbc → ZstdDec1_DecodeBlock(p=0x555555d9ea08, src=<optimized out>, inSize=<optimized out>, afterAvail=<optimized out>, outLimit=0x20000)
[#3] 0x55555568736f → ZstdDec_DecodeBlock(winLimitAdd=<optimized out>, ds=0x555555d9b040, p=<optimized out>)
[#4] 0x55555568736f → ZstdDec_Decode(dec=0x555555d9e980, p=0x555555d9b040)
[#5] 0x5555557d1e9c → NCompress::NZstd::CDecoder::Code(progress=0x555555d8ac90, outSize=0x0, inSize=0x0, outStream=0x555555d877f0, inStream=0x555555d9ae70, this=0x555555d9afe0)
[#6] 0x5555557d1e9c → NCompress::NZstd::CDecoder::Code(this=0x555555d9afe0, inStream=0x555555d9ae70, outStream=0x555555d877f0, inSize=0x0, outSize=0x0, progress=0x555555d8ac90)
[#7] 0x5555558bd57e → NArchive::NZstd::CHandler::Extract(this=0x555555d9aee0, indices=<optimized out>, numItems=<optimized out>, testMode=<optimized out>, extractCallback=0x555555d85f10)
[#8] 0x555555c1e17a → OpenArchiveSpec(stream=0x555555d5e690 <vtable for NArchive::NZstd::CHandler+16>, maxCheckStartPosition=0x555555e42341, openCallback=<optimized out>, extractCallback=0x555555d85f10, needPhySize=<optimized out>, archive=0x555555d9aee0)
[#9] 0x555555c1e17a → OpenArchiveSpec(archive=0x555555d9aee0, needPhySize=0x1, stream=0x555555d9ae70, maxCheckStartPosition=0x7fffffffcdc8, openCallback=<optimized out>, extractCallback=0x555555d85f10)
It seems we’ve zeroed in on the problematic code we were hoping to identify. Specifically, the crash occurs within the COPY_CHUNKS
macro, defined as follows:
#define COPY_CHUNKS { \
Z7_PRAGMA_OPT_DISABLE_LOOP_UNROLL_VECTORIZE \
do { COPY_CHUNK(dest, src) } \
while (len -= COPY_CHUNK_SIZE); \
}
static Z7_FORCE_INLINE void CopyLiterals(Byte *dest, Byte const *src, size_t len, size_t rem) {
COPY_PREPARE
COPY_CHUNKS // <-- Our Crash
}
The crash aligns with the disassembled instructions seen in GDB:
→ 0x55555567dab3 <Decompress_Sequences.isra.0+0e93> mov QWORD PTR [rax-0x80], rdx
Here, the program attempts to move the value in rdx into the memory address pointed to by rax-0x80. Unfortunately, in this instance, rax-0x80
doesn’t correspond to a valid memory location, triggering the crash. To understand the crash better, I examined the memory layout with GDB’s vmmap:
gef➤ vmmap 0x555555EE3000
[ Legend: Code | Heap | Stack ]
Start End Offset Perm Path
At first glance, this address is curious—it doesn’t map to anything obvious. Delving deeper, I looked at the heap’s memory boundaries:
vmmap heap
[ Legend: Code | Heap | Stack ]
Start End Offset Perm Path
0x0000555555d63000 0x0000555555d71000 0x0000000000000000 rw- [heap]
0x0000555555d71000 0x0000555555ee3000 0x0000000000000000 rw- [heap]
The crash address falls just outside the heap’s allocated boundaries, strongly suggesting an underflow condition.
Takeaway
Fuzzing with AFL proved invaluable in uncovering this heap underflow in 7-Zip’s Zstandard decompression, demonstrating how malformed inputs can reveal subtle vulnerabilities. By combining fuzzing results with source code diff analysis, we quickly pinpointed the problematic COPY_CHUNKS macro and traced its unsafe pointer arithmetic in GDB. Tools like vmmap helped confirm the heap boundary violation, validating the crash as a classic underflow issue. There’s more room to explore, including refining fuzzing inputs, analyzing the exploitability of this flaw, and tracing other code paths for potential vulnerabilities. This exercise underscores the power of fuzzing and debugging in finding critical flaws and hints at further discoveries lurking in plain sight.