Directus обновлён. Теперь найдём файлы, которых не видно в UI
Directus обновили, библиотеку файлов просмотрели, ничего подозрительного не нашли. Закрывать задачу рано: содержимое хранилища могло измениться без изменений в базе. Я, Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш», покажу, как проверить обе стороны: найти объекты без записей в directus_files и обнаружить подмену файлов, которые исправно отображаются в интерфейсе.
Что исправил патч и почему библиотека файлов ничего не доказывает
Речь о CVE-2025-55746, опубликованной 20 августа 2025 года. Для пакета directus указан диапазон уязвимых версий >=10.8.0 и <11.9.3; исправление вышло в 11.9.3. Атакующему требовались сетевой доступ и UUID существующего файла, но не учётная запись. Уязвимость позволяла менять содержимое существующих файлов без обновления метаданных либо создавать объекты, отсутствующие в библиотеке. Поэтому привычная проверка «последние загруженные файлы выглядят нормально» здесь почти бесполезна. [Карточка CVE и границы версий](https://github.com/advisories/GHSA-mv33-9f6j-pfmc).
На 5 сентября 2026 года версия 11.9.3 — историческая точка исправления конкретной ошибки. В списке релизов уже есть 12.3.1 от 25 августа 2026 года, а после старого CVE публиковались другие файловые уязвимости. Я проверяю фактически запущенный образ каждой реплики, его digest и историю развёртывания. Запись нового тега в Compose ещё не означает, что старый контейнер перестал обслуживать запросы. Переход между основными версиями сначала проверяю на копии проекта. [Релиз Directus 12.3.1](https://github.com/directus/directus/releases/tag/v12.3.1).
При этом пугать администратора немедленным захватом всей ОС неправильно. В исследованном local driver запись оставалась внутри корня хранилища; другие драйверы авторы первоначального исследования не проверяли. Загруженный PHP-файл сам по себе в Directus не исполняется, а статическая раздача Nginx не превращает его в программу. Эскалация зависит от обработчиков и размещения каталогов. Но подменённый документ с реквизитами уже способен навредить бизнесу без исполнения кода. Я выбираю две отдельные проверки: соответствие объектов записям и целостность содержимого. [Официальное описание последствий](https://github.com/directus/directus/security/advisories/GHSA-mv33-9f6j-pfmc).
Сначала фиксирую состояние и выясняю, где действительно лежат файлы
Первым делом ограничиваю доступ к подозрительной выдаче и согласую короткую остановку записи. Останавливаю все реплики Directus, фоновые задания и интеграции, которые меняют файлы. Одного запрета загрузки в интерфейсе недостаточно: другие процессы продолжают работать, а запросы изображений могут создавать производные файлы. Пока запись остановлена, сохраняю согласованные копии БД и хранилища. Локальный снимок подключаю только для чтения; отчёты складываю отдельно. Если согласованного состояния получить нельзя, прямо отмечаю возможные расхождения из-за параллельных изменений.
Дальше составляю карту storage locations. Значение storage в directus_files — алиас конфигурации, а не обязательно название драйвера. Проверяю нынешние и прежние locations, старые тома после миграций, реальные Docker mounts и bucket prefixes. Вот пример файловой части конфигурации, а не полный Compose: ```dotenv STORAGE_LOCATIONS="local,archive" STORAGE_LOCAL_DRIVER="local" STORAGE_LOCAL_ROOT="/directus/uploads" STORAGE_ARCHIVE_DRIVER="s3" STORAGE_ARCHIVE_BUCKET="vector-directus" STORAGE_ARCHIVE_ROOT="assets" ``` Для archive отдельно задаются регион, endpoint и способ аутентификации. Путь /directus/uploads внутри контейнера сопоставляю с конкретным томом или каталогом хоста. [Настройки хранилищ Directus](https://directus.com/docs/configuration/files).
Одновременно сохраняю access.log reverse proxy, журналы Directus, развёртываний и доступный аудит object storage. Окно проверки начинаю с момента появления доступной уязвимой версии, а не с даты публикации CVE. Фиксирую часовые пояса, идентификаторы снимков и SHA-256 собранных материалов. Историю Activity использую как дополнительный источник: изменение байтов вне нормального обновления метаданных могло там не отразиться. Автоматическую очистку временных объектов и lifecycle-процессы, способные уничтожить нужные версии, на время сохранения следов приостанавливаю.
Сверяю SQL с диском: готовый скрипт без удаления файлов
Список ожидаемых оригиналов получаю непосредственно из БД, с доступом ко всей таблице. UI зависит от прав и фильтров, а список через API дополнительно проходит сервисную обработку. Ни поиск по названию, ни выгрузка первой страницы здесь не подходят. Для PostgreSQL 16 использую следующий экспорт; service=directus_audit должен заранее указывать на нужную БД и учётную запись с правами чтения, каталог /srv/ir — существовать вне uploads: ```bash PGOPTIONS='-c default_transaction_read_only=on' \ psql 'service=directus_audit' -X -v ON_ERROR_STOP=1 \ --csv -P footer=off \ -c 'SELECT id, storage, filename_disk, filesize, uploaded_on, modified_on FROM public.directus_files' \ > /srv/ir/directus-files.csv ``` Если схема отличается от public, меняю её явно. [Параметры psql](https://www.postgresql.org/docs/16/app-psql.html).
Ключ сопоставления — пара storage и filename_disk. filename_download служит именем скачивания и для этой задачи не подходит. Перед автоматизацией отдельно разбираю пустые значения, повторяющиеся пары и подозрительные пути. Имена не обрезаю до basename: так легко склеить разные объекты. Ниже скрипт для одного неизменяемого локального снимка, проверенный на Python 3.12.3. Он перечисляет реальные файлы, не следует символическим ссылкам и не превращает значения из БД в команды оболочки. [Поля файлового объекта](https://docs.directus.io/reference/files).
Сохраняю код как audit.py. Четвёртый аргумент необязателен: это доверенный JSON-манифест вида {"filename_disk":"sha256"} для выбранного storage. Без него скрипт покажет, какие зарегистрированные файлы остались без проверки содержимого. ```python import csv, hashlib, json, os, sys from pathlib import Path root, export, storage = Path(sys.argv[1]), sys.argv[2], sys.argv[3] if root.is_symlink() or not root.is_dir(): raise ValueError("root must be a real directory") with open(export, newline="", encoding="utf-8") as f: rows = [r for r in csv.DictReader(f) if r["storage"] == storage] db = {r["filename_disk"]: r for r in rows} if len(db) != len(rows): raise ValueError("duplicate (storage, filename_disk)") actual, symlinks, special = {}, [], [] def fail(error): raise error for base, dirs, files in os.walk(root, followlinks=False, onerror=fail): for name in dirs + files: p = Path(base) / name key = p.relative_to(root).as_posix() if p.is_symlink(): symlinks.append(key) elif p.is_file(): actual[key] = p elif not p.is_dir(): special.append(key) dirs[:] = [d for d in dirs if not (Path(base) / d).is_symlink()] baseline = json.loads(Path(sys.argv[4]).read_text()) if len(sys.argv) > 4 else {} common = actual.keys() & db.keys() size_mismatch = [k for k in common if db[k]["filesize"] and actual[k].stat().st_size != int(db[k]["filesize"])] changed = [] for key in common & baseline.keys(): with actual[key].open("rb") as f: if hashlib.file_digest(f, "sha256").hexdigest() != baseline[key]: changed.append(key) result = {"storage": storage, "actual_count": len(actual), "registered_count": len(db), "orphan": sorted(actual.keys() - db.keys()), "missing": sorted(db.keys() - actual.keys()), "symlinks": sorted(symlinks), "special": sorted(special), "size_mismatch": sorted(size_mismatch), "hash_changed": sorted(changed), "hash_unchecked": sorted(common - baseline.keys())} print(json.dumps(result, ensure_ascii=False, indent=2)) ``` Запуск: `python3 audit.py /mnt/snapshot/uploads /srv/ir/directus-files.csv local /srv/ir/baseline.json > /srv/ir/local-report.json`. Для другого location повторяю проверку с его корнем и манифестом.
orphan означает «есть на диске, нет соответствующей записи», missing — обратную ситуацию. Это кандидаты на разбор. Производные изображения штатно могут существовать без отдельных строк directus_files; встречаются остатки прерванных операций и миграций. Узнаваемое имя временного файла не подтверждает безопасность: его можно имитировать. Производные проверяю по происхождению либо пересоздаю из проверенного оригинала после сохранения следов. Ошибку чтения каталога нельзя считать пустым результатом. [Создание производных в исходниках Directus](https://github.com/directus/directus/blob/v11.16.1/api/src/services/assets.ts).
В S3 проверяю ключи, версии и область за пределами привычного префикса
В object storage применяю ту же логику, но сопоставляю полные ключи с учётом настроенного ROOT и реализации драйвера. Начинаю с инвентаризации всего bucket, доступного учётной записи приложения, если он входит в область расследования. Это особенно существенно для отдельной TUS-уязвимости GHSA-3742-46gx-c8cc: она затрагивала версии >=10.13.0 и <12.1.0, требовала включённого TUS и пользователя с правом создания файлов. Advisory описывает возможность обращения за пределы настроенного префикса object storage. Смешивать её условия с неаутентифицированным CVE 2025 года нельзя. [TUS advisory от 5 августа 2026 года](https://github.com/directus/directus/security/advisories/GHSA-3742-46gx-c8cc).
Для обычного S3 bucket сохраняю три отдельных отчёта через AWS CLI v2. Использую профиль аудитора с необходимыми правами чтения и перечисления: ```bash aws s3api list-objects-v2 --profile ir-readonly \ --bucket vector-directus --output json --no-cli-pager \ > /srv/ir/s3-current.json aws s3api list-object-versions --profile ir-readonly \ --bucket vector-directus --output json --no-cli-pager \ > /srv/ir/s3-versions.json aws s3api list-multipart-uploads --profile ir-readonly \ --bucket vector-directus --output json --no-cli-pager \ > /srv/ir/s3-multipart.json ``` CLI автоматически обходит страницы. Не добавляю --no-paginate, ограничивающий --max-items или сворачивающий ключи --delimiter. --no-cli-pager отключает только экранный просмотрщик. Проверяю успешное завершение каждой команды. [Перечисление объектов](https://docs.aws.amazon.com/cli/latest/reference/s3api/list-objects-v2.html), [незавершённые multipart uploads](https://docs.aws.amazon.com/cli/latest/reference/s3api/list-multipart-uploads.html).
Из полного JSON сохраняю Key, Size, LastModified, ETag, VersionId и DeleteMarkers там, где они присутствуют. Текущий список не заменяет историю версий и не показывает незавершённые multipart uploads обычного bucket. Ключи сравниваю точно, без самовольного декодирования или удаления сегментов. При скачивании образца назначаю собственное локальное имя, например object-0001.bin: недоверенный ключ не должен становиться путём на рабочей машине. При наличии версий скачиваю конкретный VersionId. [Перечисление версий](https://docs.aws.amazon.com/cli/latest/reference/s3api/list-object-versions.html).
Для российского S3-совместимого сервиса задаю его --endpoint-url и проверяю поддержку операций у провайдера. Совместимость API не гарантирует одинаковые возможности аудита. Версионирование, включённое сегодня, прежние перезаписанные байты не вернёт. История обращений тоже должна была собираться заранее: например, AWS CloudTrail по умолчанию не записывает object-level data events. Отсутствие таких событий не доказывает отсутствие записи. [Поведение версионирования](https://docs.aws.amazon.com/AmazonS3/latest/userguide/manage-objects-versioned-bucket.html), [настройка событий CloudTrail](https://docs.aws.amazon.com/us_en/AmazonS3/latest/userguide/enable-cloudtrail-logging-for-s3.html).
Подмену зарегистрированного файла ищу по содержимому
Самая неприятная категория проходит проверку имён без единого замечания. UUID прежний, filename_disk прежний, строка существует, файл скачивается. Изменились только байты. Поэтому сравниваю SHA-256 с независимо сохранённым манифестом либо вычисляю его по доверенной копии до предполагаемого вмешательства. Размер использую для быстрой сортировки кандидатов: заменить ссылку или реквизиты можно без изменения длины файла. Временные метки тоже не считаю доказательством. Новый манифест полезен для фиксации сегодняшнего состояния, но ничего не рассказывает о вчерашнем.
С S3 отдельно проговариваю ограничение ETag: это не SHA-256 и не универсальный MD5 содержимого. Multipart upload и некоторые варианты серверного шифрования меняют его смысл. Для спорных объектов предпочитаю локальный SHA-256 скачанной конкретной версии. Если доверенной копии нет, честный статус — «целостность не подтверждена». Антивирус, определение фактического типа и анализ структуры помогают расставить приоритеты, но чистый PDF с чужим номером счёта может пройти все эти проверки. Документы с платежами и ссылками на авторизацию отдаю владельцу процесса на содержательное сравнение. [Семантика ETag](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html).
Дальше связываю различия с временной линией: обращения к /files, изменения объектов, действия интеграций, штатные замены редакторами. Даже ответ 403 не исключает запись: в уязвимом пути байты попадали в storage раньше обновления метаданных с проверкой доступа. В исправлении добавлена очистка при ошибке этого обновления. Поэтому фильтр access.log только по успешным ответам даст неполную картину. Расхождение хеша подтверждает изменение содержимого, но причину ещё нужно установить. Подозрительные HTML, SVG и офисные документы исследую в изолированной среде, без рабочих сессий браузера. [Исправляющий commit Directus](https://github.com/directus/directus/commit/d84dcc36f75fc5c858d43746b8f9c426c38d696b).
Учебный стенд «Вектор»: пять лишних объектов и две незаметные подмены
Для проверки алгоритма я подготовил синтетический файловый стенд условного ООО «Вектор». Это воспроизводимое упражнение, а не история раскрытого клиентского инцидента. Контекст проекта — Directus после обновления, схема хранения из примера выше и экспорт формата PostgreSQL 16. Сам тест выполнялся на Python 3.12.3: Directus, S3 и эксплуатацию уязвимости в нём не запускали. Разделяю это специально: проверка алгоритма сверки не подтверждает защищённость конкретной установленной версии.
В исходном наборе было 1200 записей CSV и 1200 соответствующих текстовых файлов location local. Сначала сохранил чистые копии и полный SHA-256-манифест. Затем добавил пять объектов без записей: три явно обозначенных имитатора временных и производных файлов, а также два безопасных HTML/SVG-маркера неожиданных загрузок. После этого заменил содержимое двух зарегистрированных файлов. У первого сохранил исходный размер, у второго изменил. CSV оставил прежним — именно такую независимость метаданных от байтов и требуется проверять.
Результат первого прохода: actual_count — 1205, registered_count — 1200, orphan — 5, missing — 0. Проверка размера обнаружила одну замену, сравнение SHA-256 — обе. Получилась полезная демонстрация: совпадение всех зарегистрированных имён не исключило подмену, а пять объектов без записей не означали пять вредоносных файлов. Отдельно проверил имена с переводом строки, внешние символические ссылки и FIFO. JSON сохранил имена корректно; ссылки и специальный файл попали в отдельные списки, их содержимое не читалось.
Два неожиданных маркера перенёс в карантин за пределами проверяемого корня, два изменённых файла восстановил из чистых копий. Повторный проход показал 1203 файла, три объяснённых orphan-объекта, ноль отсутствующих оригиналов и ноль расхождений хешей. При запуске без baseline все 1200 зарегистрированных файлов оказались в hash_unchecked. Именно такой результат я хочу получать от рабочего инструмента: он показывает границы проверки. Замеры производительности маленького синтетического набора на многотерабайтное хранилище я не переношу.
Карантин, восстановление и условия, при которых я закрываю задачу
Подтверждённо подозрительный объект сначала сохраняю вместе с ключом, версией, метаданными и хешем, затем прекращаю его выдачу. Карантин размещаю вне публичного storage; для S3 предпочитаю отдельный закрытый bucket с отдельными правами. Переименование внутри публичного каталога изоляцией не считаю. Зарегистрированный оригинал восстанавливаю из проверенной копии с сохранением нужных связей и согласованием метаданных. Повреждённые производные пересоздаю после проверки оригинала. Затем сбрасываю затронутые кеши Directus, reverse proxy и CDN и проверяю выдачу по тем URL, которыми пользуется сайт.
Если обнаружились исполняемые файлы в загружаемых сервером каталогах, неизвестные процессы или изменения за пределами ожидаемого storage, расширяю расследование до контейнера и хоста. Здесь одной файловой уборки мало. При этом сам старый CVE не доказывает утечку всех секретов: решение о пересоздании окружения и ротации принимаю по установленной области доступа и признакам компрометации. Для обычной выдачи сохраняю доступ через API Directus и убираю ненужный прямой доступ к корню uploads. [Рекомендация Directus по доступу к файлам](https://docs.directus.io/reference/files).
После восстановления оставляю регулярную сверку зарегистрированных оригиналов, независимое хранение манифестов и журналов, версионирование с защитой истории от удаления учётной записью приложения. Отдельно контролирую критичные документы: изменение PDF с реквизитами должно иметь объяснимого автора и задачу. В заключении фиксирую объём проверки, пропуски и качество исходной базы. Формулировку «следов вмешательства не обнаружено в проверенных данных» использую только с этими границами. Без доверенных копий обещать полное отсутствие подмен нельзя.
- Все действующие и исторически использовавшиеся хранилища перечислены; недоступные области явно указаны.
- Объекты без записей, отсутствующие оригиналы, дубли ключей и подозрительные пути разобраны.
- Критичные файлы сверены с доверенными копиями; непроверенные объекты перечислены отдельно.
- Карантин недоступен пользователям, восстановленные файлы проверены через реальную цепочку выдачи.
Частые вопросы
Можно удалить всё, чего нет в directus_files?
Нет. Там могут быть производные изображения, остатки штатных операций и файлы других приложений. Сначала сохраните состояние и установите происхождение объектов.
Если количество файлов совпало с количеством записей, проверка закончена?
Нет. Количество не подтверждает совпадение ключей, а совпадение ключей не подтверждает целостность байтов. Нужны обе проверки.
Что делать, если старых хешей нет?
Использовать независимо сохранённые резервные копии или версии объектов. Если доверенного источника нет, зафиксировать, что целостность не подтверждена, и приоритетно проверить критичные документы.
Достаточно отключить TUS?
Это ограничение относится к отдельным TUS-уязвимостям. Оно не очищает хранилище и не заменяет обновление и проверку последствий старой ошибки /files.
Предлагаю индивидуальный разбор вашей ситуации в rf-buh и помощь в подготовке плана проверки. Для начала укажите версию Directus, используемые хранилища и наличие резервных копий.
Бесплатная консультация →

