Ultimate adventure или поиск дыр в двоичном коде

Автор: (c)Крис Касперски ака мыщъх

Исходные тексты Linux'а и других Open Source систем в прямом смысле этого слова зачитаны до дыр и найти здесь что-то принципиально новое очень трудно. Windows - другое дело. Непроходимые джунгли двоичного кода отпугивают новичков, и огромные территории дизассемблерных листингов все еще остаются неизведанными. Причудливые переплетения вложенных вызовов скрывают огромное количество грубых программистских ошибок, дающих неограниченную власть над системой. Попробуйте их найти, а я покажу - как?

Введение

Считается, что открытость исходного кода - залог надежности любой системы, поскольку спрятать закладку (черный ход, троянскую компоненту) в таких условиях практически невозможно. Тысячи экспертов и энтузиастов со всего мира тщательно проанализируют программу и выловят всех блох - как случайных, так и преднамеренных. Что же касается откомпилированного двоичного кода - трудоемкость его анализа неоправданно велика и за "просто так" с ним возиться никто не будет. Что ж, достойный аргумент сторонников движения Open Source, известных своим радикализмом и отрицанием объективной реальности.

А реальность такова, что проанализировать исходный код современных приложений за разумное время ни физически, ни экономически невозможно. Даже старушка MS-DOS 6.0 в исходных текстах весит свыше 60 Мбайт. Для сравнения - "Поколение П" Виктора Пелевина не дотягивает и до мегабайта. Даже если уподобить исходные тексты развлекательной книге - прикиньте, сколько времени понадобится для их прочтения? А ведь исходные тексты - совсем не книга. Это нагромождение сложно взаимодействующих друг с другом структур данных, тесно переплетенных с машинным кодом...

При средней длине одной x86-команды в два байта, каждый килобайт откомпилированного кода несет на своих плечах порядка пятисот(!) дизассемблерных строк, соответствующих десяти страницам печатного текста. Мегабайтный двоичный роман за разумное время прочитать уже невозможно. Современные программные комплексы не могут быть исследованы до последней запятой и наличие исходных текстов ничего не меняет. Какая разница, сколько времени продлится работа - тысячу лет или миллион? Процедура поиска дыр плохо поддается распараллеливанию между участниками - отдельные участки программы выполняются отнюдь не изолированно друг от друга, а сложным образом взаимодействуют между собой и далеко не все ошибки сосредоточены в одном месте - многие из них "размазаны" по большой площади, а в многопоточных средах они еще и растянуты во времени.

Методик автоматизированного поиска уязвимостей, доведенных до "промышленного" использования, в настоящее время не существует и маловероятно, чтобы они появились в дальнейшем. Непосредственный анализ обнаруживает лишь малую толику наиболее грубых и самоочевидных ошибок. Остальные же приходится выявлять в процессе реальной эксплуатации программы. Тем не менее, статистические исследования показывают, что ошибки возникают не просто так. В них есть своя внутренняя система и закономерность, благодаря чему район "археологических раскопок" существенно сужается и объем дизассемблерных работ становится вполне реальным.

Анализ машинного кода имеет свои сильные и слабые стороны. Хорошая новость - здесь нет этих чудовищных дефайнов (директив условной трансляции - define) и не нужно каждый раз отвлекаться на выяснение обстоятельств - какой код компилируется, а какой идет на хрен? Нет макросов (особенно многострочных) и мы всегда можем отличить функции от констант, а константы - от переменных. Отсутствует перекрытие операторов и неявный вызов конструкторов (правда, деструкторы глобальных классов по-прежнему вызываются неявно). Короче говоря, компилятор избавляет нас от дюжины штучек, затрудняющих чтение листингов (как шутят программисты, Си/Си++ - это языки только для записи, "write only").

Плохие новости - одна-единственная строка исходного текста может соответствовать десяткам машинных команд, причем оптимизирующие компиляторы транслируют программу не последовательно, а произвольным образом перемешивают машинные команды соседних строк исходного кода, превращая дизассемблерный листинг в настоящую головоломку. Все высокоуровневые конструкции управления (циклы, ветвления) разбиваются на цепочку условных переходов, соответствующую оператору IF GOTO ранних диалектов Бейсика. Комментарии отсутствуют. Структуры данных уничтожаются. Символьные имена сохраняются лишь частично - в RTTI-классах и некоторых импортируемых/экспортируемых функциях. Иерархия классов со сложным наследованием чаще всего может быть полностью восстановлена, но расход времени на реконструкцию будет слишком велик.

