Skip to content

Гибридный фаззинг проекта jsoncpp с помощью Sydr и libfuzzer с LPM

Georgy Savidov edited this page Dec 10, 2024 · 3 revisions

Гибридный фаззинг Sydr-Fuzz для libfuzzer с библиотекой LPM на примере jsoncpp

Данный гайд демонстрирует применение фреймворка гибридного фаззинга Sydr-Fuzz с двусторонним преобразованием данных между символьным интерпретатором Sydr и libfuzzer, использующим libprotobuf-mutator (LPM). Предполагается, что читатель понимает, что такое гибридный фаззинг с DSE, имеет опыт написания фаззинг-целей для libfuzzer и представляет, как выглядит базовый набор исполняемых файлов для фаззинг-цели в случае Sydr-Fuzz.

Введение

Как работает libfuzzer с библиотекой LPM

Библиотека libprotobuf-mutator предназначена для фаззинга с учётом структуры входных данных. То есть у данных предполагается наличие некоторого формата (например, json, xml, png, пр.), представимого с помощью протокола сериализации protocol buffer (или protobuf). Учёт формата данных для мутационного фаззингa libfuzzer возможен при использовании кастомного генератора мутаций (например, libprotobuf-mutator для формата protobuf). Потенциально это позволяет на основе знаний о покрытии генерировать тестовые данные, которые имеют больше шансов успешно пройти парсинг формата и, как следствие, затрагивают более глубокие пути в программе.

В случае LPM целевой формат описывается в виде набора структур, или "сообщений" (Message), содержащих список полей, которые могут принимать различные значения соответствующего типа (пример, см. Figure 1). Такое описание-спецификация находится в специальном файле (или файлах) с расширением .proto.

Изменения, генерируемые фаззером, применяются к содержимому полей protobuf-сообщений. Затем модифицированное сообщение преобразуется к требуемому формату, с которым работает целевое ПО. Данное преобразование реализуется пользовательским кодом в рамках написания фаззинг-цели.

Таким образом, помимо задачи написания фаззинг-цели с вызовом тестируемого ПО, для работы libfuzzer с LPM аналитику требуется:

  • написать спецификацию целевого формата в виде .proto-файла;
  • реализовать конвертацию из protobuf-сообщений в целевой формат;
  • осуществить сборку фаззинг-цели с библиотекой LPM и кодом для конвертации.

Не считая вызова функциональности для конвертации, содержание фаззинг-цели будет отличаться незначительно: вместо функции LLVMFuzzerTestOneInput предлагается использовать макрос DEFINE_PROTO_FUZZER, в теле которого определяется функция TestOneProtoInput, которая получает в качестве входных данных protobuf-сообщение. Как можно заметить, корпус фаззера также состоит из файлов, содержащих protobuf-сообщения. Конвертация в целевой формат запускается внутри фаззинг-цели, и её результат в корпус не сохраняется.

Гибридный фаззинг с помощью Sydr и libfuzzer с LPM

Для включения в вышеописанную систему символьного интерпретатора Sydr в данном гайде предлагается:

  • использовать для запуска Sydr фаззинг-цель классического формата (без макроса LPM);
  • дополнительно реализовать обратную конвертацию из целевого формата в protobuf-сообщение;
  • собрать вспомогательную утилиту двусторонней конвертации (packer), запуск которой происходит отдельно от запуска фаззинг-цели.

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

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

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

Создание набора фаззинг-целей и утилиты конвертации на примере проекта jsoncpp из набора OSS-Sydr-Fuzz

Написание фаззинг-цели

Рассмотрим простую фаззинг-цель с вызовом функции parse из проекта jsoncpp.

// Copyright 2007-2019 The JsonCpp Authors
// Distributed under MIT license, or public domain if desired and
// recognized in your jurisdiction.
// See file LICENSE for detail or copy at http://jsoncpp.sourceforge.net/LICENSE

#include "fuzz.h"

#include <cstdint>
#include <json/config.h>
#include <json/json.h>
#include <memory>
#include <string>

namespace Json {
class Exception;
}

extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
  Json::CharReaderBuilder builder;

  if (size < sizeof(uint32_t)) {
    return 0;
  }

  const uint32_t hash_settings = static_cast<uint32_t>(data[0]) |
                                 (static_cast<uint32_t>(data[1]) << 8) |
                                 (static_cast<uint32_t>(data[2]) << 16) |
                                 (static_cast<uint32_t>(data[3]) << 24);
  data += sizeof(uint32_t);
  size -= sizeof(uint32_t);

  builder.settings_["failIfExtra"] = hash_settings & (1 << 0);
  builder.settings_["allowComments_"] = hash_settings & (1 << 1);
  builder.settings_["strictRoot_"] = hash_settings & (1 << 2);
  builder.settings_["allowDroppedNullPlaceholders_"] = hash_settings & (1 << 3);
  builder.settings_["allowNumericKeys_"] = hash_settings & (1 << 4);
  builder.settings_["allowSingleQuotes_"] = hash_settings & (1 << 5);
  builder.settings_["failIfExtra_"] = hash_settings & (1 << 6);
  builder.settings_["rejectDupKeys_"] = hash_settings & (1 << 7);
  builder.settings_["allowSpecialFloats_"] = hash_settings & (1 << 8);
  builder.settings_["collectComments"] = hash_settings & (1 << 9);
  builder.settings_["allowTrailingCommas_"] = hash_settings & (1 << 10);

  std::unique_ptr<Json::CharReader> reader(builder.newCharReader());

  Json::Value root;
  const auto data_str = reinterpret_cast<const char*>(data);
  try {
    reader->parse(data_str, data_str + size, &root, nullptr);
  } catch (Json::Exception const&) {
  }
  // Whether it succeeded or not doesn't matter.
  return 0;
}

Аналогичная реализация фаззинг-цели с использованием LPM выглядит следующим образом.

// Copyright 2020 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////////

#include "json.pb.h"
#include "json_proto_converter.h"
#include "libprotobuf-mutator/src/libfuzzer/libfuzzer_macro.h"

#include <cstdint>
#include <json/config.h>
#include <json/json.h>
#include <memory>
#include <string>
#include <iostream>
#include <cstddef>
#include <stdint.h>
#include <cstring>
#include <iostream>

namespace Json {
class Exception;
}

void FuzzJson(std::string data_str, int32_t hash_settings) {
  Json::CharReaderBuilder builder;

  builder.settings_["failIfExtra"] = hash_settings & (1 << 0);
  builder.settings_["allowComments_"] = hash_settings & (1 << 1);
  builder.settings_["strictRoot_"] = hash_settings & (1 << 2);
  builder.settings_["allowDroppedNullPlaceholders_"] = hash_settings & (1 << 3);
  builder.settings_["allowNumericKeys_"] = hash_settings & (1 << 4);
  builder.settings_["allowSingleQuotes_"] = hash_settings & (1 << 5);
  builder.settings_["failIfExtra_"] = hash_settings & (1 << 6);
  builder.settings_["rejectDupKeys_"] = hash_settings & (1 << 7);
  builder.settings_["allowSpecialFloats_"] = hash_settings & (1 << 8);
  builder.settings_["collectComments"] = hash_settings & (1 << 9);
  builder.settings_["allowTrailingCommas_"] = hash_settings & (1 << 10);

  std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
  
  const char* begin = data_str.c_str();
  const char* end = begin + data_str.length();

  Json::Value root;
  try {
    reader->parse(begin, end, &root, nullptr);
  } catch (Json::Exception const&) {
  }
}

DEFINE_PROTO_FUZZER(const json_proto::JsonParseAPI &json_proto) {
  json_proto::JsonProtoConverter converter;
  std::string data_str = converter.Convert(json_proto.starter());
  int32_t hash_settings = json_proto.settings();
  FuzzJson(data_str, hash_settings);
}

Как можно заметить, основная функциональность по вызову целевого ПО была вынесена в функцию FuzzJson. Вместо обычных данных фаззер передает мутированное protobuf-сообщение json_proto типа const json_proto::JsonParseAPI, из которого перед вызовом FuzzJson была сконвертирована строкаdata_str. Кроме того, можно увидеть дополнительные header-файлы: json.pb.h, json_proto_converter.h, а также нужный здесь для использования макроса libprotobuf-mutator/src/libfuzzer/libfuzzer_macro.h из библиотеки LPM.

