Новое - хорошо забытое старое
Крис Касперски
Уязвимость 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