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

    По сравнению со многими современными языками язык Си зачастую кажется крайне примитивным и небезопасным. И одной из частых претензий к языку является невозможность доступа из кода в его же внутреннее представление. В других языках это традиционно осуществляется механизмами, вроде 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

    Код плагина доступен здесь.

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 5

      +1
      Строку формата не всегда же можно вычислить на этапе компиляции, она может зависеть от каких-то данных. Что вы делаете в этом случае? Запрещаете такие строки и выдаёте ошибку или игнорируете?
        +1
        Уже сам нашёл в коде: выдаётся предупреждение.
          +1
          Да, таких случаев у меня получилось аж две штуки в проекте. И каждый, я думаю, надо рефакторить на самом деле.
        0
        Извиняюсь за оффтоп, но как давно вы используете CLion? С удовольствием использую IDE от Jetbrains для Java и Ruby. Есть огромное желание использовать CLion для работы, но без удаленной сборки и дебага это просто нереально. Приходится работать с NetBeans. Как вы решили эту проблему?
          0
          Я не использую IDE для сборки и дебага — мне хватает для этого gdb — поэтому я вряд ли могу что-то сказать по этому вопросу.

        Only users with full accounts can post comments. Log in, please.