Привет, Хабр!


Навыки статического анализа кода в арсенале исследователя безопасности приложений фактически являются must-have скиллом. Искать ручками уязвимости в коде, не прибегая к автоматизации, для небольших проектов вполне быть может и приемлемый сценарий. Но для больших задач с миллионами строк кода — это непозволительная роскошь с точки зрения временных затрат. Решения по автоматизации всего этого процесса не заставили себя долго ждать. Именно по этой причине мы имеем такой широкий выбор инструментов статического анализа, в разнообразии которого можно утонуть. Рассказать же подробно о всех не хватит и книги.


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


Присутствие не самых известных инструментов в обзоре может немного удивить. Тем не менее они являются интересными образцами и достойны рассмотреня. Если вы хотите рассказать о своем фаворите, будем рады почитать о вашем опыте в комментариях!


Прежде чем начать обзор, сперва обратимся к истокам. Напомним, что такое SAST и за что его так все не любят.


Что такое SAST?


SAST (Static Application Security Testing) – это процесс тестирования, при котором применяется техники статического анализа для поиска ошибок и потенциальных проблем безопасности. Как уже отмечалось, на данный момент существует огромное количество SAST-инструментов для различных языков программирования. Пожалуй, наиболее полный перечень всевозможных инструментов расположен в github-репозитории Analysis Tools. Внушительный по объему список также был представлен OWASP Foundation – Source Code Analysis Tools.


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


Задача этой статьи вовсе не переубедить, а показать, что эти инструменты — не панацея, а лишь вспомогательный элемент в процессе поиска уязвимостей. Зная контекст исследуемого приложения/системы, можно создавать довольно эффективные правила, которые позволят найти отправные точки к возможным багам, ну и вообще облегчат поиск в огромной груде кода.


Наши подопытные


С учетом уже упомянутых критериев для обзора были выбраны следующие SAST-инструменты:



Чтобы оценить их, нам понадобится проект с исходным кодом, содержащий уязвимости. Было бы очень здорово охватить все слабости, перечисленные в Weaknesses in Software Written in C и Weaknesses in Software Written in C++, но мы решили взять одну из них и сделать акцент на различных способах ее реализации, чтобы проверить возможности исследуемых инструментов. И это… double-free, она же CWE-415! Данная уязвимость, несмотря на свою простоту и почтенный возраст, до сих пор обнаруживается в ряде известных проектов. Правила для анализа будем создавать под этот тип ошибки.


Помимо уязвимых сниппетов кода, наш проект будет содержать несколько простых ловушек для SAST-инструментов. Для людей эти "ловушки" могут показаться смешными, но для инструментов это целое испытание. Подобным образом можно будет выявить проблемные места инструментов, например, некоторые из них могут не уметь работать с Control Flow, не обладать межпроцедурным анализом и т.д. Так что постараемся внимательно изучать документацию, быть в курсе существующих примеров правил/запросов, и конечно же, подходить творчески и экспериментировать.


Игрушечный проект расположился в github-репозитории "double-free-samples".


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


Checklist
Тип Место в коде Метод Описание
1 уязвим funcs.cpp:14 double_free() Тривиальный double-free
2 не уязвим funcs.cpp:24 no_double_free() Переназначение указателя, нет ошибки
3 уязвим funcs.cpp:41 df_by_pointer() Тривиальный double-free, вызывается через указатель на функцию
4 уязвим funcs.cpp:57 df_with_wrappers() double-free с методом-оберткой wrapper(char *ptr) над вызовом free
5 уязвим funcs.cpp:69 conditional_dfree() double-free в случае первого освобождения в if-блоке
6 уязвим funcs.cpp:70 conditional_dfree() double-free без захода в if-block + trio free 0_o
7 не уязвим funcs.cpp:80 free_null() Переназначение указателя, нет ошибки
8 уязвим funcs.cpp:89 double_delete() Тривиальный double-delete
9 не уязвим funcs.cpp:105 intrnl_reassignment() Переназначение указателя во внутреннем вызове reassignment(char *ptr), нет ошибки
10 уязвим funcs.cpp:117 bad_goto() double-free после goto-перехода
11 не уязвим funcs.cpp:133 good_goto() Недостижимый вызов free, нет ошибки

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


Начнем же наш обзор!


Semgrep



Semgrep — легковесный инструмент статического анализа для поиска ошибок в коде. Был разработан командой r2c, написан в большинстве своем на OCaml. Первый релиз состоялся в начале 2020 года. Пожалуй, это один из немногих инструментов, в котором трансформация "From Zero To Hero" для пользователя является самой скоростной. Всё это благодаря тому, что язык запросов, или в данном случае паттернов, максимально похож на язык программирования исследуемого проекта.


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


  • Не требует сборки проекта, а значит можно предоставлять несобираемые исходники, результат декомпиляции
  • Поддерживаемые языки: C#, Go, Java, JavaScript, JSX, JSON, PHP, Python, Ruby, Scala, TypeScript, TSX. C/C++ имеют статус экспериментальных языков, как и многие другие
  • Синтаксис паттернов совпадает с синтаксисом языка проекта
  • Быстрая обработка запросов благодаря многопоточности
  • Имеет крутую playground-площадку для обучения синтаксису
  • В реестре semgrep-правил можно подсмотреть security rules для множества языков, создавать свои и делиться ими
  • Есть собственный TrophyСase — найденные CVE с помощью Semgrep

И несколько недоработок:


  • Не знает про межпроцедурное взаимодействие, анализирует все методы подряд без разбора что, где и когда вызывается
  • Не так много публичных правил для C/C++

Как уже отмечалось, поддержка C/C++ пока что является экспериментальной, и публичных правил для этих языков не много. В сравнении с языками зрелой поддержки Java, Python, Go, Ruby, C#, имеющими впечатляющий ruleset.


Эту проблему поднимал исследователь Marco Ivaldi в статьe "Semgrep ruleset for C/C++ vulnerability research". Он написал несколько десятков собственных правил для поиска уязвимостей в C/C++, их можно найти в его github-репозитории. Официальный репозиторий semgrep-rules, к сожалению, не содержит правил для C++. Если вы опытный специалист и хотели бы внести вклад в развитие "сишного" комьюнити Semgrep, вы нужны, как никогда!


  • Не поддерживает межфайловое взаимодействие, разрешение внешних ссылок, Global taint

Под внешней ссылкой в данном контексте понимаются файлы к включению, указанные в директиве #include <> или #include "". Semgrep не умеет работать с внешними ссылками и абсолютно не знает, что там может находиться внутри. Таковы особенности инструмента. Теперь пару слов о taint-анализе.


Анализ DataFlow как более обширное понятие используется для вычисления возможных значений, которые переменная может содержать в различных точках программы, определяя, как эти значения распространяются и где используются. Tainting tracking как более узкое понятие подразумевает контроль потока недоверенных данных (пользовательский ввод) по всему коду проекта с целью выявить их влияние на выполнение программы. Taint-анализ подразделяется на виды:


1) Local taint анализирует поток данных внутри одной функции. В данном случае рассматриваются только ребра между узлами графа, принадлежащие одной и той же функции.


2) Global taint исследует поток "порчи" между разными функциями по всей программе.


