Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР #114, ИЮНЬ 2008 г.

Наведение порядка в хаосе атак

Крис Касперски

Хакер, номер #114, стр. 050


Ноу-хау в ошибках переполнения

Дыра – это нора, а в норе – ароматный ужин, как правило, состряпанный из переполняющихся буферов. От обилия материалов по этой теме рябит в глазах, и без систематического руководства новичку очень легко свернуть голову, соблазнившись статьей трехлетней давности. Методики атак на ошибки переполнения – весьма скоропортящейся продукт. Вот я и приготовил свежачок! Приятного аппетита!

Количество типов локальных/удаленных атак, прямо или косвенно связанных с ошибками переполнения, неуклонно растет. Защитные механизмы также не стоят на месте, но откровенно запаздывают. На передовой линии хакерского фронта царит полный хаос, совершенно не поддающийся никакой классификации. Дыры (с чисто формальной точки зрения) делятся на семейства, типы и подтипы, но при внимательном анализе выявляются подтипы из различных семейств, описывающие одну и ту же дыру, и классификация летит к черту!

Тем не менее, без систематизации не обойтись. Невозможно каждый раз описывать все характеристики дыры от начала и до конца. Я не предлагаю своей собственной классификации и не придумываю новых терминов, а упорядочиваю уже существующие. Иногда (в силу сложившихся исторических обстоятельств) – достаточно нелепые, но ставшие общепринятыми.

Переполняющиеся буфера

Возможность использования ошибок переполнения для хакерских атак была осознана и теоретически обоснована еще в 1972 году Джеймсом Андерсеном (James Anderson), а спустя десяток лет, 2 ноября 1988, впервые опробована в достопочтенном Черве Морриса, использовавшем ошибку переполнения в UNIX-демоне finger. После сокрушительной эпидемии на хакерском фронте наступило неожиданное затишье, но с конца 90х годов XX века атаки на переполняющиеся буфера вспыхнули с новой силой, да так вспыхнули, что едва не погрузили мир в средневековую тьму – хорошо, что ни один из червей не содержал в себе деструктивной начинки.

Какова же природа сатаны, с которым приходится иметь дело? Она такова: локальные буфера находятся в стеке и при их переполнении (традиционное отсутствие проверки длины перед копированием!) происходит затирание адреса возврата из функции (вместе с остальными буферами, скалярными переменными и указателями, встретившимися на пути). Если только функция не грохнется еще до своего завершения, то произойдет передача управления по адресу, записанному поверх адреса возврата. Произойдет в зависимости от «настроения» хакера, отправляющего процессор в «космос» (по случайному адресу) либо вызывающего shell-код, который по обыкновению расположен непосредственно в переполняющемся буфере (в исключительных случаях – где-то в другом месте). Техника передачи управления кратко описана в одноименной врезке.

Динамические буфера размещаются в куче (heap). Сезон ее переполнения открылся статьей «Once upon a free()», опубликованной 8 января 2001 года неизвестным хакером в #39 номере электронного журнала phrack. Кстати, со ссылкой на исследовательскую работу Solar'а Designer'а, восходящую к 25 июля 2000 года и описывающую уязвимость библиотеки glibc-2.2.3. Последняя допускает передачу управления на произвольный код или модификацию произвольных ячеек памяти (например, указателей на функции), что открывает поистине безграничные возможности для атакующего.

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

Хронология технологий защиты стека

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

Даже сегодня, когда процессоры летают со скоростью пули, подавляющее большинство программ компилируются с отключенным контролем границ буферов. Впрочем, некоторые языки программирования (и, в первую очередь, Си/Си++) никакие проверки не спасают, поскольку полноценной поддержки массивов в них нет, и программистам приходится оперировать указателями на безразмерные блоки памяти. Как следствие — ошибки переполнения носят характер фундаментальной проблемы, не имеющей общего решения.

Разработчикам компиляторов приходится извращаться и ходить другой тропой. Некогда популярное расширение для компилятора GCC (уже давно интегрированное в него) со скромным названием Stack-Guard модифицирует стековый фрейм путем помещения специального «сторожевого» слова перед адресом возврата (сначала представляющего собой константу, а затем – случайно генерируемое значение). В код эпилога добавляется проверка целостности сторожевого слова на предмет его затирания хакером. Аналогичная техника используется и в последних компиляторах от Microsoft – тех, что поддерживают ключ /GS, форсирующий проверку целостности адреса возврата.

Недостаток подобных защит в том, что они защищают лишь сам адрес возврата, но не препятствуют затиранию предшествующих ему переменных. А среди тех часто встречаются указатели на функции, позволяющие хакеру передавать управление по любому адресу, которому ему только вздумается. Последние версии GCC поддерживают множество дополнительных расширений, «оборачивающих» буфера страницами памяти с атрибутами NO_ACCESS (всякая попытка доступа к ним вызывает исключение), а также шифрующих указатели, которые хранятся в памяти, случайно сгенерированной константой по XOR. Накладные расходы на защиту (оверхих), конечно, существенно возрастают, ан, вместе с тем, затрудняется и сама атака. К счастью (для хакеров), подавляющее большинство программ поставляются в незащищенном виде.

