
Presented at Recon Montreal 2018
или продвинутый парсинг методом дробовика для дураков и бунтарей
Однажды, дорогой читатель, тёмной и бурной ночью ваш покорный автор наткнулся на озадачивающую и загадочную ситуацию — систему, в которой единственным препятствием для анализа памяти методом chip-off и реверса были неизвестная фрагментированная проприетарная файловая система в сочетании со сжатием из-за использования встроенной Java, что снижало эффективность существующих инструментов восстановления файлов.
Тогда, возможно, и было собрано какое-то ad hoc решение: вручную склеивались фрагменты, которые выглядели подходящими, с помощью чудовищной возни в консоли Python и уродливых ad hoc bash-скриптов. Этого хватало, но отнимало уйму времени.
Хотя извлечение обычных несжатых данных при анализе chip-off — довольно рутинная задача для хакера аппаратного обеспечения, сжатие создаёт серьёзные проблемы, когда его фрагменты разбросаны повсюду иррациональным и неприятным образом, даже если никакой другой реальной защиты от извлечения нет.
В zip-файлах есть кое-что интересное (это основной формат для JAR-файлов). Будучи уже довольно давно прилежным читателем International Journal of PoC||GTFO и особенно следя за работой по усечению файловых форматов Ange Albertini, я подумал, что внутри zip-файла может быть достаточно данных о самом zip-файле, чтобы прилично сшить всё обратно воедино.
Ладно, с отсылками покончено, перейдём к техническим деталям.
Прежде всего: мы можем не знать деталей файловой системы (и, с точки зрения моего исследования, независимо от системы, где я столкнулся с проблемой, я решил, что о них просто лучше не беспокоиться). Но кое-что о реализации большинства файловых систем мы знаем. В частности, они, как правило, записываются чанками. У чанков есть некий минимальный размер, известный как страницы, и мы можем определить этот размер страницы, просмотрев дамп и выявив минимальный записываемый размер блока.
Некоторые из них могут располагаться подряд, некоторые нет, и чёткой закономерности в том, когда блоки оказываются смежными, нет.
Всё это значит, что наша задача — переупорядочить страницы данных так, чтобы получить валидные (или достаточно близкие к ним) образы файлов, которые мы хотим извлечь.
Zip-файлы устроены так, что реализуют своего рода обратную иерархию. Сначала идут сжатые данные файлов (обёрнутые в локальные заголовки файлов, описывающие их). Затем — центральный каталог (перечисляющий смещения локальных заголовков файлов), а затем запись конца центрального каталога (которая, помимо прочего, описывает количество файлов в zip, смещение начала центрального каталога и его размер).
Давайте перевернём это и копнём чуть глубже:
Запись конца центрального каталога сообщает нам:
Каждая запись центрального каталога сообщает нам:
Каждая локальная запись файла сообщает нам:
Всё вышеперечисленное приводит к тому, что подавляющая часть файла оказывается восстановленной.
Прежде всего нам нужен этап, позволяющий отличать данные разных прошивок. Причина в том, что все смещения значимы только в пределах соответствующих zip-файлов: любое пересечение приведёт к несоответствию zip-потоков и порче данных, а мы крайне заинтересованы извлечь как можно больше неповреждённых данных. Кроме того, нам нужны веские гарантии, что, например, любые уязвимости, которые мы диагностируем в целевой прошивке, касаются именно той, которая обычно запущена, а не какого-то другого файла, просто оставленного валяться рядом.
Здесь нужно решение на основе алгоритма k-средних (он же «алгоритм Ллойда»). Вот отличное видео, объясняющее, как он работает. В SciPy была готовая хорошая версия, но мне пришлось найти/ пропатчить единственный crate для кластеризации/аналитики, реализующий этот алгоритм, чтобы заставить его работать в Rust-реализации. К счастью, мне не пришлось писать его с нуля.
После этого всё прошло как по маслу.
Для этого можно использовать ряд признаков. Поля Flags, Method и Version зависят от используемого zip-стека, которым сжимали файл. Кроме того, в заголовках есть метки времени, и в целом маловероятно, что все прошивки были скомпилированы и сжаты в одно и то же время.
Кстати, стоит отметить, что zip-файлы используют метки времени в формате MS-DOS — битовые упакованные short, представляющие год-месяц-день и час-минута-две-секунды. Если не преобразовать их в абсолютное скалярное значение до использования в качестве данных для классификации, вы можете придать разнице в год тот же вес, что и разнице в секунду, а это совсем никуда не годится!
Мы преобразуем их в евклидов вектор (это модное слово для n-мерного массива значений в ℝ, то есть вещественных координат, но видео по ссылке выше, пожалуй, объясняет всё проще), а алгоритм кластеризации делает за нас почти всё остальное, собирая все разобранные заголовки в нужное количество корзин.
Хотя для прототипирования этого метода я написал весьма корявый скрипт на Python, и с PoC удалось достичь примерно 70–80% восстановления содержимого JAR, я решил на этом остановиться и перейти к реализации быстрой версии на Rust.
В Rust есть crate под названием nom, который просто фантастически подходит
для написания парсеров-верификаторов. Это, кстати, была одна из главных
причин переписать всё на Rust. Возможность писать ясные и чрезвычайно строгие
парсеры в некоторых отношениях делает задачу гораздо проще, чем попытки
справиться со всем этим в Python (который, как правило, гораздо терпимее,
настолько, что порой непросто быть уверенным, что ты не пропускаешь
ошибочные сбои и не упускаешь граничный случай).
Если возможность писать потрясающие быстрые читабельные парсеры вам интересна, загляните в:
К тому же выполнение такого анализа в Python само по себе медленное, и он никогда не задумывался как нечто большее, чем путь к PoC для проверки жизнеспособности этого подхода.
В общем, хватит об этом...
Подходы к восстановлению оставшихся фрагментов включают в себя сначала фильтрацию оставшихся страниц по кандидатам с высокой энтропией, заполнение сначала меньших пробелов (исключая как можно больше страниц из списка поиска, поскольку проверка перестановок в худшем случае — задача экспоненциальной сложности, поэтому быстрое решение лёгких случаев в приоритете и экспоненциально упрощает нам задачу по ходу дела!).
Ещё один быстрый выигрыш — находить случаи, когда локальный заголовок файла не парсится из-за границы страницы (мы должны суметь сопоставить его с аналогично выровненным аналогом, по крайней мере когда такие выравнивания уникальны и не сталкиваются с другими артефактами повреждений).
Как проверять кандидатов на недостающие страницы? У нас же есть контрольные суммы CRC32 для файлов прямо в нашем центральном каталоге! Вместо вычисления CRC32 по всему файлу, вероятно, лучший способ — вычислить CRC32 по уже известным фрагментам (в прямом направлении от данных в конце страницы перед началом пробела и в обратном — от данных (или фрагмента DataDescriptor после потока deflate) и вычислить из этого, какие промежуточные CRC32 следует ожидать для каждого блока недостающих страниц.
По сути, каждый раз, выбирая самую простую/быструю задачу, мы значительно упрощаем сложные задачи, отсеивая мусор. Именно поэтому используется энтропия Шеннона, чтобы сразу отбрасывать пустые или почти пустые страницы: нет гарантии, что у нас будут только zip-страницы с высокой энтропией, но даже при наличии выбросов это даёт огромное ускорение, позволяя с самого начала не связываться с этим осложнением.
Если хотите с ней повозиться, установите Rust (рекомендуется через замечательный rustup nightly. Затем:
$ git clone [repo]
...
$ cd zipdefrag
...
$ cargo build --release
Можно не использовать флаг release, чтобы включить отладку.
Артефакты сборки будут в /target/{debug,release}
Документация собирается командой cargo doc (Этот crate очень подробно
документирован. Я люблю писать.)
Сейчас в Rust-версии по умолчанию нет вывода в терминал; если хотите
запустить CLI-обвязку, нужно установить переменную окружения
RUST_LOG=zipdefrag, которая включает подробное журналирование в терминал с
демонстрацией текущего анализа.
Быстрый переносимый нативный исполняемый файл (с хуками на Python) для решения головоломок с zip-дампами из неизвестных файловых систем.
Демонстрационный дамп
Производительность Rust-реализации сейчас никуда не годится из-за расточительного поведения при поиске соответствующих фрагментов LFH. Исправлю это и усвою урок.
Этот метод плохо работает, когда многие файлы в JAR значительно больше размера страницы. Поскольку он опирается на активное использование структуры, присущей zip-файлам, файлы с большим объёмом данных просто не очень хорошо поддаются восстановлению.
К счастью, файлы классов для мидлетов J2ME, как правило, довольно малы, но большие бинарники внутри, вероятно, будут невосстановимы.
Кроме того, Python-PoC содержит ряд арифметических ошибок.