Новое – хорошо забытое старое

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

Уязвимость 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-машины невозможно или сопряжено с потерей времени/данных.

Ошибки в JIТ-компиляторах

Современные процессоры достаточно быстрые, но Java-машины настолько неповоротливы, что способны выполнять только простейшие приложения, не критичные ко времени исполнения, например проверять корректность заполнения Web-форм перед их отправкой на сервер. Попытки создать на Java что-то действительно серьезное наталкиваются на неоправданно низкую производительность JVM, для преодоления которой придумали JIТ-компиляторы (Just-In-Time), транслирующие байт-код непосредственно в “наивный” (native) код целевого процессора, в результате чего Java-программы по скорости выполнения не сильно уступают своим аналогам на Си, а в некоторых случаях даже превосходят их.

Откомпилированный машинный код выполняется с минимумом проверок и верификатор из динамического вырождается в статический. В частности, если произойдет переполнение буфера, то хакер без труда сможет внедрить туда shell-код и передать ему управление, захватив все привилегии виртуальной машины, достаточно часто выполняемой с правами администратора. Отсутствие динамического анализа и скрупулезных проверок времени исполнения (их наличие сильно замедлило бы производительность) позволяет злоумышленнику сравнительно честными путями вырываться за пределы виртуальной машины, вызывая произвольные API-функции операционной системы или даже модифицируя саму виртуальную машину по своему усмотрению. К тому же JIТ-компиляторы при некоторых обстоятельствах сурово ошибаются, генерируя неправильный код. Рассмотрим пример некорректной работы Symantec JIТ-компилятора, используемого, в частности, в браузере Netscape версий 4.0-4.79 под Windows/x86. Байт-код забрасывает на вершину стека нулевую константу (команда aconst_null), после чего вызывает локальную подпрограмму командой jsr 11, где тут же выталкивает двойное слово с вершины стека в виртуальный регистр R1 и возвращается из нее обратно, переходя по адресу, содержащемуся в виртуальном регистре R1 (а в нем как раз и лежит адрес возврата из локальной подпрограммы). Так что с точки зрения верификатора все выглядит предельно корректно и у него никаких претензий нет. Что же касается JIТ-компилятора, то перед входом в функции он сохраняет регистр ЕАХ в стеке (условно соответствующий виртуальному регистру R1), далее обнуляет его (команда XOR ЕАХ. ЕАХ), но не кладет в стек, а прямо так в регистре и оставляет. Потом вызывает локальную подпрограмму (инструкция CALL I1), забрасывая на стек адрес возврата (то есть адрес первой следующей за ней команды – инструкции POP ЕСХ). В самой же подпрограмме компилятор стягивает с вершины стека двойное слово, помещая его в регистр ЕАХ (команда POP ЕАХ), что совершенно правильно. Затем, отрабатывая RET 1, вместо того, чтобы сразу прыгнуть на JMP ЕАХ, по совершенно непонятным причинам еще разлезет в стек и копирует в ЕАХ двойное слово, находящееся на его вершине (инструкция “MOV ЕАХ, [ESP]”), в результате чего реальный переход осуществляется по физическому указателю, находящемуся в регистре ЕАХ. Обычно там собирается мусор и программа (вместе с Java-машиной) просто рушится. При желании можно воздействовать на ЕАХ, засунув в него указатель на shell-код или что-то подобное. Для этого перед вызовом функции jump() достаточно выполнить последовательность команд виртуальной машины: iloadj/ireturn. Сейчас эта дыра уже закрыта.

Повышение собственных привилегий

Несанкционированное повышение привилегий актуально главным образом для Java-приложений, поступающих из ненадежных источников (например, из Сети) и выполняемых в “песочнице” (sandbox), прорыв за пределы которой приводит к плачевным последствиям. Злоумышленник получает возможность исполнять любой код, открывать порты, обращаться к локальным файлам и т. д.

В последних версиях JVM “песочницу” растащили на стройматериалы, ушедшие на создание новой системы безопасности, обеспечивающей разграничение доступа не на уровне Java-приложений (как это было раньше), а на уровне отдельных классов. Доверенные (trusted) классы могут делать все что угодно (если только не оговорено обратное). Остальные довольствуются обращением к публичным методам доверенных классов. Если атакующий сможет добраться до приватных (или защищенных) методов доверенного класса, его цель будет достигнута.

