.DS_Store. Раскрываем структуру директорий через системные файлы macOS
Как скрытый файл macOS Finder раскрывает дерево каталогов веб-приложения, что у него внутри и чем его обойти рекурсивно.
Введение
Возможно, вы уже видели этот файл. Подключаете USB-накопитель от коллеги на macOS, включаете отображение скрытых файлов — и он лежит в каждом каталоге: .DS_Store. Операционная система Apple создаёт его, чтобы помнить, как отображать директорию: позиции иконок, режим просмотра, размер окна, порядок сортировки, фон. Ближайшие эквиваленты в Windows — desktop.ini и Thumbs.db.
Интересно другое — что ещё он помнит. Чтобы хранить состояние отображения каждого элемента, Finder обязан завести запись на каждый элемент в директории. А значит, файл хранит названия файлов и вложенных директорий, рядом с которыми лежит.
Теперь представьте разработчика, который собирает сайт на MacBook и переносит его на веб-сервер «как есть»: перетаскивает папку проекта в SFTP-клиент, делает rsync -a рабочего каталога, пишет COPY . . в Dockerfile, коммитит всё без .gitignore. Файлы .DS_Store уезжают вместе с проектом. С этого момента веб-сервер выдаёт листинг директорий любому, кто его попросит, — даже с выключенным autoindex, даже для файлов, на которые нигде нет ссылок.
TL;DR — я написал инструмент, который рекурсивно обходит такие файлы и восстанавливает дерево каталогов целевого веб-сервера: github.com/vflame6/dsstore-tree.
После того как я разобрался в теме, я резко побежал удалять
.DS_Storeна нескольких своих веб-приложениях 😅
Почему это лучше перебора директорий
Обычный content discovery — это словарь, брошенный в цель, и наблюдение за кодами ответа. У подхода есть жёсткий потолок: вы находите только то, что кто-то уже положил в словарь.
.DS_Store переворачивает схему. Сервер сам сообщает реальные имена:
- Не нужно угадывать.
backup_2024_final.sqlилиdb_dump_prod.tgzникогда не появятся вraft-large-files.txt. Зато они будут в.DS_Store. - Почти нет шума. Один запрос на директорию вместо десятков тысяч. В логах WAF ничего не похоже на атаку.
- Имена того, что не отдаётся наружу. Запись существует, потому что файл лежал в папке на Mac разработчика. Finder не спешит вычищать записи, поэтому они могут пережить сами файлы — сегодняшний
404всё равно расскажет, что там было раньше и какие соглашения об именовании приняты в команде. - Рекурсия. У каждой вложенной директории обычно есть свой
.DS_Store. Пройдите по ним — и получите дерево целиком, а не один уровень.
Что обычно выпадает из такого дерева на реальном проекте: дампы .sql и .db, архивы .tgz / .zip с document root, забытые редактором .swp и .bak, config.old, копии .env, приватные ключи и админские пути, «спрятанные» простым отсутствием ссылок.
Это не теория. Файл .DS_Store на публичном веб-сервере Microsoft Vancouver раскрыл корень сайта и учётные данные WordPress. Ещё в 2015 году тот же класс уязвимости привёл к доступу в админ-панель TCL. Поисковики индексируют эти файлы, так что часть экспозиции находится вообще без обращения к цели, а сканеры давно подтянулись: у Tenable WAS есть плагин 98646 ровно на этот случай, а OWASP ZAP добавил разбор .DS_Store в 2023 году.
Что на самом деле внутри файла
Формат не документирован Apple и был отреверсен сообществом — опорная статья это Parsing the .DS_Store file format Себастьяна Нефа. Коротко, потому что это объясняет, как именно ломаются парсеры:
Всё в big-endian. Файл начинается с 36-байтного заголовка: 4-байтное значение выравнивания 0x01, затем сигнатура 0x42756431 — ASCII Bud1 — затем смещение и размер корневого блока (обычно 0x1000 и 0x800).
Bud1 — это buddy-аллокатор. Адреса блоков упакованы: пять младших бит хранят k, где размер блока равен 2^k; обнулив эти биты, получаем смещение. Корневой блок содержит три секции — таблицу смещений, table of contents как минимум с записью DSDB, указывающей на первый обходимый блок, и free list из 32 корзин.
Сами данные — это B-дерево. Каждый блок начинается с режима: 0x00 означает, что дальше идут записи напрямую, любое другое значение — что это внутренний узел и нужно спускаться в потомков.
Запись выглядит так:
1
2
3
4
5
[4 байта] длина имени файла (в кодовых единицах UTF-16)
[N байт] имя файла, UTF-16 big-endian
[4 байта] ID структуры напр. Iloc, bwsp, dscl, lsvo, vSrn
[4 байта] тип структуры напр. blob, long, bool, type, ustr
[переменно] данные, длина зависит от типа
ID структур — это метаданные отображения: Iloc — координаты иконки, bwsp — сериализованный plist настроек окна, dscl — было ли раскрыто дерево папки в режиме списка. Атакующему всё это безразлично. Поле имени файла перед ними — вот вся полезная нагрузка. На один элемент приходится несколько записей, поэтому работа парсера в основном сводится к дедупликации.
Два практических следствия:
- Имя в дереве — это утверждение, а не гарантия. Его всё равно нужно проверить запросом.
- Обрезанные или недокачанные файлы разбираются плохо. Если цель вернёт
200с HTML-страницей ошибки, наивный парсер с удовольствием выдаст мусор — когда результаты выглядят странно, стоит самому проверить сигнатуру.
dsstore-tree
Парсеры уже существуют — gehaxelt/Python-dsstore для формата, lijiejie/ds_store_exp для рекурсивной выкачки. Мне хотелось инструмент, который можно навести на цель прямо в ходе работ и получить чистое дерево, JSON для отчёта и трафик, который при необходимости идёт через Burp.
Установка
1
pipx install git+https://github.com/vflame6/dsstore-tree.git
Или вручную:
1
2
3
git clone https://github.com/vflame6/dsstore-tree.git
cd dsstore-tree
pip3 install -r requirements.txt
Запуск
1
dsstore-tree -u https://example.com
Это весь базовый сценарий. Инструмент забирает /.DS_Store, разбирает его, проверяет каждое найденное имя и спускается в каждую вложенную директорию, у которой есть собственный .DS_Store.
Опции
1
2
3
4
5
6
7
8
9
10
11
-u, --url URL Базовый URL для сканирования (обязательный)
-d, --download Скачать и зеркалировать найденные файлы
-q, --quiet Убрать информационный вывод
-j, --json Вывод результатов в JSON
-o, --output FILE Записать JSON-результаты в файл
-H, --header K:V Произвольный заголовок (можно повторять)
--proxy URL HTTP/SOCKS-прокси (напр. http://127.0.0.1:8080)
--threads N Параллельные запросы (по умолчанию: 10)
--timeout SEC Таймаут HTTP в секундах (по умолчанию: 10)
--depth N Максимальная глубина рекурсии (0 = без ограничения)
--no-color Отключить цветной вывод
Примеры
Через Burp, чтобы каждый запрос оказался в истории прокси:
1
dsstore-tree -u https://target.com --proxy http://127.0.0.1:8080
Скачать всё найденное и сохранить JSON-отчёт для описания:
1
dsstore-tree -u https://target.com -d -o report.json
За аутентификацией:
1
dsstore-tree -u https://target.com -H "Cookie: session=abc123"
Передать список файлов сразу дальше:
1
dsstore-tree -u https://target.com -j | jq '.files[].path'
С -d файлы пишутся в dsstore-tree_<host>/ с сохранением удалённой структуры каталогов, так что на выходе получается локальная копия той части document root, до которой удалось дотянуться.
Как устроен обход
Разбор формата — простая половина задачи. Интересная половина — превратить плоский список имён в дерево, потому что .DS_Store не говорит, какие записи являются директориями: файлы и папки хранятся одинаково.
Для каждого имени инструмент задаёт серверу три вопроса по порядку:
GET <name>/.DS_Store—200с непустым телом означает, что это директория и она раскрывает собственное содержимое. Ставим в очередь на рекурсию.HEAD <name>с отключённым следованием редиректам —301/302означает директорию без.DS_Store(сервер редиректит, чтобы добавить слеш в конце). Записываем, но спускаться некуда.HEAD <name>с ответом200— файл. Записываем и скачиваем, если задан-d.
Всё остальное отбрасывается. Проверки одной директории выполняются в пуле потоков (--threads, по умолчанию 10), поэтому уровень стоит примерно один round trip, а не по одному на каждую запись.
Несколько деталей, которые важны на практике:
- Защита от циклов. Посещённые пути складываются в множество
scanned_dirs. Симлинки и самоссылающиеся структуры не уводят сканирование в бесконечный спуск. - Защита от path traversal. Имена с
..или начинающиеся с/отбрасываются до того, как попадут в URL. Вредоносный.DS_Storeне должен заставить загрузчик писать за пределы своего выходного каталога. - Контроль глубины.
--depth Nограничивает рекурсию на глубоких деревьях — актуально для случайно задеплоенногоnode_modules. - Проверка TLS выключена, предупреждения подавлены: на внутренних работах сертификат обычно невалиден, и это не та находка, ради которой вы пришли.
Как это закрыть
Если вы по другую сторону, закрывается это дёшево.
Перестать выкладывать файлы. Добавьте .DS_Store в .gitignore репозитория и в глобальный, чтобы больше об этом не думать:
1
2
git config --global core.excludesfile ~/.gitignore_global
echo ".DS_Store" >> ~/.gitignore_global
Вычистите то, что уже закоммичено:
1
2
find . -name ".DS_Store" -print -delete
git rm --cached '*.DS_Store' -r
Заблокируйте dotfiles на веб-сервере — заодно закроются .git, .env и остальные.
nginx:
1
2
3
location ~ /\. {
deny all;
}
Apache:
1
2
3
<FilesMatch "^\.">
Require all denied
</FilesMatch>
Деплойте артефакты сборки, а не рабочие каталоги. rsync -a --exclude='.DS_Store' или нормальный этап сборки убирают весь класс проблемы целиком — вместе с .git, swap-файлами редактора и локальными копиями .env.
Уменьшите создание файлов в источнике. На macOS это запрещает Finder писать их на сетевые шары:
1
defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool true
На локальные диски не влияет, так что это полумера — настоящее решение всё равно на этапе деплоя.
Заключение
.DS_Store — это файл настроек отображения, который заодно работает как листинг директории, и он переживает любые меры против листингов, потому что веб-сервер видит обычный статический файл. Один запрос, без словаря, настоящие имена файлов.
Инструмент на GitHub: github.com/vflame6/dsstore-tree. Issues и PR приветствуются.
И стоит один раз прогнать его по собственной инфраструктуре — до того, как это сделает кто-то другой.
