
Ниже — последовательность расследования для Linux-сервера с Nginx или Apache и PHP-FPM: сохранить материалы, найти изменённые файлы, сопоставить их с запросами, проверить закрепление и восстановить проверенный экземпляр. Пример рассчитан на GNU/Linux и Python 3. Команды анализа выполняются на изолированном узле по копии данных; действия на действующем сервере явно отмечены. Итогом должны стать список отличий от эталона, временная линия, подтверждённая причина и проверка устранения, а не вывод «grep ничего не нашёл».
1. Подготовьте рабочие пути и сохраните факты
В примерах используются три разные директории: /mnt/site-snapshot — копия проверяемого сайта, смонтированная для чтения; /mnt/trusted-release — проверенный релиз той же версии; /srv/ir/case-20261011 — защищённый каталог результатов. Перед началом замените пути своими. При компрометации root доверять программам и выводу заражённой ОС нельзя: снимок анализируйте инструментами чистого узла. Не запускайте PHP из исследуемой копии.
umask 077
CASE=/srv/ir/case-20261011
mkdir -p "$CASE"
findmnt -T /mnt/site-snapshot
findmnt -T /mnt/trusted-release
date -u --iso-8601=seconds > "$CASE/analysis-start.txt"
В выводе findmnt проверьте источник и опции монтирования. Режим ro снижает риск изменения файлов, но не делает неизвестное содержимое безопасным для выполнения. Если есть только живой сервер, сначала сохраните snapshot инфраструктуры, согласованный дамп базы и журналы; файловый архив не гарантирует согласованность базы. Документируйте время и способ получения копии, включая исключённые mount points.
Для архива уже полученного snapshot используйте отдельный каталог назначения. GNU tar сохраняет дополнительные атрибуты и ссылки без разыменования. Следующая команда не копирует отдельные вложенные файловые системы: их нужно учитывать самостоятельно.
tar --acls --xattrs --numeric-owner --one-file-system \
-cpf "$CASE/site-snapshot.tar" -C /mnt/site-snapshot .
sha256sum "$CASE/site-snapshot.tar" > "$CASE/site-snapshot.tar.sha256"
Резервную копию оригинала храните отдельно от рабочей копии. Исходные URL, cookie и секреты из конфигурации не включайте в публичный отчёт. Общие принципы работы с веб-шеллами описаны в материале CISA.
2. Снимите состояние сервера до очистки
Если есть разрешённый доступ к ещё работающему серверу и его инструменты заслуживают доверия, сохраните процессы, соединения, слушающие порты и сервисы. Команды ниже читают состояние; выгрузки могут содержать чувствительные аргументы процессов. ss -p покажет часть процессов только при достаточных правах.
ps -eo user,pid,ppid,lstart,etime,args --forest > "$CASE/processes.txt"
ss -lntup > "$CASE/listeners.txt"
ss -ntup > "$CASE/connections.txt"
systemctl list-timers --all --no-pager > "$CASE/timers.txt"
systemctl list-units --type=service --all --no-pager > "$CASE/services.txt"
Ищите процессы web-пользователя с неожиданными дочерними оболочками, исполняемыми файлами из временных каталогов и неизвестными исходящими соединениями. Это признаки для исследования, а не автоматический диагноз. Запишите PID, родительский PID, путь и время. Для конкретного процесса можно прочитать readlink /proc/1234/exe и cat /proc/1234/cgroup, заменив PID. Удалённый executable может отображаться с (deleted); не завершайте процесс до сохранения нужных фактов, если это не противоречит срочной изоляции.
Состояние /proc не сохраняется обычным файловым архивом сайта. При изоляции внешнего трафика сохраните согласованный канал администрирования; правила карантина зависят от инфраструктуры и не должны применяться без проверки схемы сети.
3. Найдите новые файлы и необычные права
На read-only копии выделите изменённые объекты и исполняемые файлы в каталогах данных. mtime изменяем, поэтому это только один из источников. Сохраняйте также размер, владельца и права.
find /mnt/site-snapshot -xdev -type f \
-newermt '2026-10-09 00:00:00 UTC' \
-printf '%TY-%Tm-%TdT%TH:%TM:%TS %m %u:%g %s %p\n' \
> "$CASE/recent-files.txt"
find /mnt/site-snapshot -xdev -type f -perm -0002 \
-printf '%m %u:%g %p\n' > "$CASE/world-writable-files.txt"
find /mnt/site-snapshot -xdev -type l \
-printf '%p -> %l\n' > "$CASE/symlinks.txt"
find /mnt/site-snapshot/upload -type f \
-iregex '.*\.\(php[0-9]*\|phtml\|phar\)\(\..*\)?$' -print \
> "$CASE/upload-executable-candidates.txt"
Каталог upload условный. Отсутствующий путь даст ошибку: используйте фактические каталоги загрузок, кеша и временных данных. Наличие world-writable кода или ссылки наружу требует проверки архитектуры, но само по себе не доказывает взлом. -xdev не обходит отдельные mount points; перечислите их и исследуйте отдельными командами.
Для права записи от имени web-пользователя можно на доверенном восстановленном стенде выполнить sudo -u www-data test -w /srv/www/site/public/index.php. Код 0 означает возможность записи для выбранного пользователя; на BitrixVM и других платформах имя аккаунта может быть другим. Не меняйте права на заражённом оригинале до сохранения материалов.
4. Сравните SHA-256 по относительным путям
Два списка с разными абсолютными корнями нельзя честно сравнить обычным diff. Следующий скрипт формирует относительные имена и выдаёт NEW, CHANGED, MISSING. Символические ссылки сравниваются по цели, без чтения содержимого цели. Скрипт прекращает работу при ошибке чтения и не исключает upload или cache автоматически.
Сохраните код как /srv/ir/compare-site.py на доверенном анализирующем узле.
import hashlib, json, os, sys
from pathlib import Path
def walk_error(error):
raise error
def inventory(root):
root = Path(root).resolve(strict=True)
if not root.is_dir():
raise ValueError('Root is not a directory')
result = {}
for base, directories, files in os.walk(root, followlinks=False, onerror=walk_error):
base = Path(base)
links = [name for name in directories if (base / name).is_symlink()]
directories[:] = [name for name in directories if name not in links]
for name in files + links:
path = base / name
relative = path.relative_to(root).as_posix()
if path.is_symlink():
result[relative] = {'kind': 'link', 'target': os.readlink(path)}
elif path.is_file():
digest = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
result[relative] = {'kind': 'file', 'sha256': digest.hexdigest(),
'size': path.stat().st_size}
else:
result[relative] = {'kind': 'special'}
return result
if len(sys.argv) != 4:
raise SystemExit('Usage: compare-site.py TRUSTED SNAPSHOT REPORT.json')
try:
trusted = inventory(sys.argv[1])
observed = inventory(sys.argv[2])
changes = []
for name in sorted(trusted.keys() | observed.keys()):
status = ('NEW' if name not in trusted else
'MISSING' if name not in observed else
'CHANGED' if trusted[name] != observed[name] else None)
if status:
changes.append({'path': name, 'status': status,
'trusted': trusted.get(name),
'observed': observed.get(name)})
report = {'trusted_count': len(trusted), 'snapshot_count': len(observed),
'changes': changes}
Path(sys.argv[3]).write_text(json.dumps(report, ensure_ascii=False, indent=2),
encoding='utf-8')
print('Differences:', len(changes))
except (OSError, ValueError) as error:
print('Comparison incomplete:', error, file=sys.stderr)
raise SystemExit(2)
python3 /srv/ir/compare-site.py /mnt/trusted-release \
/mnt/site-snapshot "$CASE/differences.json"
Пример интерпретации: NEW upload/tmp/check.php — файл отсутствует в релизе; CHANGED index.php — содержимое или тип отличается; MISSING vendor/package/file.php — файл релиза пропал. Каждый кандидат проверяется отдельно. Новые пользовательские фотографии и штатный кеш тоже попадут в NEW — это ожидаемо. При необходимости создайте отдельный перечень подтверждённых исключений, сохранив полный первоначальный отчёт. Права и владельцы этим скриптом не сравниваются; их перечни собираются в предыдущем разделе.
5. Найдите закрепление вне основного PHP-файла
Исследуйте .user.ini, .htaccess, конфигурацию pool и виртуального хоста. Для уже скопированных конфигураций используйте текстовый поиск; не загружайте их заражённым интерпретатором.
find /mnt/site-snapshot -type f \
\( -name '.user.ini' -o -name '.htaccess' \) -print
grep -RInE --include='.user.ini' --include='.htaccess' \
'auto_(prepend|append)_file|AddHandler|SetHandler|php_value|RewriteRule' \
/mnt/site-snapshot > "$CASE/config-candidates.txt"
grep -RInE 'auto_(prepend|append)_file|php_admin_value|include_path' \
/mnt/server-config/php > "$CASE/php-pool-candidates.txt"
Последний путь означает отдельно собранные конфигурации сервера. Если файла там нет, сначала получите согласованную копию фактических /etc/php, конфигураций web-сервера и service overrides. Установленный PHP-FPM pool может включать дополнительные файлы; просмотрите include-цепочку. CLI php --ini не показывает все настройки FPM.
Для CGI/FastCGI .user.ini читается с учётом пути запроса и кеширования; по умолчанию период проверки составляет 300 секунд. Поэтому удалить файл и немедленно увидеть тот же эффект возможно без повторного заражения. После сохранения материалов на чистом экземпляре примените согласованную перезагрузку FPM и проверьте запрос повторно. Эти свойства приведены в документации PHP.
Проверьте также persistence ОС на сервере или его полной копии:
sudo crontab -l -u www-data
sudo crontab -l -u root
sudo grep -RInE 'curl|wget|php|python|/tmp/|/var/tmp/' \
/etc/cron.d /etc/crontab /etc/systemd/system
sudo find /root /home -type f -path '*/.ssh/authorized_keys' -print
Отсутствие crontab у пользователя нормально и обычно даёт ненулевой код. Вывод с содержимым ключей или конфигураций не публикуйте. Подозрительный timer исследуйте через systemctl cat имя.timer и соответствующий service, включая drop-in файлы.

