diff --git a/.github/workflows/daily_fuzz.yml b/.github/workflows/daily_fuzz.yml new file mode 100644 index 0000000..7952ddc --- /dev/null +++ b/.github/workflows/daily_fuzz.yml @@ -0,0 +1,147 @@ +--- +name: Fuzzing + +'on': + pull_request: + schedule: + - cron: '41 3 * * *' + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pr_smoke: + name: fuzz smoke + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout repository + # actions/checkout v5 + uses: >- + actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + persist-credentials: false + + - name: Install fuzzing build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes autoconf automake clang g++ libtool + + - name: Build and run fuzzers + env: + FUZZ_SECONDS: 30 + run: | + autoreconf -fvi + ./configure + make fuzz-smoke + + daily: + name: daily parser and serializer fuzzing + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + issues: write # Create or update the upstream issue after fuzzing fails. + steps: + - name: Check whether this commit was already fuzzed + id: daily_fuzz_marker + # actions/cache v6.1.0 + uses: >- + actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: /tmp/libfastjson-daily-fuzz-complete + key: daily-fuzz-v1-${{ github.sha }} + lookup-only: true + + - name: Report unchanged commit + if: steps.daily_fuzz_marker.outputs.cache-hit == 'true' + run: | + echo '::notice title=Fuzzing skipped::Exact commit already fuzzed.' + + - name: Checkout repository + if: steps.daily_fuzz_marker.outputs.cache-hit != 'true' + # actions/checkout v5 + uses: >- + actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + persist-credentials: false + + - name: Install fuzzing build dependencies + if: steps.daily_fuzz_marker.outputs.cache-hit != 'true' + run: | + sudo apt-get update + sudo apt-get install --yes autoconf automake clang g++ libtool + + - name: Build and run fuzzers + if: steps.daily_fuzz_marker.outputs.cache-hit != 'true' + env: + FUZZ_SECONDS: 300 + run: | + autoreconf -fvi + ./configure + make fuzz-smoke + + - name: Create or update daily fuzzing failure issue + if: >- + failure() && github.event_name == 'schedule' && + github.repository == 'rsyslog/libfastjson' + # actions/github-script v9.0.0 + uses: >- + actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const title = 'Daily fuzzing failures'; + const runUrl = `${context.serverUrl}/${owner}/${repo}` + + `/actions/runs/${context.runId}`; + const body = [ + 'The daily parser/serializer fuzzing campaign failed.', + '', + `Commit: ${context.sha}`, + `Run: ${runUrl}`, + '', + 'The failed commit has no completion marker, so the next', + 'scheduled campaign will retry it if no newer commit arrives.' + ].join('\n'); + const query = `repo:${owner}/${repo} ` + + `is:issue is:open in:title ${title}`; + const { data: issues } = + await github.rest.search.issuesAndPullRequests({ + q: query, + per_page: 1, + }); + if (issues.items.length > 0) { + await github.rest.issues.update({ + owner, + repo, + issue_number: issues.items[0].number, + body, + }); + } else { + await github.rest.issues.create({owner, repo, title, body}); + } + + - name: Mark successful fuzzing campaign + if: steps.daily_fuzz_marker.outputs.cache-hit != 'true' + run: | + marker=/tmp/libfastjson-daily-fuzz-complete + mkdir -p "$marker" + printf '%s\n' "$GITHUB_SHA" > "$marker/commit" + + - name: Cache successful fuzzing campaign + if: steps.daily_fuzz_marker.outputs.cache-hit != 'true' + # actions/cache v6.1.0 + uses: >- + actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: /tmp/libfastjson-daily-fuzz-complete + key: daily-fuzz-v1-${{ github.sha }} diff --git a/Makefile.am b/Makefile.am index 28cca77..1003f48 100644 --- a/Makefile.am +++ b/Makefile.am @@ -7,6 +7,35 @@ EXTRA_DIST += \ benchmarks/runner.py \ benchmarks/selftest.py +FUZZ_CC ?= clang +FUZZ_CFLAGS = -g -O1 -fno-omit-frame-pointer \ + -fsanitize=fuzzer,address,undefined +FUZZ_CXX_LIBS ?= -lstdc++ +FUZZ_SECONDS ?= 30 +FUZZ_RSS_LIMIT_MB ?= 2048 + +.PHONY: fuzz fuzz-smoke + +fuzz: all + $(FUZZ_CC) $(FUZZ_CFLAGS) -nostdlib++ -I$(srcdir) \ + -o tests/fuzz_parser tests/test_fuzz_parser.c \ + $(top_builddir)/.libs/libfastjson.a \ + $(top_builddir)/.libs/libfastjson-internal.a -lm $(FUZZ_CXX_LIBS) + $(FUZZ_CC) $(FUZZ_CFLAGS) -nostdlib++ -I$(srcdir) \ + -o tests/fuzz_serializer tests/test_fuzz_serializer.c \ + $(top_builddir)/.libs/libfastjson.a \ + $(top_builddir)/.libs/libfastjson-internal.a -lm $(FUZZ_CXX_LIBS) + +fuzz-smoke: fuzz + @set -e; fuzz_tmp=$$(mktemp -d); \ + trap 'rm -rf "$$fuzz_tmp"' EXIT; \ + mkdir "$$fuzz_tmp/parser" "$$fuzz_tmp/serializer"; \ + tests/fuzz_parser -dict=tests/fuzz/json.dict -max_len=65536 \ + -max_total_time=$(FUZZ_SECONDS) -rss_limit_mb=$(FUZZ_RSS_LIMIT_MB) \ + "$$fuzz_tmp/parser" tests/fuzz/corpus/parser; \ + tests/fuzz_serializer -max_len=65536 -max_total_time=$(FUZZ_SECONDS) \ + -rss_limit_mb=$(FUZZ_RSS_LIMIT_MB) "$$fuzz_tmp/serializer" tests/fuzz/corpus/serializer + SUBDIRS = . tests lib_LTLIBRARIES = libfastjson.la diff --git a/tests/Makefile.am b/tests/Makefile.am index f99e985..ab2b2fe 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -10,6 +10,8 @@ TESTS_DEFAULT+= test_float.test TESTS_DEFAULT+= test_dump_buffered.test TESTS_DEFAULT+= test_json_util.test TESTS_DEFAULT+= test_array_api.test +TESTS_DEFAULT+= test_fuzz_parser.test +TESTS_DEFAULT+= test_fuzz_serializer.test TESTS_DEFAULT+= test1.test TESTS_DEFAULT+= test2.test TESTS_DEFAULT+= test4.test @@ -42,6 +44,8 @@ cr_obj_multi_SOURCES = cr_obj_multi.c chk_version_SOURCES = chk_version.c test_printbuf_SOURCES = test_printbuf.c +test_fuzz_parser_CPPFLAGS = -DFUZZ_REPLAY +test_fuzz_serializer_CPPFLAGS = -DFUZZ_REPLAY # Note: handled by test1.test check_PROGRAMS += test1Formatted @@ -91,6 +95,17 @@ EXTRA_DIST += test_many_subobj.expected EXTRA_DIST += test_obj_obj_get_ex-null.expected EXTRA_DIST += test_json_util.expected EXTRA_DIST += test_array_api.expected +EXTRA_DIST += fuzz/README.md +EXTRA_DIST += fuzz/json.dict +EXTRA_DIST += fuzz/corpus/parser/array.json +EXTRA_DIST += fuzz/corpus/parser/deep.json +EXTRA_DIST += fuzz/corpus/parser/escaped.json +EXTRA_DIST += fuzz/corpus/parser/invalid.json +EXTRA_DIST += fuzz/corpus/parser/number.json +EXTRA_DIST += fuzz/corpus/parser/object.json +EXTRA_DIST += fuzz/corpus/parser/truncated.json +EXTRA_DIST += fuzz/corpus/serializer/nesting.seed +EXTRA_DIST += fuzz/corpus/serializer/strings.seed testsubdir=testSubDir TESTS_ENVIRONMENT = top_builddir=$(top_builddir) diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md new file mode 100644 index 0000000..56a259e --- /dev/null +++ b/tests/fuzz/README.md @@ -0,0 +1,21 @@ +# libfastjson fuzzing + +The checked-in corpus is replayed by `make check` through deterministic +standalone executables. Each input must remain safe under normal and sanitizer +builds. + +Build libFuzzer targets with Clang after configuring and building the library: + +```sh +make fuzz +make fuzz-smoke FUZZ_SECONDS=60 +``` + +The targets need Clang and its libFuzzer runtime plus a C++ standard library. +Set `FUZZ_CXX_LIBS` if the linker cannot find its default `-lstdc++`. +`FUZZ_RSS_LIMIT_MB` sets the libFuzzer memory limit (default: 2048 MiB). + +`fuzz-smoke` runs both targets with AddressSanitizer and +UndefinedBehaviorSanitizer. A minimized input that reproduces a crash must be +added to the relevant `corpus/` directory. Do not commit automatically +generated corpus growth without reviewing and minimizing it. diff --git a/tests/fuzz/corpus/parser/array.json b/tests/fuzz/corpus/parser/array.json new file mode 100644 index 0000000..0802dfc --- /dev/null +++ b/tests/fuzz/corpus/parser/array.json @@ -0,0 +1 @@ +[0,true,false,null,"text"] diff --git a/tests/fuzz/corpus/parser/deep.json b/tests/fuzz/corpus/parser/deep.json new file mode 100644 index 0000000..d6183aa --- /dev/null +++ b/tests/fuzz/corpus/parser/deep.json @@ -0,0 +1 @@ +[[[[[[[[0]]]]]]]] diff --git a/tests/fuzz/corpus/parser/escaped.json b/tests/fuzz/corpus/parser/escaped.json new file mode 100644 index 0000000..707b84f --- /dev/null +++ b/tests/fuzz/corpus/parser/escaped.json @@ -0,0 +1 @@ +{"quote":"a\\\"b","slash":"\\\\","unicode":"\\u20ac"} diff --git a/tests/fuzz/corpus/parser/invalid.json b/tests/fuzz/corpus/parser/invalid.json new file mode 100644 index 0000000..d204ed8 --- /dev/null +++ b/tests/fuzz/corpus/parser/invalid.json @@ -0,0 +1 @@ +{"key":,} diff --git a/tests/fuzz/corpus/parser/number.json b/tests/fuzz/corpus/parser/number.json new file mode 100644 index 0000000..cc1958a --- /dev/null +++ b/tests/fuzz/corpus/parser/number.json @@ -0,0 +1 @@ +-1234567890123456789.25e-10 diff --git a/tests/fuzz/corpus/parser/object.json b/tests/fuzz/corpus/parser/object.json new file mode 100644 index 0000000..aea6040 --- /dev/null +++ b/tests/fuzz/corpus/parser/object.json @@ -0,0 +1 @@ +{"a":1,"a":2,"nested":{"b":[3,4]}} diff --git a/tests/fuzz/corpus/parser/truncated.json b/tests/fuzz/corpus/parser/truncated.json new file mode 100644 index 0000000..b9f4dff --- /dev/null +++ b/tests/fuzz/corpus/parser/truncated.json @@ -0,0 +1 @@ +{"unfinished":"escape\\ diff --git a/tests/fuzz/corpus/serializer/nesting.seed b/tests/fuzz/corpus/serializer/nesting.seed new file mode 100644 index 0000000..cc67189 --- /dev/null +++ b/tests/fuzz/corpus/serializer/nesting.seed @@ -0,0 +1 @@ +0123456789abcdef0123456789abcdef diff --git a/tests/fuzz/corpus/serializer/strings.seed b/tests/fuzz/corpus/serializer/strings.seed new file mode 100644 index 0000000..c6995d2 --- /dev/null +++ b/tests/fuzz/corpus/serializer/strings.seed @@ -0,0 +1 @@ +\\\"/\\\"/\\\"/\\\"/\\\"/ diff --git a/tests/fuzz/json.dict b/tests/fuzz/json.dict new file mode 100644 index 0000000..644719b --- /dev/null +++ b/tests/fuzz/json.dict @@ -0,0 +1,12 @@ +"null" +"true" +"false" +"{" +"}" +"[" +"]" +":" +"," +"\\\"" +"\\\\" +"\\u0000" diff --git a/tests/test_fuzz_parser.c b/tests/test_fuzz_parser.c new file mode 100644 index 0000000..15d9c3b --- /dev/null +++ b/tests/test_fuzz_parser.c @@ -0,0 +1,130 @@ +/* Parser fuzz target and deterministic regression-corpus replay. */ +#include "config.h" + +#include +#include +#include +#include + +#include "../json.h" + +#define FUZZ_MAX_INPUT (64 * 1024) + +static void +check_canonical(struct fjson_object *object) +{ + const char *const encoded = fjson_object_to_json_string_ext( + object, FJSON_TO_STRING_PLAIN); + struct fjson_object *reparsed; + + if (encoded == NULL) + return; + reparsed = fjson_tokener_parse(encoded); + + if (reparsed != NULL) { + const char *const reencoded = fjson_object_to_json_string_ext( + reparsed, FJSON_TO_STRING_PLAIN); + if (reencoded == NULL || strcmp(encoded, reencoded) != 0) + abort(); + fjson_object_put(reparsed); + } +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); + +int +LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + struct fjson_tokener *tokener; + struct fjson_object *object; + int chunk_size; + size_t offset; + + if (size > FUZZ_MAX_INPUT) + return 0; + + tokener = fjson_tokener_new_ex(64); + if (tokener == NULL) + return 0; + if (size > 0 && (data[0] & 1) != 0) + fjson_tokener_set_flags(tokener, FJSON_TOKENER_STRICT); + + object = fjson_tokener_parse_ex(tokener, (const char *) data, (int) size); + if (object != NULL) { + check_canonical(object); + fjson_object_put(object); + } + fjson_tokener_free(tokener); + + tokener = fjson_tokener_new_ex(64); + if (tokener == NULL) + return 0; + if (size > 0 && (data[0] & 1) != 0) + fjson_tokener_set_flags(tokener, FJSON_TOKENER_STRICT); + chunk_size = size == 0 ? 1 : (int) (data[0] % 17) + 1; + for (offset = 0 ; offset < size ; ) { + const size_t remaining = size - offset; + const int length = (int) (remaining < (size_t) chunk_size ? + remaining : (size_t) chunk_size); + + object = fjson_tokener_parse_ex( + tokener, (const char *) data + offset, length); + offset += (size_t) length; + if (object != NULL) { + if (fjson_tokener_get_error(tokener) == fjson_tokener_success) + check_canonical(object); + fjson_object_put(object); + break; + } + if (fjson_tokener_get_error(tokener) != fjson_tokener_continue) + break; + } + fjson_tokener_free(tokener); + return 0; +} + +#ifdef FUZZ_REPLAY +static uint8_t * +read_input(const char *path, size_t *size) +{ + FILE *file = fopen(path, "rb"); + long file_size; + uint8_t *data; + + if (file == NULL) + return NULL; + if (fseek(file, 0, SEEK_END) != 0 || + (file_size = ftell(file)) < 0 || + fseek(file, 0, SEEK_SET) != 0) { + fclose(file); + return NULL; + } + data = malloc((size_t) file_size + 1); + if (data == NULL || fread(data, 1, (size_t) file_size, file) != + (size_t) file_size) { + free(data); + fclose(file); + return NULL; + } + fclose(file); + *size = (size_t) file_size; + return data; +} + +int +main(int argc, char **argv) +{ + uint8_t *data; + size_t size; + int result; + + if (argc != 2) + return 2; + data = read_input(argv[1], &size); + if (data == NULL) + return 2; + result = LLVMFuzzerTestOneInput(data, size); + free(data); + return result; +} +#endif diff --git a/tests/test_fuzz_parser.test b/tests/test_fuzz_parser.test new file mode 100755 index 0000000..8e2d0f2 --- /dev/null +++ b/tests/test_fuzz_parser.test @@ -0,0 +1,7 @@ +#!/bin/sh + +. "${srcdir-.}/test-defs.sh" + +for seed in "$srcdir"/fuzz/corpus/parser/*; do + "$top_builddir/test_fuzz_parser" "$seed" || exit $? +done diff --git a/tests/test_fuzz_serializer.c b/tests/test_fuzz_serializer.c new file mode 100644 index 0000000..408007a --- /dev/null +++ b/tests/test_fuzz_serializer.c @@ -0,0 +1,214 @@ +/* Serializer fuzz target and deterministic regression-corpus replay. */ +#include "config.h" + +#include +#include +#include +#include + +#include "../json.h" + +#define FUZZ_MAX_INPUT (64 * 1024) +#define FUZZ_MAX_NODES 256 + +struct cursor { + const uint8_t *data; + size_t size; + size_t offset; + unsigned int nodes; +}; + +struct output { + char data[16384]; + size_t filled; + size_t limit; +}; + +static uint8_t +next_byte(struct cursor *cursor) +{ + if (cursor->offset < cursor->size) + return cursor->data[cursor->offset++]; + return 0; +} + +static size_t +append(void *ptr, const char *buffer, size_t size) +{ + struct output *output = ptr; + size_t accepted = size; + + if (output->limit > 0 && accepted > output->limit) + accepted = output->limit; + if (accepted > sizeof(output->data) - output->filled) + abort(); + memcpy(output->data + output->filled, buffer, accepted); + output->filled += accepted; + return accepted; +} + +static struct fjson_object *build_value(struct cursor *cursor, unsigned int depth); + +static struct fjson_object * +build_string(struct cursor *cursor) +{ + static const char alphabet[] = "ab\\\"/\n"; + char value[17]; + const size_t length = next_byte(cursor) % (sizeof(value) - 1); + + for (size_t i = 0 ; i < length ; ++i) + value[i] = alphabet[next_byte(cursor) % (sizeof(alphabet) - 1)]; + value[length] = '\0'; + return fjson_object_new_string_len(value, (int) length); +} + +static struct fjson_object * +build_value(struct cursor *cursor, unsigned int depth) +{ + const uint8_t kind = next_byte(cursor) % 5; + struct fjson_object *object; + + if (++cursor->nodes > FUZZ_MAX_NODES || depth >= 6) + return fjson_object_new_int((int) next_byte(cursor)); + switch (kind) { + case 0: + return fjson_object_new_boolean(next_byte(cursor) & 1); + case 1: + return fjson_object_new_int64((int64_t) next_byte(cursor) - 128); + case 2: + return build_string(cursor); + case 3: + object = fjson_object_new_array(); + if (object == NULL) + return NULL; + for (unsigned int i = 0 ; i < next_byte(cursor) % 4 ; ++i) + fjson_object_array_add(object, build_value(cursor, depth + 1)); + return object; + default: + object = fjson_object_new_object(); + if (object == NULL) + return NULL; + for (unsigned int i = 0 ; i < next_byte(cursor) % 4 ; ++i) { + char key[16]; + snprintf(key, sizeof(key), "key-%u-%u", depth, i); + fjson_object_object_add(object, key, build_value(cursor, depth + 1)); + } + return object; + } +} + +static void +check_serialization(struct fjson_object *object, int flags) +{ + const char *const encoded = fjson_object_to_json_string_ext(object, flags); + size_t length; + char *expected; + const size_t sizes[] = {0, 1, 2, 7, 31, 128}; + + if (encoded == NULL) + return; + length = strlen(encoded); + expected = malloc(length + 1); + if (expected == NULL) + abort(); + memcpy(expected, encoded, length + 1); + if (fjson_object_size_ext(object, flags) != length) + abort(); + for (size_t i = 0 ; i < sizeof(sizes) / sizeof(sizes[0]); ++i) { + char temp[128]; + struct output output = {{0}, 0, 0}; + const size_t written = fjson_object_dump_buffered( + object, flags, sizes[i] == 0 ? NULL : temp, sizes[i], append, &output); + + if (written != length || output.filled != length || + memcmp(output.data, expected, length) != 0) + abort(); + } + { + char temp[7]; + struct output output = {{0}, 0, 1}; + const size_t written = fjson_object_dump_buffered( + object, flags, temp, sizeof(temp), append, &output); + + if (written != output.filled || written > length) + abort(); + } + if (flags == FJSON_TO_STRING_PLAIN) { + struct fjson_object *const reparsed = fjson_tokener_parse(expected); + const char *reencoded; + + if (reparsed == NULL) + abort(); + reencoded = fjson_object_to_json_string_ext( + reparsed, FJSON_TO_STRING_PLAIN); + if (reencoded == NULL || strcmp(reencoded, expected) != 0) + abort(); + fjson_object_put(reparsed); + } + free(expected); +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); + +int +LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + struct cursor cursor = {data, size, 0, 0}; + struct fjson_object *object; + + if (size > FUZZ_MAX_INPUT) + return 0; + object = build_value(&cursor, 0); + if (object == NULL) + return 0; + check_serialization(object, FJSON_TO_STRING_PLAIN); + check_serialization(object, FJSON_TO_STRING_SPACED); + fjson_object_put(object); + return 0; +} + +#ifdef FUZZ_REPLAY +static uint8_t * +read_input(const char *path, size_t *size) +{ + FILE *file = fopen(path, "rb"); + long file_size; + uint8_t *data; + + if (file == NULL) + return NULL; + if (fseek(file, 0, SEEK_END) != 0 || + (file_size = ftell(file)) < 0 || + fseek(file, 0, SEEK_SET) != 0) { + fclose(file); + return NULL; + } + data = malloc((size_t) file_size + 1); + if (data == NULL || fread(data, 1, (size_t) file_size, file) != + (size_t) file_size) { + free(data); + fclose(file); + return NULL; + } + fclose(file); + *size = (size_t) file_size; + return data; +} + +int +main(int argc, char **argv) +{ + uint8_t *data; + size_t size; + int result; + + if (argc != 2) + return 2; + data = read_input(argv[1], &size); + if (data == NULL) + return 2; + result = LLVMFuzzerTestOneInput(data, size); + free(data); + return result; +} +#endif diff --git a/tests/test_fuzz_serializer.test b/tests/test_fuzz_serializer.test new file mode 100755 index 0000000..f6d38233 --- /dev/null +++ b/tests/test_fuzz_serializer.test @@ -0,0 +1,7 @@ +#!/bin/sh + +. "${srcdir-.}/test-defs.sh" + +for seed in "$srcdir"/fuzz/corpus/serializer/*; do + "$top_builddir/test_fuzz_serializer" "$seed" || exit $? +done