Client Invoicing by Sprout Invoices <= 20.8.9 - Authenticated (Author+) Local File Inclusion
Description
The Client Invoicing by Sprout Invoices plugin for WordPress is vulnerable to Local File Inclusion in versions up to, and including, 20.8.9. This makes it possible for authenticated attackers, with author-level access and above, to include and execute arbitrary files on the server, allowing the execution of any PHP code in those files. This can be used to bypass access controls, obtain sensitive data, or achieve code execution in cases where images and other "safe" file types can be uploaded and included.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:HTechnical Details
<=20.8.9What Changed in the Fix
Changes introduced in v20.8.10
Source Code
WordPress.org SVN# Research Plan: CVE-2026-32401 - Authenticated LFI in Sprout Invoices ## Vulnerability Summary The **Client Invoicing by Sprout Invoices** plugin (versions <= 20.8.9) is vulnerable to **Local File Inclusion (LFI)**. The vulnerability exists in the way the plugin handles "Doc Templates" for Invoice…
Show full research plan
Research Plan: CVE-2026-32401 - Authenticated LFI in Sprout Invoices
Vulnerability Summary
The Client Invoicing by Sprout Invoices plugin (versions <= 20.8.9) is vulnerable to Local File Inclusion (LFI). The vulnerability exists in the way the plugin handles "Doc Templates" for Invoices and Estimates. An authenticated user with Author-level permissions or higher can modify the template associated with an invoice or estimate to point to an arbitrary file on the server. When that document is subsequently viewed on the frontend, the plugin includes the specified file via a template_include filter, leading to PHP code execution or sensitive information disclosure.
Attack Vector Analysis
- Endpoint: Frontend view of an Invoice (
sa_invoice) or Estimate (sa_estimate). - Vulnerable Parameter: The post meta field
_doc_template_option(defined asSI_Templating_API::TEMPLATE_OPTION). - Injection Point: The post update process (
wp-admin/post.php) where the template selection is saved. - Authentication: Author-level access (user must be able to create or edit their own Invoices/Estimates).
- Preconditions: An invoice or estimate must exist or be created by the attacker.
Code Flow
- Registration:
SI_Templating_API::init()(incontrollers/templating/Templating.php) registers thetemplate_includefilter:add_filter( 'template_include', array( __CLASS__, 'override_template' ) ); - Metadata Setup:
SI_Templating_API::init()also hooks the saving of the template selection:add_action( 'si_save_line_items_meta_box', array( __CLASS__, 'save_doc_template_selection' ) ); - Saving: When an invoice is saved,
save_doc_template_selection(inferred) reads a POST parameter (likelysa_doc_template) and updates the post meta_doc_template_option. - Retrieval: When viewing the document,
override_templateis triggered. It callsget_doc_current_template($doc_id):public static function get_doc_current_template( $doc_id ) { $template_id = get_post_meta( $doc_id, self::TEMPLATE_OPTION, true ); // ... returns the malicious path ... } - Inclusion (Sink):
override_templateuses the retrieved$template_idto locate a file. If the plugin fails to sanitize this path and passes it back to WordPress or callsinclude/requiredirectly, the file is included.
Nonce Acquisition Strategy
The vulnerability requires updating post meta, which is typically done through the standard WordPress post edit screen.
- Navigate to the Edit Page: Use
browser_navigateto go to the edit screen of an existingsa_invoice. - Extract Nonces: Use
browser_evalto extract the standard WordPress nonces required forpost.php._wpnonce:document.querySelector('#_wpnonce').value
- Identify Field Name: Verify the exact name of the template selection dropdown.
browser_eval("document.querySelector('select[name*=\"template\"]')?.name")- Based on the hook
si_save_line_items_meta_box, the expected parameter name issa_doc_template.
Exploitation Strategy
- Setup:
- Create a user with the
authorrole. - Create a new
sa_invoicepost via WP-CLI.
- Create a user with the
- Inject Malicious Path:
- Use the
http_requesttool to send aPOSTrequest towp-admin/post.php. - Set
action=editpost. - Set
post_IDto the ID of the created invoice. - Set
sa_doc_template(or the discovered field name) to the traversal path:../../../../../../../../../../../../etc/passwd. - Include the necessary
_wpnonce.
- Use the
- Trigger Inclusion:
- Find the frontend URL for the invoice (e.g.,
?post_type=sa_invoice&p=[ID]). - Use the
http_requesttool to perform aGETrequest to that URL while authenticated as the author.
- Find the frontend URL for the invoice (e.g.,
- Capture Output:
- The response body should contain the contents of
/etc/passwd.
- The response body should contain the contents of
Test Data Setup
- Author User:
wp user create attacker attacker@example.com --role=author --user_pass=password - Invoice Post:
wp post create --post_type=sa_invoice --post_status=publish --post_title="LFI Test" --post_author=$(wp user get attacker --field=ID)
Expected Results
- A successful
POSTrequest topost.phpwill return a 302 redirect back to the edit page. - A
GETrequest to the invoice frontend URL will return a200 OKresponse. - The response body will contain
/etc/passwdcontent (e.g.,root:x:0:0:root:/root:/bin/bash).
Verification Steps
- Check Meta State:
Confirm it matches the injected path.wp post meta get [POST_ID] _doc_template_option - Verify Response:
Check thehttp_requestoutput for the stringroot:x:0:0:.
Alternative Approaches
- PHP Filter Wrapper: If
/etc/passwdis blocked or if the plugin appends.php, try:php://filter/convert.base64-encode/resource=wp-config - Uploaded Payload: If the plugin only includes files relative to the uploads directory:
- Upload a text file
poc.txtcontaining<?php echo "VULNERABLE"; ?>via the Media Library. - Get its path in
wp-content/uploads/. - Set
sa_doc_templateto../../../../uploads/YYYY/MM/poc.txt.
- Upload a text file
- Estimate Post Type: If
sa_invoiceis restricted, repeat the process usingsa_estimate.
Summary
The Sprout Invoices plugin for WordPress is vulnerable to Local File Inclusion (LFI) because it fails to properly validate the 'doc_template' parameter when saving invoice or estimate metadata. This allows authenticated attackers with Author-level permissions or higher to specify arbitrary file paths, including those with directory traversal, which are subsequently executed via the plugin's template inclusion logic.
Vulnerable Code
// controllers/templating/Templating.php:686 public static function save_doc_template_selection( $post_id = 0 ) { $doc_template = ( isset( $_POST['doc_template'] ) ) ? sanitize_text_field( wp_unslash( $_POST['doc_template'] ) ) : '' ; self::save_doc_current_template( $post_id, $doc_template ); } --- // controllers/_Controller.php:444 // Note: This logic retrieves the stored path and checks for its existence within the templates folder without traversal protection foreach ( $possibilities as $p ) { if ( file_exists( SI_PATH.'/views/templates/'.$p ) ) { return SI_PATH.'/views/templates/'.$p; } }
Security Fix
@@ -433,6 +433,21 @@ protected static function locate_template( $possibilities, $default = '' ) { $possibilities = apply_filters( 'sprout_invoice_template_possibilities', $possibilities ); $possibilities = array_filter( $possibilities ); + + // Security: Validate each possibility for path traversal attempts + foreach ( $possibilities as $key => $p ) { + // Reject any path containing directory traversal sequences + if ( empty( $p ) || strpos( $p, '..' ) !== false || strpos( $p, "\0" ) !== false ) { + unset( $possibilities[ $key ] ); + // Only log in debug mode to prevent DoS via database flooding + if ( self::DEBUG ) { + do_action( 'si_error', 'Path traversal attempt detected in template path', array( + 'attempted_path' => $p, + ) ); + } + } + } + // check if the theme has an override for the template $theme_overrides = array(); foreach ( $possibilities as $p ) { @@ -444,9 +459,16 @@ } // check for it in the templates directory + // Security: Compute base path once before loop to avoid repeated filesystem calls + $base_path = realpath( SI_PATH . '/views/templates/' ); + foreach ( $possibilities as $p ) { - if ( file_exists( SI_PATH.'/views/templates/'.$p ) ) { - return SI_PATH.'/views/templates/'.$p; + $template_path = SI_PATH . '/views/templates/' . $p; + // Security: Ensure the resolved path is within the templates directory + $real_path = realpath( $template_path ); + + if ( $real_path && $base_path && strpos( $real_path, $base_path ) === 0 && file_exists( $real_path ) ) { + return $real_path; } } @@ -686,6 +686,32 @@ */ public static function save_doc_template_selection( $post_id = 0 ) { $doc_template = ( isset( $_POST['doc_template'] ) ) ? sanitize_text_field( wp_unslash( $_POST['doc_template'] ) ) : '' ; + + // Security: Validate template against whitelist to prevent path traversal attacks + if ( '' !== $doc_template ) { + $post_type = get_post_type( $post_id ); + $valid_templates = array(); + + // Get valid templates based on post type + if ( SI_Invoice::POST_TYPE === $post_type ) { + $valid_templates = array_keys( self::get_invoice_templates() ); + } elseif ( SI_Estimate::POST_TYPE === $post_type ) { + $valid_templates = array_keys( self::get_estimate_templates() ); + } + + // Only save if template is in the whitelist + if ( ! in_array( $doc_template, $valid_templates, true ) ) { + // Invalid template - reject and log (only in debug mode to prevent log flooding) + if ( self::DEBUG ) { + do_action( 'si_error', 'Invalid template selection attempted', array( + 'post_id' => $post_id, + 'attempted_template' => $doc_template, + ) ); + } + $doc_template = ''; // Reset to default + } + } + self::save_doc_current_template( $post_id, $doc_template ); }
Exploit Outline
To exploit this vulnerability, an attacker with at least Author-level access follows these steps: 1. Create a new Invoice (`sa_invoice`) or Estimate (`sa_estimate`) or edit an existing one they own. 2. When saving the document, intercept the POST request to `wp-admin/post.php` (action `editpost`). 3. Inject a directory traversal payload into the `doc_template` parameter, such as `../../../../../../etc/passwd` or `../../../../wp-config.php` (potentially using PHP filters to encode the output). 4. Update the post, which saves the malicious path into the `_doc_template_option` post meta. 5. Access the public-facing URL of the Invoice or Estimate. The plugin's `template_include` logic will trigger `SI_Templating_API::override_template`, which eventually calls `SI_Controller::locate_template`. Because the path is not restricted to the views directory, the server will include and execute the targeted file.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.