Pull to refresh

Статический анализ printf-like функций в Си при помощи libclang

Reading time 11 min
Views 8.4K
По сравнению со многими современными языками язык Си зачастую кажется крайне примитивным и небезопасным. И одной из частых претензий к языку является невозможность доступа из кода в его же внутреннее представление. В других языках это традиционно осуществляется механизмами, вроде reflections, и довольно удобно в применении.

Тем не менее, с появлением libclang, можно писать собственные анализаторы и генераторы кода прямо в compile time, устраняя достаточно большое множество проблем на ранних этапах работы. Сочетание инструментов статического анализа общего плана (coverity, clang-scan), инструментов анализа для конкретного проекта, а также дисциплины написания кода позволяет намного улучшить качество и безопасность кода, написанного на Си. Конечно, это не даст гарантий, каких дает haskell или даже rust, но позволяет существенно оптимизировать процесс разработки, особенно в случае, когда переписывать огромный проект на другом языке является нереальной задачей.

В данной статье я хотел бы поделиться опытом создания плагина статического анализа format argument для функций, похожих на printf. В ходе написания плагина, мне пришлось очень много рыться в исходниках и doxygen документации libclang, поэтому я счел полезным сделать некоторый обзор для тех, кто хочет ступить на этот тернистый путь, но пока еще не уверен в целесообразности траты времени на сбор информации. В статье не будет картинок, и даже картинок блюющих единорогов, простите.

Постановка задачи