6. Проведите статический разбор кандидатов
Сначала сравните файл с эталоном, затем прочитайте его как текст. Не выполняйте php suspect.php, не подключайте его через браузер и не отдавайте неизвестный документ обработчику на рабочем сервере.
grep -RInE --include='*.php' --include='*.phtml' \
'eval[[:space:]]*\(|base64_decode[[:space:]]*\(|gzinflate[[:space:]]*\(|shell_exec[[:space:]]*\(' \
/mnt/site-snapshot > "$CASE/code-candidates.txt"
sha256sum /mnt/site-snapshot/upload/tmp/check.php
file --mime /mnt/site-snapshot/upload/tmp/check.php
sed -n '1,160p' /mnt/site-snapshot/upload/tmp/check.php
Путь кандидата замените реально найденным. MIME и расширение не устанавливают безопасность. Ищите нетипичные изменения: получение команды из HTTP-ввода, неожиданную запись PHP в upload, изменение авторизации, отправку конфигурации на внешний адрес и замаскированное подключение другого файла. Не публикуйте исполняемый вредоносный код в отчёте: достаточно пути, хеша, функций, описания поведения и связанной временной линии.
Антивирус используйте дополнительно на копии. Если установлен и обновлён ClamAV, clamscan --recursive --infected --log="$CASE/clamav.log" /mnt/site-snapshot создаст отчёт без автоматического удаления. Отрицательный результат означает отсутствие обнаружения текущими правилами, а не подтверждённую чистоту.
7. Сопоставьте найденный путь с HTTP и входами
Предположим, обнаружен upload/tmp/check.php. Найдите обращения в копиях текущих и ротированных access logs. zgrep работает с обычными и gzip-файлами; вариант ниже требует Bash для массивов и nullglob.
shopt -s nullglob
logs=(/mnt/log-copy/nginx/access.log*)
if [ "${#logs[@]}" -eq 0 ]; then
printf 'No access logs found\n' >&2
exit 2
fi
zgrep -hF '/upload/tmp/check.php' "${logs[@]}" > "$CASE/candidate-requests.txt"
В привычном combined log поля 1 и 9 — адрес и статус, но собственный формат может быть иным. Проверьте log_format, прежде чем использовать пример агрегации:
awk '{print $1, $9}' "$CASE/candidate-requests.txt" | \
sort | uniq -c | sort -nr > "$CASE/candidate-clients-statuses.txt"
Статус 200 не доказывает выполнение вредоносной команды: это только ответ HTTP. 404 также не доказывает отсутствия компрометации, если обработка перенаправляется через front controller. Ищите загрузку кандидата, вход администратора и запрос к уязвимому компоненту перед первым вызовом. При reverse proxy адресом клиента может оказаться proxy; доверять X-Forwarded-For можно только в корректно настроенной цепочке.
На действующем доверенном сервере дополнительно выгрузите системные события за выбранное окно:
sudo journalctl --utc --since '2026-10-09 00:00:00' \
--until '2026-10-11 00:00:00' -u ssh -u sshd --no-pager \
> "$CASE/ssh-window.txt"
Если служба называется иначе, используйте её фактическое имя и сохраните rotated auth logs. Не ограничивайте расследование IP одного запроса: адрес может принадлежать proxy, NAT или захваченному узлу.
8. Закройте исполнение в upload на чистом экземпляре
Ниже — конфигурация Nginx для выделенного URL-префикса /upload/, который обслуживает только статические файлы. Она добавляется в соответствующий server, а не в каждый сайт сразу. Вложенная regex-location возвращает 403 для распространённых PHP-расширений, внешний ^~ не даёт общему PHP-location перехватить этот префикс.
location ^~ /upload/ {
location ~* \.(?:php[0-9]*|phtml|phar)(?:/|$) {
return 403;
}
try_files $uri =404;
}
Если приложение намеренно обрабатывает /upload/ динамически, этот пример неприменим без переработки маршрутов. Сверьте root/alias, дополнительные locations и все настроенные обработчики. Правила выбора location описаны в документации Nginx.
Для Apache 2.4 используйте настройку реального filesystem-каталога в virtual host. Она не заменяет проверку других executable handlers:
<Directory "/srv/www/site/public/upload">
AllowOverride None
Options -Indexes -ExecCGI
<FilesMatch "(?i)\.(php[0-9]*|phtml|phar)$">
Require all denied
</FilesMatch>
</Directory>
Контекст FilesMatch описан в Apache core. Не применяйте Apache-пример на Nginx и не рассчитывайте, что .htaccess там читается. Перед reload сохраните исходный конкретный конфиг, выполните nginx -t или apachectl configtest; при ошибке не перезагружайте сервис.
На тестовом экземпляре создайте в upload контролируемый безвредный audit-probe.php с содержимым <?php echo "audit-probe";. Проверьте URL через curl -I https://test.example/upload/audit-probe.php: ожидается 403 и отсутствие передачи в PHP-FPM. Статический тестовый файл должен отдаваться как раньше. Проверьте также верхний регистр расширения и PATH_INFO, затем удалите только созданные пробы. Если PHP отдаётся исходным текстом, запрет тоже не принят: возможна утечка содержимого файлов.
9. Восстановите релиз и перевыпустите доступы
Соберите новый сервер или контейнер из доверенного образа. Разверните проверенный релиз, данные переносите отдельно после анализа; оставленный в старой копии PHP нельзя переносить с upload «для сохранения всего». На чистом build-узле при Composer рассмотрите composer install --no-dev --no-plugins --no-scripts по доверенному lock-файлу, затем отдельно разрешите только разобранные необходимые build-операции. Не запускайте composer из заражённого checkout.
Код и данные разделите по правам: web-процесс читает код, пишет только в согласованные каталоги. Не применяйте массовый chmod 777. Права проверьте от фактического пользователя pool. Закройте исходную уязвимость и только затем передавайте новому экземпляру новые DB/SMTP/API-секреты. Отзывайте старые ключи, сеансы и учётные записи с контролем интеграций. Старый узел не должен иметь доступ к перевыпущенным секретам.
10. Проверьте устранение и сохраните технический отчёт
После переключения повторите проверку upload, авторизации, служебных заданий, исходящих соединений и файловой целостности. Сохраните доверенный manifest нового релиза вне webroot. Сравните его после периода наблюдения; динамические данные учитывайте отдельным перечнем. HTTP 200 главной страницы не заменяет проверки рабочих сценариев и закрытой причины.
В отчёте приведите: точный период и часовой пояс, источники данных, список NEW/CHANGED/MISSING, хеши подтверждённых кандидатов, связанные запросы и действия, механизм закрепления, устранённую причину и результаты отрицательных тестов. Отдельно укажите неполные журналы и непроверенные узлы. Если новый экземпляр не проходит приёмку, возвращайтесь к доверенной точке восстановления по согласованной процедуре; заражённый оригинал не является безопасным rollback.
Связанные инструкции: ограничения доступа Apache, корпоративные правила безопасности.
