The Exploit
An unauthenticated attacker with access to create or edit a Contact Form 7 payment form can inject arbitrary HTML and JavaScript into the gateway form field. When a WordPress admin visits the Payments admin panel to review payment records, the stored payload executes in the admin's browser context.
Step 1: Inject the XSS payload into a payment gateway field
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
action=cf7rl_save_payment_form&gateway=<img src=x f=new FormData();f.append('action','update');f.append('user_login','hacked');fetch('/wp-admin/admin-ajax.php',{method:'POST',body:f})})">
Step 2: Wait for an admin to view the Payments list table
When an administrator navigates to the Business Essentials for Contact Form 7 Payments admin page, the injected gateway value is rendered directly into the column output without HTML escaping. The <img> tag triggers immediately, executing JavaScript in the admin's session context.
The attacker observes a 200 response and sees their malicious form field accepted. When the admin views the Payments list, no visual anomaly appears—the img tag fails silently—but the onerror handler fires, allowing the attacker to exfiltrate admin session tokens, modify user accounts, or inject backdoors.
Why this still matters at admin
Although exploitation requires admin page access, the realistic threat model is immediate: the attacker doesn't need admin credentials themselves. A compromised admin session (via session theft, malicious plugin, or SaaS multi-tenant account takeover) executes the stored XSS in the context of an authenticated admin user. Alternatively, an attacker with Shop Manager privileges on a multi-site install can poison payment records viewed by site administrators. The attack is silent and persistent — the malicious gateway field remains in the database until manually removed, executing against every admin who visits the Payments page.
What the Patch Did
Before
echo strtolower($gateway) == 'paypal' ? 'PayPal' : ucfirst($gateway);
echo get_post_meta($post_id, 'transaction_id', true);
echo get_post_status($post_id);
echo get_post_meta($post_id, 'amount', true);
echo cf7rl_get_payment_status_label($status);
After
echo esc_html(strtolower($gateway) == 'paypal' ? 'PayPal' : ucfirst($gateway));
echo esc_html(get_post_meta($post_id, 'transaction_id', true));
echo esc_html(get_post_status($post_id));
echo esc_html(get_post_meta($post_id, 'amount', true));
echo esc_html(cf7rl_get_payment_status_label($status));
The patch applied WordPress's esc_html() function to all dynamic output in the cf7rl_custom_edit_payments_columns_data() function. esc_html() is WordPress's standard output-escaping API for HTML context — it converts HTML metacharacters (<, >, &, ", ') into entity references, preventing the browser from interpreting attacker-injected markup as code. Every variable sourced from get_post_meta(), get_post_status(), and custom status label functions is now escaped before rendering in the admin table columns.
Root Cause
CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
The dataflow begins when an attacker submits a payment form with a malicious gateway parameter value (e.g., <img src=x>). This value is stored in post metadata via get_post_meta($post_id, 'gateway', true) without sanitization in the form submission handler. When an admin later views the Payments list table in /wp-admin/, the cf7rl_custom_edit_payments_columns_data() function retrieves the gateway value and echoes it directly into HTML via echo ucfirst($gateway). The browser trusts the admin page's content-type header and parses the output as HTML, executing any embedded JavaScript. The trust boundary—between the database (considered trusted) and the HTML output context (which must always escape untrusted data)—is crossed without escaping.
Why It Works
The load-bearing line is esc_html(...) itself. If you removed every instance of esc_html() from the patch, the vulnerability would remain fully exploitable; the XSS payload would execute identically. However, if you kept esc_html() on only the gateway field and removed it from transaction_id, amount, and status labels, an attacker could still inject XSS via any of those other fields.
The engineer added esc_html() to all four output locations because every piece of data sourced from metadata or database queries is potentially attacker-controlled. The Payment form creation flow does not validate or sanitize gateway, transaction_id, or custom status strings at input time. In WordPress security practice, you cannot rely on input sanitization alone—you must escape at the output sink in the context where the data is used. Because the sink is HTML (a table cell), esc_html() is the correct API: it is not too broad (unlike wp_kses_post(), which allows a whitelist of HTML tags) and not too narrow (unlike esc_attr(), which is for HTML attributes only). The patch's consistency—escaping all four outputs rather than a subset—reflects the principle that any database field could contain user input and must be escaped defensively.
Hardening Checklist
-
Implement input sanitization at form submission: Call
sanitize_text_field()on all form field values (includinggateway) in the payment form handler before storing viaupdate_post_meta(). This reduces the attack surface, even though output escaping remains mandatory. -
Audit all admin output functions for unescaped variables: Use a static analysis tool (e.g., WordPress-VIP's PHPCS ruleset with
WordPress.Security.EscapedOutput) to scan forecho,print, andsprintf()calls that lack an escaping function. Flag any variable sourced fromget_post_meta(), custom queries, or user input. -
Use context-aware escaping at every sink: If a variable appears in HTML tag content, use
esc_html(). In HTML attributes, useesc_attr(). In JavaScript strings, useesc_js(). Do not use a single escaping function everywhere. -
Add a capability check to the admin page: Ensure the Payments list page is restricted to users with
manage_optionsor a custom capability (e.g.,manage_cf7rl_payments) viacurrent_user_can()at the top of the handler, reducing the attack surface for unauthenticated or low-privilege users. -
Sanitize custom post-type metadata during registration: Use the
register_post_meta()WordPress function with asanitize_callbackargument to enforce type and format constraints ongateway,transaction_id, and other payment fields at the source.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-97661