Поразительно, но при всех своих различиях методики анализа машинного и исходного кода удивительно схожи, что уравнивает обе стороны в правах. Дизассемблирование - вовсе не такое таинственное занятие, каким оно поначалу кажется и оно вполне по силам инженеру средней руки. Найдите и прочитайте "Фундаментальные основы хакерства", "Образ мышления ИДА" и "Технику и философию хакерских атак - записки мыщъха" или любые другие книги по этой теме, в противном случае эта статья рискует оказаться для вас слишком абстрактной и непонятной.

Затерянный в дебрях кода

Рисунок 1. Затерянный в дебрях кода...

Прежде, чем начать

Существуют различные подходы к исследованию двоичного кода. Методики слепого поиска не предполагают ничего, кроме методичного перебора различных комбинаций входных данных (которыми, как правило, являются строки различной длины, используемые главным образом для выявления переполняющихся буферов). Целенаправленный анализ требует глубоких знаний системы, нетривиального мышления и богатого опыта проектирования "промышленных" программных комплексов. Хакер должен наперед знать, что именно он ищет. Излюбленные ошибки разработчиков. Вероятные места скопления багов. Особенности и ограничения различных языков программирования. Одних лишь навыков дизассемблирования (вы ведь умеете дизассемблировать, не правда ли?) для наших целей окажется катастрофически недостаточно.

Естественно, тупой перебор не всегда приводит к положительному результату и множество дыр при этом остается незамеченным. С другой стороны, изучение дизассемблерных листингов также не гарант успешности. Вы можете просидеть за монитором многие годы, но не найти ни одного достойного бага. Это уж, как повезет или не повезет (что, кстати говоря, намного более вероятно)... Поэтому прежде чем прибегать к дизассемблированию, убедитесь, что все возможное и невозможное уже сделано. Как минимум, следует нанести массированный удар по входным полям, засовывая в них строки непомерной длины, а как максимум - испробовать типовые, концептуальные уязвимости. В частности, если атакуемый брандмауэр беспрепятственно пропускает сильно фрагментированные TCP-пакеты, дизассемблировать его не нужно, суду и так все ясно: чтобы обнаружить подобную дыру в двоичном коде, необходимо отчетливо представлять механизм работы брандмауэра и заранее предполагать ее существование. А раз так, то не проще ли будет самостоятельно сформировать фрагментированный пакет и посмотреть - как на него отреагирует брандмауэр? Подложные пакеты - другое дело. Отправляя их жертве, мы должны знать - какие именно поля проверяются, а какие нет. Без дизассемблирования здесь уже не обойтись! Мы должны выделить код, ответственный за обработку заголовков и проанализировать критерии отбраковки пакетов. В конечном счете, дизассемблер - всего лишь инструмент и на роль генератора идей он не тянет. Бесцельное дизассемблирование - это путь в никуда.

Необходимый инструментарий

Голыми руками много дыр не наловишь! Агрессивная природа двоичного кода требует применения специального инструментария. Прежде всего, вам потребуется дизассемблер. Их много разных, но лучше всех - IDA PRO. Это бесспорный лидер, оставляющий своих конкурентов далеко позади и поддерживающий практически все форматы исполняемых файлов, процессоры и компиляторы, существующие на сегодняшний день (см. рис. 2).

Дизассемблер

Рисунок 2. Консольная версия IDA PRO - среда обитания профессиональных кодокопателей.

Дизассемблер

Рисунок 3. ...и под Palm PC тоже есть дизассемблер.

Еще нам потребуется отладчик. Классический выбор - soft-ice (см. рис. 4), однако в последнее время его жирная туша начинает уступать маленькому и подвижному OllyDebugger'у (см. рис. 5), главная "вкусность" которого - автоматическое отображение распознанных ASСII-строк рядом со смещениями, что значительно упрощает поиск переполняющихся буферов, поскольку они становятся видны, как на ладони. К сожалению, будучи отладчиком прикладного уровня, OllyDebugger не может отлаживать ядерные компоненты Windows (и некоторые серверные процессы, в том числе).

