Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,61 @@ jobs:

- name: Test
run: ctest --test-dir build --build-config ${{ matrix.config }} --output-on-failure --timeout 600

sanitizers:
name: sanitizers (asan+ubsan)
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
CC: clang
CXX: clang++
steps:
- uses: actions/checkout@v4

- name: Configure
run: cmake -B build -DCMAKE_BUILD_TYPE=Debug -DNETCODE_SANITIZE=ON

- name: Build
run: cmake --build build --parallel

- name: Test
run: ctest --test-dir build --output-on-failure --timeout 600
env:
ASAN_OPTIONS: halt_on_error=1:abort_on_error=1
UBSAN_OPTIONS: halt_on_error=1:abort_on_error=1:print_stacktrace=1

fuzz:
name: fuzz (smoke)
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
CC: clang
CXX: clang++
steps:
- uses: actions/checkout@v4

- name: Configure
run: cmake -B build -DCMAKE_BUILD_TYPE=Debug -DNETCODE_SANITIZE=ON -DNETCODE_FUZZ=ON

- name: Build fuzz targets
run: cmake --build build --parallel --target fuzz_read_packet fuzz_connect_token fuzz_parse_address

- name: Smoke fuzz
run: |
mkdir -p fuzz-run
cd fuzz-run
for target in fuzz_read_packet fuzz_connect_token fuzz_parse_address; do
echo "=== $target ==="
../build/bin/$target -max_total_time=60 -rss_limit_mb=4096 -print_final_stats=1
done

- name: Upload crashes
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes
path: |
fuzz-run/crash-*
fuzz-run/oom-*
fuzz-run/timeout-*
if-no-files-found: ignore
10 changes: 10 additions & 0 deletions BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ Then you can run binaries like this:

For a debug build, use `-DCMAKE_BUILD_TYPE=Debug` and a separate build directory, e.g. `-B build-debug`.

## Sanitizers and fuzzing

To build everything with AddressSanitizer and UndefinedBehaviorSanitizer, configure with `-DNETCODE_SANITIZE=ON` and run the tests as usual:

cmake -B build-asan -DCMAKE_BUILD_TYPE=Debug -DNETCODE_SANITIZE=ON
cmake --build build-asan --parallel
ctest --test-dir build-asan --output-on-failure

Fuzz harnesses for the untrusted-input surface live in `fuzz/` and are built with `-DNETCODE_FUZZ=ON`. See [fuzz/README.md](fuzz/README.md) for details.

## Building on Windows

You need Visual Studio to build the source code. If you don't have Visual Studio you can [download the community edition for free](https://visualstudio.microsoft.com/downloads/).
Expand Down
60 changes: 60 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ else()
set(NETCODE_WARNINGS -Wall -Wextra)
endif()

option(NETCODE_SANITIZE "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF)
option(NETCODE_FUZZ "Build the fuzz targets" OFF)

# sanitizers apply to the whole build. the vendored crypto is exempted from UBSan
# below (third-party SIMD code uses intentional type punning / unaligned access that
# UBSan flags but is not netcode's to fix); it still gets AddressSanitizer.

if(NETCODE_SANITIZE)
if(MSVC)
add_compile_options(/fsanitize=address)
else()
add_compile_options(-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer)
add_link_options(-fsanitize=address,undefined)
endif()
endif()

# vendored libsodium subset, amalgamated into a single header + source pair.
# see sodium/NOTES.md for how it is generated and validated.

Expand All @@ -43,6 +59,9 @@ if(NOT MSVC)
-Wno-unknown-pragmas
-Wno-unused-variable
-Wno-type-limits)
if(NETCODE_SANITIZE)
target_compile_options(sodium PRIVATE -fno-sanitize=undefined)
endif()
endif()

# the netcode library
Expand Down Expand Up @@ -78,3 +97,44 @@ if(WIN32)
endif()

add_test(NAME netcode_test COMMAND netcode_test)

# fuzz targets. each harness compiles netcode.c into itself (like the test runner)
# so it links sodium directly, not the netcode library.
#
# where the compiler ships libFuzzer (real LLVM clang) these are libFuzzer targets.
# elsewhere (AppleClang, GCC, MSVC) they build standalone with a main() that replays
# input files, so the harnesses compile and reproduce crashes everywhere -- CI can
# verify they build on all platforms even where it cannot fuzz.