Проблема анализа printf like функций стояла у меня в проекте (https://rspamd.com) довольно давно: стандартный printf из libc не устраивал меня по многим причинам:

  • при печати в буфер, printf(3) пытается распарсить всю format string целиком, даже если она включает огромные null-terminated строки, а буфер назначения очень мал: snprintf(buf, 16, "%s", str), где str — очень длинная строка; такое поведение было мне ни к чему
  • printf крайне плохо понимает fixed length integers (uint32_t, uitn64_t)
  • хотелось печатать собственные структуры данных, например, fixed length strings без '\0' в конце
  • хотелось более «продвинутых» флагов форматирования: hex encoding, human readable integers и так далее
  • хотелось уметь печатать в собственные структуры данных, например, автоматически расширяемые строки


Поэтому в свое время я взял printf из nginx и адаптировал его для своих задач. Пример кода можно посмотреть тут. У данного подхода есть один недостаток — он совершенно отключает работу стандартного анализатора query string из компилятора, а статические анализаторы общего плана неспособны понять, какие аргументы что значат. Однако эта задача идеально решается при помощи абстрактного синтаксического дерева (AST) компилятора, доступ к которому предоставляется через libclang.
Плагин обработки AST должен выполнять следующие задачи:

  • Парсинг query string и извлечение из нее всех '%' аргументов
  • Сравнение количества аргументов в query string и переданных функции
  • Возможность проверки типа каждого аргумента (включая сложные типы)
  • Возможность проверки функций, которые принимают query string в разных позициях (например, printf/fprintf/snprintf)


Компиляция и работа с плагином


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

Первой проблемой, с которой я столкнулся, было то, что разные версии llvm предоставляют разные API. Кроме того, например, osx сборка llvm, установленная через macports, оказалась неработоспособной от слова «никак». Поэтому, я просто установил llvm на свою linux песочницу и работал конкретно с этой версией — 3.7. Впрочем, данный код должен также работать и на 3.6+.

Второй проблемой оказалась система сборки. В моем проекте используется cmake, поэтому я хотел, конечно же, использовать его для построения плагина. Идея была в том, что при включенной опции собирать плагин, а затем уже использовать его для сборки остальной части кода. В первую очередь, как заведено с cmake, пришлось писать пакет для нахождения в системе llvm и libclang, расстановку CXX флагов (например, включение c++11 стандарта). К сожалению, из-за неработоспособности llvm в osx, это напрочь отломало интеграцию с замечательной IDE CLion, которую я использую для повседневной работы, поэтому писать код пришлось без дополнений и прочих удобств, предлагаемых IDE.

Компиляция плагина проблем особых не вызвала:

FIND_PACKAGE(LLVM REQUIRED)

SET(CLANGPLUGINSRC plugin.cc printf_check.cc)

ADD_LIBRARY(rspamd-clang SHARED ${CLANGPLUGINSRC})
SET_TARGET_PROPERTIES(rspamd-clang PROPERTIES
            COMPILE_FLAGS "${LLVM_CXX_FLAGS} ${LLVM_CPP_FLAGS} ${LLVM_C_FLAGS}"
            INCLUDE_DIRECTORIES ${LIBCLANG_INCLUDE_DIR}
            LINKER_LANGUAGE CXX)
TARGET_LINK_LIBRARIES(rspamd-clang ${LIBCLANG_LIBRARIES})
LINK_DIRECTORIES(${LLVM_LIBRARY_DIRS})

А вот с включением его для работы с остальным кодом возникли проблемы. Во-первых, cmake проявлял недюжинный искусственный интеллект, группируя зачем-то опции компилятора, превращая -Xclang opt1 -Xclang opt2 в -Xclang opt1 opt2, что напрочь ломало компиляцию. Выход нашел через прямую установку CMAKE_C_FLAGS:

IF (ENABLE_CLANG_PLUGIN MATCHES "ON")
    SET(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Xclang -load -Xclang ${CMAKE_CURRENT_BINARY_DIR}/../clang-plugin/librspamd-clang.so -Xclang -add-plugin -Xclang rspamd-ast")
ENDIF ()

Как вы видите, пришлось явно указать путь до полученной библиотеки, что потенциально ломало работу системы под osx (где используется .dylib вместо .so), но это было малозначимым фактором из-за неработоспособности llvm под osx. Второй проблемой явилось то, что если указать -Xclang -plugin, как рекоммендуется почти во всех примерах, то clang перестает компилировать исходники (то есть, он не генерирует объектные файлы), выполняя исключительно анализ. Выходом из ситуации явилась замена -Xclang -plugin на -Xclang -add-plugin, что нашлось после некоторой медитации над выдачей гугла.

Написание плагина


В данной части я не хотел бы сильно акцентировать внимание на основах создания плагинов — этому посвящено довольно много материалов. Вкратце, плагин создается при помощи статического метода clang::FrontendPluginRegistry::Add, который регистрирует плагин для clang. Данный метод является шаблонным, и он принимает тип класса, который наследуется от clang::PluginASTAction и определяет в нем нужные методы:

class RspamdASTAction : public PluginASTAction {
protected:
    std::unique_ptr <ASTConsumer> CreateASTConsumer (CompilerInstance &CI,
            llvm::StringRef) override
    {
        return llvm::make_unique<RspamdASTConsumer> (CI);
    }

    bool ParseArgs (const CompilerInstance &CI,
            const std::vector <std::string> &args) override
    {
        return true;
    }

    void PrintHelp (llvm::raw_ostream &ros)
    {
        ros << "Nothing here\n";
    }
};

static FrontendPluginRegistry::Add <rspamd::RspamdASTAction>
        X ("rspamd-ast", "rspamd ast checker");

Основным интересным методом является метод CreateASTConsumer, который говорит clang'у, что полученный объект нужно вызвать на этапе, когда компилятор выполнил трансляцию кода в синтаксическое дерево. Вся дальнейшая работа ведется в ASTConsumer, в котором в свою очередь определен метод HandleTranslationUnit, который, собственно, получает контекст синтаксического дерева. CompilerInstance используется для управления компилятором, например, для генерации ошибок и предупреждений, что крайне удобно при работе с плагином. Целиком ASTConsumer описан так:

class RspamdASTConsumer : public ASTConsumer {
    CompilerInstance &Instance;

public:
    RspamdASTConsumer (CompilerInstance &Instance)
            : Instance (Instance)
    {
    }

    void HandleTranslationUnit (ASTContext &context) override
    {
        rspamd::PrintfCheckVisitor v(&context, Instance);
        v.TraverseDecl (context.getTranslationUnitDecl ());
    }
};

Здесь мы создаем ASTVisitor, который посещает узлы дерева, и выполняем обход дерева компиляции. В данном классе, собственно, и делается вся работа по анализу вызова функций. Определен этот класс предельно просто (используя pimpl идиому):

class PrintfCheckVisitor : public clang::RecursiveASTVisitor<PrintfCheckVisitor> {
    class impl;
    std::unique_ptr<impl> pimpl;

public:
    PrintfCheckVisitor (clang::ASTContext *ctx, clang::CompilerInstance &ci);
    virtual ~PrintfCheckVisitor (void);
    bool VisitCallExpr (clang::CallExpr *E);
};

Основная мысль — наследование от clang::RecursiveASTVisitor, выполняющего обход дерева, и определение метода VisitCallExpr, который вызывается при нахождении в дереве вызова функции. В данном методе (проксированном в pimpl) выполняется основная работа по разбору функций и их аргументов. Начинается метод так:

bool VisitCallExpr (CallExpr *E)
{
    auto callee = dyn_cast<NamedDecl> (E->getCalleeDecl ());
    if (callee == NULL) {
        llvm::errs () << "Bad callee\n";
        return false;
    }

    auto fname = callee->getNameAsString ();

    auto pos_it = printf_functions.find (fname);

    if (pos_it != printf_functions.end ()) {

В данном кусочке кода, мы получаем определение (декларацию) функции из выражения и извлекаем имя функции. Дальше мы ищем в хеше printf_functions, интересует ли нас данная функция:

printf_functions = {
    {"rspamd_printf",               0},
    {"rspamd_default_log_function", 4},
    {"rspamd_snprintf",             2},
    {"rspamd_fprintf",              1}
};

Число означает позицию query string в аргументах. Далее, если функция нас интересует, мы извлекаем query string и анализируем его (для этого я написал автомат, который несколько за рамками данной статьи):

const auto args = E->getArgs ();
auto pos = pos_it->second;
auto query = args[pos];

if (!query->isEvaluatable (*pcontext)) {
    print_warning (std::string ("cannot evaluate query"),
            E, this->pcontext, this->ci);
    return false;
}

clang::Expr::EvalResult r;

if (!query->EvaluateAsRValue (r, *pcontext)) {
    print_warning (std::string ("cannot evaluate rvalue of query"),
            E, this->pcontext, this->ci);
    return false;
}

auto qval = dyn_cast<StringLiteral> (
        r.Val.getLValueBase ().get<const Expr *> ());
if (!qval) {
    print_warning (std::string ("bad or absent query string"),
            E, this->pcontext, this->ci);
    return false;
}

В этом фрагменте важно то, что мы вначале пытаемся вычислить query string, если это возможно. Это полезно, например, если query string у нас формируется при помощи какого-либо выражения. К сожалению, работа со значениями в libclang делается достаточно трудно: нужно взять выражение, оценить его (EvaluateAsRValue), взять результат, который уже можно преобразовать в LValue, и далее в StringLiteral. Если вычисление не нужно, то можно брать непосредственно Expr * и приводить его к StringLiteral, что сильно упрощает код.

Далее я анализировал query string и получал вектор таких структур:

struct PrintfArgChecker {
private:
    arg_parser_t parser;
public:
    int width;
    int precision;
    bool is_unsigned;
    ASTContext *past;
    CompilerInstance *pci;

    PrintfArgChecker (arg_parser_t _p, ASTContext *_ast, CompilerInstance *_ci) :
            parser (_p), past (_ast), pci(_ci)
    {
        width = 0;
        precision = 0;
        is_unsigned = false;
    }

    virtual ~PrintfArgChecker ()
    {
    }

    bool operator() (const Expr *e)
    {
        return parser (e, this);
    }
};

Каждая такая структура содержит метод вызова, который принимает аргумент (Expr *) и проверяет его тип на соответствие заданному. Дальше мы просто проверяем все аргументы после query string на соответствие типам:

if (parsers->size () != E->getNumArgs () - (pos + 1)) {
    std::ostringstream err_buf;
    err_buf << "number of arguments for " << fname
            << " missmatches query string '" << qval->getString ().str ()
            << "', expected " << parsers->size () << " args"
            << ", got " << (E->getNumArgs () - (pos + 1)) << " args";
    print_error (err_buf.str (), E, this->pcontext, this->ci);

    return false;
}
else {
    for (auto i = pos + 1; i < E->getNumArgs (); i++) {
        auto arg = args[i];

        if (arg) {
            if (!parsers->at (i - (pos + 1)) (arg)) {
                return false;
            }
        }
    }
}

Функция print_error интересна тем, что она умеет печатать ошибку компиляции и прекращать процесс компиляции. Делается это через CompilerInstance, но довольно неочевидным способом:

static void
print_error (const std::string &err, const Expr *e, const ASTContext *ast,
        CompilerInstance *ci)
{
    auto loc = e->getExprLoc ();
    auto &diag = ci->getDiagnostics ();
    auto id = diag.getCustomDiagID (DiagnosticsEngine::Error,
            "format query error: %0");
    diag.Report (loc, id) << err;
}

Соответственно, для вывода предупреждения нужно использовать DiagnosticsEngine::Warning.

Анализ типов выполняется, в целом, двумя методами. Один умеет проверять встроенные типы, например, long/int итд, а второй — сложные типы, например, структуры. Для проверки простых типов используется clang::BuiltinType::Kind, который определяет все известные клангу типы. Возможные значения можно поискать в /usr/include/clang/AST/BuiltinTypes.def (для линукса). Тут есть две тонкости:

  • Fixed size int могут по-разному совпадать с built-in type, поэтому надо делать проверки вида if (sizeof (int32_t) == sizeof (int)) {...} if (sizeof (int32_t) == sizeof (long)) {...}
  • Аргументы могут быть алиасами на другие типы, поэтому вначале их надо от этих алиасов избавить, например typedef my_int int

Итоговая функция проверки простых типов выглядит так:

static bool
check_builtin_type (const Expr *arg, struct PrintfArgChecker *ctx,
        const std::vector <BuiltinType::Kind> &k, const std::string &fmt)
{
    auto type = arg->getType ().split ().Ty;

    auto desugared_type = type->getUnqualifiedDesugaredType ();

    if (!desugared_type->isBuiltinType ()) {
        print_error (
                std::string ("not a builtin type for ") + fmt + " arg: " +
                        arg->getType ().getAsString (),
                arg, ctx->past, ctx->pci);
        return false;
    }

    auto builtin_type = dyn_cast<BuiltinType> (desugared_type);
    auto kind = builtin_type->getKind ();
    auto found = false;

    for (auto kk : k) {
        if (kind == kk) {
            found = true;
            break;
        }
    }

    if (!found) {
        print_error (
                std::string ("bad argument for ") + fmt + " arg: " +
                arg->getType ().getAsString () + ", resolved as: " +
                builtin_type->getNameAsCString (ctx->past->getPrintingPolicy ()),
                arg, ctx->past, ctx->pci);
        return false;
    }

    return true;
}

Как видно, для снятия алиасов используется метод getUnqualifiedDesugaredType, а для получения типа выражения из выражения — arg->getType(). Но данный метод возвращает qualified type (например, включая спецификатор const), что для данной задачи не нужно, поэтому qualified type разделяется split, а из получившейся структуры берется только чистый тип.

Для сложных типов необходимо выделить имя структуры, перечисления или объединения. Функция проверки выглядит так:

static bool
check_struct_type (const Expr *arg, struct PrintfArgChecker *ctx,
        const std::string &sname, const std::string &fmt)
{
    auto type = arg->getType ().split ().Ty;

    if (!type->isPointerType ()) {
        print_error (
                std::string ("bad string argument for %s: ") +
                        arg->getType ().getAsString (),
                arg, ctx->past, ctx->pci);
        return false;
    }

    auto ptr_type = type->getPointeeType ().split ().Ty;
    auto desugared_type = ptr_type->getUnqualifiedDesugaredType ();

    if (!desugared_type->isRecordType ()) {
        print_error (
                std::string ("not a record type for ") + fmt + " arg: " +
                        arg->getType ().getAsString (),
                arg, ctx->past, ctx->pci);
        return false;
    }

    auto struct_type = desugared_type->getAsStructureType ();
    auto struct_decl = struct_type->getDecl ();
    auto struct_def = struct_decl->getNameAsString ();

    if (struct_def != sname) {
        print_error (std::string ("bad argument '") + struct_def + "' for "
                + fmt + " arg: " +
                arg->getType ().getAsString (),
                arg, ctx->past, ctx->pci);
        return false;
    }

    return true;
}

Так как мы предполагаем, что аргумент у нас не структура, а указатель на нее, то вначале мы определяем тип указателя через type->getPointeeType().split().Ty. Затем выполняем desugaring и находим декларацию типа: struct_type->getDecl(). После чего проверки делаются достаточно тривиальным способом.

Результаты


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

[ 44%] Building C object src/CMakeFiles/rspamd-server.dir/libutil/map.c.o
src/libutil/map.c:906:46: error: format query error: bad argument for %z arg: guint, resolved as: unsigned int
                msg_info_pool ("read hash of %z elements", g_hash_table_size
                                                           ^
src/libutil/logger.h:190:9: note: expanded from macro 'msg_info_pool'
        __VA_ARGS__)
        ^
1 error generated.


Так и серьезные проблемы:
[ 45%] Building C object src/CMakeFiles/rspamd-server.dir/libserver/protocol.c.o
src/libserver/protocol.c:373:45: error: format query error: bad argument 'f_str_tok' for %V arg: rspamd_ftok_t *
                                        msg_err_task ("bad from header: '%V'", h->value);
                                                                               ^
src/libutil/logger.h:164:9: note: expanded from macro 'msg_err_task'
        __VA_ARGS__)
        ^
1 error generated.
[ 44%] Building C object src/CMakeFiles/rspamd-server.dir/libstat/tokenizers/osb.c.o
src/libstat/tokenizers/osb.c:128:48: error: format query error: bad string argument for %s: gsize
                                        msg_warn ("siphash key is too short: %s", keylen);
                                                                                  ^
src/libutil/logger.h:145:9: note: expanded from macro 'msg_warn'
        __VA_ARGS__)
        ^
1 error generated.

А также проблемы с числом аргументов:

[ 46%] Building C object src/CMakeFiles/rspamd-server.dir/libmime/mime_expressions.c.o
src/libmime/mime_expressions.c:780:3: error: format query error: number of arguments for rspamd_default_log_function missmatches query string
      'process test regexp %s for url %s returned FALSE', expected 2 args, got 1 args
                msg_info_task ("process test regexp %s for url %s returned FALSE",
                ^
src/libutil/logger.h:169:30: note: expanded from macro 'msg_info_task'
#define msg_info_task(...)   rspamd_default_log_function (G_LOG_LEVEL_INFO, \
                             ^
1 error generated.

Всего было найдено 47 проблем с format query, что можно увидеть в следующем коммите: http://git.io/v8Nyv

Код плагина доступен здесь.
Tags:
Hubs:
+16
Comments 5
Comments Comments 5

Articles