-
Notifications
You must be signed in to change notification settings - Fork 32
Гибридный фаззинг проекта jsoncpp с помощью Sydr и libfuzzer с LPM
Данный гайд демонстрирует применение фреймворка гибридного фаззинга Sydr-Fuzz с двусторонним преобразованием данных между символьным интерпретатором Sydr и libfuzzer, использующим libprotobuf-mutator (LPM). Предполагается, что читатель понимает, что такое гибридный фаззинг с DSE, имеет опыт написания фаззинг-целей для libfuzzer и представляет, как выглядит базовый набор исполняемых файлов для фаззинг-цели в случае Sydr-Fuzz.
Библиотека 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 в данном гайде предлагается:
- использовать для запуска 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"}}
В Sydr-Fuzz packer-утилита вызывается при обмене входными данными между символьным интерпретатором Sydr и libfuzzer. Общая схема обмена такова: на основе метрик libfuzzer из корпуса выбирается файл для запуска Sydr, после анализа которого все полученные выходные json-файлы от Sydr преобразуются и добавляются в корпус libfuzzer в виде protobuf-сообщений. Таким образом утилита должна поддерживать двустороннее преобразование между json и protobuf, чтобы каждый инструмент получал входные данные в нужном формате.
Как было отмечено ранее, в корпусе фаззера содержатся файлы с 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;
}
В целом ничего нового здесь не появилось.
Далее можно использовать второй макрос 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;
}
Так как для Sydr используется фаззинг-цель классического формата (с реализацией функции LLVMFuzzerTestOneInput
), то собирается она обычным способом.
Для сборки фаззинг-цели libfuzzer с LPM можно выполнить следующие шаги.
0*. Создать директорию /proto
и скопировать туда фаззинг-цель jsoncpp_fuzz_proto.cc
, спецификацию json.proto
, файлы с функциями конвертации json_proto_converter.cc
и json_proto_converter.h
.
- Склонировать и собрать библиотеку 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
- Скомпилировать спецификацию
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-сообщений из спецификации.
- Собрать основной проект и фаззинг-цель с библиотеками 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-утилиты, написанной с помощью макросов
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