MAATRIX / Блог / Файл выглядит как картинка, но исполняется: разбор находки

Файл выглядит как картинка, но исполняется: разбор находки

MAATRIX

В конце августа 2026 года к нам пришла заявка на разбор находки: в папке с загруженными пользователями аватарками лежал файл avatar_92f1.jpg, который открывался в браузере как обычная картинка, проходил валидацию по расширению и даже показывал корректное превью — но при прямом обращении через уязвимый обработчик на сервере из него неожиданно выполнялся код. Разбираем, как файл может одновременно быть картинкой и исполняемым содержимым, как это проверить руками за пять минут и что настроить на сервере, чтобы такие файлы были безопасны даже если случайно туда попадут.

Что нашли и почему это не баг антивируса

Файл действительно был валидным JPEG — открывался в любом просмотрщике, отдавал корректные EXIF-данные, проходил проверку getimagesize() в PHP (частый способ «проверить, что это картинка» в старом коде). Антивирус на сервере его не трогал, потому что сигнатур известных вредоносов внутри не было — там был не готовый шелл с сигнатурой, а фрагмент PHP-кода, обёрнутый так, что он не портил структуру JPEG.

Ключевой момент: изображение и исполняемый код — это байтовые потоки, интерпретируемые разными парсерами по разным правилам. JPEG-декодер ищет маркеры FFD8 в начале и FFD9 в конце, а всё, что лежит внутри, ему в целом безразлично, если общая структура не нарушена. PHP-интерпретатор, наоборот, ищет <?php где угодно в потоке байт и начинает выполнять код с этого места, не заботясь о том, что было до и после. Два независимых парсера применили к одному файлу свои правила — и оба нашли в нём «свой» валидный контент. Такой файл называют polyglot: он одновременно корректен с точки зрения нескольких форматов.

Сам по себе такой файл на диске не опасен ни для пользователя, ни для сервера — открытая в браузере или редакторе картинка не «выполнится» просто от того, что внутри неё лежит PHP-код: браузер декодирует JPEG как JPEG. Опасность возникает только тогда, когда файл передаётся PHP-интерпретатору как код — то есть когда серверная конфигурация или уязвимость в приложении заставляют веб-сервер попытаться исполнить .jpg как скрипт, или когда код внутри картинки инклюдится (include/require) в другой PHP-файл.

Как проверить реальный тип файла независимо от расширения

Расширение — это метаданные имени файла, ни к чему не обязывающие. Реальный тип определяется по содержимому, и первый инструмент здесь — file, который смотрит на magic bytes (сигнатурные байты в начале файла) и делает вывод по базе /usr/share/misc/magic:

file avatar_92f1.jpg
# avatar_92f1.jpg: JPEG image data, JFIF standard 1.01, ...

Важно понимать ограничение: file честно скажет, что это JPEG, если структура заголовка валидна — он не ищет внутри файла посторонний исполняемый код, это не антивирусный сканер. Poliglot-файл с корректным JPEG-заголовком пройдёт эту проверку так же чисто, как и обычная картинка. Поэтому file — это первый фильтр («точно не замаскированный .php под чужим расширением»), а не гарантия чистоты содержимого.

Более строгая проверка — сравнить заявленный MIME-тип с реальным через file --mime-type:

file --mime-type -b avatar_92f1.jpg
# image/jpeg

Если сервер принимает загрузку файлов от пользователей, эта проверка должна идти на бэкенде при приёме файла, а не полагаться на Content-Type из HTTP-запроса (его полностью контролирует клиент) и не только на расширение. В PHP разумный минимум — finfo_file() из расширения fileinfo вместо getimagesize() в одиночку, потому что getimagesize() в некоторых версиях можно обмануть повреждённым, но частично валидным заголовком:

$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $tmpFilePath);
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
    throw new RuntimeException('Недопустимый тип файла');
}

Но даже эта проверка не гарантирует, что внутри валидного JPEG нет постороннего кода — она отсекает файлы, которые вообще не являются картинками. Дальше нужен ручной осмотр содержимого.

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

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

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

Как посмотреть на содержимое: hexdump и strings

Чтобы увидеть, что реально лежит внутри файла, а не доверять чужой интерпретации, смотрим байты напрямую. xxd или hexdump -C показывают первые байты в hex и ASCII одновременно:

xxd avatar_92f1.jpg | head -20

Валидный JPEG начинается с ff d8 ff e0 (или ff d8 ff e1 для файлов с EXIF) и заканчивается маркером ff d9. Дальше ищем текстовые фрагменты, которые не характерны для бинарного изображения:

strings avatar_92f1.jpg | grep -iE '<\?php|eval\(|base64_decode|system\(|shell_exec|passthru'

strings вытаскивает из бинарного файла всё, что похоже на печатаемый текст длиной от 4 символов — и в обычной фотографии, кроме служебных EXIF-полей (модель камеры, координаты, программа обработки), никакого читаемого кода быть не должно. Находка <?php посреди «картинки» — однозначный признак вставки, обычно в конец файла после маркера FFD9 (декодеру JPEG эти байты уже не важны, он остановился раньше) или внутрь COM-сегмента (маркер комментария FFFE), который многие декодеры пропускают, не проверяя содержимое.