В верификаторе Java-машины, встроенной в MS IE версий 4.0,5.0 и 6.0, присутствовал коварный дефект, позволяющий создавать полностью инициализированные экземпляры классов, даже при возникновении исключения в методе super(). Метод super() похож на указатель this, поддерживаемый Java/ Си++, однако в отличие от this, указывающего на экземпляр данного класса, super() вызывает конструктор суперкласса (или базового класса, если в терминах Си++), к которому принадлежит данный экземпляр производного класса. Узнать подробнее о методах this() и super() можно по ссылке www. laas. org/docs/javap/c5/s5.html. Хорошая идея – взять доверенный класс и создать экземпляр производного класса (sub-класса) и проинициализировать его вызовом super(). Тогда злоумышленник сможет вырваться за пределы “песочницы”. Единственная проблема, с которой столкнется атакующий,-Java-компилятор откажется транслировать такой код. Но если

Историческая справка

Java возникла в результате внутреннего проекта Stealth Project (позднее переименованного в Green Project), начатого в 1990 г. компанией Sun. Его целью было создание языка программирования для своей же операционной системы Green Operating System, используемой для управления встраиваемыми устройствами и бытовой электроникой. Идея создания языка принадлежит Патрику Наутону, уставшему программировать микроконтроллеры на Си/Си++, преодолевая несовместимость различных компиляторов вкупе с их привязанностью к конкретному железу. Для “отвязки” от него, он решил сделать эффективную системно-независимую виртуальную машину. Позднее к нему присоединились Джеймс Гослинг (придумавший имя Oak, но оно оказалось уже зарезервированной торговой маркой) и Майк Шеридан. Они завершили создание языка в 1992 г. и продемонстрировали успешную работу Green OS на PDA-компьютере типа Star?.

Что такое enterprise-приложения?

