Перейти к содержанию
Обсудить задачу →
Статьи и инструкции

Как проверить PHP-сайт на взлом: техническая инструкция для администратора

Как проверить PHP-сайт на взлом: SHA-256 и сравнение релиза, процессы, HTTP-логи, закрепление, запрет PHP в upload и проверка чистого восстановления.
Иллюстрация к инструкции: Как проверить PHP-сайт на взлом: файлы, журналы, доступы и восстановление

Ниже — последовательность расследования для 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 файлы.

Расследование PHP-сайта: копия, SHA-256 по относительным путям, журналы запросов, закрепление и проверка запрета исполнения
Порядок проверки и настройки. Команды, ограничения и диагностика приведены в статье.

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, корпоративные правила безопасности.