Маленький и шустрый OllyDebuger

Рисунок 4. Маленький и шустрый OllyDebuger.

Профессионально-ориентированный отладчик soft-ice

Рисунок 5. Профессионально-ориентированный отладчик soft-ice.

Если исследуемая программа упакована, перед началом дизассемблирования ее следует распаковать. Сделать это можно любым универсальным дампером (Proc Dump, PE-Tools, Lord-PE), а еще лучше - специализированным распаковщиком, знающим данный упаковщик "в лицо" (правда, не для всех упаковщиков распаковщики существуют). Дампы, снятые с программы, чаще всего неработоспособны и для своего запуска требуют серьезной доработки напильником. Однако зачем нам их запускать? А для дизассемблирования они подойдут и так.

Все вышеупомянутые продукты можно найти в Осле (Donkey) или в Муле (Mule) - файлообменных сетях, грубо говоря, представляющие собой Интернет внутри Интернета. С их появлением поиски вареза на Web'е стали уже неактуальны (удивительно, но о существовании осла многие до сих пор не знают!).

Осел

Рисунок 6. Осел - животное упрямое. Может качать, а может и не качать. Но если он разгонится - ничто его не остановит! К тому же, он практически всеяден - в смысле, в нем есть все! Любой софт, музыка, фильмы и документация!

Ошибки переполнения

Любая программа в значительной мере состоит из библиотек, анализировать которые бессмысленно - они уже давным-давно проанализированы и никаких радикально новых дыр здесь нет. К тому же, подавляющее большинство библиотек распространяется вместе с исходными текстами, так что корпеть над их дизассемблированием вдвойне не нужно. В большинстве случаев библиотечный код располагается позади основного кода программы и отделить его достаточно просто. Сложнее идентифицировать имена библиотечных функций, без знания которых мы конкретно завязнем в простыне дизассемблерных листингов, словно в трясине. К счастью, подавляющее большинство стандартных библиотек автоматически распознаются Идой. Сигнатуры же экзотических библиотек от сторонних производителей в любой момент можно добавить и самостоятельно, благо Ида допускает такую возможность (подробности в "Hacker Disassembling Uncovered" by Kris Kaspersky и штатной документации).

Решение о загрузке той или иной сигнатурной базы принимается Идой на основе анализа стартового кода и "чужеродные" библиотеки рискуют остаться нераспознанными. То же самое происходит и при загрузке дампов памяти с поврежденным или отсутствующим стартовым кодом или неверно установленной Entry Point (хроническая болезнь всех дамперов). Поэтому если большая часть функций программы осталась нераспознанной (см. рис. 7), попробуйте подключить сигнатурную базу вручную, выбрав в меню File\Load file пункт FLIRT Signature file. Появится обширный перечень известных Иде библиотек (см. рис. 9). Какую из них выбрать? Если вы новичок в дизассемблировании и нужную библиотеку не удается отождествить "визуально", действуйте методом перебора, загружая одну сигнатуру за другой, добиваясь максимального расширения голубой заливки (см. рис. 8).

Вид навигатора

Рисунок 7. Вид навигатора IDA PRO - доминирование синей заливки указывает на то, что большинство библиотечных функций так и остались нераспознанными, поскольку дизассемблер не смог определить тип компилятора и соответствующие сигнатуры должны быть загружены вручную.

Вид навигатора

Рисунок 8. Нежно-голубая заливка, пришедшая на смену голубой, подтверждает что теперь в Багдаде полный порядок!

Перечень известных ИДЕ сигнатур

Рисунок 9. Перечень известных ИДЕ сигнатур.

