← Back to Security Research Portal (ccsimplyspolit.github.io)

Structure-Aware Fuzzing of High-Frequency RPC & Simulation Protocols with libprotobuf-mutator

Author: Serhii Shunko (@sshunko / @ccsimplyspolit) • ORCID: 0009-0002-4183-0361 • Tooling: protobuf-bounds-auditor v1.1.0

0x00 // Why Raw Byte Fuzzers Stall at Wire Deserialization

When auditing C++ servers that ingest Protocol Buffers (such as real-time simulation ticks or distributed RPC handlers), raw byte-level coverage-guided fuzzers spend most cycles mutating varint wire tags that are immediately rejected by ParseFromArray(). However, the highest-severity vulnerabilities live past wire deserialization:

Wire Packet --> [Protobuf ParseFromArray()] --> [Missing Post-Parse Invariants!] --> [Simulation / State Engine]
                                                 |- 1. Unbounded repeated_field_size() (CWE-1284 / CWE-400)
                                                 |- 2. IEEE-754 NaN / +/-Inf floats (CWE-20 / CWE-682)
                                                 |- 3. Unclamped tick / history offsets (CWE-20)

0x01 // Automating Invariant Harness Generation (--emit-lpm-harness)

In protobuf-bounds-auditor v1.1.0, I added structure-aware C++ harness generation for Google's libprotobuf-mutator (DEFINE_PROTO_FUZZER). Running:

$ python proto_audit.py usercmd.proto --root-message CBaseUserCmdPB --emit-lpm-harness fuzz_usercmd.cc

synthesizes both the static SARIF v2.1.0 report and the defensive C++ post-parse validator:

// Auto-generated by protobuf-bounds-auditor v1.1.0
#include <cmath>
#include <cstdint>
#include "src/libfuzzer/libfuzzer_macro.h"

static constexpr int kMaxRepeatedElements = 64;
static constexpr uint64_t kMaxWindowOffset = 4096;

template <typename MsgT>
bool ValidateDeserializedInvariants(const MsgT& input) {
  // PB-003 (CWE-20): Bound integer tick/offset window
  if (input.prediction_offset_ticks_x256() > kMaxWindowOffset) {
    return false;
  }
  // PB-001 (CWE-1284 / CWE-400): Guard repeated field cardinality
  if (input.subtick_moves_size() > kMaxRepeatedElements) {
    return false;
  }
  return true;
}

DEFINE_PROTO_FUZZER(const CBaseUserCmdPB& input) {
  if (!ValidateDeserializedInvariants(input)) {
    return;
  }
  // Forward validated protobuf message to target ingestion handler under ASAN/UBSAN
}

0x02 // Case Study: Auditing ValveSoftware/GameNetworkingSockets Schemas

Applying protobuf-bounds-auditor across ValveSoftware/GameNetworkingSockets/src/common/ (Allman-style proto2 wire definitions) automatically identifies 6 repeated wire fields requiring explicit cardinality caps at the transport ingestion boundary (examples/gns_lpm_invariant_harness.cc):

[HIGH] PB-001 (CWE-1284) CMsgSteamDatagramSessionCryptInfo.ciphers (#5)
[HIGH] PB-001 (CWE-1284) CMsgSteamDatagramLinkLifetimeStats.multipath_packets_recv_sequenced (#12)
[HIGH] PB-001 (CWE-1284) CMsgSteamDatagramLinkLifetimeStats.multipath_packets_recv_later (#13)
[HIGH] PB-001 (CWE-1284) CMsgSteamDatagramCertificate.gameserver_datacenter_ids (#5)
[HIGH] PB-001 (CWE-1284) CMsgSteamDatagramCertificate.app_ids (#10)
[HIGH] PB-001 (CWE-1284) CMsgSteamDatagramCertificate.ip_addresses (#13)

0x03 // Enforcing Post-Parse Path & Descriptor Invariants on External Blobs

When a Protobuf message references external backing files (such as out-of-line tensor weights or replay segments), schema validation alone is insufficient. A defensive post-parse validator must enforce three structural invariants before opening or mapping external descriptors:

// 1. Canonicalize relative paths and reject parent-directory traversal (".."),
//    absolute paths, or URI-like prefixes regardless of whether base_dir is empty.
// 2. Verify that the opened file descriptor refers to a regular file (S_ISREG on POSIX,
//    FILE_TYPE_DISK on Windows) prior to mmap() or read().
// 3. Validate that (offset >= 0), (length >= 0), and (offset <= file_size - length)
//    using overflow-safe subtraction before allocating or slicing buffers.