По сложившейся традиции enterprise-приложениями (от английского “enterprise” – предприятие) называются программы, ориентированные на промышленное применение в больших организациях. Соответственно enterprise-серверы – это серверы, обслуживающие предприятия и включающие в себя: web-серверы, серверы печати, базы данных и прочие жизненно важные для функционирования корпоративной сети службы. К ключевым характеристикам enterprise-приложений относят их отказоустойчивость, возможность быстрого восстановления после “падений” и, конечно, безопасность (подробнее – на http://en. wiki pedia. orq/wiki/Enterprise server и http://wiki. debian. org/EnterpriseServer).

Изменения JVM

Структура байт-кода и набор инструкций JVM не остаются постоянными, а меняются от версии к версии, что существенно затрудняет как создание независимых трансляторов от сторонних производителей, так и реализацию атак на байт-код. Хакеру приходится либо фокусироваться на строго определенных версиях (которых может вообще не оказаться у жертвы), либо учитывать особенности каждой отдельно взятой реализации JVM, что весьма непросто. К тому же команды виртуальной машины медленно, но неуклонно движутся к изъятию потенциально опасных инструкций. В частности, из лексикона Java SE 6 исчезли команды JSR и JSR. W, представляющие собой отдаленный аналог Бейсик-команды GOSUB, передающей управление на процедуру. Sun по этому поводу пишет: “Верификатор запрещает выполнение инструкций JSR и RET. Эти инструкции используются для вызова подпрограмм при генерации try/f inally-блоков. Вместо этого компилятор будет встраивать код программ непосредственно по месту вызова”. вручную запрограммировать зловредную программу на Java-ассемблере (в качестве которого можно взять бесплатный транслятор Жасмин -jasmin. sf. net), верификатор байт-кода примет ее как родную, поскольку Java-машина, реализованная в IE, выполняет линейный анализ кода, а с этой точки зрения код вполне нормален.

Похожие ошибки содержатся и в других виртуальных машинах. В частности, в Netscape версий 4.0-4.79 вообще можно обойтись без вызовов this() и super(), заменив их ветвлениями (jsr/astore/ret).

Дыры в runtime-библиотеках и системных классах

В конце апреля 2007 г. в Apple QuickTime Player’e всех версий, вплоть до 7.1.5, обнаружилась дырка, позволяющая Java-приложениям исполнять произвольный код на удаленной системе. Достаточно зайти на Web-страничку злоумышленника… Учитывая огромную распространенность Apple QuickTime Player’a и Microsoft Internet Explorer’a, произошло своеобразное перекрестное “опыление”, в результате чего пострадали сразу обе системы: вся линейка Windows NT (включая Vista) и Mac OS.

Но сама Java-машина тут не при чем. Ошибка сидит во внешнем (по отношению к ней компоненте), и потому уязвимость распространяется не только на IE, но и FireFox.

Exploit, пробивающий практически любую Java-машину, при наличии установленного Apple QuickTime Player’a с версией 7.1.5 или более ранней // инициализирует Quick-Time QTSession. openO;

// получает обработчик, указывающий на что угодно

Byte b[] = new byte[l Л здесь может быть любое число V];

QTHandle h = new QTHandle(b);

// превращает обработчик в указатель на объект

// огромное отрицательное значение обходит проверку диапазона QTPointerRef p = h. toQTPointer(-2000000000 /* смещение */, 10 /* размер */);

II перезаписывает объект p. copyFromArray(0 Л смещение V, b Л источник V, О, 1 /* длина V),’

Этот пример наглядно доказывает, что говорить о безопасности Java в отрыве от надежности всех остальных компонентов операционной системы и ее окружения- наивно. Java должна либо быть “вещью в себе” и не допускать никаких внешних вызовов (так вели себя некоторые диалекты Бейсика на 8-разрядных компьютерах, из лек-сикона которых были исключены операторы CALL, PEEK и РОКЕ), либо открыто признать, что на шатком фундаменте крепости не построишь и доверять Java-приложениям даже с учетом всей многоуровневой системы безопасности на 100% нельзя. Впрочем, отказ от внешних вызовов ничего не решает, поскольку системные библиотеки, поставляемые вместе с Java-машиной, ничем не лучше прочих компонентов и могут содержать различные дефекты проектирования. Хотите пример? В начале 2007 г. в Sun JRE 5.0 Update 9 (включая и более ранние версии) была обнаружена ошибка, связанная с обработчиком заголовков GIF-файлов и содержащая уязвимость, которая приводила к возможности передачи управления на shell-код, расположенный непосредственно в самом GIF-файле. Технические подробности можно найти нa www. securityfocus. com/archive/1/457159. а код exploit’a есть нa www. securitvfocus. сom/archive/1/457638. Для нас же важен сам факт небезупречной реализации Java-машины. Получается, что в то время как теоретики от программирования старательно выводят запутанные диаграммы, иллюстрирующие “продвинутую” модель безопасности Java с многоуровневой системой защиты, хакеры дизассемблируют библиотечные файлы на предмет поиска реальных уязвимостей.

Военная мудрость гласит-чем больше расставлено линий обороны, тем выше вероятность прорыва противника, ибо надежная линия обороны справляется со своей задачей и одна. Можно сколько угодно укреплять замок крепостными валами и рвами, но это не защитит его от пикирующего бомбардировщика. С “воздуха” (т. е. изнутри системных библиотек) Java не защищена. Ошибки там были и будут. Достаточно взглянуть на размер дистрибутива (полсотни мегабайтов в упакованном виде) и попытаться представить себе, сколько человеко-часов требуется для его тестирования и реально ли собрать такое количество высококвалифицированных специалистов под одной крышей. А ведь помимо стандартных библиотек общего назначения есть еще и нестандартные (например, JGL, используемая для обработки трехмерных объектов и широко применяемая в задачах моделирования).

Заключение

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

Как этому противостоять? Универсальных рецептов нет. Однако эксплуатация Java-приложений ничем не отличается от остальных, написанных на Си/Си++, DELPHI, PHP. Залог здоровья – в своевременной установке заплаток и правильном выборе партнеров, реализация JVM от Sun по многим параметрам лучше, чем у IBM, но в силу своей огромной распространенности у Sun-машины гораздо больше шансов быть атакованной.

Список литературы

IT спец № 07 ИЮЛЬ 2007


Новое – хорошо забытое старое