среда, 28 мая 2014 г.

Об анализе исходного кода и автоматической генерации эксплоитов

        В последнее время об анализе защищенности исходного кода не написал только ленивый. Оно и понятно, ведь тот парень из Gartner, как предложил рассматривать анализ исходного кода в качестве нового хайпа несколько лет назад, так до сих пор и не дал отмашку на то, чтобы прекратить это делать. А, учитывая текущее направление моей работы (участие в разработке PT Application Inspector, далее AI), и тот факт, что в последнее время годных статей на тему анализа исходного кода в общем-то не было, как-то даже странно, что до сегодняшнего дня в этом блоге не было ни одной грязной подробности на эту животрепещущую тему. Что ж, исправляюсь :)

        Собственно все, что можно было сказать о нашем подходе к автоматизации анализа защищенности исходного кода в AI уже сказали Сергей Плехов и Алексей Москвин в докладе "Проблемы автоматической генерации эксплоитов по исходному коду" на PHDays IV. Тем, кто не присутствовал на докладе и не смотрел его запись - крайне рекомендую сделать это прежде, чем читать статью дальше. Однако, в конце доклада от Ивана Новикова aka @d0znpp прозвучало сразу несколько вопросов на тему "в чем кейс?", "чем ваш подход отличается от того же RIPS?" и "как вы тогда получаете точки входа?" в контексте утверждения о том, что без развертывания приложения невозможно получить внешние данные, необходимые для построения эксплоита (такие к примеру, как имя привилегированного пользователя и его пароль, маршруты к точкам входа и т.п). Хочу сразу оговориться, что здесь имеет место некоторая (внесенная безусловно с нашей стороны) терминологическая путаница: название "проблемы автоматического вывода множеств векторов атак по исходному коду" гораздо более точно отражало бы суть решенных в ходе работы над AI задач. Называть то, что получается на выходе AI эксплоитом действительно не вполне корректно. Хотя бы потому, что это круче, чем просто эксплоит в традиционном понимании этого термина :) И далее я постараюсь раскрыть эту мысль и дополнить своих коллег более развернутым ответом на заданные Иваном вопросы.

