Один POST к форме — и сервер выполняет ваш код
Один POST к форме. Вот всё расстояние между анонимным посетителем и кодом, работающим на вашем сервере. Запрос ниже — это и есть вся уязвимость, и его уже отправляют на живые сайты. CVE-2026-85097 — загрузка произвольных файлов без аутентификации в Bricksforge, популярном дополнении к конструктору Bricks. Затронуты все версии вплоть до 3.1.8.9 включительно, исправлено начиная с 3.1.8.10. [1][2] Wordfence, присвоившая CVE, ставит 9.8. Patchstack, поймавшая эксплуатацию, называет её 10.0. [1][2]
Bricksforge уведомили 3 сентября, а детали стали публичными 7 октября. [2] Patchstack увидела первую попытку эксплуатации в тот же день, в 21:47 UTC, с автоматизированной инфраструктуры, которая явно ждала. [1] Я не воспроизводил это на живом стенде. Дальше — разбор материала Patchstack и опубликованного ею кода, бюллетеня Wordfence, журнала версий вендора и документации WordPress, прочитанных 12 октября 2026 года. [1][2][3][4] Интересно не то, что загрузка файла привела к выполнению кода. Интересно, что плагин тщательно проверил файл и всё равно был взломан.
POST /wp-json/bricksforge/v1/form_submit HTTP/1.1
Host: victim.example
Content-Type: application/json
{
"temporaryFileUploads": [
{
"field": "form-field-file",
"file": {
"file": "/var/www/.../uploads/bricksforge/tmp/img.gif", // real GIF, passed the MIME check
"url": "https://victim.example/wp-content/uploads/shell.php", // attacker names the destination
"type": "image/gif"
}
}
]
}Сервер проверил байты. Атакующий назвал файл
Вот замысел, которому доверял Bricksforge. Когда посетитель прикрепляет файл, плагин загружает его во временную папку и там проверяет MIME-тип, как надо, до всего остального. GIF обязан быть настоящим GIF, и эта проверка работает. Дальше разработчики сделали то, что кажется безобидным. При финальной отправке формы они позволили браузеру сообщить серверу, какой это был файл и куда он должен попасть.
В этом вся ошибка, и «не проверяет поле url» её преуменьшает. Плагин проверил одно свойство файла, его содержимое, по одному полю. А имя и расширение итогового файла выбрал из другого поля, которым управляет атакующий и которое повторно не проверяется. То, что он осмотрел, и то, с чем он действовал, — разные вещи. Байты GIF ничего не говорят о том, будет ли файл, который вы сейчас запишете, называться shell.php, а на связке Apache плюс mod_php именно .php на конце, а не заголовок картинки, решает, запустит ли сервер файл.
Настоящая картинка, скопированная в исполняемый файл
Пройдём два этапа. Сначала загрузка. Атакующий запрашивает у неаутентифицированной точки nonce, затем загружает полиглот: файл, который одновременно валидный GIF и содержит строку PHP. Поскольку это настоящая картинка, mime_content_type сообщает image/gif, и файл ложится во временный каталог нетронутым. [1] Пока плагин всё сделал правильно. Файл на диске действительно картинка.
Затем форму отправляют, и работу делает метаданные. Отправленный JSON несёт объект file с двумя важными полями: file — серверный путь к тому, что прошло проверку, и url — куда это должно попасть. Код ниже — уязвимый путь, сжатый из опубликованного Patchstack. [1] Имя назначения берётся из поля url, прогоняется через basename и используется как имя, в которое копируются проверенные байты.
Посмотрите, что делает этот copy(). Он берёт настоящий GIF из временного каталога и пишет его байты по пути, который назвал атакующий, с окончанием .php, внутри корня веб-приложения. Заголовок картинки оставляет файл валидным GIF; PHP внутри — это то, что сервер выполнит при первом обращении к этому URL. [1] У MIME-проверки не было шансов, потому что она отвечала на вопрос, который никто не задавал. Она доказала, что файл — картинка. Она не доказала, что файл будет сохранён или отдан как картинка.
// Bricksforge Pro Forms (condensed from the vulnerable path)
$file_path = $file_data['file']; // server path of the file that was MIME-checked
$file_url = $file_data['url']; // client-supplied, never re-validated
$attachment['guid'] = $file_url; // destination comes from the URL
$desired_filename = basename($attachment['guid']); // ...so from the attacker
$destination_path = $upload_path . '/' . $desired_filename;
copy($file_path, $destination_path); // GIF bytes written to an attacker-named .phpПочему «у нас в формах нет загрузки файлов» вас не спасёт
Теперь часть, которая должна встревожить, даже если вы не считаете себя уязвимым. Можно решить, что это касается только форм, принимающих загрузки. Собственный журнал версий Bricksforge говорит иначе: дефект затрагивает любой сайт с активным Pro Forms, включая формы вообще без полей загрузки. [3] Причина в коде. Список файлов для обработки читается не из настоящей multipart-загрузки. Он читается из значения temporaryFileUploads в теле JSON, которое атакующий пишет с нуля. [1] Обычной текстовой контактной формы достаточно.
И nonce — не тот барьер, каким кажется. Bricksforge открывает точку bricksforge_regenerate_nonce, выдающую свежий nonce любому анонимному посетителю, что уже сводит смысл на нет. Даже без неё WordPress генерирует одинаковый nonce для всех незалогиненных пользователей, ведь у всех общий user ID 0. [4] Nonce доказывает, что запрос пришёл со страницы, а не от доверенного человека. Документация WordPress говорит в одну строку: на nonce нельзя полагаться для аутентификации, авторизации или контроля доступа. [4] Этот обработчик опёрся на него именно ради этого.
// The file list is read from the JSON body, not from a real multipart upload:
$file_uploads = isset($form_data['temporaryFileUploads'])
? json_decode($form_data['temporaryFileUploads'], true) // attacker-supplied
: [];
$merged_files = array_merge($file_uploads, $request->get_file_params());
// The gate in front of it:
// GET bricksforge_regenerate_nonce -> hands any anonymous visitor a nonce
// WordPress gives every logged-out user the SAME nonce (user ID 0)
// A nonce is CSRF hygiene. It was never authentication.Исправление возвращает имя файла серверу
Исправление восстанавливает очевидное правило: где окажется загруженный файл и как он будет называться, решает сервер, а не клиент. Patchstack и бюллетень Wordfence называют исправленной сборкой 3.1.8.10. [1][2] Журнал версий Bricksforge относит исправление загрузки файлов к 4.0.1 от 8 октября и обещает подробности после раскрытия. [3] Обновляйтесь до той версии, что даёт лицензия, но считайте 3.1.8.10 нижней планкой.
Принцип под этим переживёт сам плагин. Имя сохраняемого файла — решение по безопасности, поэтому оно должно принадлежать серверу: сгенерируйте случайное имя, закрепите расширение по белому списку и храните вне корня веб-приложения или там, где сервер не запускает PHP. Блок ниже — эта форма, а не дифф вендора. Есть и ловушка распространения, свойственная Bricksforge. Это коммерческий плагин, не из каталога WordPress.org, обновляемый через лицензию. [3] Истёкшая лицензия может незаметно оставить вас на уязвимой сборке, пока панель выглядит нормально. «Мы купили его однажды» — это не «мы пропатчены».
// The fix, as a principle: the SERVER owns the name and extension, never the client.
$ext = 'bin'; // or an allowlisted image extension
$stored = wp_generate_uuid4() . '.' . $ext; // random name the attacker can't choose
$destination = $upload_path . '/' . $stored;
copy($validated_tmp_file, $destination);
// And decide execution by WRITE location, not by MIME: store outside the web root,
// or in a directory where the server is configured never to run PHP.Как я ищу это в коде, не имеющем отношения к Bricksforge
Эта ошибка — класс, и класс старше WordPress: проверить одно свойство, действовать по другому. Как только обработчик загрузки проверяет содержимое файла, но позволяет вводу из запроса выбирать имя, расширение или каталог файла, появляется та же дыра — хоть на PHP, хоть на Node, хоть на Java. Когда я разбираю путь загрузки, я не начинаю с MIME-проверки, которой все гордятся. Я начинаю с записи.
Я нахожу каждый приёмник, пишущий байты на диск, и иду назад — к тому, откуда берётся имя назначения. grep ниже даёт кандидатов; настоящая работа — прочитать каждого. Для каждой записи я задаю один вопрос, тот же, что поймал бы CVE-2026-85097 на ревью: свойство, которое вы проверили, определяет ли свойство, которое исполняется? Если байты проверены, а имя файлу даёт отдельное поле под контролем клиента, проверка — декорация.
# Smell to hunt in any upload handler:
# the stored filename, extension or directory derived from request input.
rg -n "basename\(|get_param|\$_POST|\$_REQUEST|json_decode" --glob '*.php'
# Then read each write sink by hand: move_uploaded_file, copy, file_put_contents, rename.
# The one question that confirms the bug:
# does the property you VALIDATED (the bytes, the MIME) decide the property
# that gets EXECUTED (the extension, the directory)?
# If a separate, client-controlled field names the sink, the check is decoration.Это ваш пожар? Только если у вас Bricksforge — но тогда да
Честно о масштабе. У Bricksforge около 6000 активных установок по подсчёту Patchstack, так что это не событие масштаба всего интернета. [1] Если у вас нет Bricksforge, можете закрыть вкладку. Если есть, окно не теоретическое. Patchstack зафиксировала эксплуатацию с 63 уникальных IP, начавшуюся в течение часов после раскрытия, причём 95% вредоносных запросов маскировали PHP-нагрузку под GIF или PNG, а небольшие кластеры перебирали десятки написаний расширения, чтобы обойти наивные фильтры. [1]
На обход стоит посмотреть, потому что он подсказывает, что искать grep-ом. Пробовали альтернативные расширения PHP вроде .php7 и .phtml, игру регистром вроде .PHp и URL-кодирование вроде %2ephp — всё нацелено на тот же copy(). [1] Файлы, которые пытались положить, следовали шаблонам вроде login_admin_*.php в каталоге загрузок, включая временную папку Bricksforge. [1] На 12 октября CVE ещё нет в каталоге известных эксплуатируемых уязвимостей CISA, а её оценка EPSS ниже одного процента, но ни то, ни другое не означает тишину. [2] Это значит рано. KEV — запаздывающий индикатор; ловушки — опережающий, и ловушки уже шумят.
Итак, три условия, чтобы это стало вашей аварией: у вас есть Bricksforge, версии ниже 3.1.8.10, доступный из интернета. Если все три верны, патч — шаг первый, а не финиш, потому что PHP-файл, который можно было записать, возможно, уже записан и выполнен. Для владельца сайта вопрос не только «обновились ли мы», но и «добрался ли кто-то до точки form_submit с файловой нагрузкой раньше нас». Ответ лежит в журналах и каталоге загрузок, а не в экране плагинов.
Что сделать, прежде чем закрыть эту вкладку
Вот порядок, в каком работал бы я. Сначала патч, потому что окно эксплуатации измеряется часами, не неделями. [1] Но если уязвимая версия смотрела в интернет, считайте, что кто-то пробовал, и ищите, прежде чем объявлять победу. Патч останавливает следующую загрузку; он ничего не делает с шеллом, который положили на прошлой неделе.
Список ниже конкретен. Сигнатура в журналах, которую стоит искать, — та, которую атака не может избежать: запрос к точке отправки формы с телом temporaryFileUploads, где путь файла указывает на картинку, а поле url оканчивается на расширение PHP. Это несоответствие — отпечаток уязвимости, и невинно подделать его трудно.
Patch and verify (in this order):
[ ] Find every site running Bricksforge; confirm the version actually LOADED.
[ ] Update to 3.1.8.10+ (3.x branch) or 4.0.1+. Bricksforge is license-gated and
not on WordPress.org, so a lapsed license can silently block the update.
[ ] Cannot update now? Block at the WAF / server: POST to
/wp-json/bricksforge/v1/form_submit and the admin-ajax action
bricksforge_form_submit carrying a temporaryFileUploads body.
If a vulnerable version faced the internet, assume compromise and hunt:
[ ] Grep uploads for unexpected .php, especially
/wp-content/uploads/bricksforge/tmp/ and names like login_admin_*.php.
[ ] Audit for hidden admin accounts by querying the database directly,
and review the must-use (mu-plugins) directory.
[ ] In access logs, look for form_submit requests whose file path is an image
but whose url field ends in a PHP extension.
[ ] Rotate secrets reachable from PHP: database, API keys, SMTP.То, чего «мы проверяем загрузки» никогда не покрывает
CVE-2026-85097 — аккуратный урок о том, почему «мы проверяем загрузки» не ответ на «безопасны ли загрузки». Плагин проверил то, что проверить было легко, и доверился тому, что имело значение. Этот разрыв, между свойством, которое вы проверяете, и свойством, которое исполняется, — место, где живёт удивительно много уязвимостей загрузки, и его не видно ни по номеру версии, ни по зелёной галочке сканера. Нужно прочитать путь от запроса до записи.
Проследить этот путь, от анонимного запроса через фреймворк до точной строки, называющей файл на диске, — суть аудита веб-приложения, и запах «проверить одно, действовать по другому» из этой CVE я ищу в коде, не имеющем отношения к WordPress. Если у вас есть формы, загрузки или любой поток, превращающий внешний ввод в файлы на сервере, именно эту границу я бы проверил для вас. Напишите, что вы выпускаете, и мы определим объём.
Источники
Нужна такая проверка для вашего продукта?
Пентест веб-приложений и API