MAATRIX / Блог / Загрузка файлов на сайте: как понять, что через неё уже заходили

Загрузка файлов на сайте: как понять, что через неё уже заходили

MAATRIX

Форма загрузки файлов — аватар, вложение к заявке, документ к анкете — редко попадает под пристальное внимание при аудите: это обычный HTTP POST на легитимный URL, файрвол его не блокирует, а WAF далеко не всегда разбирает содержимое multipart-запроса так же строго, как параметры в GET. Если такая форма есть на сайте и вы не уверены в её валидации, разумный вопрос — не «а вдруг взломают», а «а не заходили ли уже». Ниже — как проверить это по остаткам на диске и по access-логам за 30-40 минут.

Почему форма загрузки — типичная точка входа

Механика почти всегда одна: приложение проверяет файл не по содержимому, а по тому, что легко подделать — расширению в имени, заголовку Content-Type (его задаёт отправитель) или короткому чёрному списку, из которого забыли .phtml или .pht. Файл сохраняется внутри веб-корня, а веб-сервер обрабатывает .php глобально, без исключений для загрузок. В итоге файл с именем avatar.php.jpg или просто photo.php проходит проверку, попадает на диск и тут же исполняется по прямой ссылке.

Разбор самой уязвимости и настройки сервера, чтобы PHP из uploads не исполнялся, — в статье про поиск чужого PHP в папке uploads. Здесь фокус на другом: как понять постфактум, использовали ли этот вектор уже, даже если сайт внешне работает нормально.

Задача в двух частях. Первая — посмотреть на директорию загрузок, не лежит ли там лишнее. Вторая, более надёжная — сопоставить это с access-логами и найти классический паттерн: запрос на загрузку, а через секунды запрос на исполнение только что залитого файла. Файл на диске без такого подтверждения может быть просто мусором; совпадение времени загрузки и первого обращения к файлу — почти всегда прямое доказательство эксплуатации.

Разведка в директории загрузок: расширение врёт, magic bytes — нет

Сначала смотрим, что лежит там, куда должны попадать только медиафайлы:

find /var/www -path "*/uploads/*" -regextype posix-extended \
  -iregex ".*\.(php[0-9]?|phtml|pht|phar|cgi|jsp)(\..+)?$"

Замените uploads на реальный путь, если структура не WordPress-овская. Расширение — грубый фильтр: он не поймает файл с честным именем report.pdf, внутри которого PHP-код. Нужны magic bytes — сигнатура в первых байтах, определяющая реальный тип независимо от имени:

find /var/www -path "*/uploads/*" -type f -exec file --mime-type -b {} \; -print | grep -B1 -Ei "php|x-executable"

Находка вроде text/x-php для файла с именем cover.jpg — конкретный факт, требующий разбора. Но сама по себе она не отвечает на главный вопрос: файл загрузили или его успели исполнить. Ответ даёт не диск, а лог.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Access-log: главный признак — «POST → мгновенный GET»

Атака через уязвимую форму почти всегда состоит из двух запросов, разнесённых на секунды. Сначала POST на обработчик загрузки — он либо возвращает путь к файлу в ответе, либо путь предсказуем по шаблону приложения. Сразу за ним — GET на путь только что загруженного файла, чтобы сервер его исполнил. Обычный пользователь не станет тут же дёргать файл напрямую по URL с расширением .php — за него превью, если оно вообще есть, отрисовывает браузер, и не так.

В сыром логе (combined format) пара выглядит так:

198.51.100.23 - - [28/Aug/2026:03:14:07 +0000] "POST /wp-content/plugins/formidable/upload.php HTTP/1.1" 200 142 "-" "python-requests/2.31.0"
198.51.100.23 - - [28/Aug/2026:03:14:09 +0000] "GET /wp-content/uploads/2026/08/wp-x1a2b3.php HTTP/1.1" 200 27 "-" "python-requests/2.31.0"

Две секунды между загрузкой и обращением к результату — человек так быстро вручную не успевает, а инструмент делает это штатно. Дальше по логу часто видны повторные GET с разными query-параметрами вроде ?cmd=whoami — это уже не проверка, что шелл сработал, а его активное использование.

Искать такое вручную неудобно, проще скриптом:

#!/usr/bin/env python3
import re
from datetime import datetime

LOG, WINDOW_SEC = "/var/log/nginx/access.log", 60
pattern = re.compile(
    r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] '
    r'"(?P<method>\S+) (?P<path>\S+) \S+" (?P<status>\d+) \S+ "(?P<ref>[^"]*)" "(?P<ua>[^"]*)"'
)
rows = []
for line in open(LOG):
    m = pattern.search(line)
    if m:
        ts = datetime.strptime(m["time"], "%d/%b/%Y:%H:%M:%S %z")
        rows.append((ts, *m.group("ip", "method", "path", "status", "ua")))