Просматривая список распознанных и импортируемых функций, отберем наиболее опасные из них. В первую очередь к ним относятся функции, принимающие указатель на выделенный буфер и возвращающие данные заранее непредсказуемого размера (например, sprintf, gets и т.д.). Функции с явным ограничением предельно допустимой длины буфера (fgets, GetWindowText, GetFullPathName) намного менее опасны, однако никаких гарантий в их лояльности ни у кого нет. Очень часто программист выделяет буфер намного меньшего размера и предохранительный клапан не срабатывает. Вот, например (см. листинг 1). Очевидно, что если пользователь введет с клавиатуры строку в 100 и более байт, то произойдет неминуемое переполнение буфера и никакие ограничители длины не спасут! Но это уже лирика.

#define MAX_BUF_SIZE 100
#define MAX_STR_SIZE 1024
char *x; x = malloc(MAX_BUF_SIZE); fgets(x, MAX_STR_SIZE, f);

Листинг 1. Пример программы, подверженной переполнению со срывом предохранительного клапана.

Полный перечень потенциально опасных функций занимает слишком много места и потому здесь не приводится. Будем учиться действовать по обстоятельствам. Загружаем исследуемую программу в дизассемблер (лучше всего в IDA PRO), нажимаем <Shift-F3>, щелкаем мышью по колонке "L" (сокращение от Library - библиотечная функция), отделяя библиотечные функции ото всех остальных. Достаем с полки толстый том справочного руководства (для лицензионных пользователей) или запускаем свой любимый MSDN (для всех остальных) и смотрим на прототип каждой из перечисленных здесь функций. Если среди аргументов присутствует указатель на буфер (что-то типа char*, void*, LPTSTR и т.д.), и этот буфер принимает возвращаемые функцией данные, то почему бы не проверить - как он относится к переполнению?

Нажимаем на <Enter>, переходя к началу функции, а затем входим в меню View\Open Subview\Cross Reference, открывая окно с перекрестными ссылками, каждая из которых ведет к точке вызова нашей функции. В зависимости от особенностей компилятора и сексуальный наклонностей программиста, проектировавшего исследуемое приложение, вызов может быть как непосредственным (типа CALL our_func), так и косвенным (типа mov ecx, pClass/mov ebx,[ecx + 4]/call ebx/.../pClass DD xxx/DD offset our_func). В последнем случае перекрестные ссылки на out_func будут вести к DD offset our_func и определить место ее реального вызова будет не так-то просто! Обычно хакеры в таких случаях нанимают отладчик, устанавливая на our_func точку останова, а затем записывают EIP всех мест, откуда она вызывается (кстати говоря, наличие интегрированного отладчика в последних версиях ИДЫ существенно ускоряет этот процесс).

И вот мы находимся в окрестностях вызывающего кода! Если аргумент, определяющий размер принимаемого буфера, представляет собой непосредственное значение (что-то типа push 400h, см. листинг 2) - это хороший знак и дыра, скорее всего, ждет нас где-то поблизости. Если же это не так - не отчаивайтесь, а, прокручивая курсор вверх, посмотрите - где сей размер инициализируется? Быть может, он все-таки представляет собой константу, передаваемую через более или менее длинную цепочку переменных или даже аргументов материнских функций!

.text:00401017        push   400h
.text:0040101C        mov    ecx, [ebp+var_8]
.text:0040101F        push   ecx
.text:00401020        call  _fgets

Листинг 2. Непосредственное значение максимальной длины буфера, передаваемое функции - хороший признак возможного переполнения.

Теперь найдите код, осуществляющий выделение памяти под буфер (обычно за это отвечают функции malloc и new). Если аргумент, определяющий размер выделяемой памяти, также представляет собой константу, причем эта константа меньше предельно допустимой длины возвращаемых данных - дыра найдена и можно смело переходить к фазе анализа возможных способов воздействия на переполняющийся буфер через поля входных данных.

Законы безопасного проектирования гласят - прежде чем выделять буфер, определи точный размер данных, которые ты туда собираешься положить. Т.е. в правильной программе вызову malloc или new всегда предшествует strlen, GetWindowTextLength или еще что-то типа этого. В противном случае программа потенциально уязвима. Разумеется, наличие превентивной проверки размера само по себе еще не гарант стабильности, поскольку далеко не во всех случаях затребованный размер определяется правильно, особенно если в буфер сливаются данные с нескольких источников.

Берем библиотечную функцию

