"Сегодня целый день идет снег. Он падает, тихо кружась. Ты помнишь? Тогда тоже все было засыпано снегом - это был снег наших встреч. Он лежал перед нами, белый-белый, как чистый лист бумаги, и мне казалось, что мы напишем на этом листе повесть нашей любви"
Снегопад "Пламя"
Читая текст программы, написанный на языке высокого уровня, мы только в исключительных случаях изучаем реализацию стандартных библиотечных функций, таких, например, как printf. Да и зачем? Ее назначение известно и без того, а если и есть какие непонятки – всегда можно заглянуть в описание…
Анализ дизассемблерного листинга – дело другое. Имена функций за редкими исключениями в нем отсутствуют, и определить printf это или что-то другое "на взгляд" невозможно. Приходится вникать в алгоритм… Легко сказать! Та же printf представляет собой сложный интерпретатор строки спецификаторов – с ходу в нем не разберешься! А ведь есть и более монструозные функции. Самое обидное – алгоритм их работы не имеет никакого отношения к анализу исследуемой программы. Тот же new может выделять память и из Windows-кучи, и реализовывать собственный менеджер, но нам-то от этого что? Достаточно знать, что это именно new, - т.е. функция выделения памяти, а не free или fopen, скажем.
Доля библиотечных функций в программе в среднем составляет от пятидесяти до девяноста процентов. Особенно она велика у программ, составленных в визуальных средах разработки, использующих автоматическую генерацию кода (например, Microsoft Visual C++, DELPHI). Причем, библиотечные функции под час намного сложнее и запутаннее тривиального кода самой программы. Обидно – львиная доля усилий по анализу вылетает впустую… Как бы оптимизировать этот процесс?
Уникальная способность IDA различать стандартные библиотечные функции множества компиляторов, выгодно отличает ее от большинства других дизассемблеров, этого делать не умеющих. К сожалению, IDA (как и все, созданное человеком) далека от идеала – каким бы обширный список поддерживаемых библиотек ни был, конкретные версии конкретных поставщиков или моделей памяти могут отсутствовать.
И даже из тех библиотек, что ей известны, распознаются не все функции (о причинах будет рассказано чуть ниже). Впрочем, нераспознанная функция – это полбеды, неправильно распознанная функция – много хуже, ибо приводит к ошибкам (иногда трудноуловимым) анализа исследуемой программы или ставит исследователя в глухой тупик. Например, вызывается fopen и возвращенный ей результат спустя некоторое время передается free – с одной стороны: почему бы и нет? Ведь fopen
возвращает указатель на структуру FILE, а free ее и удаляет. А если free – никакой не free, а, скажем, fseek? Пропустив операцию позиционирования, мы не сможем правильно восстановить структуру файла, с которым работает программа.
Распознать ошибки IDA будет легче, если представлять: как именно она выполняет распознание. Многие почему-то считают, что здесь задействован тривиальный подсчет CRC (контрольной суммы). Что ж, заманчивый алгоритм, но, увы, непригодный для решения данной задачи. Основной камень преткновения – наличие непостоянных фрагментов, а именно – перемещаемых элементов (подробнее см. "Шаг четвертый Знакомство с отладчиком :: Бряк на оригинальный пароль"). И хотя при подсчете CRC перемещаемые элементы можно элементарно игнорировать (не забывая проделывать ту же операцию и в идентифицируемой функции), разработчик IDA пошел другим, более запутанным и витиеватым, но и более быстрым путем.
Ключевая идея заключается в том, что незачем тратить время на вычисление CRC, - для предварительной идентификации функции вполне сойдет и тривиальное посимвольное сравнение, за вычетом перемещаемых элементов (они игнорируются и в сравнении не участвуют). Точнее говоря, не сравнение, а поиск заданной последовательности байт в эталонной базе, организованной в виде двоичного дерева. Время двоичного поиска, как известно, пропорционально логарифму количества записей в базе. Здравый смысл подсказывает, что длина шаблона (иначе говоря, сигнатуры – т.е. сравниваемой последовательности) должна быть достаточной для однозначной идентификации функции.
Однако разработчик IDA по непонятным для меня причинам решил ограничиться только первыми тридцать двумя байтами, что (особенно с учетом вычета пролога, который у всех функций практически одинаков) – довольно мало.
И верно! Достаточно многие функции попадают на один и тот же лист дерева – возникает коллизия, - неоднозначность отождествления. Для разрешения ситуации, у всех "коллизиеных" функций подсчитывается CRC16 с тридцать второго байта до первого перемещаемого элемента и сравнивается с CRC16 эталонных функций. Чаще всего это "срабатывает", но если первый перемещаемый элемент окажется расположенным слишком близко к тридцать второму байту – последовательность подсчета контрольной суммы окажется слишком короткой, а то и вовсе равной нулю (может же быть тридцать второй байт перемещаемым элементом, - почему бы и нет?). В случае повторной коллизии – находим в функциях байт, в котором все они отличаются, и запоминаем его смещение в базе.
Все это (да просит меня разработчик IDA!) напоминает следующий анекдот: поймали туземцы немца, американца и хохла и говорят им: мол, или откупайтесь чем-нибудь, или съедим. На откуп предлагается: миллион долларов (только не спрашивайте меня: зачем туземцам миллион долларов – может, костер жечь), сто щелбанов или съесть мешок соли. Ну, американец достает сотовый, звонит кому-то… Приплывает катер с миллионом долларов и американца благополучно отпускают. Немец в это время героически съедает мешок соли, и его полуметрового спускают на воду. Хохол же ел соль, ел-ел, две трети съел, не выдержал и говорит, а, ладно, черти, бейте щелбаны. Бьет вождь его, и только девяносто ударов отщелкал, хохол не выдержал и говорит, да нате миллион, подавитесь!
Так и с IDA, - посимвольное сравнение не до конца, а только тридцати двух байт, подсчет CRC не для всей функции – а сколько случай на душу положит, наконец, последний ключевой байт – и тот то "ключевой", да не совсем. Дело в том, что многие функции совпадают байт в байт, но совершенно различны по названию и назначению.
Не верите? Тогда как вам понравится следующее:
read: write:
push ebp push ebp
mov ebp,esp mov ebp,esp
call _read call _write
pop ebp pop ebp
ret ret
Листинг 55
Тут без анализа перемещаемых элементов не обойтись! Причем, это не какой-то специально надуманный пример, - подобных функций очень много. В частности библиотеки от Borland ими так и кишат. Неудивительно, что IDA часто "спотыкается" и впадает в грубые ошибки. Взять, к примеру, следующую функцию:
void demo(void)
{
printf("DERIVED\n");
};
Даже последняя на момент написания этой книги версия IDA 4.17 ошибается, "обзывая" ее __pure_error:
CODE:004010F3 __pure_error_ proc near ; DATA XREF: DATA:004080BCvo
CODE:004010F3 push ebp
CODE:004010F4 mov ebp, esp
CODE:004010F6 push offset aDerived ; format
CODE:004010FB call _printf
CODE:00401100 pop ecx
CODE:00401101 pop ebp
CODE:00401102 retn
CODE:00401102 __pure_error_ endp
Стоит ли говорить: какие неприятные последствия для анализа эта ошибка может иметь? Бывает, сидишь, тупо уставившись в листинг дизассемблера, и никак не можешь понять: что же этот фрагмент делает? И только потом обнаруживаешь – одна или несколько функций опознаны неправильно!
Для уменьшения количества ошибок IDA пытается по стартовому коду распознать компилятор, подгружая только библиотеку его сигнатур. Из этого следует, что "ослепить" IDA очень просто – достаточно слегка видоизменить стартовый код. Поскольку, он по обыкновению поставляется вместе с компилятором в исходных текстах, сделать это будет нетрудно. Впрочем, хватит и изменения одного байта в начале startup-функции. И все, - хакер скинет ласты! К счастью, в IDA предусмотрена возможность ручной загрузки базы сигнатур ("FILE\Load file\FLIRT signature file"), но… попробуй-ка вручную определить: сигнатуры какой именно версии библиотеки требуется загружать! Наугад – слишком долго… Хорошо, если удастся визуально опознать компилятор (опытным исследователям это обычно удается, т.к.
каждый из них имеет свой уникальный "почерк"). К тому же, существует принципиальная возможность использования библиотек из поставки одного компилятора, в программе, скомпилированной другим компилятором.
Словом, будьте готовы к тому, что в один прекрасный момент столкнетесь с необходимостью самостоятельно опознавать библиотечные функции. Решение задачи состоит из двух этапов. Первое – определение самого факта "библиотечности" функции, второе – определение происхождения библиотеки и третье – идентификация функция по этой библиотеке.
Используя тот факт, что линкер обычно располагает функции в порядке перечисления obj модулей и библиотек, а большинство программистов указывают сначала собственные obj-модули, а библиотеки – потом (кстати, так же поступают и компиляторы, самостоятельно вызывающие линкер по окончании своей работы), можно заключить: библиотечные функции помещаются в конце программы, а собственно ее код – в начале. Кончено, из этого правила есть исключения, но все же срабатывает оно достаточно часто.
