Мы продолжаем делиться с обитателями Хабра кратким изложением выступлений гостей финала Russian Code Cup 2013. Сегодня мы представляем вашему вниманию конспект рассказа Дмитрия Склярова о реверс-инжиниринге.
Дмитрий Скляров — доцент кафедры информационной безопасности МГТУ им. Баумана и аналитик компании Positive Technologies. Работает в области информационной безопасности более 13 лет. Разработчик алгоритма программы Advanced eBook Processor.
Реверсинг — это, конечно, не самая простая дисциплина из области IT. Тем не менее, чтобы получить результат, то есть понять, что делает программа, не всегда необходимо анализировать каждую строчку кода и каждую ассемблерную команду. Иногда достаточно ряда простых логических умозаключений и умения «думать как программист». Понять, что я имею в виду, нам помогут два примера анализа, взятые из моей практики.
Что внутри черного ящика?
С первой задачей я столкнулся на CTF. CTF — это, своего рода, олимпиадное программирование, только для специалистов в области информационной безопасности. Задание называлось «Donn Beach». Итак, нам дано:
- некоторый программно реализованный «чёрный ящик» — исполняемый файл, преобразующий входную последовательность в выходную
- значение, которое должно получиться на выходе
Необходимо определить последовательность из 12 байт, которую нужно подать на вход.
Очевидно, что простым перебором эта задача не решается. Нужно выяснить, что же происходит внутри программы, что преобразует вход в выход, и как подобрать входные значения, чтобы получить заданный выход.
Разумеется, на первом этапе программа грузится в дизассемблер. Вот два фрагмента кода, которые он выдал:
Здесь легко заметить строчки, которые остались от процесса сборки. Скорее всего, эти сообщения специально заложены организаторами, чтобы легче было догадаться, о чём идёт речь. При взгляде на строчки vm_set_params и vm_get_output в голове сразу возникает вопрос: что такое vm? При этом ничего, кроме virtual machine, в общем-то, в голову не приходит. Таким образом, можно сразу предположить, что здесь используется виртуальная машина и, исходя из этого, строить дальнейший анализ.
Мы видим четыре идущих подряд вызова функции (точки между ними — это просто подготовка параметров). Что может стоять между загрузкой параметров и извлечением результата? Логично предположить, что там происходит инициализация и запуск виртуальной машины. Попробуем дизассемблировать код инициализации VM.
Итак, мы смотрим на код vm_init, и видим достаточно большое количество не совсем понятных операций. Несмотря на то, что с языком ассемблера для Intel-совместимых компьютеров я знаком уже почти 20 лет, инструкции с плавающей точкой мне приходится анализировать крайне редко. Поэтому, просто посмотрев на этот код, без помощи справочника я не могу сказать, что он делает. Какие выводы все же можно сделать по этому фрагменту кода?
- Мы работаем с 64-битовыми ММХ-регистрами
- Код функции состоит из 420 команд
- Ничего не понятно
Конечно, можно сесть с блокнотом и проанализировать все 420 команд, понять, что делает каждая из них, и, следовательно, что происходит в результате. Однако мне показалось, что это не самый эффективный способ, и я решил попробовать подойти к этой задаче по-другому.
У нас есть функция vm_init, которая как-то загружает регистры из начальных данных. На вход подаётся восемь 32-битовых значений. Что, если задать этим значениям константные числа, и пометить байты от 00 до 1F?
Видно, что каждый байт скопировался по одному разу. Нет ни одного повтора, ни одного нового значения, и, просто раскрасив их разными цветами, можно увидеть, какие байты куда отображаются.
Итак, мы поняли, что делает функция, не разобрав ни одной строчки, а просто сравнив вход и выход. Почему я предположил, что это можно сделано таким образом? Потому что это логично, это именно то, чего мы ждем от функции загрузки начального состояния виртуальной машины.
После этого я решил проанализировать, что же делает функция, которую я назвал vm_run. В ней можно увидеть следующее:
- огромное количество кодов для работы с multimedia-регистрами
- при разборе потока команд виртуальной машины видно, что каждый код команды занимает 1 байт и бывает 1 аргумент (опционально)
- у каждого байта команды есть своя функция обработки
- сложность каждой функции обработки примерно такая же, как и в vm_init (так что разбирать её нет ни малейшего желания: это сложный и наверняка не самый эффективный путь)
- большинство инструкций, на самом деле, отображаются в один и тот же обработчик (вероятно, это функция nop)
- вся обработка идёт в одном цикле
Итак, перед нами стандартный процесс разбора команд для виртуальной машины: получить код команды и её аргументы, указывающие на операнды, выполнить через функции, реализующие тот или иной код операции, и после этого перейти к следующему коду операции.
Я написал небольшой отладчик, но вместо того, чтобы смотреть в отладчике пошагово, что делает программа, решил проследить состояние всех ММX-регистров в каждой точке. В результате был создан лог-файл, в котором для каждой текущей команды записывалось состояние VM до и после выполнения, а также код операции и один дополнительный байт, чтобы можно было восстановить логику, зная коды операций.
Вот что у меня получилось:
На вход подаётся значение в регистре R0 и на выходе оно оказывается на стеке. Видно, что значение регистра R6 уменьшается, а R7, наоборот, увеличивается.
Из этого можно сделать вывод, что R6 — это счётчик-указатель стека, который уменьшается при добавлении данных на стек, R7 — это указатель команд. При этом команда с кодом операции 0D-00 перенесла значение из нулевого регистра в стек.
Значение, которое было в регистре R4, оказалось опять же на стеке, а код команды 0D04 от предыдущей отличается только аргументом. Из этого можно предположить, что 0D, — код операции push, — это размещение данных на стеке, а значение в первом байте после кода операции — это указатель на номер регистра, который будет использован в этой операции.
Состояние регистров до и после выполнения различаются только одним регистром, который я назвал указатель команд (Instruction Pointer) — это значит, что никаких изменений не произошло. Значит, можно предположить, что код операции 06 — nop, который ничего не делает.
В данном случае аргументом было значение FF, оно оказалось на стеке, а указатель стека уменьшился. Легко сделать предположение, что данная команда загружает значение, которое было в аргументе, как 32-битовое значение в стек.
При выполнении этой команды значение на стеке оказалось во втором регистре. Таким образом, код операции 4С — это извлечение из стека, а аргумент, идущий после него, это номер регистра. Можно сделать такие предположения просто посмотрев на пары вход-выход,.
После того, как протокол был полностью проанализирован и лишился ненужных строчек, оказалось, что в этой виртуальной машине достаточно маленькое количество команд. Есть команда nop, команда размещения данных на стеке из регистра, извлечение данных из стека, перемещение между регистрами, сложение, логические операции, сдвиг xor и, собственно, всё.
Некоторые операции, такие как mov и сдвиг налево, реализованы двумя или тремя разными способами, но это неважно — ведь мы уже знаем, что они делают. Итак, после сокращения протокола мы получили листинг программы на языке этой виртуальной машины. Осталось просто проанализировать его и обратить.
К сожалению, я решил эту задачу через 15 минут после окончания зачётного времени, то есть сдать её мы не успели, но сам процесс решения показался мне достаточно интересным. Почему всё получилось так просто? Потому что авторы задания реализовали всё состояние виртуальной машины в ММХ-регистрах процессора.
Всё остальное, что происходило внутри — вполне логичная работа виртуальной машины. Если бы я стал писать простую виртуальную машину, я бы написал её практически так же. Используя единственную идею, что регистры отображаются в регистры ММХ однозначным образом, и я могу это однозначное отображение легко выявить, я решил задачу, не прибегая к анализу ассемблерного кода.
Так ли это надежно?
Вторую задачу, возникшую уже в реальной практике, мне удалось решить, не загружая дезассемблер вообще. Ко мне пришли коллеги, которые занимаются анализом безопасности системного оборудования, и сказали, что они получили новую железяку, которую надо протестировать.
В этой железяке есть возможность поставить пароли, которые преобразуются в некоторое значение в конфиге. Значения строковые, выглядят как какой-то хеш, но какой именно — непонятно. Передо мной поставили задачу выяснить все детали алгоритма, которые будут нужны коллегам для того, чтобы оценить, насколько хорошо защищено устройство, анализируя те конфиги, которые им доступны.
Мне сгенерировали пароли для девяти пользователей:
Что видно на первый взгляд? Видно, что последние символы во всех паролях одинаковые, а первые значительно меняются. Кроме того, видно, что в хеше всегда 24 символа, последние 13 символов никогда не меняются, и всего используется 50 разных букв, знаков препинания и цифр.
К сожалению, этого явно недостаточно для сколь-нибудь значимого вывода о том, что происходит внутри алгоритма, который преобразует пароль в хеш.
Мне потребовалось больше этих паролей, и я попросил ребят это сделать, но был конец дня и пароли я получил не сразу. Однако до того, как появились новые данные, я смог сделать несколько догадок.
- Используется кодирование, которое несколько бит кодирует в меньшее количество бит. Возможно, это стандартные кодировки, например, Base16, Base32 или Base64.
- Количество символов >=50, но 50 символов как-то не круто, не степень двойки. Между тем, 64 символа в таблице кодировки — достаточно хорошее предположение. Почему? Потому что Base64 означает, что одна буква кодирует 6 бит, то есть суммарно мы имеем 64 состояния.
- Длина пароля 24 символа, из них 13 не меняются, а 11 символов являются переменными. Если каждый из них представляет 6 бит, то есть меняется 66 бит, это уже достаточно близко к степени двойки и к 8-ми байтовому блоку.
- Очевидно, что мы имеем дело не с mime64, потому что для нее чётко определены символы, которые могут использоваться, а в хешах паролей, которые мне предоставили, использовались недопустимые символы.
- Таким образом, мы имеем кодировку, похожую на Base64, с таблицей кодировки, отличной от mime64. Кроме того, хеш разбивается на две явно заметные части, одна из которых меняется, а вторая нет. Это позволяет предположить, что, возможно, используется шифрование 64-битовым блоком, причем каждый блок шифруется отдельно.
Что ещё можно сделать, пока нет входного материала? Можно воспользоваться поиском. Если поискать строчку, которая была константной, то выяснится, что поисковик находит примерно 43 тысячи страниц, где эта строчка упоминается. В этом нет ничего удивительного: оборудование широко распространено, и люди часто использовали куски из документации и из своих конфигурационных файлов для описания возникающих у них проблем. Пролистав страницы, на которых встречалась эта строчка, я собрал куски хешей, которые там приводились. Выяснилось, что используется ровно 64 символа. Вот они:
!"#$%&'()*+,-./0123456789:;<=>@A
BCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`a
Итак, мы восстановили charset. Но кроме самих символов нужно ещё знать, в каком порядке они идут. Есть статистическая последовательность, в которой все символы идут один за другим, но нет символа «?», а в конце есть буква «а», которая имеет самый большой код среди всех приведённых символов. Тем не менее, непонятно, в каком порядке они должны идти, и интернет помочь в этом вопросе не может.
Что ещё можно выудить из найденной в интернете документации? Там есть упоминание, что если длина пароля 16 символов или меньше, то он зашифровывается в 24-символьную строчку. Если же пароль от 16 до 63 символов, то он превращается в 88-символьную строчку. При этом такое количество символов очень легко объяснить.
24*6 == 144 == 18*8 == (2*8 + 2)*8
88*6 == 528 == 66*8 == (8*8 +2)*8
Если мы возьмём 24 6-битовых символа, то получим 144 бита, что легко делится на два 8-байтовых блока, плюс ещё 2 байта. 88 символов точно так же делится на восемь 8-байтовых блоков плюс опять же 2 байта. Перед нами логичная и предсказуемая схема перевода входных символов в выходные. Если у меня меньше 16 символов в пароле, то я получаю два 8-байтных блока, каждый из которых зашифровываю, добавляю 2 байта в конце, и всё это вместе кодирую.
Наконец-то мои коллеги сгенерировали мне 511 паролей. Я попросил пароли разной длины, пустой пароль, пароль длиной больше 16. Оказалось, что конкретно это устройство не умеет создавать хеши для паролей больше 16 символов, а пустой пароль поставить нельзя. Тем не менее, мне сгенерировали хеши для паролей со значением от 0000 до 0510.
Как и следовало ожидать, у всех этих хешей последние 13 символов не менялись, а в позициях с нулевой по девятую встретились все 64 варианта символов, которые я до этого нашёл поиском в интернете.
В 10-й позиции нашлись только те символы, которые на картинке ниже выделены красным.
Нетрудно заметить, что символы чередуются с периодом 4, в районе буквы «а» чередование сбивается, и дальше, чередование сбивается ещё раз в самом конце. Что будет, если букву «а» переставить на то место, должен был стоять пропущенный «?»? Получится вот такая красивая картинка, где мы наблюдаем чёткий период в 4 символа.
Можно предположить, что это правильная таблица, в которой символы идут именно так, как они будут кодироваться. Еще одно подтверждение этому: последние 4 символа, выделенные прямоугольником, на следующей картинке, демонстрирующей хеши для паролей разной длины, выделены красным.
Из этого списка видно, что пока пароль не длиннее 8 символов, правая половина хеша является константной. А когда пароль оказывается содержит больше 8 символов, то последние 2 символа также не меняются, первые 10 оказываются константой, одиннадцатый принимает 4 состояния, а затем идёт блок из 11 символов, которые меняются хаотическим образом.
Под какую схему можно подогнать такую логику изменения?
- pwd[0:8] влияет на out[0:10] + 4 бита out[10]
- pwd[8:16] влияет на 2 бита out[10] + out[11:21] + 2 бита out[21]
- out[22:24] == “!!”
Это вполне можно описать функцией, в которой происходит Base64-кодирование по тому алфавиту, который был получен ранее. Внутри происходит конкатенация двух 8-байтовых блоков, которые являются вычислением некоторой функции от первой и второй половины пароля, а в конце добавляется два нулевых байта, которые при помощи всё той же таблицы кодирования превращаются в восклицательные знаки.
Base64(fn(pwd[0:8]) + fn(pwd[8:16]) + “”)
Схема красивая, но есть один неизвестный элемент: мы не знаем, что это за функция. Как именно функция преобразует 8 символов пароля в выход? Все, что мы знаем — что идёт преобразование 64 битов в 64 бита. Теоретически это может быть хеш-функция, но, во-первых, я не знаю хороших 64-битовых хеш-функций, которые бы часто использовались, во-вторых, как-то подозрительно, что размер входа равен размеру выхода. Проще было бы подать весь пароль на вход хеш-функции и получить на выходе некоторое значение заданной длины.
Возможно, это алгоритм шифрования с 64-битовым блоком. Я решил узнать, какие алгоритмы бывают, и выяснилось, что вариантов очень много, они все более или менее популярны. Кроме того, даже если мы будем знать алгоритм, то самый главный элемент — ключ шифрования — остаётся загадкой.
В результате перед нами стоит вопрос: «А как узнать алгоритм шифрования и где взять ключ?» Разумная идея — заглянуть в прошивку. Я попытался загрузить прошивку в дизассемблер, и сразу выяснилось, что я не знаю архитектуру процессора. Архитектур существует много, дизассемблер, которым я пользуюсь, поддерживает порядка 50 разных типов архитектур, а на свете их существует гораздо больше. По байт-коду далеко не всегда можно определить, с какой архитектурой мы имеем дело. Если бы у файла были заголовки, если бы это был .exe-файл или ELF — одно дело; однако передо мной был просто двоичный файл размером больше 20 мегабайт. Так как я не смог начать анализировать этот файл за короткое время, я решил попробовать другую идею.
А что если предположить, что используется самый популярный алгоритм шифрования с 8-байтовым блоком? Наверное, это алгоритм DES.
Остаётся второй вопрос: «А где найти ключ шифрования?». Есть такое английское слово —hardcoded. Очень во многих системах безопасности элементы, которые являются фундаментальными для обеспечения секретности, оказываются захардкожены в исполняемые файлы. А что если поискать ключ шифрования в прошивке?
В итоге была написана программа на 20 строк, которая открывала файл с прошивкой, читала его в память, брала первые 8 байт и пыталась интерпретировать их как ключ шифрования в описанную выше функцию. Потом брался блок 8 байт со смещением 1 байт, 2 байта, и так далее до конца файла. Если находилось совпадение, которое мне требовалось (то есть после шифрования и кодирования Base64 у меня получалась такая строчка, какую я ожидал получить), то выдавалось соответствующее сообщение.
Выяснилось, что ключ встречается в файле 12 раз и представляет собой вполне логичную последовательность байт:
01 02 03 04 05 06 07 08
Я даже не уверен, что эта последовательность является частью ключа. Возможно, ключ формируется хитрым, сложным образом, но эта строчка является ключом, подходящим для DES, при помощи которого можно получить функцию, делающую необходимые преобразования.
В итоге код для расшифровывания этих хешей (которые оказались вовсе не хешами, а зашифрованными и обратимыми паролями) на языке Python с использованием библиотеки pyDes уместился в 10 строчек:
from pyDes import *
charset = """!"#$%&'()*+,-./0123456789:;<=>a@"""
+ """ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`"""
def numFromHash(s):
return reduce (lambda v, c: (v << 6) + charset.index(c), s, 0L)
def restorePwd(s):
v = ("%036X" % numFromHash(s)).decode("hex")
k = des("0102030405060708".decode("hex"))
pwd = k.decrypt(v[0:8]) + k.decrypt(v[8:16])
return pwd.rstrip('')
Мораль
Почему так просто получается проводить анализ кода? Потому что код пишут программисты, которые нацелены на эффективность, логичность и понимание кода. Например, что было нестандартного во втором примере? Нестандартна таблица перекодировки Base64, но при этом сама таблица элементарно восстанавливается на основании статистики. Всё остальное — это типичный подход студента, выполняющего лабораторную работу. Здесь нет никакой изюминки, которая бы усложнила процесс восстановления. Возможно, разработчики не ставили такую задачу, но в целом, если вы работаете в области информационной безопасности, при разработке элемента не старайтесь делать его максимально простым и понятным. Это может выйти для вас боком, потому что противник попытается мыслить логично, как программист, и очень быстро вскроет все механизмы, которые вы заложили в своё решение.
Автор: TeamMRG