Ещё один полезный трюк — сравнить размер файла с ожидаемым для такого разрешения. Если картинка 800×600 весит подозрительно больше аналогичных фото с той же камеры — стоит посмотреть на хвост файла через tail -c 2000 avatar_92f1.jpg | xxd, где часто и обнаруживается дописанный код после конца JPEG-потока.

Для системной проверки на сервере можно прогнать директорию загрузок через grep рекурсивно по бинарным файлам:

grep -rlaE '<\?php|<%[^-]|<script[^>]*>.*eval' /var/www/uploads/ 2>/dev/null

Флаг -a заставляет grep обрабатывать бинарные файлы как текстовые (иначе он молча пропустит совпадение с сообщением «binary file matches»), -l выводит только имена файлов с совпадением. Это грубый, но быстрый способ найти явные вставки кода в папке, где лежат тысячи загруженных пользователями файлов — прежде чем разбирать каждый вручную.

Типичные признаки polyglot-файла

На практике встречаются несколько устойчивых паттернов, по которым такие файлы выделяются на фоне обычных изображений:

ПризнакЧто смотретьНа что указывает
Размер сильно больше нормы для разрешенияls -la + визуальное сравнениеДописанные данные после конца изображения
Код после маркера конца JPEG (FFD9)`tail -c N file.jpg \xxd`Классическая схема: валидный JPEG + хвост с PHP
Текст в COM-сегменте (FFFE) или EXIF-поляхstrings, exiftool file.jpgКод спрятан в служебных метаданных, которые декодер не проверяет
PNG с лишними chunk'ами неизвестного типаpngcheck -v file.pngPNG допускает произвольные ancillary-чанки — туда тоже можно вписать что угодно
Файл одновременно валиден как два формата (например GIF89a в начале + ZIP в конце)binwalk file.jpgКлассическая «GIFAR»-конструкция или архив, склеенный с изображением
Расширение не совпадает с реальным MIMEfile --mime-typeПростейшая, но и самая частая маскировка — переименованный .php под .jpg
Необычно длинные или закодированные строки (base64) в EXIF-полях UserComment, Copyrightexiftool -a -u -g1 file.jpgПолезная нагрузка спрятана в текстовых метаданных, которые редко проверяют

binwalk стоит отдельного упоминания — утилита ищет сигнатуры встроенных файлов внутри бинарных blob'ов (обычно применяется к прошивкам, но так же полезна для поиска «файла в файле»):

binwalk avatar_92f1.jpg

Вторая сигнатура (например ZIP или ELF) далеко не в начале файла — явный признак, что перед вами не просто картинка.

Почему сама по себе картинка с кодом внутри не опасна

Важно закрыть частое заблуждение: файл, который является валидным JPEG и содержит внутри себя текст <?php ... ?>, не опасен сам по себе для сервера, который просто отдаёт его как статику. Nginx или Apache, настроенные раздавать /uploads/ как обычные файлы, прочитают байты, отдадут их браузеру с заголовком Content-Type: image/jpeg, и браузер честно нарисует картинку — PHP-код внутри так и останется нетронутым текстом, никто его не исполнит.

Опасность возникает в трёх типичных сценариях, и все три — не про сам файл, а про то, как сервер или приложение с ним обращаются:

  1. Веб-сервер конфигурирован исполнять файлы по неверному признаку. Классическая грабля старых связок Apache + mod_php: обработчик PHP навешан по маске, которая шире, чем нужно (регэксп с .php где-то в имени вместо строгого совпадения расширения в конце строки), и файл вида avatar.php.jpg проходит через PHP-обработчик.
  2. В приложении есть Local File Inclusion (LFI). Код вида include($_GET['page'] . '.php') позволяет подставить путь до загруженного файла — PHP-интерпретатор откроет avatar_92f1.jpg не как картинку, а как исходный код для инклюда, и выполнит всё, что найдёт после <?php. Расширение вообще не имеет значения для include() — оно проверяет только литерально переданный путь.
  3. Файл лежит в директории, где включено исполнение любых скриптов, и через неверно настроенный location в nginx запрос до него попадает в PHP-FPM напрямую.

Во всех трёх случаях корень проблемы — не то, что файл «заражён», а то, что сервер согласился исполнить содержимое папки, предназначенной для хранения пользовательских данных. Это тот же класс ошибки, что и в разборе инцидента, где веб-шелл нашли в папке загрузок и сервер рассылал фишинг — там файл вообще не маскировался под картинку, но причина та же: исполняемый код оказался доступен там, где его быть не должно.

Как настроить сервер, чтобы .jpg никогда не исполнялся как код

Единственная надёжная защита — не полагаться на проверку содержимого при загрузке (она снижает риск, но не закрывает его полностью), а сделать так, чтобы директория с пользовательскими файлами физически не могла интерпретироваться как код, независимо от расширения или содержимого.