if(NETCODE_FUZZ)
include(CheckCSourceCompiles)
set(CMAKE_REQUIRED_FLAGS "-fsanitize=fuzzer")
check_c_source_compiles("
#include <stdint.h>
#include <stddef.h>
int LLVMFuzzerTestOneInput( const uint8_t * d, size_t s ) { (void) d; (void) s; return 0; }
" NETCODE_HAVE_LIBFUZZER)
unset(CMAKE_REQUIRED_FLAGS)

foreach(fuzzer fuzz_read_packet fuzz_connect_token fuzz_parse_address)
add_executable(${fuzzer} fuzz/${fuzzer}.c)
target_include_directories(${fuzzer} PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/sodium)
target_link_libraries(${fuzzer} PRIVATE sodium)
if(WIN32)
target_link_libraries(${fuzzer} PRIVATE ws2_32 iphlpapi)
endif()

if(NETCODE_HAVE_LIBFUZZER)
target_compile_options(${fuzzer} PRIVATE -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer)
target_link_options(${fuzzer} PRIVATE -fsanitize=fuzzer,address,undefined)
else()
target_compile_definitions(${fuzzer} PRIVATE NETCODE_FUZZ_STANDALONE)
if(NOT MSVC)
target_compile_options(${fuzzer} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
target_link_options(${fuzzer} PRIVATE -fsanitize=address,undefined)
endif()
endif()
endforeach()
endif()
43 changes: 43 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# netcode fuzzing

Fuzz harnesses for netcode's untrusted-input surface — the code paths that parse bytes
arriving off a socket or in a connect token from the backend:

- `fuzz_read_packet.c` — `netcode_read_packet`, the packet reader every client and server
runs on received datagrams. Covers pre-decryption parsing and rejection, and (for
packets it builds and encrypts with real keys) the post-decryption parsing plus a
write/read round-trip invariant.
- `fuzz_connect_token.c` — the public and private connect token readers, plus a
write/read round-trip over the private token.
- `fuzz_parse_address.c` — `netcode_parse_address`, plus a parse → to_string → parse
round-trip.

## Build and run

Requires a compiler with libFuzzer (LLVM clang; AppleClang and GCC do not ship it):

```
CC=clang cmake -B build -DCMAKE_BUILD_TYPE=Debug -DNETCODE_SANITIZE=ON -DNETCODE_FUZZ=ON
cmake --build build --target fuzz_read_packet fuzz_connect_token fuzz_parse_address
./build/bin/fuzz_read_packet # fuzz until a crash, or Ctrl-C
./build/bin/fuzz_read_packet -max_total_time=60 # bounded run, as CI does
```

## Reproducing a crash

libFuzzer writes the offending input to `crash-<sha>` (CI uploads these as the
`fuzz-crashes` artifact). Replay it with the same binary:

```
./build/bin/fuzz_read_packet crash-<sha>
```

On a compiler without libFuzzer the harnesses still build (with AddressSanitizer /
UndefinedBehaviorSanitizer where available) as a standalone replayer that takes input
files on the command line, so a crashing input can be reproduced anywhere:

```
CC=clang cmake -B build -DNETCODE_FUZZ=ON -DNETCODE_SANITIZE=ON # or default cc
cmake --build build --target fuzz_read_packet
./build/bin/fuzz_read_packet crash-<sha> other-input.bin
```
105 changes: 105 additions & 0 deletions fuzz/fuzz.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
netcode fuzz harness support

Each harness compiles netcode.c directly into itself so it can reach internal
functions, and defines LLVMFuzzerTestOneInput.

Built with -fsanitize=fuzzer this is a libFuzzer target. Compilers without
libFuzzer (AppleClang, GCC, MSVC) define NETCODE_FUZZ_STANDALONE instead, which
provides a main() that replays input files given on the command line, so
harnesses always compile everywhere and crashes can be reproduced from files.
*/

#ifndef NETCODE_FUZZ_H
#define NETCODE_FUZZ_H

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int LLVMFuzzerTestOneInput( const uint8_t * data, size_t size );

#define FUZZ_CHECK( condition ) \
do \
{ \
if ( !(condition) ) \
{ \
fprintf( stderr, "fuzz check failed: ( %s ), file %s, line %d\n", \
#condition, __FILE__, __LINE__ ); \
abort(); \
} \
} while(0)

// read little endian values out of the fuzz input, returning 0 once it runs dry

struct fuzz_input_t
{
const uint8_t * data;
size_t size;
size_t offset;
};

static uint8_t fuzz_read_u8( struct fuzz_input_t * in )
{
if ( in->offset >= in->size )
return 0;
return in->data[in->offset++];
}

static uint16_t fuzz_read_u16( struct fuzz_input_t * in )
{
uint16_t value = fuzz_read_u8( in );
value |= ( (uint16_t) fuzz_read_u8( in ) ) << 8;
return value;
}

static uint64_t fuzz_read_u64( struct fuzz_input_t * in )
{
uint64_t value = 0;
int i;
for ( i = 0; i < 8; i++ )
{
value |= ( (uint64_t) fuzz_read_u8( in ) ) << ( 8 * i );
}
return value;
}

static void fuzz_read_bytes( struct fuzz_input_t * in, uint8_t * output, size_t bytes )
{
size_t i;
for ( i = 0; i < bytes; i++ )
{
output[i] = fuzz_read_u8( in );
}
}

#ifdef NETCODE_FUZZ_STANDALONE

int main( int argc, char ** argv )
{
int i;
for ( i = 1; i < argc; i++ )
{
FILE * file = fopen( argv[i], "rb" );
if ( !file )
{
fprintf( stderr, "could not open %s\n", argv[i] );
return 1;
}
fseek( file, 0, SEEK_END );
long file_size = ftell( file );
fseek( file, 0, SEEK_SET );
uint8_t * data = (uint8_t*) malloc( file_size > 0 ? (size_t) file_size : 1 );
size_t bytes_read = fread( data, 1, (size_t) file_size, file );
fclose( file );
LLVMFuzzerTestOneInput( data, bytes_read );
free( data );
printf( "%s: ok\n", argv[i] );
}
return 0;
}

#endif // #ifdef NETCODE_FUZZ_STANDALONE

#endif // #ifndef NETCODE_FUZZ_H
Loading
Loading