Рисунок 10. Берем библиотечную функцию, прототип которой допускает возможность переполнения (1) и переходим по перекрестным ссылкам в окрестности ее вызова (2), смотрим на ограничитель предельно допустимой длины возвращаемых данных, сверяя его с размером выделяемого буфера (3), делаем вывод о возможности (или невозможности) переполнения.

С локальными переменными в этом плане намного сложнее, поскольку их размер приходится явным образом задавать на этапе компиляции программы, когда длина возвращаемых данных еще неизвестна. Неудивительно, что переполняющиеся буфера чаще всего обнаруживаются именно среди локальных переменных.

Локальные переменные хранятся в стековых фреймах (по-английски "frames"), также называемых кадрами или автоматической памятью. Каждой функции выделяется свой персональный кадр, в который помещаются все принадлежащие ей локальные переменные. Формирование кадра чаще всего осуществляется машинной командой "SUB ESP, xxx", реже - "ADD ESP, -xxx", где xxx - размер кадра в байтах. Текущие версии IDA PRO по умолчанию трактуют все непосредственные значения как беззнаковые числа и преобразование "xxx" в "-xxx" приходится осуществлять вручную путем нажатия на клавишу <->.

К сожалению, "разобрать" монолитный кадр на отдельные локальные переменные в общем случае невозможно, поскольку компилятор полностью уничтожает исходную информацию и анализ становится неоднозначным. Однако для наших целей возможностей автоматического анализатора IDA PRO более, чем достаточно. Мы будем исходить из того, что локальные буфера чаще всего (но не всегда!) имеют тип byte *, а их размер составляет, по меньшей мере, 5 байт (правда, как показывает статистика, ошибки переполнения чаще всего встречаются именно в четырехбайтовых буферах, которые при беглом анализе легко спутать с DWORD).

Рассмотрим в качестве примера следующий кадр стека, "разобранный" автоматическим анализатором IDA PRO, и попытаемся обнаружить в нем локальные буфера:

.text:00401012 sub_401012  proc near      ; CODE XREF: start+AFvp
.text:00401012
.text:00401012 var_38      = dword ptr -38h
.text:00401012 var_34      = byte ptr -34h
.text:00401012 var_24      = byte ptr -24h
.text:00401012 var_20      = byte ptr -20h
.text:00401012 var_10      = dword ptr -10h
.text:00401012 var_C       = dword ptr -0Ch
.text:00401012 var_8       = dword ptr -8
.text:00401012 var_4       = dword ptr -4
.text:00401012

Листинг 3. Локальные переменные, автоматически восстановленные IDA.

Переменная var_38 имеет тип DWORD и занимает 4 байта (размер переменной определяется путем вычитания адреса текущей переменной из адреса следующей: - 34h - (- 38h) = 4h). На буфер она мало похожа.

Переменная var_34 имеет тип BYTE и занимает 10h байт, что типично для локального буфера. То же самое можно сказать и о переменной var_20. Переменная var_24, хотя и имеет тип BYTE, но занимает всего 4 байта, поэтому может быть как компактным локальным буфером, так и простой скалярной переменной (причем последние встречаются намного чаще). До тех пор, пока на предмет переполнения не будут исследованы все явные буфера, возиться с подобными "кандидатами в буфера" нет никакого смысла.

Просматривая дизассемблерный код функции, найдите все ссылки на выявленный буфер и проанализируйте возможные условия его переполнения. Вот, например:

text:0040100B       push   300h
text:0040100D       lea    eax, [ebp+var_34]
text:00401010       push   eax
text:00401011       call   _fgets
text:00401016       add    esp, 0Ch

Листинг 4. Передача указателя на локальный буфер.

Сразу видно, что переменная var_34 используется для хранения введенной строки (значит, это все-таки буфер!) с предельно допустимой длиной в 300h байт, при длине самой локальной переменной в 10h байт. Не исключено, что var_34, var_24 и var_20 в действительности представляют собой "кусочки" одного буфера, однако в данном случае это ничего не меняет, поскольку их совокупный размер много меньше 300h!

Если же среди локальных переменных обнаружить переполняющиеся буфера, несмотря на все усилия, так и не удастся, можно попытать счастья среди развалин динамической памяти, отслеживая все перекрестные ссылки на функции типа new и malloc и анализируя окрестности их вызова.