Для nginx с PHP-FPM самое надёжное — явно запретить передачу в PHP-обработчик всего, что лежит в папке загрузок, отдельным location, который стоит выше общего PHP-блока:

location ^~ /uploads/ {
    # запрещаем исполнение чего бы то ни было в этой директории
    location ~ \.php$ {
        deny all;
        return 403;
    }
    # сами файлы отдаём как статику с безопасными заголовками
    add_header X-Content-Type-Options nosniff;
    try_files $uri =404;
}

location ~ \.php$ {
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    include fastcgi_params;
}

Обратите внимание на ^~ — модификатор говорит nginx не продолжать проверку регулярными location'ами, если совпал префиксный, и это критично: без него файл с двойным расширением всё ещё может свалиться на общий PHP location ниже по конфигу. Про грабли с приоритетом location'ов — отдельный разбор: правило nginx поставили выше, и трафик ушёл в заглушку.

Заголовок X-Content-Type-Options: nosniff тоже не лишний — запрещает браузеру «угадывать» тип содержимого по байтам вместо заявленного Content-Type. Подробнее про такие заголовки — в статье про настройку security headers для сайта.

Для Apache то же самое достигается через .htaccess в самой папке загрузок (или, лучше, в основном конфиге виртуального хоста, потому что .htaccess можно случайно перезаписать при деплое):

<Directory "/var/www/site/uploads">
    <FilesMatch "\.(php|phtml|php3|php4|php5|php7|pht|phar)$">
        Require all denied
    </FilesMatch>
    php_admin_flag engine off
</Directory>

Директива php_admin_flag engine off — более сильная мера: она полностью выключает PHP-обработчик для директории, а не просто блокирует файлы по маске расширения. Это закрывает и обходные варианты вроде .pHp, .php5, .phtml, которые иногда забывают добавить в regex.

Более радикальный уровень защиты — вынести загруженные файлы на отдельный поддомен или сервис (объектное хранилище, CDN, отдельный VPS без PHP), который физически не умеет исполнять код. Тогда вопрос «а что если злоумышленник обойдёт правило в конфиге» снимается полностью. Для проектов, где загрузки — это в первую очередь аватарки и картинки в CMS, это часто проще в эксплуатации, чем городить исключения в конфиге основного сервера.

Что проверить дополнительно, если находка уже случилась

Если polyglot-файл или подозрительный код в загрузках уже найден, точечное удаление одного файла закрывает симптом, но не причину. Порядок действий:

  • Найти, как файл попал на сервер — через форму загрузки без проверки типа, уязвимость в стороннем плагине CMS, или его положил туда администратор для теста. Логи веб-сервера (access.log) и время создания файла (stat filename) обычно дают прямой ответ.
  • Проверить, не появилось ли следов закрепления в системе. После первого проникновения это часто выглядит как reverse shell в cron у пользователя, которого не заводили — стоит свериться с crontab -l для всех пользователей и системными таймерами.
  • Прогнать сервер на признаки более серьёзной компрометации, если находка не единичная — общий подход описан в материале как проверить сервер на майнер и вирусы.
  • Закрыть саму дыру — проверку типа при загрузке, конфиг сервера из раздела выше и права на директорию: find /var/www/uploads -type f -exec chmod 644 {} \;, сама директория — chmod 755 без бита исполнения для файлов.

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

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

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

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

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

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

Если антивирус ничего не находит в файле, значит он безопасен?

Не обязательно. Антивирусы ищут известные сигнатуры, а простая вставка <?php system($_GET['c']); ?> в конец картинки может не совпасть ни с одной сигнатурой, оставаясь полноценным веб-шеллом при определённых условиях исполнения. Проверка содержимого руками (strings, hexdump) и конфигурация сервера — более надёжная защита, чем ожидание срабатывания антивируса.

Достаточно ли проверять расширение файла при загрузке?

Нет. Расширение полностью управляется тем, кто загружает файл. Проверка должна идти по реальному содержимому (magic bytes через finfo/file), и даже она не гарантирует отсутствие встроенного кода — финальная защита всегда на уровне сервера, который не должен исполнять файлы из папки загрузок.

Можно ли просто пересжать загруженные изображения, чтобы убрать возможный код?

Это существенно снижает риск для растровых форматов — при перекодировании через GD или libvips декодер разбирает изображение до пиксельных данных и заново сохраняет их, отбрасывая посторонние байты. Хорошая дополнительная мера, но не замена правильной конфигурации сервера.

Влияет ли выбор веб-сервера на риск такой атаки?

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

Нужно ли что-то менять, если сайт не принимает загрузку файлов от пользователей?

Риск ниже, но не нулевой — логотипы и импортированный контент от третьих лиц тоже могут содержать встроенный код, если попадают на сервер не через доверенный пайплайн. Правило «директория со сторонним контентом не исполняется как код» стоит применять независимо от источника файлов.

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

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

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