В чем кейс?

        В первую очередь, кейс заключается в поиске недостатков в коде и подтверждении их уязвимости к тем или иным классам атак. Задача по автоматической генерации эксплоитов в рамках данного кейса сводится к выводу минимального вектора атаки, подтверждающего существование уязвимости. При этом, под вектором подразумевается не конкретный HTTP-запрос, а некоторая совокупность факторов, приводящая систему в состояние уязвимости и позволяющая провести на нее успешную атаку. Скажу даже больше: в общем случае, выразить вектор атаки в виде только HTTP запроса не представляется возможным. Во-первых, потому что данный вектор может требовать выполнения нескольких запросов. Во-вторых (и это главное), потому что вектор может включать в себя условия на какие-либо свойства окружения, которые невозможно описать в контексте запроса HTTP. Тем не менее, в рамках рассматриваемого кейса мы должны: а) вывести все подобные условия; б) каким-то образом оформить их в результатах анализа. Именно это и привело к столь замысловатому определению вектора. Приведу простой пример (здесь и далее рассматривается код на C# под ASP.NET Web Forms):
Очевидно, что в данном случае уязвимость к атаке XSS зависит от значения параметра key1 в конфигурационном файле settings.xml. И, если мы по-честному прочитаем его (т.е. фактически, а не символьно осуществим вызов Settings.ReadFromFile("settings.xml") и присвоим переменной settings полученный результат), то далее мы пойдем только по одному из двух возможных путей выполнения, что неизбежно приведет нас к пропуску уязвимости в том случае, если параметр key1 в файле не будет установлен в значение "validkey". Выполняя же первый вызов символьно, мы придем в итоге к следующей формуле, которая и будет являться искомым вектором:

Мы также можем вывести из этого и HTTP-эксплоит:

который, тем не менее, не является самодостаточным и зависит от условий, накладываемых на окружение веб-приложения.

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

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

        Подытоживая: в настоящий момент, нет никаких препятствий к тому, чтобы научить AI читать данные из БД и использовать их в ходе символьного выполнения анализируемого кода. Однако, это потребует развертывания, как минимум, БД веб-приложения с одной стороны и существенно просадит возможности анализатора по обнаружению уязвимостей с другой, не дав при этом каких-либо ощутимых преимуществ в рамках поставленной перед нами задачи, которую я описывал выше.

Чем ваш подход отличается от RIPS?

        Насколько я могу судить о подходе принятом в RIPS, AI'шный - отличается чуть более, чем всем. Начиная с того, что в RIPS реализован классический static-taint-analysis через разметку тегами путей в графе потока данных с эмуляцией ряда стандартных библиотечных функций, а поход AI предполагает построение модели (по одной на каждую точку входа) в виде системы логических утверждений, описывающих состояние приложения в каждом узле CFG и условия его достижости, что дает возможность разрешать любые пути в нем (включая if'ы, условные return'ы, обработку исключений и т.п) еще и с частичным выполнением реального кода вместо его эмуляции там, где это дает лучший результат по сравнению с символьным выполнением. И заканчивая (но не ограничиваясь) тем, что RIPS тупо обламывается на кастомных фильтрующих функциях, в то время, как AI пытается с ними работать (причем весьма успешно в большинстве реальных случаев).

        Наверное, лучше показать на примере. Допустим, у нас есть следующий фрагмент исходного кода[1]:
Очевидно, что здесь дважды имеет место выполнение потенциально-опасной операции (далее PVO - Potentially Vulnerable Operation) - вызова метода Response.Write, осуществляющего запись в поток формируемого сервером ответа на HTTP-запроса. В первом случае методу передается константа "Wrong Key!", что не представляет для нас никакого интереса. Зато во втором, в ответ отправляется результат вызова метода CustomSanitize с аргументом, значение которого вычисляется из значений параметров полученного запроса. Но какими они должны быть, чтобы мы получили возможность пробросить в str1 значение, достаточное для подтверждения возможности проведения атаки XSS через инъекцию элементов разметки HTML? Давайте рассмотрим, как приблизительно может выглядеть процесс поиска ответа на этот вопрос[2].

        Для начала, выведем условие достижимости второго Response.Write. Несмотря на то, что сам он не вложен в какие-либо конструкции, влияющие на поток управления, в предшествующих ему блоках кода имеет место возврат из общей для всего кода функции, условие достижимости которого одновременно является условием недостижимости нашей PVO. Очевидно, что условием выполнения оператора return будет являться логическое выражение: (name == "adm" && key1 != "validkey"). Следовательно, условием его недостижимости будет являться выражение: (name != "adm" || name == "adm" && key1 == "validkey"). Поскольку этот return - единственный оператор, влияющий на достижимость второго Response.Write, последнее выражение и будет являться условием достижимости PVO.

        Фактически, выражение (name != "adm" || name == "adm" && key1 == "validkey") дает нам два взаимоисключающих условия формирования пути к PVO на графе потока управления. Рассмотрим возможные значения str1 при выполнении каждого из них. При (name != "adm") переменная str1 получает константное значение "Wrong role!", что определенно не может привести нас к успешной атаке. Но при (name == "adm" && key1 == "validkey") в str1 попадает результат вызова метода Encoding.UTF8.GetString с аргументом data, который в свою очередь может принимать два значения: new byte[0] при string.IsNullOrEmpty(parm) и Convert.FromBase64String(parm) при !string.IsNullOrEmpty(parm). Отбрасывая неинтересные с т.ч. эксплуатируемости уязвимости значения и раскручивая значения всех переменных вплоть до их taint-источников, получаем следующую формулу:
Графическое представление модели выполнения, построенной в данном случае, будет иметь вид (кликабельно):


Таким образом, значения параметров запроса name и key1 у нас уже есть и все, что осталось сделать - это найти такое значение Request.Params["parm"], при котором конечное значение выражения CustomSanitize(Convert.FromBase64String(Request.Params["parm"])) даст нам эксплуатацию уязвимости, приводящей к XSS.

        И вот здесь возникает проблема, справиться с которой традиционные средства статанализа не в состоянии. Метод Convert.FromBase64String является библиотечным и может быть описан в базе знаний анализатора, как имеющий обратную функцию Convert.ToBase64String, из чего мы можем сделать вывод, что результат выполнения CustomSanitize должен попасть на вход Convert.ToBase64String. Но что делать с CustomSanitize, который не является библиотечным, нигде не описан и представляет из себя на данном этапе анализа черный-пречерный ящик? Хорошо, если нам доступны исходники этого метода - в этом случае, мы можем "провалиться" в его тело и продолжить символьное выполнение кода образом, аналогичным описанному выше. Но что же делать, если исходников нет? Ответ прозвучал в предыдущем предложении: забыть на некоторое время про то, наш анализ является статическим и работать с данным методом, как с черным ящиком. У нас есть уже выведенное ранее выражение Convert.ToBase64String(CustomSanitize(Request.Params["parm"])), есть множество возможных векторов XSS (пусть это будет {`<script>alert(0)</script>`, `'onmouseover='a[alert];a[0].call(a[1],1)` и `"onmouseover="a[alert];a[0].apply(a[1],[1])`}) - так почему бы не профаззить эту формулу, специфицируя символьную переменнную Request.Params["parm"] значениями векторов и непосредственно выполняя получившееся выражение?

        Допустим, CustomSanitize удаляет исключительно символы угловых скобок. Тогда, в результате фаззинга, получаем три значения:

scriptalert(0)/script
'onmouseover='a[alert];a[0].call(a[1],1)
"onmouseover="a[alert];a[0].apply(a[1],[1])


из которых два последних представляются достойными рассмотрения в качестве векторов атаки. Итак, мы знаем полное выражение, передаваемое в качестве аргумента PVO. Мы знаем точное место, в которое попадет значение символьной переменной Request.Params["parm"] при ее спецификации значениями векторов. Что еще нам нужно для того, чтобы выбрать из этих двух тот вектор, использование которого приведет к инъекции? Те, кто внимательно слушали вебинар "Как разработать защищенное веб-приложение и не сойти при этом с ума?" или разобрались с алгоритмом детектирования XSS в IRV, сразу ответят, что больше нам ровным счетом ничего не нужно :)

        Т.о. конечным результатом анализа этого кода является контекстный (определяющий значения символьных переменных в контексте выполнения PVO) эксплоит:
из которого уже можно вывести и HTTP (определяющий требования к фактическим параметрам HTTP-запроса) эксплоит:

В AI,если интересно, это выглядит так (кликабельно):

Консольная версия    GUI версия

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

Как вы получаете точки входа?

        Я намеренно опустил во всех примерах вопрос получения "/path/to/document.aspx" (т.е. маршрута к точки входа в веб-приложение), т.к. данная задача не имеет универсального решения и требует описания специфики различных фреймворков в базе знаний анализатора. Для ASP.NET Webforms, например, точками входа являются методы-обработчики т.н. postback'ов элементов управления веб-форм (что требует разбора .aspx файлов и связывания их с соответствующими codebehind-файлами). В ASP.NET MVC маршруты задаются через наполнение коллекции RouteCollection прямо в коде инициализации приложения. Нельзя также забывать и о возможности появления в WebConfig секциий urlMappings, urlrewritingnet и им подобных, также влияющих на маршрутизацию HTTP-запросов к приложению. Да и разработчику ничего не мешает определить собственный HTTP-обработчик, реализующий кастомную логику роутинга, реверс которой является алгоритмически неразрешимой задачей. В этом случае, нам ничего не остается, кроме как рассматривать в качестве точек входа все public и protected методы в случае Java/C# либо все .php-файлы в случае с PHP, смирившись с ростом вероятности словить false-positive на недостижимом снаружи коде. Однако живьем, лично я пока таких .NET приложений не встречал, а существующий зоопарк PHP-фреймворков хоть и внушает, но вполне формализуется в базе знаний анализатора, в том числе и в части, касающейся получения маршрутов к точкам входа. Экзотику типа описания правил роутинга в БД, как уже наверное понятно, мы пока обрабатываем упомянутым выше прямом перебором всех потенциальных точек входа (что, кстати, дает вовсе не такие плохие результаты, как может показаться на первый взгляд).

That's all

        Надеюсь, что на поставленные вопросы ответить все же удалось. Но если вдруг возникли новые, или остались непонятные моменты - welcome, как говорится :)


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

2 комментария:

  1. Привет! Спасибо!

    Продолжение тех же самых вопоросов.

    Есть задача найти уязвимость и есть задача сделать эксплоит.

    С точки зрения поиска уязвимостей - предложенный подход отличается от RIPS.

    С точки зрения посторения эксплоитов - тоже отличается. Но не в части определения точек входа. Здесь все как раз тоже самое.

    Разница в посторении эксплоитов в том, что предлагаемый подход предусматривает также возможность использования захардкоженых пар для функций кодирования.

    То есть если вы встретили base64_decode - то из конфига берется обратная к ней base64_encode и применяется для данных в payload сплоита.

    То есть это только встроенные функции. Плюс их реализация должна быть захардкожена в сканер. Смущают обновления, версионность, регрессии этих функций. Для явы вообще такой подход пугает, где названия функций часто ни о чем не говорят - все переопределяется в каких-то jar'никах, которые лежат в архиве, а какую из них подцепит приложение вообще ведает только конкретный класслоадер. Это все то самое, что говорил Дима - https://twitter.com/d_olex/status/471646006723883008

    А второе отличие в построении сплоитов заключается в прохождению по вызовам с целью выполнения условий из констант if ($magic=="abcd"){... и фаззите какие-то комбинации типа 1,2,3 переборов.

    Собственно, что на докладе, что в блогпосте вопрос один - поясни, пожалуйста, какую задачу решаем? Какая постановка? Я, видать, переобщался с академическим @p3tand, но так ничего не понятно.

    Я хочу и дальше спорить о жизнеспособности такого подхода для явы, но не могу - так как подозреваю, что задача стояла анализировать простые РНР приложения (cgi вариации на тему)...

    ОтветитьУдалить
  2. Отвечу не по порядку, если не против.

    >поясни, пожалуйста, какую задачу решаем

    Дык я обозначил ее в посте: по-максимуму переложить рутину по анализу защищенности исходников с человека на машину. Найти все уязвимости в коде и для каждой из них вывести множество факторов необходимое и достаточное для проведения успешной атаки. Если информации в базе знаний достаточно, чтобы вывести из этих факторов конкретный HTTP-запрос, то построить и его. Если нет, то передать множество факторов человеку для дальнейшей работы.

    >подозреваю, что задача стояла анализировать простые РНР приложения

    Это не так и в плане языка (я, к примеру, участвую в реализации поддержки шарпа, который все же ближе к яве), и в плане простоты: пусть не смущает тривиальный пример из поста - подход более чем оправдывает себя и на масштабных приложениях)

    >То есть если вы встретили base64_decode - то из конфига берется обратная к ней base64_encode и применяется для данных в payload сплоита.

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

    >То есть это только встроенные функции.

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

    >Плюс их реализация должна быть захардкожена в сканер. Смущают обновления, версионность, регрессии этих функций.

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

    >Для явы вообще такой подход пугает, где названия функций часто ни о чем не говорят - все переопределяется в каких-то jar'никах, которые лежат в архиве, а какую из них подцепит приложение вообще ведает только конкретный класслоадер.

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

    >А второе отличие в построении сплоитов заключается в прохождению по вызовам с целью выполнения условий из констант if ($magic=="abcd"){... и фаззите какие-то комбинации типа 1,2,3 переборов.

    Почему только из констант? Значения для прохождения условий типа таких:

    if (Regex.IsMatch(str1.SubString(1,5), "^[0-9a-zA-Z]+$")) {...

    мы тоже успешно резолвим, в общем-то =/

    ОтветитьУдалить

Пожалуйста, будьте вежливы к автору и остальным посетителям этого блога