Содержание статьи
- Кто такие шелл-коды и с чем их едят
- Что в ARM пошло «не так»
- Фиксированный размер команд
- Наличие нескольких режимов работы процессора и возможность динамического переключения между ними
- Возможность условного выполнения инструкций
- Возможность прямого обращения к счетчику инструкций
- При вызове функций аргументы помещаются в регистры
- Что осталось в наследство от x86
- Выводы
warning
Внимание! Информация представлена исключительно с целью ознакомления! Ни авторы, ни редакция за твои действия ответственности не несут!
Кто такие шелл-коды и с чем их едят
Сегодня мы будем разговаривать об одном из видов вредоносных инструкций, эксплуатирующих уязвимости в удаленном программном обеспечении, в частности уязвимости работы с памятью. Исторически такие наборы инструкций называют шелл‑кодами — в старые добрые времена атаки такого типа предоставляли доступ к шеллу, так и повелось. Типичные уязвимости памяти, эксплуатируемые шелл‑кодами, — это прежде всего так любимое переполнение буфера и переполнение кучи, строковых переменных и других структур.
Попробуем рассмотреть их «на пальцах» — на примере наиболее простого типа, к которому принято относить переполнение буфера в стеке. Итак, шелл‑коды работают следующим образом. Пусть в коде есть вызов некоторой уязвимой функции, работающей с пользовательскими данными. При ее вызове со стеком происходит одновременно несколько вещей: там сохраняется адрес возврата на следующую инструкцию (чтобы знать, куда передать управление после того, как выполнение уязвимой функции будет завершено); сохраняется значение флага BP
; выделяется область определенного размера для локальных данных (что в нашем случае и есть «пользовательский ввод»). Если количество считываемых данных функцией не проверяется, а злоумышленник передал данных больше, чем нужно, то они перезатрут служебные значения на стеке — и значения флагов, и адрес возврата. Куда будет передано управление после выхода из функции? Ровно по тому адресу, который теперь оказался в зоне адреса возврата. Так вот, цель злоумышленника — сформировать данные таким образом, чтобы передать управление на вредоносную нагрузку, которая также содержится в передаваемых данных. Попросту говоря — выполнить произвольный код на машине‑жертве.
Традиционно как шелл‑коды, так и методы их детектирования принято ассоциировать с платформой x86 — ввиду того, что тип атаки довольно старый и был нацелен на наиболее распространенную платформу. Последние несколько лет ситуация меняется. Устройств на базе платформы ARM уже в несколько раз больше, чем x86, и их число будет продолжать расти. Речь идет и о смартфонах, и о планшетах, а Google с Samsung уже выпускают хромбуки на базе ARM. Архитектура ARM (Advanced RISC Machine) — семейство лицензируемых 32-битных и 64-битных микропроцессорных ядер разработки компании ARM Limited. ARM представляет собой RISC-архитектуру процессора. Казалось бы, платформа и шелл‑коды находятся на несколько разных уровнях абстракции. Как смена платформы может изменить вид шелл‑кода? Ответ кроется в ряде существенных отличий двух платформ, которые предоставляют злоумышленникам возможность использовать весьма различные техники написания шелл‑кодов.
Что в ARM пошло «не так»
Существующие решения обнаружения шелл‑кодов, работающие для платформы x86, анализируют типичные признаки шелл‑кодов. Тем не менее для платформы ARM они оказываются неприменимы — вид шелл‑кодов меняется. Рассмотрим более подробно отличия двух архитектур, которые непосредственно влияют на вид шелл‑кодов.
Фиксированный размер команд
В архитектуре ARM все инструкции имеют фиксированный размер (32 бита в ARM-режиме, 16 бит в режиме Thumb), в отличие от архитектуры х86, где размер инструкций варьируется от одного до 16 байт.
Из‑за данного свойства архитектуры ARM дизассемблирование с разных смещений не синхронизируется (self-synchronizing disassembly). Self-synchronizing disassembly — особенность дизассемблирования инструкций в архитектуре х86. С какого бы байта оно ни началось, найдется точка синхронизации между дизассемблированием с произвольного байта и дизассемблированием от начала потока инструкций. Данное свойство упрощает поиск шелл‑кодов в трафике (он может находиться в произвольном месте потока), но усложняет анализ шелл‑кода — существует возможность пропустить инструкции, критические для шелл‑кода.
При детектировании ARM шелл‑кодов в байтовом потоке достаточно дизассемблировать с четырех смещений от начала потока: 0, 1, 2, 3. Так как инструкции имеют фиксированную длину, дизассемблирование всегда будет идти с начала шелл‑кода, без пропуска важных инструкций.
Наличие нескольких режимов работы процессора и возможность динамического переключения между ними
Ранее мы уже упомянули о наличии двух режимов процессора. В реальности же все еще сложнее: помимо двух упомянутых, есть еще и Thumb2 (в 16-битный Thumb добавлено несколько 32-битных инструкций, такая техника позволяет существенно увеличить плотность опкодов) и Jazelle, поддерживающий аппаратное ускорение выполнения Java-байт‑кода.
Режим Thumb, в частности, исторически использовался, чтобы сделать компактнее программы на встроенных системах, для которых типичным является ограниченный размер памяти. В других условиях использование Thumb-режима не так оправданно — он работает медленнее режима ARM: нет поддержки условного выполнения, хуже работает модуль предсказания переходов и так далее. Ввиду этого большинство приложений используют режим ARM, однако при написании шелл‑кодов вопрос объема данных также встает достаточно остро. Поэтому техника динамического переключения режима процессора — одна из самых распространенных для шелл‑кодов. В частности, она присутствует более чем в 80% публично доступных образцов. Пример шелл‑кодов, написанных в разных режимах процессора, показан на рисунке.
Переключение производится при помощи инструкций перехода:
- происходит переход по метке
label
и переключение в режим, отличный от текущего; - происходит переход по адресу, записанному в регистр
Rm
; - а также в зависимости от значения последнего бита регистра происходит переключение режимов (0 — ARM, 1 — Thumb).
Работает это следующим образом. В шелл‑кодах проявляется наличие команды смены режима процессора (BX
) на пересечении цепочек команд из разных режимов процессора. Так как уязвимая программа может работать в ARM-режиме, то, чтобы выполнить данный шелл‑код, необходимо переключить процессор в режим Thumb. Для того чтобы переключить процессор в другой режим, необходимо считать регистр счетчика инструкций и использовать его значение для перехода на начало шелл‑кода при помощи команды BX
, где Rm
— это регистр общего назначения, в который записывается адрес начала шелл‑кода. Для сообщения процессору, в какой режим следует переключиться, в старшем бите адреса ставится значение в зависимости от требуемого режима.
Инструкции разных режимов могут находиться на некотором расстоянии друг от друга, но в силу ограниченности шелл‑кода можно сделать предположение о том, что отступ между ARM-кодом переключения процессора и Thumb-шелл‑кодом будет соизмерим с размером шелл‑кода. Пример такого шелл‑кода можно видеть на рисунке (шелл‑код взят с exploit-db.com).
Техника динамической смены режима процессора используется в шелл‑кодах не только для внедрения большей функциональности в тот же размер, но и для обфускации в том числе. Очевидно, что смена режима процессора и, соответственно, иное дизассемблирование приведет к тому, что сигнатурные подходы не будут срабатывать на одном и том же образце. С другой стороны, если знать об этой особенности, никто не мешает нагенерировать все возможные сигнатуры, с учетом смен режима процессора. Тем не менее до сих пор внимание на этом не акцентировалось.
Возможность условного выполнения инструкций
На наш взгляд, это одна из самых интересных особенностей ARM, которая привносит еще один метод обфускации шелл‑кодов. Каждая инструкция в ARM выполняется или игнорируется процессором в зависимости от значения регистра флагов. Значение регистра флагов, при котором команда должна выполниться, выбирается при помощи дописывания определенного суффикса к команде.
Например, команда MOVEQ
выполнится только тогда, когда флаг Z
(используется суффикс EQ
). Всего существует 15 значений суффиксов. Также существует суффикс S
, который означает, будет ли данная команда изменять значение регистра флагов.
Что дает эта техника для шелл‑кодов? Во‑первых, благодаря этому можно существенно уменьшить объем кода. Во‑вторых, возможен новый тип обфускации: что, если выстроить вредоносную нагрузку из абсолютно легитимной программы, меняя только значения флагов на нужные? Ведь совершенно очевидно, что статический анализ при такой технике будет затруднен в разы. Например, один из типичных и достаточно эффективных подходов статического анализа — различный анализ графов потока управления (CFG — control flow graph) и графов потока инструкций (IFG — instruction flow graph).
Условное выполнение не даст возможности правильно анализировать CFG в любом методе, основанном на анализе CFG, так как при статическом анализе невозможно узнать, какие именно инструкции выполнятся, а какие будут проигнорированы. Граф потока инструкций также не будет отражать реальную структуру анализируемой программы, потому что многие из вершин графа могут быть проигнорированы процессором. То есть мы потеряли один из самых интересных методов детектирования шелл‑кодов (этому методу было посвящено уж очень много статей).
Данное свойство также усложняет динамический анализ шелл‑кодов (при помощи эмулятора). Все дело в том, что из‑за наличия условного выполнения инструкций возможно перенаправлять поток выполнения инструкций таким образом, что один и тот же код в зависимости от начального распределения значений флагов может выполнять абсолютно разные действия (например, может существовать некоторое начальное распределение флагов, которое инициирует вредоносную полезную нагрузку). Для получения начального распределения флагов шелл‑код использует значение регистра флагов, которое было перед передачей управления на шелл‑код, так называемая техника non-selfcontained шелл‑кодов.
Каким образом это происходит? Рассмотрим пример, показанный на рисунке. Здесь мы имеем одну и ту же программу, запущенную два раза при разных начальных условиях (для простоты рассмотрим только два флага: ZERO
и CARRY
). В начале программы имеется блок инструкций с суффиксом AL
, это означает, что все инструкции будут выполнены, несмотря на значение флагов. Далее имеется инструкция ADDEQS
, которая выполнится только тогда, когда флаг ZERO
, то есть только в правой эмуляции. К тому же данная инструкция изменит распределение флагов в правой эмуляции (благодаря суффиксу S
). Далее мы имеем два блока: CS
: CARRY
, и NE
: ZERO
. Причем блок NE
может повлиять на значения регистров r3
и r4
таким образом, что в последующей инструкции ADDCCS
(выполненной в обеих эмуляциях) флаги будут изменены различным образом. Обрати внимание на то, что одна и та же инструкция даст различные результаты при разных начальных условиях эмуляции. К тому же данная инструкция влияет на возможные последующие инструкции. Таким образом формируется скрытая последовательность инструкций, которая активируется только при нужных шелл‑коду условиях.
Так вот, ARM шелл‑код чрезвычайно хитер, чтобы быть обнаруженным за один проход эмулятора. Поэтому нужно проводить эмуляцию при всевозможных начальных условиях (их 15 — столько же, сколько и условных суффиксов). К тому же мы не знаем, где именно в трафике находится шелл‑код, поэтому нужно проводить анализ с каждого смещения. В совокупности эти факторы «очень отрицательно» влияют на производительность анализа трафика в реальном времени.
Возможность прямого обращения к счетчику инструкций
Архитектура ARM позволяет напрямую обращаться к счетчику инструкций. Что, конечно, сильно облегчает жизнь злоумышленнику. И вот уже даже не обязателен GetPC code, который часто используется при написании х86-шелл‑кодов. Как ты помнишь, данная техника позволяет получить значение регистра счетчика инструкций без прямого обращения к нему. В шелл‑код вставляется инструкция вызова функции call
по близкому смещению (внутри шелл‑кода), далее выполняется команда взятия регистра со стека pop
, так как инструкция call
сохраняет адрес возврата на вершине стека. Таким образом, типичная эвристика для x86-шелл‑кодов должна быть изменена: теперь вместо GetPC-кода мы будем искать Get-UsePC-код.
Под Get-UsePC-кодом понимается наличие считывания значения счетчика инструкций PC
и последующее использование этого значения. Чтобы считать значение счетчика инструкций, можно непосредственно обращаться к регистру PC
(R15
), можно также вызвать функцию с помощью инструкции BL
и потом использовать значение регистра LR
(R14
), в котором сохранился адрес возврата.
Проблема состоит в том, что в легитимных программах обязательно будет использоваться GetPC-код: он нужен для того, чтобы вернуть управление из функции (MOV
или LDR
). Поэтому, чтобы отделить мух от котлет, нужно также учитывать использование значения регистра счетчика инструкций. То есть нужно узнать, какие регистры косвенно ссылаются на счетчик инструкций, и проверять, где они использовались. Например, если данный регистр использовался в команде чтения из памяти, то можно утверждать, что перед нами находится декриптор, так как чтение будет происходить по близкому смещению, а следовательно, из полезной нагрузки шелл‑кода. Пример такого шелл‑кода приведен на рисунке (шелл‑код взят с exploit-db.com).
При вызове функций аргументы помещаются в регистры
В отличие от архитектуры х86, где аргументы функциям передаются через стек, в архитектуре ARM аргументы записываются в регистры R0–R3
. Если аргументов больше, то оставшиеся аргументы кладутся на стек, как и в х86.
Так что «пока», ROP-шелл‑коды! Ну, то есть не совсем «пока», конечно... По крайней мере, данная особенность затрудняет их написание. ROP-шелл‑код вызывает функции из системных библиотек, чтобы из их частей составить вредоносную полезную нагрузку. Использовать эту технику затруднительно из‑за того, что паттерны для составления шелл‑кода должны содержать в себе инициализацию аргументов в регистры. То есть приходим к тому, что таких нужных «кусочков», которых и без того было не особо много, станет еще меньше. Ну что ж, по крайней мере, полет фантазии будет уже не таким высоким.
Так как исследуемый объект является исполнимым кодом, он обладает характеристиками, специфичными для различных операционных систем. В частности, исполнимый код должен работать с вызовами операционной системы или библиотеки ядра. Поэтому для x86 типичным являлся детектор, основанный на поиске специфичных паттернов инструкций, в которые входил системный вызов и подсчет их числа. В ARM опять все работает не так.
Для вызова функции в ARM происходит следующая последовательность действий: аргументы функции кладутся в регистры общего назначения (R0–R3
) и на стек (если количество аргументов больше четырех), и далее следует вызов функции BL
(BLX
, если функция написана с использованием инструкций другого процессорного режима). Для того чтобы сделать системный вызов svc
, нужно загрузить номер системного вызова в регистр R7
и загрузить аргументы вызова в регистры общего назначения (если аргументы требуются для системного вызова).
Поэтому для детектирования данного признака нужно применять технику абстрактного выполнения инструкций. В чем заключается эта техника? Проводится выполнение кода без сохранения значения регистров. Единственное, что мы можем отследить, — это то, какие из регистров были инициализированы некоторыми значениями. Таким образом, мы должны проверять, были ли инициализированы регистры общего назначения (и регистр R7
для системных вызовов) перед системным вызовом или вызовом функции. Например, на рисунке показан код, в котором делаются системные вызовы (часть шелл‑кода из Metasploit).
Что осталось в наследство от x86
Часть особенностей шелл‑кодов под x86 осталась в наследство и для ARM. Соответственно, детекторы для таких особенностей также можно оставить без существенных изменений на своем нагретом месте. К ним можно отнести часть особенностей шелл‑кодов, выделяемых статически, и техники динамического выявления декриптора (шелл‑коды, как правило, обфусцированы).
-
Обнаружение NOP-следа.
Как правило, такие детекторы анализируют корректное дизассемблирование инструкций с каждого смещения — типичный признак NOP-следа. Одна оговорка: количество дизассемблированных инструкций считается только для каждого режима процессора отдельно, так как, если использовать инструкции разных режимов в NOP-sled, управление может попасть на инструкцию, использующую отличный от текущего режим процессора, и произойдет прерывание:
unexpected
.instruction -
Адрес возврата находится в определенном диапазоне значений.
В шелл‑кодах адрес возврата перезаписывается значением, которое находится в диапазоне адресного пространства исполнимого процесса. Нижняя граница диапазона определяется адресом начала перезаписываемого буфера. Верхняя граница определяется как
RA˘SL
, гдеRA
— адрес поля адреса возврата иSL
— длина вредоносного исполнимого кода, или какBS˘SL
, гдеBS
— адрес начала стека. Этот признак общий для всех исследуемых объектов, не использующих технику рандомизации адресного пространства (ASLR). Детект не изменяется для всех рассматриваемых платформ. -
Анализ количества чтений полезной нагрузки и количества уникальных записей в память.
В случае если эти параметры превышают определенный порог и поток управления хотя бы один раз передается из адресного пространства входного буфера на адрес, по которому ранее осуществлялась запись, считается, что обнаруженный объект может быть полиморфным шелл‑кодом (рис. 9). Количество чтений считается не по количеству команд (так как ARM поддерживает одновременную загрузку сразу нескольких регистров), а по количеству загруженных регистров командой
LDM
(командаLDR
может считывать только один регистр). Также для дешифровки шелл‑кода в память по близкому адресу записывается расшифрованная полезная нагрузка шелл‑кода. Количество записей считается по количеству загруженных в память регистров командойSTM
(командаSTR
может записывать только один регистр). После расшифровки требуется передать управление на расшифрованную полезную нагрузку, то есть на адрес, по которому ранее производилась запись.
Выводы
Резюмируя все, что сказано выше, можно сделать следующее заключение. Эра процессоров ARM, на наш взгляд, переживает второе рождение. В ближайшие годы задача защиты подобных систем будет вставать все более остро. Что же касается шелл‑кодов... Внимательно рассмотрев разницу двух платформ, можно сделать следующие выводы. Эти отличия, во‑первых, отчасти изменили вид шелл‑кодов, во‑вторых, позволили злоумышленникам применять существенно иные техники обфускации. Все это привело к тому, что существующие методы детектирования под x86 в случае с ARM либо ломаются, либо применимы с большой натяжкой — например, работают дольше если не на порядок, то в разы. С учетом того, что ограничения на скорость обнаружения очень существенны, выводы неутешительны. С другой стороны, эта разница двух платформ позволяет найти некоторые фичи, свойственные только ARM-шелл‑кодам, что, безусловно, является плюсом. Другими словами, разработка детекторов шелл‑кодов под ARM и нужна, и возможна.