Post

.DS_Store. Раскрываем структуру директорий через системные файлы macOS

Как скрытый файл macOS Finder раскрывает дерево каталогов веб-приложения, что у него внутри и чем его обойти рекурсивно.

🇬🇧 English · 🇷🇺 Русский

Введение

Возможно, вы уже видели этот файл. Подключаете 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.

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 — было ли раскрыто дерево папки в режиме списка. Атакующему всё это безразлично. Поле имени файла перед ними — вот вся полезная нагрузка. На один элемент приходится несколько записей, поэтому работа парсера в основном сводится к дедупликации.

Два практических следствия:

  1. Имя в дереве — это утверждение, а не гарантия. Его всё равно нужно проверить запросом.
  2. Обрезанные или недокачанные файлы разбираются плохо. Если цель вернёт 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 не говорит, какие записи являются директориями: файлы и папки хранятся одинаково.

Для каждого имени инструмент задаёт серверу три вопроса по порядку:

  1. GET <name>/.DS_Store200 с непустым телом означает, что это директория и она раскрывает собственное содержимое. Ставим в очередь на рекурсию.
  2. HEAD <name> с отключённым следованием редиректам — 301 / 302 означает директорию без .DS_Store (сервер редиректит, чтобы добавить слеш в конце). Записываем, но спускаться некуда.
  3. 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 приветствуются.

И стоит один раз прогнать его по собственной инфраструктуре — до того, как это сделает кто-то другой.

This post is licensed under CC BY 4.0 by the author.