Хронология защиты кучи

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

Разработчики всех операционных систем: BSD, Linux, Windows прилагают нехилые усилия по защите базового аллокатора и воздвигают многоуровневую линию обороны, призванную обеспечить контроль целостности кучи и не допустить затирания служебных структур данных. Microsoft отчаянно пропагандирует защиту кучи в Висле (впрочем, уже давно поломанную), забыв о том, что это никак не препятствует атакам. А проверка целостности кучи на уровне RTL конкретных компиляторов ни в DELPHI, ни в MS VC, ни даже в последних версиях C# должным образом так и не реализована, и все это хозяйство (неважно – работающее под W2K, XP или Вислой) атакуется влет.

Библиотека LIBC (стандартная библиотека в мире Linux/BSD) и GLIBC (стандартная библиотека компилятора GCC) защищена намного сильнее, но хакеров особенно высаживает то, что в различных версиях этих библиотек применяются различные аллокаторы. А без точного знания схемы размещения служебных структур кучи ее не атакуешь – в лучшем случае получится отказ в обслуживании. Написание универсальных exploit'ов затруднено и для удачной атаки необходимо знать точную версию библиотеки, используемую жертвой. Определить ее удаленно не так-то просто!

Хронология неисполняемого стека/кучи

Запрет на исполнение кода в стеке/куче/сегменте данных когда-то казался весьма радикальным решением проблемы, гарантирующим мир и спокойствие. А все потому, что руководящие работники не привыкли думать головой. Ни своей, ни чужой.

