Крис Касперски
Уязвимость java-приложений
Производители java-машин довольно оперативно реагируют на сообщения об уязвимостях, выпуская многочисленные заплаты, неспешно устанавливаемые пользователями на компьютеры. В результате чего шансы на успешную атаку с течением времени неуклонно уменьшаются и в какой-то момент дыра становится неактуальной.
Тем не менее даже корпоративные пользователи обновляют свои компьютеры отнюдь не мгновенно, и для реализации атаки у хакеров имеется от нескольких часов до нескольких дней. Более чем достаточно, особенно если учесть, что некоторые злоумышленники ведут круглосуточный мониторинг всех информационных ресурсов, публикующих сообщения о безопасности. Помимо этого злоумышленники активно занимаются самостоятельным поиском дыр, который иногда оказывается весьма плодотворным.
К тому же не все дыры могут быть закрыты заплатками. В отличии от Си, исправление подавляющего большинства ошибок в котором носит характер мелкой косметической правки (к примеру, забыли проверить длину строки перед ее копированием), дефекты виртуальных Java-машин трансцендентальны —дыра не сосредоточена в какой-то конкретной строчке кода, а представляет собой некую комбинацию свойств виртуальной машины, что при стечении определенных обстоятельств приводит к возможности реализации атаки. Очевидно, что таких комбинаций у виртуальной машины очень много, аусловий взаимодействия еще больше. Поэтому ликвидация уязвимости требует огромной аналитической работы и существенной перестройки архитектуры виртуальной машины, что, в свою очередь, приводит к появлению новых дефектов. Тем более что производители виртуальных машин тяготеют к закрытию дыр простыми шаблонными фильтрами.
Допустим, для реализации атаки необходимо задействовать свойства А, В и С. Допустим также, что подобная комбинация свойств практически не используется в обычных программах. Тогда производитель добавляет фильтр, блокирующий их выполнение. Но если представить свойство В в виде прямых (или побочных) эффектов свойств D и Е, то фильтр, очевидно, пропустит такую комбинацию. Отсюда следует важный вывод: даже если производитель рапортует об успешной ликвидации дыры и пользователи уже установили заплаты, это еще не значит, что дыры действительно нет. Исправляется ведь причина, а не следствие. Хакеры просто находят другой путь для достижения того же самого следствия, «воскрешая» дыры, о которых все забыли. Уязвимости, завязанные на спецификацию Java-машины и набор исполняемых ею команд, вообще невозможно залатать без потери совместимости с уже существующими Java-приложениями. В некоторых случаях помогает установка нового Java-компилятора с последующей пересборкой исходных текстов проекта, которых, кстати говоря, у большинства пользователей просто нет. Но даже если бы они и были, эту работу должен выполнять программист, поскольку следует заранее быть готовым к тому, что в тексты придется вносить изменения, иначе программа может отказаться собираться или поведет себя непредсказуемым образом.
Обход системы типов
Жесткая типизация языка Java предотвращает множество непредумышленных ошибок программирования, связанных с присвоением одного типа другому. Защита реализована науровне виртуальной машины, а не в самом языке программирования (как, например, это сделано в Си++). Попытка совершить выход из функции, подсунув инструкции return массив или символьную строку вместо адреса возврата (типичный сценарий хакерской атаки на переполняющиеся буферы), приведет к аварийному завершению Java-приложения. На этом же держится контроль границ массивов, доступ к приватным полям классов и многое другое. Если перехитрить типизацию, можно сломать всю систему безопасности Java и делать практически все что угодно: переполнять буферы, вызывать приватные методы защищенных классов и т. д. Естественно, это уже будут умышленные «ошибки», построенные на слабости механизмов контроля типов. А хакерская атака и есть не что иное, как умышленный отход от спецификации.
Далеко не все знают, что на уровне виртуальной машины Java-платформы защищены намного слабее, чем это следует из популярных руководств для чайников. В спецификации на JVM присутствует большое количество документированных инструкций для низкоуровневого преобразования типов: i2b, i2c, i2d, i2f, i2l, i2s, I2i, I2f, I2d, f2i, f 21, f 2d, d2i, d2l, d2f и даже пара команд для работы с классами: checkcst, instanceof. Их описание выложено на http://mrl.nyu.edu/~meyer/jvmref/ref-Java.html. Более того, в пакете инструментария для разработчика Java-программ содержится официальный код, показывающий, как «правильно» преобразовать один тип к любому другому. Так что в некотором смысле это не взлом, а документированная особенность Java.
Как используется эта особенность на практике? Допустим, мы имеем два класса: trusted (исполняющийся в привилегированном интервале, а значит, владеющий всеми ресурсами виртуальной машины) и untrusted (помещенный в «песочницу»). Допустим также, что класс trusted имеет ряд приватных методов, предназначенных строго для внутреннего использования и скрытых от «внешнего мира» системой типизации. На бумаге эта схема выглядит безупречной, но в действительности ее легко обойти путем создания подложного класса (назовем его spoofed), полностью идентичного данному, но только с public-атрибутами вместо private. Явное преобразование экземпляров класса trusted и spoofed позволит обращаться к защищенным методам, вызывая их не только из других классов, но даже из untrusted-кода. Такой беспредел происходит потому, что в JVM отсутствует runtime-проверка атрибутов полей класса при обращении к ним методами getfield/putfield. А если бы они и были (сжирая дополнительные процессорные такты), злоумышленнику ничего бы не стоило хакнуть атрибуты путем прямой модификации структуры класса двумя замечательными инструкциями JVM — getLong и putLong, специально предназначенными для низкоуровневого взаимодействия с памятью виртуальной Java-машины. Однако в этом случае атакующему придется учитывать версию JVM, поскольку «физическая» структура классов не остается постоянной, а подвержена существенным изменениям. Теперь перейдем к переполняющимся буферам. Как уже было сказано выше, Java контролирует выход за границы массивов на уровне JVM и потому случайно переполнить буфер невозможно. Однако это легко сделать умышленно—достаточно воспользоваться преобразованием типов, искусственно раздвинув границы массива. При записи в буфер JVM отслеживает лишь выход индекса за его границы, но (по соображениям производительности) не проверяет, принадлежит ли записываемая память кому-то еще. Учитывая, что экземпляры классов (они же объекты) располагаются в памяти более или менее последовательно, можно свободно перезаписывать атрибуты, указатели и прочие данные остальных классов (в том числе и доверенных).
Естественно, для реализации данной атаки необходимо написать зловредное Java-приложение и забросить его на целевой компьютер. Атаковать уже установленные приложения не получится, поскольку хакер не в состоянии осуществлять удаленное преобразование типов. Но и тут кое-какие зацепки есть. Некоторые программисты при переносе Си-программ на Java испытывают потребность в работе с указателями на блок «неразборчивых» данных заранее неизвестной длины. Java таких фокусов не позволяет, приходится выкручиваться путем явных преобразований. Фактически это означает, что программист сознательно отказывается от жесткой типизации и говорит Java-машине, что не надо проверять типы, границы массивов и т. п.
Таким образом, нельзя априори утверждать, что Java-приложения свободны от ошибок переполнения буферов. Встречаются они, конечно, намного реже, чем в Си-программах, но все-таки встречаются...
Обход верификатора
Верификатор относится к одному из самых разрекламированных компонентов JVM. Весь Java-код (в том числе и добавляемый динамически) в обязательном порядке проходит через сложную систему многочисленных фильтров, озабоченных суровыми проверками на предмет корректности исполняемого кода. В частности, если тупо попытаться забросить на вершину стека массив командой aloadj, а потом уйти в возврат из функции по ireturn, верификатор немедленно встрепенется и этот номер не пройдет. Как и любая другая достаточно сложная программа, верификатор несовершенен и подвержен ошибкам. Первую ошибку обнаружил сотрудник Маргбурского университета Карстер Зор (Karsten Sohr) в далеком 1999-м, он обратил внимание на то, что верификатор начинает «буксовать» в случае, если последняя проверяемая инструкция находится внутри обработчика исключений. Что позволяет, в частности, осуществлять «нелегальное» преобразование одного класса к любому другому.
Несмотря на то что данная ошибка уже давно устранена и сейчас представляет не более чем исторический интерес, сам факт ее наличия указывает на множество неоткрытых (а значит, еще не исправленных) дефектов верификатора. И учитывая резкое усложнение верификатора в последних версиях JVM, есть все основания полагать, что на безопасности это отразилось не лучшим образом. Ошибки в верификаторах виртуальных Java-машин обнаруживаются одна за другой — производители уже запыхались их латать, а пользователи устали ставить заплатки.
Другой интересный момент—атака на отказ в обслуживании. Обычно для проверки метода, состоящего из N инструкций виртуальной машины, верификатору требуется совершить N итераций, в результате чего сложность линейно растет с размером класса. Если же каждая инструкция метода взаимодействует со всеми остальными (например, через стек или как-то еще), то верификатору уже требуется N в квадрате итераций и сложность соответственно возрастает. Подсунув верификатору метод, состоящий из десятков (или даже сотен) тысяч инструкций, взаимодействующих друг с другом, можно ввести его в глубокую задумчивость, выход из которой конструктивно не предусмотрен, и пользователю придется аварийно завершать работу Java-программы вместе с Java-машиной в придачу. Лекарства от данной «болезни» нет. И хотя Sun делает некоторые шаги в этом направлении, пересматривая набор команд JVM и выкидывая из него все «ненужное», сложность анализа байт-кода остается такой же. Особенно эта проблема актуальна для серверов, встраиваемых устройств, сотовых телефонов—там, где снятие зависшей Java-машины невозможно или сопряжено с потерей времени/данных.