Segurium Research WPvivid Backup

WPvivid Backup 0.9.133 fixes a Zip Slip path traversal in restore

Segurium Research 6 min read

Plugin
WPvivid Backup wpvivid-backuprestore
Affected versions
0.9.132 and earlier
Fixed in
0.9.133
Class
Path traversal
Severity
high Segurium Research assessment. No CVSS score published yet.
CVE
None assigned at the time of writing
Installs
900,000 active
Patch released
Sources
Advisory record for WPvivid Backup 0.9.133 The same finding as a structured entry in the advisory directory.

WPvivid Backup is a backup, migration and staging plugin for WordPress. It is installed on ~900,000 sites. Version 0.9.133 shipped on 2026-08-27.

The vendor changelog says little. It reads: “Fixed some vulnerabilities in the plugin code.” and “Optimized the plugin code.” That does not tell you what broke or where.

The diff tells you. A new class, WPvivid_Extract_Security, now checks every file path before extraction. It stops a crafted backup from writing files outside the restore folder. This is a Zip Slip path traversal fix. The same release also switches many uploads-cleaner queries to prepared SQL and sanitizes $_POST input.

What the release fixes

Before 0.9.133, restore and import extracted backup zip archives without checking where each file went. A backup archive stores a path for every file inside it. WPvivid trusted that path. A crafted backup could set a path with directory traversal segments, and the file would be written outside the intended folder. On many servers that means writing a PHP file into the web root and running code.

The new WPvivid_Extract_Security class validates each target path against a restricted root before the file is written. The restore code also stops taking the extraction root from the backup’s own metadata file.

How the bug works

WPvivid extracts backups with a bundled PclZip library. PclZip calls a pre-extract callback for each file in the archive. The callback gets $p_header['filename']. That value is the full path where the file will be written. The callback returns 1 to extract the file, 2 to skip it, or 0 to skip without error.

In 0.9.132 the restore callback wpvivid_function_pre_extract_callback_2 never checked that this path stayed inside the extraction folder. It looked at plugin and theme rules, then returned 1. PclZip then wrote the file to $p_header['filename']. If that path pointed outside the folder, PclZip still wrote it there. That is Zip Slip: the archive controls the write location.

The new callback adds the check at the top:

function wpvivid_function_pre_extract_callback_2($p_event, &$p_header)
{
    $final_filename = isset($p_header['filename']) ? $p_header['filename'] : '';

    // Package metadata is read separately and must never be restored.
    if (basename(str_replace('\\', '/', $final_filename)) === 'wpvivid_package_info.json')
    {
        return 0;
    }

    if (!WPvivid_Extract_Security::validate($final_filename))
    {
        return 2;
    }

validate() is the core of the fix. It normalizes the root and the target path, then checks the target sits inside the root and has no unsafe symlink:

public static function validate($target_path)
{
    if (!isset($GLOBALS['wpvivid_extract_security_root']) ||
        $GLOBALS['wpvivid_extract_security_root'] === '')
    {
        self::fail('The extraction root is missing.');
        return false;
    }

    $restricted_root = $GLOBALS['wpvivid_extract_security_root'];
    ...
    $normalized_root = WPvivid_PclZipUtilNormalizePath($restricted_root);
    $normalized_target = WPvivid_PclZipUtilNormalizePath($target_path);

    if ($normalized_root === false || $normalized_target === false ||
        !WPvivid_PclZipUtilIsPathInside($normalized_root, $normalized_target) ||
        !WPvivid_PclZipUtilHasSafeSymlinkPath($normalized_root, $normalized_target))
    {
        self::fail("Filename ".$target_path." is outside the permitted extraction directory.");
        return false;
    }

    return true;
}

Each extraction call now wraps the extract with begin() and end(), and forces a failure if any path was rejected:

WPvivid_Extract_Security::begin($restricted_path);
$archive = new WPvivid_PclZip($file_name);
$zip_ret = $archive->extract(WPVIVID_PCLZIP_OPT_PATH, $root_path, ... , 'wpvivid_function_pre_extract_callback_2', ...);
$path_validation_failed = WPvivid_Extract_Security::failed();
$path_validation_error = WPvivid_Extract_Security::error();
WPvivid_Extract_Security::end();

if ($path_validation_failed)
{
    $zip_ret = false;
}

There is a second, deeper problem the diff fixes in class-wpvivid-restore-file-2.php. The old code took the extraction root from the backup package itself:

$root_path = '';
if (isset($file['options']['root']))
{
    $root_path = $this->transfer_path(get_home_path() . $file['options']['root']);
}

$file['options'] comes from wpvivid_package_info.json inside the backup. An attacker who supplies the backup controls it. So the attacker could set both the root folder and, with weak per-file checks, the file paths. The new code refuses this. A new comment states the rule: “Resolve the extraction root from trusted restore semantics, not directly from package-controlled wpvivid_package_info.json data.” The new get_restore_root_path() maps a fixed backup type to a fixed WordPress folder (WP_CONTENT_DIR, ABSPATH, the uploads dir) using an allow-list:

$allowed_flags = array(
    'themes'     => array(WPVIVID_BACKUP_ROOT_WP_CONTENT),
    'plugin'     => array(WPVIVID_BACKUP_ROOT_WP_CONTENT),
    'upload'     => array(WPVIVID_BACKUP_ROOT_WP_CONTENT, WPVIVID_BACKUP_ROOT_UPLOADS_RELATIVE),
    'wp-content' => array(WPVIVID_BACKUP_ROOT_WP_CONTENT, WPVIVID_BACKUP_ROOT_WP_ROOT),
    'wp-core'    => array(WPVIVID_BACKUP_ROOT_WP_ROOT),
    'mu-plugins' => array(WPVIVID_BACKUP_ROOT_WP_CONTENT),
    'custom'     => array(WPVIVID_BACKUP_ROOT_WP_ROOT),
);

The metadata file itself was also handled loosely. The old code checked it with strpos(...,'wpvivid_package_info.json')!==false and placed the check late. The new code checks basename() at the top of the callback and returns 0. This blocks the package metadata from being restored or overwritten.

The same class is now used across the restore, import and database-restore paths. The diff wires WPvivid_Extract_Security::begin/validate/end into class-wpvivid-zipclass.php, class-wpvivid-importer.php, class-wpvivid-restore-db-2.php and class-wpvivid-restore-file-2.php. Each extraction now returns a clear error, WPVIVID_PCLZIP_ERR_DIRECTORY_RESTRICTION (-21), when a path is blocked.

The release fixes a second bug class in the uploads cleaner: SQL injection. Several AJAX handlers in class-wpvivid-uploads-cleaner.php read $_POST['search'] and $_POST['folder'] with no sanitizing:

$search='';
if(isset($_POST['search']))
{
    $search=$_POST['search'];
}

Those values then went straight into SQL. In class-wpvivid-uploads-scanner.php the get_unused_files() and related functions built the query by string concatenation:

$where.="`path` LIKE '%$search%'";
...
$where.="`folder` = '$folder'";

The $folder value reached the query directly, so a crafted folder value changed the SQL. The patch sanitizes the input at the handler and moves every query to $wpdb->prepare():

$folder=sanitize_text_field(wp_unslash($_POST['folder']));
...
$conditions[]='`folder` = %s';
$values[]=(string)$folder;
...
$sql=$wpdb->prepare("SELECT * FROM $table".$where,$values);

get_scan_result() had the same flaw with a raw file path ("... WHERE path = '$file'") and is now prepared too. The IN (...) list builders that used implode(",", $selected_list) now cast to integers and use %d placeholders.

The diff shows the sinks and the fix. It does not show the AJAX action names or the capability and nonce checks on these handlers, so the exact role needed to reach the SQL injection is not established here.

Who is exposed

Sites running WPvivid Backup 0.9.132 or earlier are affected.

For the path traversal, an attacker needs a crafted backup archive to be restored or imported on the site. Restore and import are privileged actions. The diff does not show the hook or the capability that guards them, so the entry route and the exact role are not established from these bytes. The risk is real for any workflow where a site accepts and restores a backup file from an untrusted source, including migration between sites.

The write goes wherever the crafted path points, limited by file permissions. On a common setup, that reaches the web root and allows code execution.

For the SQL injection, the target is the uploads cleaner feature. The diff shows the injectable $_POST values but not the auth around the handlers.

What to do

Update to 0.9.133 now. This is the release that carries the fix.

If you restored or imported any backup from an outside source before updating, check the site. Look for PHP files in folders that should hold only media or backups, for example the uploads directory. Look for files with recent modified times that you did not create. Check the WPvivid import and restore logs for the error WPVIVID_PCLZIP_ERR_DIRECTORY_RESTRICTION; on a patched site this line means a bad path was blocked. If you use the uploads cleaner, review it for unexpected results.

The patch adds path containment. Before each extraction, WPvivid_Extract_Security::begin() sets a restricted root. A callback checks every file path against that root and skips any file that falls outside it. The restore code now sets the root from a fixed allow-list, not from the backup’s own metadata. The uploads-cleaner queries now use prepared statements and sanitized input. A site on 0.9.132 or earlier still has the flaw until it updates.

More research

Segurium is a WordPress malware scanner and cleanup tool on the official directory at wordpress.org/plugins/segurium/.