Файл json.pb.h является автоматически сгенерированным (его содержание читатель может рассмотреть, собрав Docker-контейнер для проекта). Появляется он при компиляции спецификации json.proto. В ней содержатся различные типы сообщений, включая "заглавное" сообщение типа JsonParserAPI и остальные типы, используемые как поля других сообщений. Помимо типа поле обладает дополнительной характеристикой кардинальности, указывающей возможное количество таких полей в сообщении.

syntax = "proto2";

package json_proto;

message JsonParseAPI {
  required int32 settings = 1;
  required JsonStarter starter = 2;
}

message JsonStarter {
  oneof value {
    // object list: one or multiple json objects
    JsonObjectList object_list_value = 1;

    // array: an array of values
    ArrayValue array_value = 2;
  }
}

message JsonObject {
  required string name = 2;
  required JsonValue value = 3;
}

message JsonObjectList {
  repeated JsonObject object_value = 1;
}

message JsonValue {
...

Таким образом, с помощью данной спецификации файл в формате json:

{
    "list" : {
        "id" : 1 ,
        "name" : "Bobby"
    }
}

можно представить в качестве следующего набора вложенных сообщений:

settings: 1
starter {
  object_list_value {
    object_value {
      name: "list"
      value {
        object_list_value {
          object_value {
            name: "id"
            value {
              number_value {
                integer_value {
                  value: 1
                }
              }
            }
          }
          object_value {
            name: "name"
            value {
              string_value {
                value: "Bobby"
              }
            }
          }
        }
      }
    }
  }
}

Возвращаясь к недостающим частям фаззинг-цели, можно найти объявление функции Convert в файле json_proto_converter.h (а также соответствующий код json_proto_converter.cc перевода protobuf-сообщений в json-строку). Данный код конвертации автору фаззинг-цели для LPM требуется написать самостоятельно.

// Copyright 2020 Google Inc.
// Modifications copyright (C) 2024 ISP RAS
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////////

#ifndef JSON_PROTO_CONVERTER_H_
#define JSON_PROTO_CONVERTER_H_

#include <sstream>
#include <string>

#include "json.pb.h"

namespace json_proto {

class JsonProtoConverter {
 public:
  std::string Convert(const json_proto::JsonStarter&);

 private:
  std::stringstream data_;

  void AppendArray(const json_proto::ArrayValue&);
  void AppendNumber(const json_proto::NumberValue&);
  void AppendObject(const json_proto::JsonObject&);
  void AppendObjectList(const json_proto::JsonObjectList&);
  void AppendStarter(const json_proto::JsonStarter&);
  void AppendValue(const json_proto::JsonValue&);
};

}  // namespace json_proto

#endif  // TESTING_LIBFUZZER_PROTO_JSON_PROTO_CONVERTER_H_

Так как json_proto_converter.cc не добавляет пробельные символы при восстановлении формата json, результатом вызова функции Convert для protobuf-сообщения из предыдущего примера будет:

{"list":{"id":1,"name":"Bobby"}}

Написание утилиты двусторонней конвертации (packer)

В Sydr-Fuzz packer-утилита вызывается при обмене входными данными между символьным интерпретатором Sydr и libfuzzer. Общая схема обмена такова: на основе метрик libfuzzer из корпуса выбирается файл для запуска Sydr, после анализа которого все полученные выходные json-файлы от Sydr преобразуются и добавляются в корпус libfuzzer в виде protobuf-сообщений. Таким образом утилита должна поддерживать двустороннее преобразование между json и protobuf, чтобы каждый инструмент получал входные данные в нужном формате.

Получение файла с входными данными от libfuzzer для запуска Sydr.

Как было отмечено ранее, в корпусе фаззера содержатся файлы с protobuf-сообщениями. Для запуска Sydr, выбранный файл нужно сконвертировать в json. Внимательный читатель помнит, что подобная операция уже выполняется в коде фаззинг-цели для работы с LPM с помощью функции Convert. Замечательно, значит, можно переиспользовать уже готовый код конвертации. Кроме того, чтобы избавиться от стандартных операций чтения и записи в файл, при написании утилиты можно воспользоваться аналогом LPM-интерфейса в виде макроса DEFINE_CONVERT_PB, в теле которого предлагается определить функцию std::string ConvertOneProtoInput(const json_proto::JsonParseAPI &msg). В качестве возвращаемого значения для данной функции ожидается строка, которая будет сохранена в виде json-файла для последующего запуска Sydr. Выглядит это следующим образом:

// embodies std::string ConvertOneProtoInput(arg)
// here arg = 'const json_proto::JsonParseAPI &msg'
DEFINE_CONVERT_PB(const json_proto::JsonParseAPI &msg) {
  // Convert Message -> string
  json_proto::JsonProtoConverter converter;
  auto starter = msg.starter();
  std::string data_str = converter.Convert(starter);
  int32_t hash_settings = msg.settings();

  // Pack the data extracted from the message.
  char res[4];
  res[0] = static_cast<char>(hash_settings & 0xff);
  hash_settings = hash_settings >> 8;
  res[1] = static_cast<char>(hash_settings & 0xff);
  hash_settings = hash_settings >> 8;
  res[2] = static_cast<char>(hash_settings & 0xff);
  hash_settings = hash_settings >> 8;
  res[3] = static_cast<char>(hash_settings & 0xff);

  std::string res_str = {reinterpret_cast<const char*>(res), 4};
  res_str += data_str;

  return res_str;
}

В целом ничего нового здесь не появилось.

Преобразование выходного файла Sydr для добавления в корпус libfuzzer.

Далее можно использовать второй макрос DEFINE_CONVERT_DATA. Здесь входные данные будут получены в качестве строки std::string data, которую следует преобразовать в protobuf-сообщение. Может показаться, что она взялась из ниоткуда, но на деле это аргумент очередной прячущейся за макросом функции void ConvertOneDataInput(json_proto::JsonParseAPI &msg, std::string data).

Для реализации данной функции понадобятся методы конструирования protobuf-сообщений, в случае C++ это в основном методы аллокации памяти google::protobuf::Arena (например, set_allocated_<field_name> или AddAllocated для добавления значений в поля). После парсинга строки, который здесь рекурсивно выполняется вспомогательными функциями, заполняем соответствующие поля сообщения msg (в данном случае, с помощью msg.set_settings(1) и msg.set_allocated_starter(st)). Так как это не вполне строгий парсер json, а, скорее, средство для оркестрации разнородных корпусов при фаззинге, то пробельные символы во всей строке data удаляются ради упрощения.

// embodies void ConvertOneDataInput(PackerProtoType &msg, std::string data)
// here PackerProtoType = 'json_proto::JsonParseAPI'
DEFINE_CONVERT_DATA(json_proto::JsonParseAPI &msg, std::string data) {
  // Strip space symbols in given json data string.
  data.erase(std::remove(data.begin(), data.end(), '\b'), data.end());
  data.erase(std::remove(data.begin(), data.end(), '\n'), data.end());
  data.erase(std::remove(data.begin(), data.end(), '\r'), data.end());
  data.erase(std::remove(data.begin(), data.end(), '\t'), data.end());
  data.erase(std::remove(data.begin(), data.end(), '\f'), data.end());
  data.erase(std::remove(data.begin(), data.end(), ' '), data.end());
  size_ = data.size();
  jstr_ = data.c_str();
  // Skip settings bytes if this doesn't look like valid json start.
  if (size_ > 4 && jstr_[0] != '{' && jstr_[0] != '[') {
    size_ -= sizeof(uint32_t);
    jstr_ += sizeof(uint32_t);
  }

  // Parse Json
  std::string n(jstr_, size_);
  google::protobuf::Arena arena;
  json_proto::JsonStarter *st = new json_proto::JsonStarter();
  auto sym = jstr_[0];
  if ((sym == '{' || sym == '[') && check_paired_braces(0, size_)) {
    auto endpos = size_ - 1;
    // Parse JsonObjectList.
    if (sym == '{') {
      json_proto::JsonObjectList *ol = new json_proto::JsonObjectList();
      ol = ParseJsonObjectList(0, endpos, arena);
      st->set_allocated_object_list_value(ol);
    } else {
      // Parse array.
      json_proto::ArrayValue *av = new json_proto::ArrayValue();
      *av = ParseArrayValue(0, endpos, arena);
      st->set_allocated_array_value(av);
    }
    msg.set_settings(1);
    msg.set_allocated_starter(st);
  }
  else {
    std::cout << "ERROR: Json Object parsing failed - not matching braces";
  }
  return;
}

Cборка исполняемых файлов для фаззинг-целей и packer-утилиты.

Так как для Sydr используется фаззинг-цель классического формата (с реализацией функции LLVMFuzzerTestOneInput), то собирается она обычным способом.

Для сборки фаззинг-цели libfuzzer с LPM можно выполнить следующие шаги.

0*. Создать директорию /proto и скопировать туда фаззинг-цель jsoncpp_fuzz_proto.cc, спецификацию json.proto, файлы с функциями конвертации json_proto_converter.cc и json_proto_converter.h.

  1. Склонировать и собрать библиотеку LPM:
git clone --depth 1 https://github.com/google/libprotobuf-mutator.git
mkdir LPM && cd LPM && cmake ../libprotobuf-mutator -GNinja \
    -DLIB_PROTO_MUTATOR_DOWNLOAD_PROTOBUF=ON \
    -DLIB_PROTO_MUTATOR_TESTING=OFF -DCMAKE_BUILD_TYPE=Release && ninja
  1. Скомпилировать спецификацию json.proto:
mkdir genfiles && /path-to/LPM/external.protobuf/bin/protoc /proto/json.proto \
    --cpp_out=genfiles --proto_path=/proto

После этого в директории genfiles появятся автосгенерированные файлы json.pb.h и json.pb.cc, содержащие методы для работы с типами protobuf-сообщений из спецификации.

  1. Собрать основной проект и фаззинг-цель с библиотеками LPM с такими же флагами инструментации, как для обычной фаззинг-цели libfuzzer.

Сборка для packer-утилиты отличается тем, что флаги инструментации и сборка основного проекта не нужны, а также требуется указать путь до файла packer_macro.h (например, -I /proto, где уже лежит конвертер, и куда добавляется код утилиты json_packer.cc). Кстати, packer_macro.h и main_packer.cc также можно найти в папке /opt при использовании базового докера.

Сборка фаззинг-цели с инструментацией для сбора покрытия возможна в виде двух вариантов:

  • непосредственно на результирующем корпусе, состоящем из protobuf-сообщений;

Данный вариант предполагает сборку версии кода с макросом DEFINE_PROTO_FUZZER. Поэтому для сборки нужны такие же шаги, как для исполняемого файла libfuzzer с LPM, за исключением других флагов инструментации.

  • с предварительной конвертацией в целевой формат;

В таком случае используется классический вариант исполняемого файла для сбора покрытия с функцией LLVMFuzzerTestOneInput и обычными шагами для сборки.

Применение packer-утилиты и запуск гибридного фаззинга.

Для запуска гибридного фаззинга с применением packer-утилиты, написанной с помощью макросов DEFINE_CONVERT_PB и DEFINE_CONVERT_DATA, достаточно добавить в таблицу [libfuzzer] конфигурационного toml-файла параметр proto_packer_path. Данный параметр должен содержать строку с путём до исполняемого бинарного файла и может использоваться только для пары Sydr + libfuzzer. При запуске Sydr-Fuzz с опцией -l debug можно увидеть сообщения о запуске packer-утилиты при передаче входных данных от одного инструмента к другому, а также сообщения о количестве успешно добавленных в корпус фаззера непустых файлов, полученных из результатов работы Sydr.

Для предварительной конвертации итогового корпуса в целевой формат при сборе покрытия в таблице [cov] конфигурационного файла следует выставить значение флага proto_to_native_cov = true.

Packer-утилиту также удобно использовать для генерации начального корпуса protobuf-сообщений. Например, для jsoncpp можно найти файлы с расширением .json как в коде самого проекта, так и среди готовых тестовых наборов. Утилита поддерживает всего две команды (которые можно увидеть в логах Sydr-Fuzz):

/path-to/packer <input_file> <output_file> --to-proto

/path-to/packer <input_file> <output_file> --from-proto

Пример запуска утилиты для формирования корпуса protobuf-сообщений с помощью скрипта prepare_corpus.sh из корпуса json-файлов /corpus:

#!/bin/bash

mkdir -p /proto/corpus

# Create corpus in protobuf format
for filename in /corpus/*.json; do
    echo "";
    echo "Try to process: ${filename}";
    name=${filename##*}
    /pack/json_packer "${filename}" "/proto${filename}.pb" "--to-proto" || true;
done