Стандартные правила Semgrep выполняются только в отношении одного файла, а новомодный "Taint mode" выполняется только в рамках одной функции, что для больших проектов и более сложных ошибок скорее бестолково, чем полезно. На этот счет r2c выпустила новый проприетарный инструмент под названием DeepSemgrep, который должен был добавить функционал global taint. Чтобы им воспользоваться, необходимо к запуску стандартной утилиты semgrep в командной строке добавить дополнительный аргумент, вот так:


semgrep --deep

а в правиле указать дополнительные поля pattern-sources и pattern-sinks. К сожалению, поддерживаемыми языками на данный момент являются только Java и Ruby. На этом обсуждение достоинств и недостатков Semgrep заканчиваем и переходим к созданию правила.


Локальные правила в Semgrep бывают двух типов — эфемерные (единоразовые) и YAML-правила.


Эфемерный тип может быть полезен, если знаете, как кратко сформулировать ваше правило "вместо тысячи слов". Например, хотим примитивный double-free:


semgrep -e 'free($VAR); ... free($VAR);' --lang=c path/to/src

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


Для написания правила воспользуемся следующими конструкциями Semgrep:


  • id — строковый идентификатор правила
  • metadata — данные о правиле, указывается по желанию, как здесь, references содержит информацию о CWE
  • message — поле сообщения, которое будет выводиться после каждого успешного поиска
  • severity — серьезность ошибки, известные вариации: INFO, WARNING, ERROR
  • languages — язык, для которого определяется данное правило
  • patterns — оператор, объединяющий шаблоны с использованием логического "AND"
  • pattern-either — как patterns, только с логическим "OR"
  • pattern — шаблон, представляющий собой выражение, которое необходимо найти в коде проекта
  • pattern-not — выражение, которое нужно игнорировать при поиске
  • pattern-inside — ограничивает поиск требуемого паттерна внутри указанного выражения
  • pattern-not-inside — не включает в результат, если требуемый паттерн содержится в данном выражении

Semgrep богат на различные конструкции. С ними можно дополнительно ознакомиться в документации. Очень полезными являются, например, pattern-regex, позволяющий искать в коде по заданной PCRE-регулярке, metavariable-pattern и metavariable-regex, позволяющие гибко настроить метапеременные по заданным шаблонам/PCRE-регуляркам соответственно.


Итоговое правило будет выглядеть так:


config.yaml
rules:
  - id: detect-double-free
    metadata:
      references:
        - https://cwe.mitre.org/data/definitions/415.html       
    message: >-
      Some vulnerable (and not) double-free cases. If malloc() returns
      the same value twice and the program later gives the attacker control 
      over the data that is written into this doubly-allocated memory,
      the program becomes vulnerable to a buffer overflow attack.
    severity: ERROR
    languages:
      - cpp
    pattern-either:
      - patterns:
        - pattern: |
            free($PTR);
            ...
            free($PTR);
        - pattern-not: |
            free($PTR);
            ...
            $PTR = $EXPR;
            ...
            free($PTR);
        - pattern-not-inside: |
            $FTYPE $FUNC(..., $TYPE $ARG, ...){
              ...
              $ARG = $EXPR;
              ...
            }
            ...
            $FUNC($PTR);
            ...
        - pattern-not-inside: |
            if ($CONF){
              ...
              goto $LAB;
            }
            ...
            free($PTR);
            ...
            $LAB:
            ...
            free(ptr);            
      - patterns:
          - pattern: |
              if ($COND){
                ...
                free($PTR);
                ...
              }
              ...
              free($PTR);
          - pattern-not: |
              if ($COND){
                ...
                free($PTR);
                ...
                $PTR = $EXPR;
                ...
              }
              ...
              free($PTR);
          - pattern-not: |
              if ($COND){
                ...
                free($PTR);
                ...
              }
              ...
              $PTR = $EXPR;
              ...
              free($PTR);
      - patterns:
        - pattern-inside: |
            $FTYPE $FUNC(..., $TYPE $ARG, ...){
              ...
              free($ARG);
              ...
            }
            ...
        - pattern-not-inside: |
            $FTYPE $FUNC(..., $TYPE $ARG, ...){
              ...
              free($ARG);
              ...
              $ARG = $EXPR;
              ...
            }
            ...
        - pattern: |                
            $FUNC(..., $PTR, ...);
            ...
            $FUNC(..., $PTR, ...);
        - pattern-not: |
            $FUNC(..., $PTR, ...);
            ...
            $PTR = $EXPR;
            ...
            $FUNC(..., $PTR, ...);
      - patterns:
          - pattern: |
              delete [] $PTR;
              ...
              delete [] $PTR;
          - pattern-not: |
              delete [] $PTR;
              ...
              $PTR = $EXPR;
              ...
              delete [] $PTR;
      - patterns:
          - pattern: |
              delete $PTR;
              ...
              delete $PTR;
          - pattern-not: |
              delete $PTR;
              ...
              $PTR = $EXPR;
              ...
              delete $PTR;

Запускаем:


semgrep --config config.yaml [PATH/TO/SRC]

Результаты
Findings:

         13┆ free(buf1);
         14┆ free(buf1);
          ⋮┆----------------------------------------
         38┆ free(buf2);
         39┆ 
         40┆ buf3 = (char *) malloc(SIZE);
         41┆ free(buf2);
          ⋮┆----------------------------------------
         56┆ wrapper(buf1);
         57┆ wrapper(buf1);
          ⋮┆----------------------------------------
         66┆ if (condition){
         67┆    free(ptr);
         68┆ }
         69┆ free(ptr);
          ⋮┆----------------------------------------
         66┆ if (condition){
         67┆    free(ptr);
         68┆ }
         69┆ free(ptr);
         70┆ free(ptr);
          ⋮┆----------------------------------------
         69┆ free(ptr);
         70┆ free(ptr);
          ⋮┆----------------------------------------
         88┆ delete [] x;
         89┆ delete [] x;
          ⋮┆----------------------------------------
        113┆     free(ptr);
        114┆     goto free_me;
        115┆ 
        116┆ free_me:
        117┆     free(ptr);

Ran 1 rule on 3 file: 8 findings.

Что ж, в этом и есть сила Semgrep! Были найдены все кейсы двойного освобождения, ложноположительные результаты отсутствуют. Всё это потому, что мы создали соответствующие паттерны для тех ситуаций в коде проекта, где ошибка двойного освобождения могла бы существовать, и отфильтровали те случаи, когда ошибки нет. Это правило хорошо работает для игрушечного проекта и является достаточным. Запустите это правило на другом, более сложном проекте — результат вас не обрадует, поскольку правило охватывает далеко не все паттерны ситуаций, при которых в принципе может существовать ошибка double-free, а таких кейсов огромное количество. Пробуйте, дополняйте свои правила разнообразными паттернами, и их объемы будут соразмерно расти с качеством результатов.


Semgrep, несмотря на свои недостатки, оставляет о себе положительное впечатление. Инструмент продолжает развиваться благодаря отличной команде разработчиков и широкому заинтересованному AppSec комьюнити. Продолжим наш обзор и перейдем к рассмотрению следующего инструмента — CodeQL.


CodeQL



CodeQL впервые был представлен компанией Semmle в 2018 году. Бывалые ресечеры могут помнить его как SemmleQL. Он довольно быстро был выкуплен GitHub и с 2020 года принадлежит Microsoft. Одним из основных преимуществ CodeQL является, пожалуй, лучшая реализация DataFlow-анализа/Taint tracking в рамках кодовой базы по сравнению с другими инструментами. Последняя является иерархическим представлением кода, она включает в себя абстрактное синтаксическое дерево (AST) всего проекта, CFG (Control Flow Graph) и DataFlow данные, которые требуются для taint-анализа.


О CodeQL уже бывали выступления на международных площадках (например, ZeroNights 2021 — "Company wide SAST"), о нем не раз писали отличные статьи, в том числе и на Хабре (например, CodeQL: SAST своими руками (и головой), Сканирование кода C++ с помощью GitHub Actions), так что не будем повторяться и кратко обозначим некоторые особенности инструмента.


Преимущества:


  • Поддерживаемые языки: C/C++, C#, Go, Java, JavaScript, Python, Ruby, TypeScript
  • Удобная интеграция в Visual Studio Code — CodeQL extension
  • Неплохая документация, а также полезные лекции от Semmle
  • Качественный анализ DataFlow/TaintTracking
  • Возможность реализации объектно-ориентированных запросов (object-oriented queries)
  • В наличии воркшопы и полезные интро как от самих создателей, так и от комьюнити
  • В онлайн-консоли LGTM (Looks Good To Me) можно создавать и тестировать СodeQL-запросы к доступным базам opensource-проектов или к собственным GitHub-репозиториям. Однако недавно появилась новость, что LGTM заявляет о своем закрытии к концу 2022 года и рекомендует использовать GitHub Code Scanning
  • Список уязвимостей безопасности, найденных с помощью CodeQL, заметно пополняется

Недостатки:


  • Необходимость создания кодовой базы

Это значит, что, если проект написан на компилируемом языке, он должен быть готов к сборке. Если проект по каким-либо причинам не может быть собран, CodeQL использовать не получится.


  • Специфика языка запросов

К сожалению, как в случае с Semgrep, где можно с лету начинать писать собственные паттерны, с CodeQL придется потратить время на изучения языка запросов.


  • Время исполнения запросов к базе

Это больше относится к taint-анализу. Если кодовая база велика, то некоторые запросы могут исполняться крайне долго, что, пожалуй, не удивительно. С базой в 4 миллиона строк кода запрос мог исполняться до полутора часов. Иногда taint-анализ окончательно замораживался и не возвращался из состояния поиска путей.


  • Лицензионные ограничения

Учитывая формальную сторону, Github не разрешает использовать CodeQL для создания и анализа кодовых баз в ряде случаев. Более подробная информация располагается в GitHub CodeQL Terms and Conditions.


  • Мистика результатов DataFlow анализа

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



Теперь вернемся к нашему проекту. Для начала создадим СodeQL базу данных из директории с кодом, вот так:


codeql database create my_db --language=cpp

Прикрепляем ее в VSCode в качестве основной базы и приступаем к поиску баг. В официальном репозитории CodeQL есть ряд готовых запросов для поиска некоторых типов уязвимостей. Попробуем воспользоваться одним из таких как раз для поиска ошибок типа double-free. Вот, что мы получим:



Как можно заметить, этот запрос не обнаружил double-delete, а также двойное освобождение через метод-обертку wrapper. Для новичков в CodeQL этот запрос может оказаться сложным и запутанным, поэтому попробуем создать наш собственный, поменьше и попроще, который будет обнаруживать конкретно double-delete в проекте.


Общий макет CodeQL-запросов обычно следующий:


import <language>
import <deps_you_need>  

from /* ... variable declarations ... */
where /* ... logical formulas ... */
select /* ... expressions ... */

Дополним его объектами FunctionCall и предикатами:


  • getTarget() — получает функцию этого вызова как отельный объект;
  • getAPredecessor() — получает прямого предка данной ноды потока управления.

Теперь сформируем наш запрос:


import cpp

from FunctionCall fc, FunctionCall fc2
where 
fc.getTarget().hasName("operator delete[]")
and fc2.getTarget().hasName("operator delete[]") and
fc != fc2 and fc.getAPredecessor*() = fc2
select fc, "Double-delete $@ and $@", fc2, "here", fc, "and here"

Результат нас обрадует — CodeQL найдет примитивный double-delete, как мы и хотели:



Отлично, а теперь попробуем применить taint tracking и найти столько double-free, сколько сможем. Воспользуемся semmle.code.cpp.dataflow.TaintTracking для создания собственной taint-конфигурации, а также DataFlow::PathGraph для корректного отображения найденных путей.


taint.ql
/**
 * @name Double free
 * @kind path-problem
 * @id double-free
 */

import cpp

import semmle.code.cpp.dataflow.TaintTracking
import DataFlow::PathGraph

class Config extends TaintTracking::Configuration {
    Config() {this = "Double free"}

    override predicate isSource(DataFlow::Node source) {
        exists(FunctionCall call |         
          source.asDefiningArgument() = call.getArgument(0) 
          and
          call.getTarget().hasGlobalOrStdName("free")                   
        )
      }   

      override predicate isSink(DataFlow::Node sink) {
        exists(FunctionCall call |
             call.getTarget().hasGlobalOrStdName("free")
              and
              sink.asExpr() = call.getArgument(0)
          )              
        }
}

from Config config, DataFlow::PathNode source, DataFlow::PathNode sink
where config.hasFlowPath(source, sink)
select sink, source, sink, "Memory is $@ and $@, causing a potential vulnerability.", source, "freed here", sink, "here"

Почти хорошо. Запрос успешно нашел вызов free в методе-обертке и обозначил путь требуемых данных (указателя на чанк) от источника к приемнику.



Однако CodeQL выдал один false-positive результат в кейсе с внутренним вызовом функции reassignment(char *ptr), где поинтер снова будет содержать адрес того самого чанка, который распределитель кучи достанет из корзины. К сожалению, переопределение предиката isSanitizer taint-конфигурации никак не смогло на это повлиять, так как taint path не включил в себя вызов этой функции, а значит и фильтрация будет безуспешной.


Таким образом, CodeQL — неплохой инструмент статического анализа, где результат сильно зависит от качества запросов, которые мы создаем к базе. CodeQL оставляет довольно противоречивое впечатление, так как несмотря на свой потенциал (особенно для объектно-ориентированных языков программирования) он имеет множество недостатков. В статье исследователя Valtteri Rahkonen "SAST Tool Comparison Using Secure C Coding Standard Examples", где сравнивались ряд open-source и коммерческих SAST-инструментов, CodeQL показал один из самых слабых результатов. Однако не будем останавливаться на плохой ноте. CodeQL может открыть большие возможности для исследования кода, что доказывает растущее число CVE, найденных с его помощью.


Weggli


Weggli — инструмент семантического поиска в кодовых базах на C/C++. Разработан исследователем Felix Wilhelm из Google Project Zero и опубликован осенью 2021 года с целью помочь ресечерам находить интересные места в больших кодовых базах. "Killer feature" данного инструмента — возможность исследовать C/C++ проекты без необходимости их сборки, в чем он очень похож на Semgrep.


На данный момент о Weggli не так много информации. Одна из немногих статей о Weggli от парочки бывалых пользователей CodeQL, "Playing with Weggli", рассказывает о попытках исследователей разобраться в синтаксисе запросов Weggli и получить желаемые результаты. В следующем github-репозитории содержится пример занятного Weggli-запроса для поиска double-free в ядре Linux. Мы тоже попробуем разобраться и создать свой запрос для поиска этой ошибки в нашем проекте. Но сперва отметим несколько особенностей инструмента.


Преимущества:


  • Не требует сборки проекта
  • Первоклассная поддержка C и C++
  • Высокая скорость поиска

Недостатки:


  • Отсутствие выделенной документации
  • Мало примеров, статей от комьюнити и иных ресурсов, которые могли бы быть полезными для тех, кто пытается разобраться в работе инструмента
  • Отсутствие межфайловой/межпроцедурной семантики
  • Не умеет в Control Flow

Опишем конструкции Weggli, которыми воспользуемся для написания правила:


  • $func — переменная, в данном случае содержащая имя функции
  • $ptr — переменная, обозначающая указатель, который хотим проследить в рамках double-free
  • NOT — отрицательный подзапрос, фильтрующий результаты по заданному условию
  • _ — любая AST-нода

Правило для поиска double-free в нашем проекте сделаем таким:


weggli --cpp -R '$func=free' '{ $func($ptr); NOT: $ptr = _; NOT: return; $func($ptr); }' ~/project

Результат
funcs.cpp:9
void double_free()
{
    char *buf1;
    buf1 = (char *) malloc(SIZE);   
    **free(buf1);**
    **free(buf1);**
}
funcs.cpp:29
void df_by_pointer()
{
..
    char *buf3;

    buf1 = (char *) malloc(SIZE);
    buf2 = (char *) malloc(SIZE);
    free(buf1); 
    **free(buf2);**

    buf3 = (char *) malloc(SIZE);   
    **free(buf2);**
    free(buf3);
}
funcs.cpp:61
void conditional_dfree()
{
    int condition = 1;
    char *ptr;
    ptr = (char *) malloc(SIZE);
    if (condition) 
        **free(ptr);**

    **free(ptr);**
    free(ptr);
}
funcs.cpp:61
void conditional_dfree()
{
    int condition = 1;
    char *ptr;
    ptr = (char *) malloc(SIZE);
    if (condition) 
        free(ptr);

    **free(ptr);**
    **free(ptr);**
}
funcs.cpp:61
void conditional_dfree()
{
..
    char *ptr;
    ptr = (char *) malloc(SIZE);
    if (condition) 
        **free(ptr);**

    free(ptr);
    **free(ptr);** // Это прям нечто!
}
funcs.cpp:99
void intrnl_reassignment()
{
    char *ptr;
    ptr = (char *) malloc(SIZE);
    **free(ptr);**
    reassignment(ptr);
    **free(ptr);**
}
funcs.cpp:109
void bad_goto()
{
    char *ptr;
    ptr = (char *) malloc(SIZE);
    **free(ptr);**
    goto free_me;

free_me:
    **free(ptr);**
    return;
}

Weggli, как и ожидалось, не смог обработать внутренние вызовы, и поэтому пропустил double-free через вызов wrapper, выдал false-positive с переопределением указателя во внутреннем вызове reassignment и улыбнул на находке:


if (condition) 
  **free(ptr);** // first free

free(ptr);
**free(ptr);** // second free, это прям нечто!

Составим аналогичный запрос для поиска double-delete — и Weggli хорошо справится с ним, успешно найдет этот простой кейс:


weggli --cpp 'delete $a; NOT: $a = _; delete $a' ~/project

Weggli — молодой инструмент и пока его функциональность не столь широка, как у Semgrep или CodeQL, но это отличное начинание. Ждем от Феликса новых идей и улучшений!


Joern



Joern — инструмент командной строки для статического анализа исходного кода и байт-кода от ShiftLeft. Реализован на языке Scala, а анализ кода выполняется с использованием языка запросов CPGQL, предметно-ориентированного языка (также основан на Scala), разработанного специально для работы с CPG (code property graph). CPG как понятие в контексте Joern является основообразующим, это описано в научной работе самих разработчиков — "Modeling and Discovering Vulnerabilities with Code Property Graphs". CPG представляет собой промежуточное представление кода, включает в себя AST проекта, Control Flow граф, информационные потоки и иные необходимые данные для дальнейшего анализа. Реализацию CPG можно найти в github-репозитории ShiftLeft.


Одноименный предок Joern со схожей концепцией анализа кода на основе CPG появился еще в далеком 2012 году и был представлен в рамках международной конференции ACSAC'12 (Annual Computer Security Applications Conference) исследователем Fabian Yamaguchi и командой компьютерной безопасности Геттингенского университета. CPG прежней реализации хранился в графовой СУБД Neo4J (в новом Joern заменен на OverflowDB), а в качестве языка запросов выступал Gremlin. На своем веку Joern-old успел обжиться собственной TrophyCase из нескольких десятков CVE. Однако ряд концептуальных недостатков с каждым годом становился все более явным, поэтому разработчики решили переосмыслить текущую реализацию и создать тот Joern, который есть сейчас.


Одной из главных особенностей Joern является возможность осуществления taint-анализа без необходимости сборки проекта. Эта мысль также поднимается в цикле статей о Joern, где исследователь сравнивает его работу с CodeQL на примере уже упомянутого CodeQL U-Boot challenge. Joern во многом не уступал в этом "версусе", и порой его запросы выглядели гораздо более компактными и эффективными. Посмотрим, насколько Joern хорош на деле. Но сперва отметим и другие положительные стороны Joern:


  • Отличная документация как по работе с самим инструментом, так и к языку запросов CPGQL
  • Не требует сборки проекта
  • Имеет собственный Discord-чат для быстрой поддержки и обмена опытом
  • Поддерживаемые языки: C/C++, Javascript, Kotlin, Python, Ghidra(x86/x64), JVM bytecode, LLVM bitcode
  • Поддержка taint-анализа
  • В наличии примеры запросов на CPGQL для разных языков, пусть и не в большом количестве
  • На его основе сделан инструмент Joern Scan для сканирования кода и поиска некоторых категорий уязвимостей
  • Расширяемость функционала плагинами, подробнее

Недостатки:


  • Требуется знания не только CPGQL, но и основ Scala, чтобы создавать более-менее эффективные запросы
  • Ruleset, к сожалению, совсем не велик
  • Не для крупных проектов, т.к. создание CPG может быть крайне долгим или завершиться ошибкой в таких случаях
  • Ошибки межпроцедурного анализа, приводящие к ложным срабатываниям. К ним мы еще вернемся.

Теперь попробуем создать собственные запросы для поиска double-free и double-delete. Первой командой, которую запустим в интерактивной оболочке, является importCode, которая создаст новый каталог проекта и сохранит в нем двоичное представление CPG:


     ██╗ ██████╗ ███████╗██████╗ ███╗   ██╗
     ██║██╔═══██╗██╔════╝██╔══██╗████╗  ██║
     ██║██║   ██║█████╗  ██████╔╝██╔██╗ ██║
██   ██║██║   ██║██╔══╝  ██╔══██╗██║╚██╗██║
╚█████╔╝╚██████╔╝███████╗██║  ██║██║ ╚████║
 ╚════╝  ╚═════╝ ╚══════╝╚═╝  ╚═╝╚═╝  ╚═══╝
Version: 1.1.1140
Type `help` or `browse(help)` to begin

joern> importCode(inputPath="путь_к_исходникам", projectName="имя_проекта")
res1: Cpg = Cpg (Graph [67082 nodes])

Объект cpg является корневым всего языка запросов, так что чаще всего обращаться придется именно к нему и его предикатам. Например, хотим получить все вызовы free в проекте:


cpg.call("free").l.map(
    call => (
      call.id,
      call.method.name,
      call.code,
      call.location.lineNumber match {
        case Some(n) => n.toString
        case None => "n/a"
        }
    )
  ) 

Результат
res3: List[(Long, String, String, String)] = List(
  (24797L, "double_free", "free(buf1)", "13"),
  (24799L, "double_free", "free(buf1)", "14"),
  (24812L, "no_double_free", "free(ptr)", "22"),
  (24820L, "no_double_free", "free(ptr)", "24"),
  (24844L, "df_by_pointer", "free(buf1)", "37"),
  (24846L, "df_by_pointer", "free(buf2)", "38"),
  (24854L, "df_by_pointer", "free(buf2)", "41"),
  (24856L, "df_by_pointer", "free(buf3)", "42"),
  (24863L, "wrapper", "free(ptr)", "47"),
  (24901L, "conditional_dfree", "free(ptr)", "67"),
  (24903L, "conditional_dfree", "free(ptr)", "69"),
  (24905L, "conditional_dfree", "free(ptr)", "70"),
  (24918L, "free_null", "free(ptr)", "78"),
  (24923L, "free_null", "free(ptr)", "80"),
  (24966L, "intrnl_reassignment", "free(ptr)", "103"),
  (24970L, "intrnl_reassignment", "free(ptr)", "105"),
  (24983L, "bad_goto", "free(ptr)", "113"),
  (24987L, "bad_goto", "free(ptr)", "117"),
  (25005L, "good_goto", "free(ptr)", "127"),
  (25011L, "good_goto", "free(ptr)", "131")
)

Обратите внимание на первый атрибут id — значение типа Long, которое точно идентифицирует каждый узел графа потока управления (cfgNode в синтаксисе CPGQL). Опробуем же taint-анализ для поиска double-free!


Для этого создадим следующий запрос:


joern> run.ossdataflow // начиная с Joern v1.1.299 не обязателен к объявлению
joern> def source = cpg.call("free").argument
joern> def sink = cpg.call("free").argument
joern> sink.reachableByFlows(source).filter(f => f.elements.size > 1).p

Здесь мы объявили две переменные — source и sink, которые содержат единственный аргумент free — указатель, который мы хотим проследить по пути "порчи". Этим займется вызов reachableByFlows, который возвращает пути для потоков данных от источника к приемнику. filter (filter step) позволит продолжить обход для всех узлов графа, которые соответствуют его условию. В данном случае условие уберет так называемые "петли" (поток от вызова free к самому себе). Таковы особенности Joern.


Выхлоп будет следующий:


Спрятан под спойлер
res10: List[String] = List(
  """____________________________________________________
| tracked   | lineNumber| method   | file                |
|========================================================|
| free(ptr) | 113       | bad_goto | ~/sources/funcs.cpp |
| free(ptr) | 117       | bad_goto | ~/sources/funcs.cpp |
""",
  """_____________________________________________________________
| tracked   | lineNumber| method            | file                |
|=================================================================|
| free(ptr) | 67        | conditional_dfree | ~/sources/funcs.cpp |
| free(ptr) | 69        | conditional_dfree | ~/sources/funcs.cpp |
| free(ptr) | 70        | conditional_dfree | ~/sources/funcs.cpp |
""",
  """_____________________________________________________________
| tracked   | lineNumber| method            | file                |
|=================================================================|
| free(ptr) | 69        | conditional_dfree | ~/sources/funcs.cpp |
| free(ptr) | 70        | conditional_dfree | ~/sources/funcs.cpp |
""",
  """__________________________________________________________
| tracked    | lineNumber| method        | file                |
|==============================================================|
| free(buf2) | 38        | df_by_pointer | ~/sources/funcs.cpp |
| free(buf2) | 41        | df_by_pointer | ~/sources/funcs.cpp |
""",
  """_____________________________________________________________________________
| tracked                 | lineNumber| method              | file                |
|=================================================================================|
| free(ptr)               | 103       | intrnl_reassignment | ~/sources/funcs.cpp |
| reassignment(ptr)       | 104       | intrnl_reassignment | ~/sources/funcs.cpp |
| reassignment(char *ptr) | 92        | reassignment        | ~/sources/funcs.cpp |
| void                    | 92        | reassignment        | ~/sources/funcs.cpp |
| reassignment(ptr)       | 104       | intrnl_reassignment | ~/sources/funcs.cpp |
| free(ptr)               | 105       | intrnl_reassignment | ~/sources/funcs.cpp |
""",
  """________________________________________________________
| tracked    | lineNumber| method      | file                |
|============================================================|
| free(buf1) | 13        | double_free | ~/sources/funcs.cpp |
| free(buf1) | 14        | double_free | ~/sources/funcs.cpp |
""",
  """_____________________________________________________________
| tracked   | lineNumber| method            | file                |
|=================================================================|
| free(ptr) | 67        | conditional_dfree | ~/sources/funcs.cpp |
| free(ptr) | 69        | conditional_dfree | ~/sources/funcs.cpp |
"""
)

К сожалению, результат taint-анализа содержит ряд ошибок. Несомненно, он смог обнаружить примитивные кейсы двойного освобождения, кейсы с if-блоком и goto, но не нашел случай с оберткой над free (вызовы wrapper), выдал ложноположительный результат с внутренним переназначением указателя (вызов reassignment). Исследуя, почему произошли такие ошибки, мы выяснили:


1) У Joern нет какой-либо кастомизации taint-конфигурации, кроме назначения непосредственного источника/приемника данных. В секции "Data-Flow Steps" документации не обнаружилось каких-либо примитивов, которыми можно было бы воспользоваться и, к примеру, ограничить пути с учетом собственных требований. Да, это прямой намек на CodeQL и его предикаты isSanitizer и isBarrier, которые позволяют настраивать анализ с заданными условиями. Это могло бы помочь отфильтровать часть ложноположительных путей, не связанных с уязвимостью.


2) Ложноотрицательный результат:


Joern не выдал результат с вызовом free в обертке, и было принято решение сделать еще один маленький taint-анализ от аргумента первого вызова wrapper(buf1) (funcs.cpp:56) к аргументу free:


joern> def source = cpg.call.id(28247).argument  
joern> def sink = cpg.call("free").argument
joern> sink.reachableByFlows(source).p

Результат оказался странным:


 """_______________________________________________________________________
| tracked            | lineNumber| method           | file                |
|=========================================================================|
| wrapper(buf1)      | 56        | df_with_wrappers | ~/sources/funcs.cpp |
| wrapper(buf1)      | 57        | df_with_wrappers | ~/sources/funcs.cpp |
| wrapper(char *ptr) | 45        | wrapper          | ~/sources/funcs.cpp |
| free(ptr)          | 47        | wrapper          | ~/sources/funcs.cpp |
"""

То есть, Joern видит в контексте этой ситуации только один вызов free, а значит double-free здесь тоже не может существовать, что неверно.


3) Ложноположительный результат:


Внутреннее переназначение указателя (вызов reassignment) на пути между двумя вызовами free защищает от ошибки, но Joern так не посчитал. Исследуя проблему, оказалось, что мы не единственные, кто столкнулся с ней. Было обнаружено открытое issue "Dataflow inconsistency related to reassignment and subcalls" в github-репозитории Joern, где рассматривается крайне схожая ситуация с внутренними вызовами и переназначениями. Будем надеяться, что эта ошибка будет исправлена разработчиками и улучшит качество taint-анализа.


Ложноположительные результаты также могут появляться по той причине, что Joern не имеет представления о том, по какой ветке пойдет поток исполнения, и знать исход условных выражений он не способен. Именно поэтому инструмент предложил все варианты двойных освобождений в вызове conditional_dfree(). Если бы мы имели такой кейс, как:


bool res = 1;
free(ptr);
if (res){
  goto goodbye;
}
free(ptr); // недостижим

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


Несомненно, taint-анализ для поиска ошибок двойного освобождения является наиболее удобным и эффективным выбором, если таковой имеется. Но поскольку в данном случае он не отработал так, как хотелось бы, попробуем углубиться в CPGQL и создать более сложный запрос, не прибегая к taint-конструкциям Joern. В конце концов, у нас в руках сам CPG! Вручную опишем необходимые условия, в которых видим (или не видим) ошибку double-free в рамках нашего проекта. Чем больше ситуаций будет охватывать наше правило, тем качественнее будут результаты. Получилось оно следующим:


Правило
import scala.collection.mutable.Map
import scala.collection.mutable.ListBuffer
import scala.util.control.Breaks._

var result = new ListBuffer[Call]()
var preparedCalls = Map[String, Int]()

def do_job(call:Call, argIndex:Int, otherCalls:Map[Call, Int]): Unit = {

  val localArgDeclCall = call.argument.argumentIndex(argIndex).isIdentifier.refsTo.id.head
  val sameArgDeclCalls = otherCalls.filter(x =>
  x._1.argument.argumentIndex(x._2).isIdentifier.refsTo.id.head == localArgDeclCall).toSet

  if (sameArgDeclCalls.isEmpty){
     return
   }

  for (suspectCall <- sameArgDeclCalls){
    val assignList = suspectCall._1.postDominatedBy.assignment
    .filter(_.id < call.id)
    .filter(_.argument.isIdentifier.name.head.equals(call.argument(argIndex).code))
    if (suspectCall._1.id < call.id && assignList.isEmpty){
      val innerCalls = suspectCall._1.postDominatedBy.isCall.filter(_.id <= call.id).toSetMutable
      if (innerCalls.contains(call)){
        innerCalls -= call
        if (innerCalls.isEmpty) {
          result += call
          return
        }
        else {
          var argUsage = false
          for (innerCall <- innerCalls) {
            breakable {
              var arg_idx = 0
              for (arg <- innerCall.argument.isIdentifier) {
                if (arg.refsTo.id.head.equals(localArgDeclCall)) {
                  arg_idx = arg.argumentIndex
                }
              }
              if (arg_idx == 0){
                break
              }
              val ptr_name = cpg.method(innerCall.name).parameter.index(arg_idx).name
              val innerAssignments = cpg.method(innerCall.name).postDominatedBy.assignment.argument.isIdentifier.name.toList
              if (innerAssignments.contains(ptr_name.head)){
                argUsage = true
                return
              }
            }
          }
          if (!argUsage) {
            result += call
            return
          }
        }
      }
    }
  }
}

preparedCalls += ("free" -> 1)
for (call <- cpg.call("free")){
  val arg_name = call.argument(1).code
  val localAssignments = call.method.postDominatedBy.assignment
  .filter(x => arg_name.contains(x.argument.isIdentifier.code))
  if (localAssignments.isEmpty){
    for (arg <- call.method.parameter.toList){
      if (arg_name.contains(arg.name)){
        preparedCalls += (call.method.name -> arg.index)
      }
    }
  }
}
for ((methodName, argIndex) <- preparedCalls){
  for (call <- cpg.call(methodName)){
    val otherCalls = Map[Call, Int]()
    for ((name, index) <- preparedCalls){
      for (chosenOne <- cpg.call(name).filter(_.id != call.id).toList){
        otherCalls += (chosenOne -> index)
      }
    }
    do_job(call, argIndex, otherCalls)
  }
}
result.l.map(
    call => (
        call.method.name,
        call.code,
        call.location.lineNumber match {
           case Some(n) => n.toString
           case None => "n/a"
        }
    )
)

В сравнении с предыдущими объем этого правила впечатляет. Оно не идеально, поскольку не обрабатывает многие другие кейсы, в которых ошибка двойного освобождения допустима, однако для нашего проекта является достаточным. Правило собирает все возможные вызовы free и чистые обертки над ними, которые удовлетворяют условию, что не содержат переназначений аргумента-указателя на всем протяжении исполнения. Далее для каждого подобного вызова делается ряд проверок. Исследуются, есть ли такие вызовы, которые имеют один и тот же аргумент (в соответствии с его объявлением) в методе. Затем проверяется, есть ли между этими вызовами переназначения указателя, в том числе и во внутренних вызовах между ними. Само переназначение, кстати, в CPG выступает как вызов <operator>.assignment:


res20: List[operatorextension.OpNodes.Assignment] = List(
  Call(
    id -> 28159L,
    argumentIndex -> -1,
    argumentName -> None,
    code -> "buf1 = (char *) malloc(SIZE)",
    columnNumber -> Some(value = 2),
    dispatchType -> "STATIC_DISPATCH",
    dynamicTypeHintFullName -> ArraySeq(),
    lineNumber -> Some(value = 12),
    methodFullName -> "<operator>.assignment",
    name -> "<operator>.assignment",
    order -> 2,
    signature -> "",
    typeFullName -> "<empty>"
  )
)

Удовлетворяющие всем условиям вызовы собираются в лист и выводятся. Результат для нашего проекта будет таким:


res102: List[(String, String, String)] = List(
  ("df_with_wrappers", "wrapper(buf1)", "57"),
  ("double_free", "free(buf1)", "14"),
  ("df_by_pointer", "free(buf2)", "41"),
  ("conditional_dfree", "free(ptr)", "69"),
  ("conditional_dfree", "free(ptr)", "70"),
  ("bad_goto", "free(ptr)", "117")
)   

Очень неплохо! Найдены все ошибки, ложноположительных кейсов нет. Ручная работа с CPG оказалась не напрасной и дала свои плоды.


Для поиска примитивного double-delete можно также воспользоваться reachableByFlows, но в качестве названия метода указать <operator>.delete. Данный кейс успешно найдется:


"""______________________________________________________________
| tracked     | lineNumber| method        | file                |
|===============================================================|
| delete [] x | 88        | double_delete | ~/sources/funcs.cpp |
| delete [] x | 89        | double_delete | ~/sources/funcs.cpp |
"""