Неисполняемый стек/куча впервые появился в UNIX-системах, причем довольно давно. Для достижения аналогичного результата парням из Microsoft понадобилась специальная аппаратная поддержка со стороны процессоров, которая была предоставлена с большим запозданием. Проблема (если это можно назвать проблемой) в том, что UNIX (равно как и Windows) поддерживает линейное адресное пространство, выделяющее в распоряжение каждого процесса 4 Гбайта виртуальной памяти. В них размещаются: код операционной системы, код программы (со всеми динамическими библиотеками, секция данных, стек и куча.

x86-процессоры поддерживают раздельные селекторы для кода, данных и стека – каждый со своими атрибутами, разрешающими (или не разрешающими) чтение, запись и исполнение, однако, для упрощения кода операционной системы разработчики Windows «распахнули» селекторы кода/стека/данных на все адресное пространство, присвоив им идентичные лимиты и атрибуты защиты. Также поступили и разработчики первых версий Linux/BSD.

На уровне отдельных страниц x86-процессоры поддерживают только два атрибута защиты: доступа и записи, при этом понятие «доступа» включает в себя как чтение, так и исполнение. Вплоть до недавнего времени атрибуты чтения и исполнения были тождественны друг другу.

Первыми спохватились разработчики Linux/BSD. Они «разнесли» стек/кучу и код по разным концам адресного пространства, скорректировали лимиты селекторов, в результате чего стек/куча оказались совершенно неисполняемыми. Хакеры конкретно приуныли. Но и ряду честных программ (например, компиляторам, транслирующим код в оперативную память) пришлось либо нехило извратиться, чтобы преодолеть прелести нововведений, либо объявить забастовку, как большинство из них и поступило.

Так что поддержка атрибута на уровне отдельных страниц в последних версиях x86-процессоров пришлась ко двору не только Windows, но и Linux/BSD. Но не успели разработчики опохмелиться после сдачи «защищенного» релиза, как хакеры уже изобрели атаку, получившую название return2libc. В общих чертах, она сводится к засылке в стек указателей на функции, выделяющие блок памяти с атрибутами на запись + исполнение и тут же копирующие в него shell-код (с передачей на него управления классическим способом). При этом в стеке оказывался не код, а данные – указатели на функции, замещающие оригинальный адрес возврата. И хотя в Windows нет библиотеки LIBC, зато там есть KERNEL32.DLL и потому атака return2libc работает на ура даже с неисполняемым стеком.

Как водится, первыми отреагировали разработчики Linux/BSD (то ли пьют они меньше, то ли трезвеют быстрее). Пакет PaX (кстати говоря, портированный и под Windows) выполняет рандомизацию адресного пространства (Address Space Layout Randomization или, сокращенно, ASLR), размещая стек, кучу и системные библиотеки по случайным адресам. В итоге, хакер уже не может просто так засунуть в стек указатели на необходимые ему функции, ведь их местоположение заранее неизвестно!

Разработчики Open-BSD поступили иначе, внедрив технологию W^X (что расшифровывается как «W XOR X»), препятствующую одновременной установке атрибутов записи и исполнения, что существенно затрудняет атаку. По совести сказать, PaX – круче. Поэтому спустя некоторое время коллектив Open-BSD дал ему добро, предоставив пользователю выбор: какую защитную систему использовать. ASLR, реализованный должным образом, действительно, представляет серьезное препятствие для атакующих, однако, даже в Linux/BSD часть критических структур данных по-прежнему располагается по вполне предсказуемым адресам. Что же касается Windows, то ASLR там поддерживается только, начиная с Висты, и реализован настолько криво, насколько это возможно. К тому же, ранее написанные программы с убитой таблицей перемещаемых элементов всегда загружаются по одному и тому же базовому адресу и в принципе не поддаются рандомизации. Так что, для защиты от атак мало установить Висту на свой компьютер. Как минимум, требуется перекомпилировать все используемое программное обеспечение, а как его откомпилируешь, когда исходных текстов нет?

Старые среды разработки DELPHI, Visual Basic вообще не поддерживают возможность установки бита рандомизации и – помимо перекомпиляции – над сгенерированными файлами/динамическими библиотеками еще предстоит поработать руками (и головой) или же полностью переписать проект на C#. Заманчивая перспектива, не правда ли? Так стоит ли удивляться, что существенного снижения хакерской активности ожидать не приходится, во всяком случае на ближайшие года два, а там… хакеры снова что-то придумают.

Целочисленное переполнение

В большинстве языков программирования (и в языке Си, в том числе) значение выражения (n + k) для целочисленных типов в общем случае неопределенно – оно может быть равно арифметической сумме n и k… а может и не быть!

При сложении двух беззнаковых типов x86-процессоры дают корректный результат лишь до тех пор, пока конечная сумма остается в пределах разрядной сетки. В противном случае процессор выставляет знак переноса, и мы имеем «заворот», то есть UCHAR_MAX + UCHAR_MAX == UCHAR_MAX-1 == FEh. Аналогичным образом дела обстоят и с UINT_MAX.

А вот со знаковыми типами все гораздо интереснее. В x86-процессорах старший бит числа используется для задания знака (в некоторых процессорах за это отвечает младший бит, но разговор не о них). На 32-разрядных платформах INT_MAX = 2147483647, но (INT_MAX+1) == INT_MIN == -2147483648. Получается, от наибольшего положительного до наименьшего отрицательного – один шаг! Ни процессор, ни компилятор никак не реагируют на эту ситуацию и, если программист не озаботился рукотворными проверками, программа может выдать весьма неожиданный результат.

Но дальше – еще интереснее. По умолчанию int представляет собой signed int – знаковый тип, а вот функция malloc, выделяющая память, в качестве аргумента, задающего размер блока, принимает size_t, определенный в заголовочных файлах как unsigned int (как и множество функций подобного типа, включая memcpy),.

Посмотрим, к чему приводит такое несоответствие. Возьмем следующий (кстати говоря, широко распространенный) код:

foo(int len, char *p)

{

char buf[MAX_SIZE];

if (len > MAX_SIZE) return -1;

memcpy(buf, p, len);

…

return 1;

}

Что произойдет, если в качестве len передать отрицательное число? Поскольку любое отрицательное число больше всякого положительного (очень умную мысль сказал, да?), то выражение (len > MAX_SIZE) окажется ложно и переменная len благополучно «докатится» до функции memcpy. Там небольшое отрицательное знаковое число превратится в очень большое положительное беззнаковое (INT_MIN = 80000000h). Именно столько байт памяти будет скопировано функцией memcpy. Точнее, она попытается их скопировать, но поскольку 80000000h — это половина адресного пространства, выделенная процессу, из которой ему реально доступно еще меньше, дело закончится исключением типа «нарушение доступа», и хакер получит «всего лишь» отказ в обслуживании.

А вот еще один пример вполне типичного кода:

bar(int len, char *s)

{

char *p;

p = (char *) malloc(len+1);

*(((char*)memcсpy(p,s,0, len))) = 0;

return 1;

}

Программист, копирующий строку, выделяет на один байт больше, куда и ставит завершающий нуль (на тот случай, если *s окажется без такового). На первый взгляд, все ОК. Но если в качестве len передать UINT_MAX, то при добавлении к нему единицы функция malloc в качестве аргумента получит нуль! По стандарту попытка выделения блока нулевого размера является вполне допустимой операцией и функция malloc обязана возвратить валидный указатель на... Технически создать блок нулевого размера в памяти невозможно, поэтому обычно выделяется блок минимально возможного размера, который только поддерживает данная реализация malloc (что-то около 16 байт). А вот дальше! Дальше функция memcpy попытается скопировать туда UINT_MAX байт (FFFFFFFFh), что опять-таки приведет к нарушению доступа.

А что насчет захвата управления? Даже в примерах, рассмотренных выше, он вполне возможен, поскольку, прежде чем «врезаться» в невыделенный регион памяти или область памяти, принадлежащую операционной системе (и, естественно, защищенную от записи), функция memcpy имеет хорошие шансы перезаписать обработчики структурных исключений (как правило, хранящиеся в стеке). Тогда, при генерации исключения, вместо отказа в обслуживании управление подхватит хакерский код!

В Linux/BSD никакого SEH'а нет (там для этого используются сигналы, реализованные совсем иначе и неподвластные атаке). В Windows, начиная с XP, предпринята попытка защиты SEH-обработчиков от хакерских домогательств и развернута компания под названием SafeSEH. Вышел Server 2003, Виста, Server 2008, а SafeSEH все еще улучшается и улучшается, но так до ума и не доведена!

Если же с целочисленными переменными осуществляются махинации в стиле memcpy(dst, src, x*y+z), что вовсе не редкость, то у хакера появляется реальная возможность получить в результате переполнения именно то число, которое ему нужно. То есть, превышающее размер выделенного буфера, но не такое большое, чтобы «вылететь» за пределы адресного пространства.

В принципе, некоторые компиляторы (например, GCC) поддерживают специальный ключ, форсирующий проверку на целочисленные переполнения. Но, во-первых, она довольно сильно тормозит (и в случае переполнения опять-таки высаживает на отказ в обслуживании), а во-вторых, от кастинга (явного/неявного преобразования типов) она не спасает. Приведенный пример, с точки зрения компилятора – вполне законное программистское творение, а потому атаки данного типа прекращаться не собираются (к тому же, лишь немногие программисты способы провести надлежащий аудит кода на предмет поиска багов).

Занавес

Разумеется, разновидности атак на этом не заканчиваются и за кадром остались удары по памяти, использование освобожденных буферов, неинициализированные локальные переменные и указатели, неспецифические разрушения памяти, ошибки синхронизации потоков… – малая часть того, что можно использовать для атаки с захватом управления или отказом в обслуживании. В рубрике «Обзор эксплойтов» я планирую планомерно и систематично окучить эту плодородную тему, детально описывая детали технической реализации в разделе full disclose.

Передача управления на shell-код

Казалось бы, если атакующий может перезаписывать адрес возврата (или любой другой указатель на функцию), то проблема передачи управления на shell-код решается сама собой, но все не так просто! Допустим, переполняющийся буфер расположен в стеке, а стек, как известно, растет снизу вверх (или сверху вниз — это уж кому как привычнее) и точное положение указателя вершины стека неизвестно. Следовательно, неизвестна и локация shell-кода. Так куда же передавать управление?

Одно из решений проблемы (известное под именем NOP SLED техники) заключается в дописывании в конец буфера большого количества незначащих инструкций NOP. На x86-процессорах им соответствует опкод 90h, тождественный операции XCHG EAX,EAX – обмен содержимого регистра EAX с регистром EAX, в конце которых стоит команда относительного (relative) перехода на начало shell-кода. Она не требует знания абсолютных адресов (неизвестных атакующему).

NOP'ы при этом оказываются расположены как до адреса возврата, так и после. Естественно, если управление будет передано «вперед», то цепочка управления, докатившись до адреса возврата, попытается интерпретировать его как машинную команду со всеми вытекающими отсюда последствиями типа непредсказуемого поведения. Поэтому перед адресом возврата вставляется еще одна команда относительного перехода.

Для реализации NOP SLED-техники хакеру должен быть известен хотя бы приблизительный адрес буфера с shell-кодом, а известен он далеко не всегда. Что ж, приходится прибегать к другой технике, которая передает управление на вершину стека через команду JMP ESP, в x86-процессорах представляющую собой двухбайтовую машинную инструкцию с опкодом FFh E4h. Вся хитрость в том, чтобы найти такую последовательность байт в памяти и подсунуть ее адрес на место адреса возврата из функции. Тогда в момент стягивания последнего со стека регистр ESP будет смотреть на двойное слово, следующее за адресом возврата, где может быть либо сам shell-код, либо команда перехода к нему.

Если целевая операционная система (или атакуемое приложение) известна с точностью до версии – найти двухбайтовую последовательность не проблема. Не обязательно искать именно JMP ESP. FFh E4h вполне может быть и частью совсем другой команды, например, инструкции CALL DbgPrint с опкодом E8h FFh E4h FEh FFh 56h. На машинах с неисполняемым стеком/кучей, последовательность FFh E4h необходимо искать только в кодовых секциях динамических библиотек или исполняемом файле атакуемого приложения. Если же защиты нет (или она отключена), подойдет и область данных.

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

Содержание