Как бы там ни было, обнаружив переполняющийся буфер в одной из глубоко вложенных функций, не спешите радоваться - возможно, он никак не связан с потоком пользовательских данных или (что ничуть не менее неприятно) одна из материнских функций ограничивает предельно допустимую длину ввода сверху и переполнения не происходит. Пользователи графической версии IDA могут воспользоваться инструментом CALL GRAPH для просмотра дерева вызовов, уродливо отображающем взаимоотношения между дочерними и материнскими функциями, и позволяющим (во всяком случае, теоретически) проследить маршрут передвижения введенных пользователем данных по программе. К сожалению, отсутствие каких бы то ни было средств навигации (нет даже простейшего поиска!) обесценивают все прелести CALL GRAPH'а и в построенных им диаграммах просто нереально сориентироваться. Однако никто не запрещает разрабатывать адекватные средства визуализации самостоятельно.

Иерархия функций

Рисунок 11. Иерархия функций в графическом представлении.

Пока же адекватный инструмент не готов, приходится иметь секс с отладчиком, причем не простой, а анальный. История начинается просто. Заполняем все доступные поля пользовательского ввода, устанавливаем точку останова на вызов считывающей их функции (например, recv), устанавливаем точки останова непосредственно на буфер, принимающий введенные нами данные, и затем ждем последующих обращений. Чаще всего, данные обрабатываются не сразу после приема, а перегоняются через множество промежуточных буферов, каждый из которых может содержать ошибки переполнения. Чтобы удержать ситуацию под контролем, мы вынуждены устанавливать точки останова на каждый из промежуточных буферов, обязательно отслеживая их освобождение (после освобождения локального буфера принадлежащая ему область памяти может быть использована кем угодно, вызывая ложные всплытия отладчика, отнимающие время и сильно нервирующие нас). А ведь точек останова всего четыре... Как же мы будем отслеживать обращения к десяткам локальных буферов с помощью всего четырех точек?

А вот как! Версия soft-ice для Windows 9x поддерживает установку точки останова на регион, причем количество таких точек практически не ограничено. К сожалению, в soft-ice для Windows NT эта вкусность отсутствует и ее приходится эмулировать путем хитроумных (хитрожопных) манипуляций с атрибутами страниц. Переводя страницу в состояние NO_ACCESS, мы будем отлавливать все обращения к ней (и к подопытному буферу, в том числе). Естественно, если размер буфера много меньше размера страницы (который, как известно, составляет 4 Кбайт), нам придется каждый раз разбираться - к какой именно переменной произошло обращение. При желании этот процесс можно полностью или частично автоматизировать (к soft-ice имеется множество примочек, поддерживающих развитые скриптовые языки).

Вот так дыры и ищутся! Минимум творчества, максимум рутины... Стрельбы и гонок по пересеченной поверхности здесь тоже нет. Тем не менее, сидеть в отладчике намного круче, чем смотреть Матрицу (кстати, никто не знает где найти оригинальную версию третьей части, не изувеченную переводом?) или апгрейдить компьютер для игры в DOOM 3...

ФункцияСклонность к переполнению
getsЭкстремальная
strcpy/strcatЭкстремальная
memmove/memcpyВысокая
sprintf/vsprintf/fsprintfВысокая
scanf/sscanf/fscanf/vscanf/vsscanfВысокая
wcscpy/wcscat/wcsncatВысокая
wmemset/wcsncpyВысокая
wmemmove/wmemcpyВысокая
strncpy/vsnprint/snprintf/strncatНизкая

Таблица 1. Некоторые потенциально опасные функции стандартной библиотеки языка Си.

Примечания

Рисунок 12. Встреча рассвета за монитором.

Заключение

Успех операции во многом зависит не только от вашего опыта, но и пространственной ориентации монитора, геометрии мыши и степени потертости клавиатуры, а попросту говоря - от степени вашей везучести. Поговаривают, что мыши и новые клавиатуры приносят несчастье - в самый ответственный момент курсор прыгает немного не туда и переполняющийся буфер остается незамеченным...