
Эксплойт proof-of-concept и техническое описание уязвимости CVE-2023-6553 — неаутентифицированная уязвимость включения PHP-файлов, позволяющая удалённое выполнение кода в плагине WordPress Backup Migration версии <=1.3.7.
Плагин: 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() для загрузки файла по этому пути. Атакующему достаточно отправить заголовок Content-Dir, указывающий на каталог с вредоносным PHP-кодом, — и сервер автоматически подключит и выполнит его.
Стоит отметить, что файл backup-heart.php не требует аутентификации — он проверяет только, является ли метод запроса POST, без проверки nonce или прав пользователя. Любой пользователь интернета может отправить к нему запрос.
⇒ Это уязвимость типа zero-click.
| Атрибут | Значение |
|---|---|
| CVE ID | CVE-2023-6553 |
| Оценка CVSS | 9.8 (критический) |
| Плагин | backup-backup (Backup Migration) ≤ 1.3.7 |
| Аутентификация | Не требуется |
| Взаимодействие с пользователем | Нет (zero-click) |
| Исправлено | Версия 1.3.8 |
В PHP есть функции include(), require(), require_once(), используемые для включения других PHP-файлов в работающую программу. Когда путь к файлу, передаваемый в эти функции, поступает из пользовательского ввода без проверки, атакующий может заставить сервер подключить любой нужный ему файл:
allow_url_include=On (обычно отключено по умолчанию).Этот CVE относится к категории LFI — атакующий контролирует путь, передаваемый в require_once(), который указывает на PHP-файл, размещённый атакующим на сервере.
Многие разработчики считают HTTP-заголовки «внутренними» метаданными, известными только серверу и клиенту. В действительности атакующий контролирует 100% содержимого заголовков — он может задать любое имя и значение заголовка. Доверять заголовкам так же опасно, как доверять данным форм, — их необходимо проверять.
define() и константы PHPdefine('NAME', $value) создаёт константу, используемую во всём приложении. Будучи однажды определённой через define(), константа не может быть изменена. Если $value поступает от атакующего, то все места, где используется эта константа, оказываются под угрозой.
Я начал с поиска по всем выражениям require и include в плагине с помощью grep:
grep -rn "require\|include" includes/

Поиск вернул множество вызовов require/include. При их просмотре выяснилось, что большинство — это вызовы include_once в banner/misc.php и banner/views/index.php; они относятся к коду отрисовки административного интерфейса с жёстко заданными путями, поэтому использовать их нельзя.
Однако моё внимание привлекли две строки в 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.
Я открыл backup-heart.php в VS Code на строке 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 содержит все HTTP-заголовки, полученные через getallheaders() и полностью контролируемые клиентом. Здесь нет ни 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, чтобы наглядно подтвердить ход атаки. Я установил две точки останова — на строке 62 и строке 118 в backup-heart.php, после чего отправил эксплойт-запрос с помощью curl.
Точка останова 1 — строка 62:
Отладчик остановился прямо на define('BMI_ROOT_DIR', $fields['content-dir']). Раскрыв переменную $fields на панели Variables, я увидел массив из 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() не применяется.