uploads = [r for r in rows if r[2] == "POST" and "upload" in r[3].lower()]
gets = [r for r in rows if r[2] == "GET" and "/uploads/" in r[3]]

for ts, ip, _, path, status, _ in uploads:
    for gts, gip, _, gpath, gstatus, gua in gets:
        delta = (gts - ts).total_seconds()
        if gip == ip and 0 <= delta <= WINDOW_SEC:
            print(f"[{delta:5.1f}s] {ip}  POST {path} -> GET {gpath} ({gstatus})  UA={gua!r}")

Два нюанса. Логи ротируются — если атака была неделю назад, свежий access.log её уже не содержит, нужно прогнать скрипт и по архивам (zcat access.log.*.gz), иначе можно «не найти» то, что случилось до ротации. И часовой пояс: stat на файл обычно показывает локальное время сервера, а в логе явно указан offset — сверяйте с его учётом.

Как отличить атаку от обычного поведения формы

Не любой быстрый GET после POST — атака: у части CMS легитимный сценарий тоже выглядит как «загрузили — сразу открыли», например превью аватара тем же браузером. Различает сочетание признаков:

ПризнакОбычная загрузкаПохоже на атаку
Расширение GET-запроса.jpg, .png, .pdf, .webp.php, .phtml, .jsp, без расширения
User-AgentПолноценный браузерpython-requests, curl, пустой
RefererСтраница вашего сайтаПусто или сторонний домен
IP-адресЗнакомый — офис, известный пользовательДата-центр, VPN-диапазон
Повторные запросы к файлуЕдинично, максимум параМного, с разными ?cmd=, ?p=

Отдельно проверьте, не идёт ли массовый перебор: форма атакуется не единичным ударом, а десятками попыток с разными именами и расширениями с одного источника, пока не найдётся комбинация, которую пропускает валидация. Даже если ни один GET следом не «выстрелил» — это разведка боем, источник стоит заблокировать.

Порядок действий, если паттерн подтвердился

Если корреляция нашлась — считайте компрометацию подтверждённой и действуйте по порядку, не удаляя файл первым делом:

  1. Скопируйте файл в изолированную директорию вне веб-корня — понадобится разобраться, что он делает.
  2. Пройдите по всей истории обращений к нему, включая архивные ротации — команды в ?cmd=, попытки скачать дополнительные файлы.
  3. Проверьте, не появились ли соседние файлы тем же способом примерно в то же время — рядом с одним шеллом часто закрепляют запасной.
  4. Проверьте cron, systemd-таймеры и профиль оболочки пользователя веб-сервера на закрепление, не зависящее от файла в uploads.
  5. Смените секреты, до которых мог дотянуться процесс веб-сервера — пароли БД, ключи в .env.

Пошаговый разбор действий на этапе «нашли файл, что дальше» — в статье нашли веб-шелл, что делать по порядку и чего не делать. Если шелл использовался активно — масштаб выходит за рамки одной формы, стоит следовать плану: что делать при взломе сервера.

Валидация и защита на будущее

Разовая находка закрывает инцидент, но не саму уязвимость — форма как принимала файлы по расширению, так и принимает. Проверяйте содержимое на уровне приложения по magic bytes, а не по расширению и не по Content-Type из запроса:

$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $uploaded_file_tmp_path);
$allowed = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (!in_array($mime, $allowed, true)) {
    http_response_code(415);
    exit('Недопустимый тип файла');
}

Генерируйте имя файла на сервере (случайную строку или UUID), а не берите присланное пользователем — убирает трюки с двойными расширениями и обход путём вида ../../config.php.

Запретите исполнение любого кода в директории загрузок на уровне веб-сервера — эта защита срабатывает, даже если валидация приложения где-то окажется дырявой. Конкретные конфиги для nginx и Apache — в статье про поиск чужого PHP в uploads. При настройке сервера с нуля стоит пройтись по чек-листу безопасности нового сервера — запрет исполнения в uploads там один из пунктов.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

В access.log нет содержимого POST-запроса — как узнать, что именно загрузили?

Стандартный access.log не пишет тело multipart-запроса, только факт запроса, путь и код ответа. Ориентируйтесь на файл, оставшийся на диске, и последующие GET-запросы к нему.

Логи уже сгорели по ротации — есть другой способ проверить постфактум?

Частично. Время изменения (mtime) подозрительного файла само по себе говорит, когда он появился. Проверьте auth.log и cron на то же время — но без лога не увидите, какие команды выполнялись через шелл.

Быстрый GET после POST нашёлся, но расширение файла — .jpg. Это точно не атака?

Не гарантия. Если сервер не проверяет magic bytes, а код где-то исполняет файл по имени без учёта расширения, вредоносный код в файле с картиночным расширением тоже сработает.

Сколько хранить логи для такой проверки?

Практический минимум — 30 дней, доступных для грепа без восстановления из бэкапа. Многие уязвимости эксплуатируют не в день обнаружения, а спустя недели после скана, который нашёл дырявую форму заранее.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →