>
>
>
Поиск потенциальных уязвимостей в коде,…

Константин Волоховский
Статей: 16

Поиск потенциальных уязвимостей в коде, часть 2: практика

В прошлый раз мы ознакомились с общими подходами в поиске уязвимостей безопасности в приложениях. В этот раз спустимся ближе к земле и посмотрим на то, как мы реализовали эти механизмы в нашем статическом анализаторе для Java.

Для тех, кто пропустил

Эта серия статей состоит из двух частей, и первая уже вышла некоторое время назад. Там рассматриваются теоретические основы построения SAST-инструментов для поддержки анализа помеченных данных (taint-анализа). Для понимания материала этой статьи лучше сначала ознакомиться с прошлой. В общем, приглашаю прочитать. В этот раз мы посмотрим поближе на то, как недавно вышедший taint-механизм работает в PVS-Studio для Java.

Что мы реализовали

Перед началом стоит всё же вспомнить, на чём мы остановились в прошлой статье. Тогда я составил список механизмов в анализаторе, которые нам понадобятся для анализа потенциально заражённых данных. Собственно, вот они:

  • абстрактное синтаксическое дерево (AST) — представление кода, удобное для работы;
  • система аннотаций — позволяет помечать стоки, источники и санитизацию;
  • граф потока данных (control flow graph, CFG) — показывает порядок выполнения операций в методе;
  • цепи определений-использований (def-use chains, DU chains) — показывают путь значений переменных от определения ко всем использованиям;
  • промежуточное представление (IR) — позволяет упростить представление кода для более удобной работы;
  • граф вызовов (call graph, CG) —помогает понять, откуда вызываются методы;

Что из этого мы сделать смогли, что пока не получилось, а что не захотели?

Наш фреймворк

Абстрактное синтаксическое дерево

На AST долго останавливаться не будем. PVS-Studio поддерживает Java не первый год, и, разумеется, AST в нём есть. В нашей старой статье мы описывали процесс его создания, и там же было отмечено, что анализатор базируется на фреймворке разбора Spoon. Так что его деревом мы и пользуемся. Вот примерная визуализация того, как он разбирает код вычисления факториала:

public static long factorial(int number) {
    long factorial = 1;
    for (int i = 1; i <= number; i++) {
        factorial *= i;
    }
    return factorial;
}

Система аннотаций

С аннотациями ситуация интересная. С одной стороны, они уже есть и работают, а с другой, нам только предстоит их сделать. Чтобы вы поняли неприятность ситуации, приведу пример аннотаций для SQL-инъекции:

Class("java.sql.Statement")
  - Function("execute", varArgs)
      .Set(FunctionClassification::SqlInjectionSink)
  - Function("executeUpdate", varArgs)
      .Set(FunctionClassification::SqlInjectionSink)
  - Function("executeQuery", varArgs)
      .Set(FunctionClassification::SqlInjectionSink)
  - Function("executeLargeUpdate", varArgs)
      .Set(FunctionClassification::SqlInjectionSink)
  - Function("addBatch", varArgs)
      .Set(FunctionClassification::SqlInjectionSink)
;

Я надеюсь, вы уловили главную проблему. А именно то, что это код не на Java, а на C++. Про сложность поддержки кода на этом языке Java программистами я заикаться не буду.

Лучше поговорим о более принципиальных проблемах, которые у нас есть сейчас:

  • нельзя повесить аннотацию на конкретный номер аргумента, только метод целиком;
  • нельзя сообщить, надо ли нам смотреть на методы у наследников или нет;
  • так как формат описания не принадлежит к обычно сериализуемым (xml, json), задача поддержки пользовательских аннотаций усложняется на порядок.

А ещё это забористое легаси, работа с которым подкидывает сюрпризы. Мы уже несколько раз случайно ломали аннотации в процессе разработки нового модуля, и подобные палки в колёсах иметь неприятно.

В общем, нами уже было принято единственное (не)правильное решение: перенести это дело на Java. С этим может быть сопряжено некоторое количество проблем, вроде отсутствия в Java функциональной альтернативы (по производительности) битовым флагам, но это решаемые сложности. Зато с переписыванием получится решить обозначенные выше проблемы и сразу же поддержать пользовательские аннотации, которые уже поддерживают C# и C++ анализаторы.

Граф потока управления

Руководствуясь знаменитым принципом "лучший код — этот тот, который не был написан" здесь история также вышла короткой. Выше я только что сослался на то, что мы базируемся на Spoon. И, на наше счастье, прототип потока управления там уже был. Он уже покрывал большую часть нужных нам ситуаций, и с минимальной доработкой мог рисовать такие красивые картинки:

В общем, тут мы тоже отделались лёгкой кровью, так что можно сразу идти дальше.

Граф вызовов

Следующим на очереди был механизм создания графа вызовов. Так как в нашем анализаторе изначально есть AST, то задача превращается в несложную: надо лишь обойти всё дерево, получая из тел методов все вызовы. Для демонстрации покажу ту же картинку (она кликабельна) из прошлой статьи, которую мы создали для Lua анализатора:

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

Как мы решили эту проблему? Пока никак. В прошлый раз я обошёл эту тему стороной, чтобы лишний раз не раздувать материал, но сейчас о наших планах рассказать готов.

Граф иерархий

Как можно увидеть на картинке выше, сейчас мы работаем согласно крайности "соединяем, только если на 100% уверены, какой метод вызывается". То есть связь будет только тогда, когда вызывается метод, у которого есть какая-то реализация, — он не принадлежит интерфейсу или абстрактному классу. В случае наследования связь будет, но только обратная, при вызове метода у super.

Пока что я придерживаюсь мысли, что нужно начать с противоположной крайности и добавлять вообще все возможные связи. Основных принципов здесь два:

  • Если вызывается метод интерфейса или абстрактного класса, то там может быть любой класс, его реализующий;
  • Аналогично, если вызывается метод экземплярного класса, у которого есть наследники, то на его месте может оказаться любой из них.

В Spoon, аналогично с вызовами методов, мы можем получить родительский тип класса, но не можем сделать обратного. Что мы делаем, когда у нас не хватает информации? Конечно же строим ещё один граф!

Если серьёзно, то но нам действительно нужна какая-то структура, которая всегда сможет ответить на вопрос, какие у типа родители, а какие наследники. Словом, нужен класс "бабушка на лавочке". И да, это тоже очень удобно можно сделать через граф. Из-за множественного наследования деревом это назвать не выйдет.

Такой граф может выглядеть достаточно просто:

Но здесь встаёт другая проблема: делать связи вообще со всеми возможными методами не очень разумно. И всё же, с чего-то начинать надо. Предположу, что в отдельных случаях — вроде вызовов статических методов — будет легко не удариться в крайность. Но даже в таком простом случае ситуация усложнится драматически:

var a = new A();
a.method();

Допустим, у A есть наследники B и C: как нам узнать, что это за тип, если мы смотрим только на вторую строчку? Ответ: никак. Единственным выходом здесь будет обход тела метода, в котором находится код. Но делать это для каждого вызова метода может быть слишком затратно.

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

Цепи определений-использований

Про CFG мы кратко поговорили, время двигаться на одну абстракцию выше. DU-цепи строятся напрямую из узлов CFG, но при этом рёбра объединяются в цепь для конкретной переменной, которая участвует в операциях внутри узлов.

Продолжая пример с факториалом, вот цепь для переменной i:

Цепь начинается в объявлении счётчика цикла, используется в условии и для вычисления факториала, а заканчивается в инкременте, где появляется ещё одна цепь из единственного элемента i++. Так как i++ можно расшифровать как i = i + 1, то здесь есть как использование справа от присваивания, так и новая цепь слева, — каждое переопределение рождает новую цепь.

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

Чтобы проследить поток данных в приложении, цепи связываются между собой. Для такого кода:

public static void taint(String tainted) {
    var notTainted = "str";
    var a = notTainted + tainted;
    var b = "";
    var c = b + a;
    var sql = b + c;
    st.execute(sql);
}

Будет построен такой набор цепей:

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

В общем, наибольшую сложность представляли две вещи:

  • Эффективно обходить граф потока данных, чтобы не слишком сильно затягивать анализ;
  • Не сохранять ничего лишнего, чтобы потребление памяти не выросло на порядок.

Так как многим приходилось с этим бороться, у нас в отделе в это время появилась внутренняя шутка — JVM оператор. JVM оператор занимается тем, что запускает Java процесс, после чего с умным лицом очень долго и напряжённо смотрит в профилировщик.

Поддержка объектов

Второй вещью, в которую я в прошлый раз тактично не стал погружаться, стали объекты. Если немного об этом подумать, то довольно быстро можно прийти к выводу, что DU-цепи в деле отслеживания значений у полей объектов не могут помочь почти никак.

Пока что наш анализатор не учитывает запись в поля, и тем более изменение состояния объекта внутри методов. Учитываются только изменения значения объекта как такового, и для него и будет построена цепь.

Казалось бы, можно просто строить цепи из полей объектов, да? Нет. Тут вылезает незамысловатая проблема:

var a = new A();
a.field = "value";
a = null;
var b = a.field;

Значение поля напрямую не изменялось, но вот обращаться к нему больше не стоит, так как возникнет NPE. А в нашей цепи для поля field переменная a не учитывается. То же самое будет происходить и с вызовами методов, которые меняют внутреннее состояние, только такое отследить ещё сложнее.

Есть и более банальные вещи, вроде того, что при построении цепи надо смотреть не только на само поле, но ещё и на объект, которому оно принадлежит. Иначе у нас будут выстраиваться цепи между одними полями разных объектов.

Как это исправить? Я не знаю, но буду благодарен, если подскажете :) Но кое-какие соображения у меня есть.

В коде выше DU-цепь для a.field должна содержать узел a = null;, но вот если там будет что-то вроде var other = a, то добавлять связь к этому узлу уже незачем. Анализ псевдонимов мы пока не рассматриваем. Примерно так бы выглядели CFG и DU-цепи для a.field из примера выше:

Слева граф потока управления, а справа три разные цепи для поля field. Первые две состоят всего из одного узла, так как после определения значения нет никаких использований.

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

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

Обход графов

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

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

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

public static void taint(String tainted) {
    var notTainted = "str";
    var a = notTainted + tainted;
    var b = "";
    var c = b + a;
    var sql = b + c;
    st.execute(sql);
}

Порядок действий такой:

  • Увидеть, что у java.sql.Statement вызывается execute, который является стоком;
  • Пойти смотреть, что такое sql, перемещаясь вверх по его DU цепи;
  • Дойдя до верха цепи для sql, мы увидим, что она зависит от b и c, и проход пойдёт вверх уже для них;
  • Повторяем этот процесс для всех остальных переменных: a, notTainted, tainted;
  • Если в любой момент встретилась бы санитизация, то обход бы прекратился. В примере её нет, так что мы обойдём все цепи полностью;
  • Когда мы дойдём до цепи tainted то увидим, что эта переменная — параметр публичного метода, т.е. источник.

На тему того, почему публичные методы являются источниками, мы написали отдельную статью.

Немного про ветвления

Ещё я сторонился темы ветвлений. Делаю я это для того, чтобы не усложнять примеры и сохранять лёгкость материала. Но вот в анализаторе, разумеется, с этим работать как-то надо. Для примера отойдём от SQL-инъекции и посмотрим на XPath который появился в PVS-Studio 7.35:

String user = request.getParameter("username");

if (user.contains("'")) {
    return;
}

var xpath = XPathFactory.newInstance().newXPath();

String query = "//users/user[" +
        "username/text() = '%s']" +
        "/data/text()";
query = String.format(query, user);

String result = xpath.evaluate(query, doc);

И вот вопрос: при анализе снизу-вверх нам как-то надо узнать, что проверка сверху прервала поток выполнения, если данные оказались вредоносными. Пока эта проблема решалась прозаично. Возможно, даже слишком:

  • Сначала мы размечаем then или else-ветки как санитизированные;
  • Затем проверяем, доступен ли последний узел цепочки, по которой мы сейчас идём, из санитизированной ветки условия.

Так, на рисунке ниже из опасной левой ветви невозможно попасть в сток в правой. Значит, уязвимости нет.

Этот механизм писал я и, по глупости не проверив API, поначалу написал его так, что проверялись все возможные ветви. Из-за этого код ниже оказался для моего алгоритма криптонитовым:

if (!sql.contains("'"))
    st.execute(sql);
else {
    var str = sql.replace("'", "");
    st.execute(str);
}

if (!sql.contains("\""))
    st.execute(sql);
else {
    var str = sql.replace("\"", "");
    st.execute(str);
}

if (!sql.contains(";"))
    st.execute(sql);
else {
    var str = sql.replace(";", "");
    st.execute(str);
}

if (!sql.contains("--"))
    st.execute(sql);
else {
    var str = sql.replace("--", "");
    st.execute(str);
}

// ....

И вот так 200 строк. К счастью, во время тестирования это было выявлено и исправлено более эффективным алгоритмом, но этот случай мне запомнился.

Как обойти граф в графе?

Сверху мы рассмотрели простую ситуацию, где сток и источник в одном методе, но чаще всего такого происходить не будет. Значение, попавшее в сток, может зависеть от двух вещей:

  • значение получено из вызова другого метода. Заражение через него попасть может, например, из чтения тела веб-запроса или формы в десктопном приложении;
  • значение попало из параметра метода, но этот метод приватный. Однако рано или поздно наверху может оказаться метод контроллера или просто публичный метод.

Вот тут в игру и вступает граф вызовов (а в будущем и граф иерархий). Вспомнили заглавную картинку?

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

То есть да, и правда граф в графе :)

Путь заражения

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

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

Во-первых, это решает проблему с зацикливанием, ведь мы уже знаем, какие узлы обошли.

Во-вторых, на узлы можно вешать полезную метаинформацию. Нам это пригодилось для реализации разделения Command и Argument injection.

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

Вот, кстати, неказистая визуализация такого пути прохода:

Неказистая она потому, что это реальная картинка, которую я использовал для отладки алгоритма. Зато она очень информативная: из неё можно увидеть, что методы сверху вызываются из исходного par1 (я сам не помню, что эти имена значат), а метод test1, наоборот, его вызывает сам.

От чего решили отказаться

В нашей прошлой статье я сказал, что необязательно иметь и DU-цепи и промежуточное представление. И гнаться за максимализмом мы сами и не стали: SSA у нас нет.

В команде было много обсуждений на тему того, стоит ли его реализовать. Всё-таки у упрощённого представления есть ощутимые плюсы, а с одной из проблем его отсутствия мы недавно столкнулись. Так, две строки кода ниже идентичны:

query = query + string;
query += string;

Но в наших узлах потока данных содержатся узлы AST без изменений. А это значит, что во втором случае чтения данных из query как будто бы и нет: синтаксически же оно отсутствует.

И всё же я пока не убедился, что это нам нужно. Да, есть аргументы "за":

  • с промежуточным представлением проще работать;
  • использование SSA может потенциально сократить количество рёбер в графе.

Но есть и весомые "против":

  • Для выполнения задач отслеживания заражений и вычисления значений хватает и DU-цепей. В этом случае они сами превращаются в псевдо-SSA, так как начинаются по новой каждый раз, когда мы присваиваем значение:
    • IR надо сделать. Не совсем понятно, зачем писать решение для проблемы, которой, по сути, нет;
    • Система уже состоит из большого количества модулей, и усложнять её ещё сильнее — значит повышать порог вхождения.
  • Построение ещё одного представления для всей кодовой базы поднимет потребление ОЗУ на порядок;
  • Ситуации, где упрощённое представление всё-таки нужно, потенциально можно решить путём создания каких-то адаптеров, которые будут выдавать упрощённые синтаксические конструкции (как в примере выше).

В общем, споры у нас не утихли, но пока аргументы "против" перевесили.

Послесловие

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

Taint-решение для нашего Java анализатора сейчас находится в активной разработке. Чтобы следить за нами, вы можете подписаться на X (ex-Twitter) PVS-Studio и наш ежемесячный дайджест статей. Сам инструмент можно бесплатно попробовать здесь.

А если вам хочется следить за мной, то можете подписаться на мой личный блог.