From b6d6a548ef59a4dee929326bda1675943c24c0bc Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Thu, 9 Jul 2026 00:59:29 -0400 Subject: [PATCH 1/2] Add sanitizer and fuzzing support with CI coverage Adds two CMake options, both off by default so the existing build and CI matrix are unaffected: - NETCODE_SANITIZE: builds everything with AddressSanitizer + UndefinedBehaviorSanitizer. The vendored sodium subset is exempted from UBSan (third-party SIMD crypto uses intentional type punning / unaligned access) but still gets ASan. - NETCODE_FUZZ: builds the fuzz/ harnesses. Where the compiler ships libFuzzer (LLVM clang) they are libFuzzer targets; elsewhere they build as a standalone file replayer so they compile everywhere and crashes reproduce anywhere. Fuzz harnesses cover netcode's untrusted-input surface, each with write/read round-trip invariants: - fuzz_read_packet: netcode_read_packet (the socket packet reader), both raw input and encrypted round-trip paths including the connect token request. - fuzz_connect_token: the public and private connect token readers. - fuzz_parse_address: netcode_parse_address plus a to_string round-trip. CI gains a Linux ASan+UBSan leg (builds all, runs ctest) and a bounded smoke-fuzz leg (60s per target, uploads any crash inputs as an artifact). BUILDING.md and fuzz/README.md document usage. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 58 +++++++++ BUILDING.md | 10 ++ CMakeLists.txt | 60 ++++++++++ fuzz/README.md | 43 +++++++ fuzz/fuzz.h | 105 ++++++++++++++++ fuzz/fuzz_connect_token.c | 121 +++++++++++++++++++ fuzz/fuzz_parse_address.c | 42 +++++++ fuzz/fuzz_read_packet.c | 246 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 685 insertions(+) create mode 100644 fuzz/README.md create mode 100644 fuzz/fuzz.h create mode 100644 fuzz/fuzz_connect_token.c create mode 100644 fuzz/fuzz_parse_address.c create mode 100644 fuzz/fuzz_read_packet.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84cc948..796f443 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/BUILDING.md b/BUILDING.md index 284d20c..2402864 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -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/). diff --git a/CMakeLists.txt b/CMakeLists.txt index d240323..cf5eece 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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. @@ -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 @@ -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 + #include + 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() diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..6ccbc39 --- /dev/null +++ b/fuzz/README.md @@ -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-` (CI uploads these as the +`fuzz-crashes` artifact). Replay it with the same binary: + +``` +./build/bin/fuzz_read_packet crash- +``` + +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- other-input.bin +``` diff --git a/fuzz/fuzz.h b/fuzz/fuzz.h new file mode 100644 index 0000000..32c9050 --- /dev/null +++ b/fuzz/fuzz.h @@ -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 +#include +#include +#include + +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 diff --git a/fuzz/fuzz_connect_token.c b/fuzz/fuzz_connect_token.c new file mode 100644 index 0000000..8aefe2c --- /dev/null +++ b/fuzz/fuzz_connect_token.c @@ -0,0 +1,121 @@ +/* + fuzz the connect token readers. + + the public connect token is read by the client from whatever the game's backend + returned, and the private connect token is read by the server after decrypting + the portion of a connection request packet that was encrypted with the private + key. both are parsers over untrusted bytes. + + mode 0: raw fuzz netcode_read_connect_token (public token, exact size required). + mode 1: raw fuzz netcode_read_connect_token_private (post-decrypt private data). + mode 2: round trip: build a valid private token from fuzz-derived fields, write + it, read it back, and check the fields survive. +*/ + +#include "netcode.c" +#include "fuzz.h" + +static int initialized = 0; + +static void fuzz_initialize() +{ + if ( initialized ) + return; + netcode_init(); + netcode_log_level( NETCODE_LOG_LEVEL_NONE ); + initialized = 1; +} + +static void fuzz_read_public_token( struct fuzz_input_t * in ) +{ + uint8_t buffer[NETCODE_CONNECT_TOKEN_BYTES]; + memset( buffer, 0, sizeof( buffer ) ); + fuzz_read_bytes( in, buffer, sizeof( buffer ) ); + + struct netcode_connect_token_t connect_token; + netcode_read_connect_token( buffer, NETCODE_CONNECT_TOKEN_BYTES, &connect_token ); +} + +static void fuzz_read_private_token( struct fuzz_input_t * in ) +{ + uint8_t buffer[NETCODE_CONNECT_TOKEN_PRIVATE_BYTES]; + memset( buffer, 0, sizeof( buffer ) ); + fuzz_read_bytes( in, buffer, sizeof( buffer ) ); + + struct netcode_connect_token_private_t connect_token_private; + netcode_read_connect_token_private( buffer, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES, &connect_token_private ); +} + +static void fuzz_round_trip_private_token( struct fuzz_input_t * in ) +{ + struct netcode_connect_token_private_t input_token; + memset( &input_token, 0, sizeof( input_token ) ); + + input_token.client_id = fuzz_read_u64( in ); + input_token.timeout_seconds = (int) fuzz_read_u16( in ); + input_token.num_server_addresses = 1 + ( fuzz_read_u8( in ) % NETCODE_MAX_SERVERS_PER_CONNECT ); + + int i; + for ( i = 0; i < input_token.num_server_addresses; i++ ) + { + struct netcode_address_t * address = &input_token.server_addresses[i]; + if ( fuzz_read_u8( in ) & 1 ) + { + address->type = NETCODE_ADDRESS_IPV6; + int j; + for ( j = 0; j < 8; j++ ) + address->data.ipv6[j] = fuzz_read_u16( in ); + } + else + { + address->type = NETCODE_ADDRESS_IPV4; + int j; + for ( j = 0; j < 4; j++ ) + address->data.ipv4[j] = fuzz_read_u8( in ); + } + address->port = fuzz_read_u16( in ); + } + + fuzz_read_bytes( in, input_token.client_to_server_key, NETCODE_KEY_BYTES ); + fuzz_read_bytes( in, input_token.server_to_client_key, NETCODE_KEY_BYTES ); + fuzz_read_bytes( in, input_token.user_data, NETCODE_USER_DATA_BYTES ); + + uint8_t buffer[NETCODE_CONNECT_TOKEN_PRIVATE_BYTES]; + netcode_write_connect_token_private( &input_token, buffer, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES ); + + struct netcode_connect_token_private_t output_token; + FUZZ_CHECK( netcode_read_connect_token_private( buffer, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES, &output_token ) == NETCODE_OK ); + + FUZZ_CHECK( output_token.client_id == input_token.client_id ); + FUZZ_CHECK( output_token.timeout_seconds == input_token.timeout_seconds ); + FUZZ_CHECK( output_token.num_server_addresses == input_token.num_server_addresses ); + for ( i = 0; i < input_token.num_server_addresses; i++ ) + { + FUZZ_CHECK( netcode_address_equal( &output_token.server_addresses[i], &input_token.server_addresses[i] ) ); + } + FUZZ_CHECK( memcmp( output_token.client_to_server_key, input_token.client_to_server_key, NETCODE_KEY_BYTES ) == 0 ); + FUZZ_CHECK( memcmp( output_token.server_to_client_key, input_token.server_to_client_key, NETCODE_KEY_BYTES ) == 0 ); + FUZZ_CHECK( memcmp( output_token.user_data, input_token.user_data, NETCODE_USER_DATA_BYTES ) == 0 ); +} + +int LLVMFuzzerTestOneInput( const uint8_t * data, size_t size ) +{ + fuzz_initialize(); + + struct fuzz_input_t in; + in.data = data; + in.size = size; + in.offset = 0; + + if ( size < 1 ) + return 0; + + switch ( fuzz_read_u8( &in ) % 3 ) + { + case 0: fuzz_read_public_token( &in ); break; + case 1: fuzz_read_private_token( &in ); break; + case 2: fuzz_round_trip_private_token( &in ); break; + } + + return 0; +} diff --git a/fuzz/fuzz_parse_address.c b/fuzz/fuzz_parse_address.c new file mode 100644 index 0000000..5b14e28 --- /dev/null +++ b/fuzz/fuzz_parse_address.c @@ -0,0 +1,42 @@ +/* + fuzz netcode_parse_address. + + address strings come from the game's backend via connect tokens and from + application code, and the parser does in-place string surgery with a safety + margin, so it deserves adversarial input. + + also checks the round trip property: any address that parses must survive + netcode_address_to_string -> netcode_parse_address unchanged. +*/ + +#include "netcode.c" +#include "fuzz.h" + +#define FUZZ_MAX_ADDRESS_LENGTH 4096 + +int LLVMFuzzerTestOneInput( const uint8_t * data, size_t size ) +{ + char address_string[FUZZ_MAX_ADDRESS_LENGTH]; + + size_t length = size; + if ( length > FUZZ_MAX_ADDRESS_LENGTH - 1 ) + length = FUZZ_MAX_ADDRESS_LENGTH - 1; + + memcpy( address_string, data, length ); + address_string[length] = '\0'; + + struct netcode_address_t address; + if ( netcode_parse_address( address_string, &address ) != NETCODE_OK ) + return 0; + + FUZZ_CHECK( address.type == NETCODE_ADDRESS_IPV4 || address.type == NETCODE_ADDRESS_IPV6 ); + + char round_trip_string[NETCODE_MAX_ADDRESS_STRING_LENGTH]; + netcode_address_to_string( &address, round_trip_string ); + + struct netcode_address_t round_trip_address; + FUZZ_CHECK( netcode_parse_address( round_trip_string, &round_trip_address ) == NETCODE_OK ); + FUZZ_CHECK( netcode_address_equal( &address, &round_trip_address ) ); + + return 0; +} diff --git a/fuzz/fuzz_read_packet.c b/fuzz/fuzz_read_packet.c new file mode 100644 index 0000000..cf78894 --- /dev/null +++ b/fuzz/fuzz_read_packet.c @@ -0,0 +1,246 @@ +/* + fuzz netcode_read_packet, the primary hostile-input surface: every byte a netcode + server or client accepts off a socket goes through this function. + + mode 0: feed raw fuzz data straight into netcode_read_packet. this exercises all + parsing and rejection ahead of AEAD authentication (a fuzzer cannot forge + a valid MAC, so decryption is expected to fail here). + + mode 1: build a packet from fuzz-derived fields, write and encrypt it with the + real keys, optionally corrupt one byte, then read it back. this drives + the post-decryption parsing, and asserts the round-trip property: an + uncorrupted packet written by netcode_write_packet must read back with + the same type and sequence. connection requests get their connect token + encrypted with the real private key, so the full request path including + token decrypt and read is covered. +*/ + +#include "netcode.c" +#include "fuzz.h" + +#define FUZZ_PROTOCOL_ID 0x1122334455667788ULL + +static int initialized = 0; +static uint8_t packet_key[NETCODE_KEY_BYTES]; +static uint8_t private_key[NETCODE_KEY_BYTES]; + +static void fuzz_initialize() +{ + if ( initialized ) + return; + netcode_init(); + netcode_log_level( NETCODE_LOG_LEVEL_NONE ); + memset( packet_key, 0xAA, sizeof( packet_key ) ); + memset( private_key, 0xBB, sizeof( private_key ) ); + initialized = 1; +} + +static void * fuzz_call_read_packet( uint8_t * buffer, int buffer_length, uint64_t * sequence, struct netcode_replay_protection_t * replay_protection ) +{ + uint8_t allowed_packets[NETCODE_CONNECTION_NUM_PACKETS]; + memset( allowed_packets, 1, sizeof( allowed_packets ) ); + + return netcode_read_packet( buffer, + buffer_length, + sequence, + packet_key, + FUZZ_PROTOCOL_ID, + 0, // current timestamp: zero so fuzz-chosen expire timestamps pass + private_key, + allowed_packets, + replay_protection, + NULL, + NULL ); +} + +static void fuzz_raw_packet( struct fuzz_input_t * in ) +{ + uint8_t buffer[NETCODE_MAX_PACKET_BYTES]; + + int packet_bytes = (int) ( in->size - in->offset ); + if ( packet_bytes > NETCODE_MAX_PACKET_BYTES ) + packet_bytes = NETCODE_MAX_PACKET_BYTES; + + fuzz_read_bytes( in, buffer, packet_bytes ); + + struct netcode_replay_protection_t replay_protection; + netcode_replay_protection_reset( &replay_protection ); + + uint64_t sequence; + void * packet = fuzz_call_read_packet( buffer, packet_bytes, &sequence, &replay_protection ); + if ( packet ) + free( packet ); +} + +static void fuzz_round_trip_packet( struct fuzz_input_t * in ) +{ + uint8_t packet_type = fuzz_read_u8( in ) % NETCODE_CONNECTION_NUM_PACKETS; + uint64_t sequence = fuzz_read_u64( in ); + uint8_t corrupt = fuzz_read_u8( in ) & 1; + uint16_t corrupt_offset = fuzz_read_u16( in ); + uint8_t corrupt_xor = fuzz_read_u8( in ); + + // build the packet struct for the chosen type from fuzz input + + struct netcode_connection_request_packet_t request_packet; + struct netcode_connection_denied_packet_t denied_packet; + struct netcode_connection_challenge_packet_t challenge_packet; + struct netcode_connection_response_packet_t response_packet; + struct netcode_connection_keep_alive_packet_t keep_alive_packet; + struct netcode_connection_disconnect_packet_t disconnect_packet; + + uint8_t payload_buffer[sizeof( struct netcode_connection_payload_packet_t ) + NETCODE_MAX_PAYLOAD_BYTES]; + struct netcode_connection_payload_packet_t * payload_packet = (struct netcode_connection_payload_packet_t*) payload_buffer; + + void * packet = NULL; + + switch ( packet_type ) + { + case NETCODE_CONNECTION_REQUEST_PACKET: + { + // encrypt a connect token with the real private key so the read side can + // fully decrypt and accept the request + + uint64_t expire_timestamp = fuzz_read_u64( in ); + if ( expire_timestamp == 0 ) + expire_timestamp = 1; + + uint8_t nonce[NETCODE_CONNECT_TOKEN_NONCE_BYTES]; + fuzz_read_bytes( in, nonce, sizeof( nonce ) ); + + uint8_t token_data[NETCODE_CONNECT_TOKEN_PRIVATE_BYTES]; + fuzz_read_bytes( in, token_data, sizeof( token_data ) - NETCODE_MAC_BYTES ); + memset( token_data + NETCODE_CONNECT_TOKEN_PRIVATE_BYTES - NETCODE_MAC_BYTES, 0, NETCODE_MAC_BYTES ); + + if ( netcode_encrypt_connect_token_private( token_data, + NETCODE_CONNECT_TOKEN_PRIVATE_BYTES, + NETCODE_VERSION_INFO, + FUZZ_PROTOCOL_ID, + expire_timestamp, + nonce, + private_key ) != NETCODE_OK ) + { + return; + } + + request_packet.packet_type = NETCODE_CONNECTION_REQUEST_PACKET; + memcpy( request_packet.version_info, NETCODE_VERSION_INFO, NETCODE_VERSION_INFO_BYTES ); + request_packet.protocol_id = FUZZ_PROTOCOL_ID; + request_packet.connect_token_expire_timestamp = expire_timestamp; + memcpy( request_packet.connect_token_nonce, nonce, NETCODE_CONNECT_TOKEN_NONCE_BYTES ); + memcpy( request_packet.connect_token_data, token_data, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES ); + + packet = &request_packet; + } + break; + + case NETCODE_CONNECTION_DENIED_PACKET: + { + denied_packet.packet_type = NETCODE_CONNECTION_DENIED_PACKET; + packet = &denied_packet; + } + break; + + case NETCODE_CONNECTION_CHALLENGE_PACKET: + { + challenge_packet.packet_type = NETCODE_CONNECTION_CHALLENGE_PACKET; + challenge_packet.challenge_token_sequence = fuzz_read_u64( in ); + fuzz_read_bytes( in, challenge_packet.challenge_token_data, NETCODE_CHALLENGE_TOKEN_BYTES ); + packet = &challenge_packet; + } + break; + + case NETCODE_CONNECTION_RESPONSE_PACKET: + { + response_packet.packet_type = NETCODE_CONNECTION_RESPONSE_PACKET; + response_packet.challenge_token_sequence = fuzz_read_u64( in ); + fuzz_read_bytes( in, response_packet.challenge_token_data, NETCODE_CHALLENGE_TOKEN_BYTES ); + packet = &response_packet; + } + break; + + case NETCODE_CONNECTION_KEEP_ALIVE_PACKET: + { + keep_alive_packet.packet_type = NETCODE_CONNECTION_KEEP_ALIVE_PACKET; + keep_alive_packet.client_index = (int) fuzz_read_u16( in ); + keep_alive_packet.max_clients = (int) fuzz_read_u16( in ); + packet = &keep_alive_packet; + } + break; + + case NETCODE_CONNECTION_PAYLOAD_PACKET: + { + int payload_bytes = 1 + ( fuzz_read_u16( in ) % NETCODE_MAX_PAYLOAD_BYTES ); + payload_packet->packet_type = NETCODE_CONNECTION_PAYLOAD_PACKET; + payload_packet->payload_bytes = payload_bytes; + fuzz_read_bytes( in, payload_packet->payload_data, payload_bytes ); + packet = payload_packet; + } + break; + + case NETCODE_CONNECTION_DISCONNECT_PACKET: + { + disconnect_packet.packet_type = NETCODE_CONNECTION_DISCONNECT_PACKET; + packet = &disconnect_packet; + } + break; + } + + uint8_t buffer[NETCODE_MAX_PACKET_BYTES]; + + int written = netcode_write_packet( packet, buffer, NETCODE_MAX_PACKET_BYTES, sequence, packet_key, FUZZ_PROTOCOL_ID ); + + FUZZ_CHECK( written > 0 ); + FUZZ_CHECK( written <= NETCODE_MAX_PACKET_BYTES ); + + if ( corrupt ) + { + buffer[corrupt_offset % written] ^= corrupt_xor; + } + + struct netcode_replay_protection_t replay_protection; + netcode_replay_protection_reset( &replay_protection ); + + uint64_t read_sequence; + void * read = fuzz_call_read_packet( buffer, written, &read_sequence, &replay_protection ); + + if ( !corrupt || corrupt_xor == 0 ) + { + // an uncorrupted packet written by netcode_write_packet must read back + // with the same type and sequence + + FUZZ_CHECK( read ); + FUZZ_CHECK( ( (uint8_t*) read )[0] == packet_type ); + if ( packet_type != NETCODE_CONNECTION_REQUEST_PACKET ) + { + FUZZ_CHECK( read_sequence == sequence ); + } + } + + if ( read ) + free( read ); +} + +int LLVMFuzzerTestOneInput( const uint8_t * data, size_t size ) +{ + fuzz_initialize(); + + struct fuzz_input_t in; + in.data = data; + in.size = size; + in.offset = 0; + + if ( size < 1 ) + return 0; + + if ( fuzz_read_u8( &in ) & 1 ) + { + fuzz_round_trip_packet( &in ); + } + else + { + fuzz_raw_packet( &in ); + } + + return 0; +} From 7c638bd807e2b758ece959db176edf2a9a119b2a Mon Sep 17 00:00:00 2001 From: Glenn Fiedler Date: Thu, 9 Jul 2026 01:12:24 -0400 Subject: [PATCH 2/2] Fix integer overflow in replay protection found by fuzzing netcode_replay_protection_already_received computed "sequence + NETCODE_REPLAY_PROTECTION_BUFFER_SIZE <= most_recent_sequence", which wraps for sequence values in the top 256 of the uint64 space and falsely rejects legitimate packets as replays. Not reachable in practice (sequences start at 0 / 1<<63 and increment per packet), but wrong, and in security-relevant code. Found by fuzz_read_packet's round-trip invariant within the first minute of the first CI smoke-fuzz run, on input sequence 0xFFFFFFFFFFFFFF00. The check is now written subtraction-side so it cannot overflow, and test_replay_protection covers the top of the sequence space, including that replays up there are still caught. Co-Authored-By: Claude Fable 5 --- netcode.c | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/netcode.c b/netcode.c index 864f472..3096635 100755 --- a/netcode.c +++ b/netcode.c @@ -1677,7 +1677,11 @@ int netcode_replay_protection_already_received( struct netcode_replay_protection { netcode_assert( replay_protection ); - if ( sequence + NETCODE_REPLAY_PROTECTION_BUFFER_SIZE <= replay_protection->most_recent_sequence ) + // written so it cannot overflow: "sequence + BUFFER_SIZE <= most_recent" wraps for + // sequence values near UINT64_MAX and falsely rejects them as replays + + if ( replay_protection->most_recent_sequence >= NETCODE_REPLAY_PROTECTION_BUFFER_SIZE && + sequence <= replay_protection->most_recent_sequence - NETCODE_REPLAY_PROTECTION_BUFFER_SIZE ) return 1; int index = (int) ( sequence % NETCODE_REPLAY_PROTECTION_BUFFER_SIZE ); @@ -6593,6 +6597,26 @@ void test_replay_protection() check( netcode_replay_protection_already_received( &replay_protection, sequence ) == 1 ); } } + + // sequence numbers near UINT64_MAX must not be falsely rejected as replays. + // "sequence + buffer size" overflowed in the already received check and treated + // the top of the sequence space as ancient packets. found by fuzz_read_packet. + + netcode_replay_protection_reset( &replay_protection ); + + check( netcode_replay_protection_already_received( &replay_protection, UINT64_MAX - NETCODE_REPLAY_PROTECTION_BUFFER_SIZE ) == 0 ); + netcode_replay_protection_advance_sequence( &replay_protection, UINT64_MAX - NETCODE_REPLAY_PROTECTION_BUFFER_SIZE ); + + check( netcode_replay_protection_already_received( &replay_protection, UINT64_MAX - 1 ) == 0 ); + netcode_replay_protection_advance_sequence( &replay_protection, UINT64_MAX - 1 ); + + // and a replayed packet up there is still caught + + check( netcode_replay_protection_already_received( &replay_protection, UINT64_MAX - 1 ) == 1 ); + + // while packets that fell out of the window are rejected as before + + check( netcode_replay_protection_already_received( &replay_protection, UINT64_MAX - 1 - NETCODE_REPLAY_PROTECTION_BUFFER_SIZE ) == 1 ); } void test_client_create()