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