Вот такие результаты! Можно сказать, что Joern — хороший инструмент статического анализа для небольших/средних по объему проектов, с интересным бэкграундом, задумкой, не без изъянов, но с крепкой научной основой. Joern позволил реализовать правила, в которых, ручками исследуя CPG/CFG, можно было учесть очень важные детали, без которых точность поиска была бы значительно ниже. Для инструмента, не требующего сборки проекта, получить подобные результаты — это очень здорово.


MATE



MATE представляет собой целый набор инструментов статического анализа кода для C/C++ проектов. В этом обзоре данный toolset является темной лошадкой, разработан командой Galois совместно с DARPA. Релиз состоялся совсем недавно, в конце лета этого года, и инструментарий еще не успел обрасти многочисленными примерами использования, набором правил, воркшопами и каким-либо фидбеком. Однако потенциал MATE достаточно велик, таким широким кругом возможностей может похвастаться не каждый инструмент. Стоит отметить, что на данный момент MATE является программным обеспечением исследовательского уровня, разработчики проводят большую работу, чтобы сделать его более надежным.


Как и Joern, MATE в своем анализе формирует граф свойств кода (CPG). Для создания запросов к CPG MATE предоставляет предметно-ориентированный язык на основе Python и SQLAlchemy. Согласно документации MATE, CPG-проекта формируется из следующих позиций:


  • абстрактное синтаксическое дерево (AST)
  • граф вызовов (CG)
  • граф потока управления (CFG)
  • межпроцедурный граф потока управления (ICFG)
  • межпроцедурный граф потока данных (DFG)
  • граф зависимости управления (CDG)
  • points-to graph (PTG)
  • сопоставления исходного кода с LLVM
  • memory layout
  • отладочная информация DWARF

Также в документации можно ознакомиться с таблицей сравнения MATE с другими SAST-инструментами, большинство из которых уже побывали в обзоре, с ограничениями MATE, которые важно учитывать при анализе. Самым важным из них, пожалуй, является обязательное требование предоставлять собираемые исходники, поскольку MATE анализирует LLVM-байткод, и для этого требуется компиляция с clang.


Теперь уделим внимание некоторым составным компонентам MATE:



Утилита командной строки для взаимодействия с MATE REST API. Точка старта при анализе проекта. Например, компиляция из исходников и создание проекта в среде MATE происходит следующим образом:


mate-cli oneshot -p ~/sources/

Далее заходим в веб-интерфейс (http://localhost:3000/builds) и видим наш проект:




Проводят автоматический анализ проекта. Детекторы нацелены на поиск конкретных типов уязвимостей, таких как command injection, path traversal, использование неинициализированной памяти, раскрытие указателей и других. В рамках поиска уязвимостей в нашем игрушечном проекте каких-либо ценных точек интереса детекторы MATE не обнаружили, что видно на скриншоте.




Инструмент Flowfinder визуализирует CPG проекта и позволяет взаимодействовать с ним. Играет вспомогательную функцию как при анализе результатов POI, так и во время создания запросов. Предназначен для обеспечения межпроцедурного анализа потоков данных программы на высоком уровне абстракции.




Предоставляет из себя веб-интерфейс для изучения приложений с помощью механизма символьного исполнения Manticore в режиме с недостаточными ограничениями, что означает, что символьному исполнению могут подвергаться отдельные функции. Это очень крутой функционал, поскольку он позволяет оценить поведение функций с заданными аргументами обособленно от всего приложения/системы. В нашем случае встроенный Manticore будет не очень полезен по той причине, что все исследуемые методы в проекте не зависят от подаваемых в аргументы значений. В остальных случаях этот функционал может очень пригодиться. В разделе "Specifying additional symbolic constraints" подробно описаны некоторые конструкции для создания собственных ограничений.



UsageFinder — автономное приложение в составе Jupyter Notebook для поиска уязвимостей, возникающих в результате неправильного использования внутренних или внешних API. Результатом его работы является ряд визуализаций тех методов, которые были выбраны для подробного анализа. Например, UsageFinder построит следующую таблицу вызовов для метода df_by_pointer(), в которой видны два вызова free с одним и тем же аргументом (зеленая ячейка):



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




Интерактивный блокнот, интегрированный в среду MATE, позволяет создавать кастомные запросы к CPG проекта на уже упомянутом предметно-ориентированном языке.



Здесь мы и остановимся. С учетом документации по API создадим свой запрос для поиска примитивного кейса с double-delete. Исполним в блокноте такой код:


delete_calls = session.query(cpg.Function).filter_by(name="_ZdaPv").one()
for call in delete_calls.callsites:
  filtered_calls = delete_calls.callsites.copy()
  filtered_calls.remove(call)
  path = (
      db.PathBuilder(cfl.ForwardCFGPath)
      .starting_at(lambda Node: Node.uuid == call.uuid)
      .stopping_at(lambda Node: Node.uuid.in_([c.uuid for c in filtered_calls]))
      .limited_to(2000)
      .build(cpg)
  )

  flow = (
      session.query(path)
      .join(cpg.Instruction, cpg.Instruction.uuid == path.target)
      .with_entities(cpg.Instruction)
  )
  visualize_ctxt(cpg, flow, "double-delete")        

Результат:


                  funcs.cpp:89:5:double_delete()
                  ==============================
                  88:   delete [] x;
double-delete --> 89:   delete [] x;
                  90:   }

В запросе мы использовали конструктор PathBuilder, который используется для задания необходимых условий для поиска желаемого пути в CPG-графе. Например, при помощи оператора starting_at можно задать условие, где должен начинаться путь. Аналогичный оператор stopping_at служит для указания, на каких узлах должны заканчиваться пути графа. Возможно странным может показаться название функции _ZdaPv — оно вовсе не похоже на operator delete[]. На этот счет вспоминаем, что MATE работает с LLVM IR, заглядываем в FlowFinder и убеждаемся, что вызов оператора действительно соответствует <function>:llvm-link:@_ZdaPv.


Попробуем усложнить наш запрос, добавив ряд проверок и дополнений, чтобы найти все ошибки double-free в нашем мини-проекте. Для этого напишем код на python, следуя схожей логике, что и при создания правила в Joern. То есть, сперва соберем обертки над free без внутренних переназначений указателя. Оценить это нам помогут узлы STORE/LOAD графа. Далее для каждого вызова free или обертки над ним отфильтруем такие, которые имеют один и тот же аргумент, убедимся, что между ними не было переназначений, в том числе и во внутренних вызовах. Правило получилось также объемным, поэтому было решено скрыть его под спойлером.


Правило для MATE
class extCall:
    def __init__(self, call, arg_idx):
        self.call = call
        self.arg_idx = arg_idx

def collectWrappers():    
    for call in frees.callsites:        
        operations = call.argument0.operand0.used_by
        operations.pop(0)
        for op in operations:   
            if op.opcode.name == "STORE":
                break;
        index = 0
        for arg in call.parent_block.parent_function.arguments:
            if call.argument0.operand0.variable == arg:
                for parent in call.parent_block.parent_function.callsites:
                    funcName = call.parent_block.parent_function.name
                    if funcName not in callNames:
                        callNames.append(funcName)
                    if extCall(parent, index) not in allCalls:
                        allCalls.append(extCall(parent, index))                        

            index+= 1

