Идентификация библиотечных функций
"Сегодня целый день идет снег. Он падает, тихо кружась. Ты помнишь? Тогда тоже все было засыпано снегом - это был снег наших встреч. Он лежал перед нами, белый-белый, как чистый лист бумаги, и мне казалось, что мы напишем на этом листе повесть нашей любви"
Снегопад "Пламя"
Читая текст программы, написанный на языке высокого уровня, мы только в исключительных случаях изучаем реализацию стандартных библиотечных функций, таких, например, как 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-модули, а библиотеки – потом (кстати, так же поступают и компиляторы, самостоятельно вызывающие линкер по окончании своей работы), можно заключить: библиотечные функции помещаются в конце программы, а собственно ее код – в начале. Кончено, из этого правила есть исключения, но все же срабатывает оно достаточно часто.
Рисунок 14 0х009 Художнику заштриховать что ли? Структура pkzip.exe. Обратите внимание - все библиотечные функции (голубые) в одном месте - в конце сегмента кода перед началом сегмента данных
Рассмотрим, к примеру, структуру общеизвестной программы pkzip.exe, - на диаграмме, построенной IDA 4.17, видно, что все библиотечные функции сосредоточены в одном месте – в конце сегмента кода, вплотную примыкая к сегменту данных. Самое интересное – start-up функция в подавляющем большинстве случаев расположена в самом начале региона библиотечных функций или находится в непосредственной близости от него. Найти же саму start-up не проблема – она совпадает с точкой входа в файл!
Таким образом, можно с высокой долей уверенности утверждать, что все функции, расположенные "ниже" Start-up (т.е. в более старших адресах) – библиотечные. Посмотрите – распознала ли их IDA или переложила эту заботу на вас? Грубо - возможны две ситуации: вообще никакие функции не распознаны и не распознана только часть функций.
Если не распознана ни одна функция, скорее всего IDA не сумела опознать компилятор или использовались неизвестные ей версии библиотек. Техника распознавания компиляторов – разговор особый, а вот распознание версий библиотек – это то, чем мы сейчас и займемся.
Прежде всего, многие из них содержат копирайты с названием имени производителя и версии библиотеки – просто поищите текстовые строки в бинарном файле. Если их нет, - не беда – ищем любые другие текстовые строки (как правило, сообщения об ошибках) и простым контекстным поиском пытаемся найти во всех библиотеках, до которых удастся "дотянуться" (хакер должен иметь большую библиотеку компиляторов и библиотек на своем жестком диске). Возможные варианты: никаких других текстовых строк вообще нет; строки есть, но они не уникальны – обнаруживаются во многих библиотеках; наконец, искомый фрагмент нигде не обнаружен. В первых двух случаях следует выделить из одной (нескольких) библиотечных функций характерную последовательность байт, не содержащую перемещаемых элементов, и вновь попытаться отыскать ее во всех доступных вам библиотеках. Если же это не поможет, то… увы, искомой библиотеки у вас в наличие нет и положение – ласты.
Ласты, да не совсем! Конечно, автоматически восстановить имена функций уже не удастся, но надежда на быстрое выяснение назначения функций все же есть. Имена API-функций Windows, вызываемые из библиотек, позволяют идентифицировать по крайней мере категорию библиотеки (например, работа с файлами, памятью, графикой и т.д.) Математические же функции по обыкновению богаты командами сопроцессора.
Дизассемблирование очень похоже на разгадывание кроссворда (хотя не факт, что хакеры любят разгадывать кроссворды) – неизвестные слова угадываются за счет известных. Применительно к данной ситуации – в некоторых контекстах название функции вытекает из ее использования. Например, запрашиваем у пользователя пароль, передаем ее функции X вместе с эталонным паролем, - если результат завершения нулевой – пишем "пароль ОК" и, соответственно, наоборот.
Не подсказывает ли ваша интуиция, что функция X ни что иное, как strcmp? Конечно, это простейший случай, но по любому, столкнувшись с незнакомой подпрограммой, не спешите впадать в отчаяние, приходя в ужас от ее "монстроузности" – просмотрите все вхождения, обращая внимания кто вызывает ее, когда и сколько раз.
Статистический анализ на очень многое проливает свет (функции, как и буквы алфавита, встречаются каждая со своей частотой), а контекстная зависимость дает пищу для размышлений. Так, функция чтения из файла не может предшествовать функции открытия!
Другие зацепки: аргументы и константы. Ну, с аргументами все более или менее ясно. Если функция получает строку, то это очевидно функция из библиотеки работы со строками, а если вещественное значение – возможно, функция математической библиотеки. Количество и тип аргументов (если их учитывать) весьма сужают круг возможных кандидатов. С константами же еще проще, - очень многие функции принимают в качестве аргумента флаг, принимающий одно из нескольких значений. За исключением битовых флагов, которые все похожи друг на друга как один, довольно часто встречаются уникальные значения, пускай не однозначно идентифицирующие функцию, но все равно сужающие круг "подозреваемых". Да и сами функции могут содержать характерные константы, скажем, встретив стандартный полином для подсчета CRC, можно быть уверенным, что "подследственная" вычисляет контрольную сумму…
Мне могут возразить: мол, все это частности. Возможно, но, опознав часть функций, назначения остальных можно вычислить "от противного" и уж по крайней мере понять: что это за библиотека такая и где ее искать.
Напоследок, - идентификацию алгоритмов (т.е. назначения функции) очень сильно облегчает значение этих самих алгоритмов. В частности, код, осуществляющий LZ-сжатие (распаковку), настолько характерен, что узнается с беглого взгляда – достаточно знать этот механизм упаковки. Напротив, если не иметь о нем никакого представления – ох, и нелегко же будет анализировать программу! Зачем открывать изобретать колесо, когда можно взять уже готовое? Хоть и бытует мнение, что хакер – в первую очередь хакер, а уж потом программист (да и зачем ему уметь программировать?), в жизни все наоборот, - программист, не умеющий программировать, проживет – вокруг уйма библиотек, воткни – и заработает! Хакеру же знание информатики необходимо, - без этого далеко не уплывешь (разумеется, отломать серийный номер можно и без высшей математики).
Понятное дело, библиотеки как раз на то и создавались, чтобы избавить разработчиков от необходимости вникать в те предметные области, без которых им и так хорошо. Увы, у исследователей программ нет простых путей – приходится думать и руками, и головой, и даже… пятой точкой опоры вкупе со спинным мозгом, - только так и дизассемблируются серьезные программы. Бывает, готовое решение приходит в поезде или во сне…
Анализ библиотечных функций – это сложнейший аспект дизассемблирования и просто замечательно, когда есть возможность идентифицировать их имена по сигнатурам.