session.rollback()
with session.no_autoflush:
    allCalls = []
    callNames = []
    callNames.append("free")
    frees = session.query(cpg.Function).filter_by(name="free").one()
    for callsite in frees.callsites:
        allCalls.append(extCall(callsite, 0))
    collectWrappers()   

    for extObject in allCalls:
        call = extObject.call
        arg_idx = extObject.arg_idx
        argumentObj = getattr(call, "argument" + str(arg_idx))
        call_list = [c.call for c in allCalls]
        call_list.remove(call)  
        filtered_list = [c for c in call_list 
                         if getattr(c, "argument" + str(arg_idx)).operand0.variable == argumentObj.operand0.variable]
        if len(filtered_list) == 0:
            continue;

        for otherCall in filtered_list:
            for operation in argumentObj.operand0.used_by:
                opUuid = int(operation.uuid)             
                if opUuid > int(call.uuid) and opUuid < int(otherCall.uuid):
                    if operation.opcode.name == "STORE":
                        filtered_list.remove(otherCall)
                        break;                   
                    if operation.opcode.name == "LOAD" \
                    and operation.used_by[0].callee_operand.name not in callNames:
                        call_uid = int(operation.used_by[0].uuid)
                        load_uid = int(operation.uuid)
                        arg_num = len(operation.used_by[0].callee_operand.arguments)
                        idx = arg_num - (call_uid - load_uid)
                        arg = operation.used_by[0].callee_operand.arguments[idx]
                        internal_use = arg.used_by[0].operand1.used_by
                        internal_use.pop(0)
                        for op in internal_use:
                            if op.opcode.name == "STORE":
                                filtered_list.remove(otherCall)
                                break; break; continue;

        session.rollback()
        path = (
            db.PathBuilder(cfl.ForwardCFGPath)
            .starting_at(lambda Node: Node.uuid == call.uuid)
            .stopping_at(lambda Node: Node.uuid.in_([cs.uuid for cs in filtered_list]))
            .limited_to(2000)
            .build(cpg)
        )

        flow = (
            session.query(path)
            .join(cpg.Instruction, cpg.Instruction.uuid == path.target)
            .with_entities(cpg.Instruction)
        )

        visualize_ctxt(cpg, flow, "double-free here")

А вот какой результат оно выдаст
                     funcs.cpp:14:2:double_free()
                     ============================
                     13:    free(buf1);
double-free here --> 14:    free(buf1);
                     15:    }

                     funcs.cpp:41:2:df_by_pointer()
                     ==============================
                     40:    buf3 = (char *) malloc(SIZE);
double-free here --> 41:    free(buf2);
                     42:    free(buf3);

                     funcs.cpp:69:2:conditional_dfree()
                     ==================================
                     68:    }
double-free here --> 69:    free(ptr);
                     70:    free(ptr);

                     funcs.cpp:70:2:conditional_dfree()
                     ==================================
                     69:    free(ptr);
double-free here --> 70:    free(ptr);
                     71:    }

                     funcs.cpp:70:2:conditional_dfree()
                     ==================================
                     69:    free(ptr);
double-free here --> 70:    free(ptr);
                     71:    }

                     funcs.cpp:117:5:bad_goto()
                     ==========================
                     116:   free_me:
double-free here --> 117:   free(ptr);
                     118:   return;

                     funcs.cpp:57:2:df_with_wrappers()
                     =================================
                     56:    wrapper(buf1);
double-free here --> 57:    wrapper(buf1);
                     58:    }

MATE справился со всеми задачами, не выдав ни одного ложноположительного кейса. Отличный результат! В очередной раз убеждаемся, что работать ручками напрямую с CPG очень интересно, и, получается, довольно результативно. MATE предоставил нам много возможностей для качественного анализа и однозначно заслуживает популяризации.


Итоги


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


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


И напоследок опрос мнений! У нас в Digital Security SAST является неотъемлемой частью гибридного подхода в услугах по поиску уязвимостей.

Комментарии (8)


  1. ReadOnlySadUser
    01.12.2022 07:44

    Удивляет, что в опросе нельзя выбрать больше одного варианта) Мы вот используем сразу 3 из списка + неупомянутый вроде как отечественный SVACE , который на удивление хорош (только документация отстой).


    1. Nalen98
      01.12.2022 12:14
      +2

      Будем знать, но эту опцию опроса уже не изменить, к сожалению. Таковы правила Хабра


  1. foto_shooter
    01.12.2022 08:43
    +3

    Спасибо за статью.

    Интересно, что кол-во инструментов в духе "собери статический анализатор сам" растёт. Как я понял, по сути всё сводится к написанию рулсета (в совокупности с правилами отслеживания данных, а-ля синки и сорцы для taint-анализа).

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

    Например:

    • плагины для IDE;

    • удобство интеграции в CI;

    • возможности разметки false positives;

    • baselinig;

    • ...

    Даже если вернуться к рулсетам. Кажется, одно дело — писать их самим и самим же заниматься правками false positives и т. п., и другое — отдать всё это на откуп разработчикам анализатора.

    Отсюда и вопрос — насколько удобно использовать подобные инструменты на регулярной основе в рамках процесса разработки.

    Будет интересно почитать, если кто-нибудь поделится своим опытом. :)


    1. Nalen98
      01.12.2022 13:30
      +1

      Отличный комментарий.
      Да, статья, можно сказать, написана с колокольни исследователей безопасности. Большинство инструментов в обзоре были созданы как раз с целью помогать ресечерам находить интересные/уязвимые места в исходном/декомпилированном коде. Эти инструменты едва ли будут полезны в процессе разработки, ибо это не главная цель их использования. Однако CodeQL и Semgrep имеют гораздо более широкий спектр применения и могут пригодиться всем сторонам.

      Подробнее об интеграции Semgrep в CI/CD

      Подробнее об интеграции CodeQL в CI/CD


    1. vitaly_il1
      01.12.2022 21:08

      Применяются очень широко. Насколько вижу (я DevOps фрилансер из Израиля) SonarCloud (+SonarQube) самый популярный. Он очень легко интегрируется с GitHub и т.п.
      Используют SAST и по требованию проверяльщиков безопасности, и/или просто руководство хочет "правильный" код.

      Кстати поэтому мне жаль, что SonarCloud не вошел в разбор. Насколько понимаю, многие его сканеры используются и в других продуктах - например в Codacy.

      Насчет  "собери статический анализатор сам" - ИМХО, как и во многих случаях, "в принципе возможно". Но когда мы хотим  GUI, легкую интеграцию в CI и т.п., то лучше выбрать сервис, пусть даже и платный.


  1. estet
    01.12.2022 11:19
    +1

    Неоспоримый плюс semgrep перед другими подобными инструментами - это поддержка большого количества языков. Не нашел другого варианта для написания правил для Lua кроме как semgrep.


  1. webhamster
    02.12.2022 13:43

    Игрушечный проект расположился в github-репозитории "double-free-samples".

    А что за странности с форматированием?

    https://raw.githubusercontent.com/Nalen98/double-free-samples/main/funcs.cpp

    Так и задумывалось? Зачем такие отступы?

    Или в битве tabs vs spaces опять победили tabs?


    1. Nalen98
      02.12.2022 18:16

      Fixed.