From 67d2cdab04ddac66753e072d2e88ecaf45b02fe4 Mon Sep 17 00:00:00 2001 From: zgiles Date: Wed, 22 Jul 2026 15:14:23 -0400 Subject: [PATCH] Add fuzz targets for the terminal emulator and network input paths The existing fuzzers miss the surfaces that matter: terminal_parser_fuzzer runs the parser but never applies actions to a terminal, and terminal_fuzzer feeds bytes through the keystroke path (Parser::UserByte), not the host-output emulator. Nothing fuzzes the network input paths at all. Add four libFuzzer targets (built under --enable-fuzzing): terminal_host_fuzzer - drives Complete::act(std::string), the escape/ control-sequence path a server's output takes: terminaldispatcher, terminalfunctions, OSC dispatch (incl. OSC 8), the framebuffer mutators, and new_frame() rendering. statesync_fuzzer - the serialized protobuf diff a counterparty sends, via Complete::apply_string (client/host state) and UserStream::apply_string (server/user state). fragment_fuzzer - the wire Fragment header parse and the FragmentAssembly reassembly state machine. crypto_fuzzer - the keyless pre-auth surface: a raw datagram fed into Session::decrypt (AES-OCB) with a fixed key, checking that forged ciphertext is rejected without any out-of-bounds access. The state-sync, fragment and crypto harnesses treat a Crypto::CryptoException as a dropped packet (the graceful path); an abort or any other uncaught exception is a finding. --- src/fuzz/Makefile.am | 23 ++++++++++++- src/fuzz/crypto_fuzzer.cc | 32 ++++++++++++++++++ src/fuzz/fragment_fuzzer.cc | 58 ++++++++++++++++++++++++++++++++ src/fuzz/statesync_fuzzer.cc | 38 +++++++++++++++++++++ src/fuzz/terminal_host_fuzzer.cc | 33 ++++++++++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/fuzz/crypto_fuzzer.cc create mode 100644 src/fuzz/fragment_fuzzer.cc create mode 100644 src/fuzz/statesync_fuzzer.cc create mode 100644 src/fuzz/terminal_host_fuzzer.cc diff --git a/src/fuzz/Makefile.am b/src/fuzz/Makefile.am index e28e1031c..7c1663bf5 100644 --- a/src/fuzz/Makefile.am +++ b/src/fuzz/Makefile.am @@ -1,7 +1,7 @@ AM_CXXFLAGS = $(WARNING_CXXFLAGS) $(PICKY_CXXFLAGS) $(HARDEN_CFLAGS) $(MISC_CXXFLAGS) $(CODE_COVERAGE_CXXFLAGS) $(FUZZING_CFLAGS) if ENABLE_FUZZING - noinst_PROGRAMS = terminal_parser_fuzzer terminal_fuzzer + noinst_PROGRAMS = terminal_parser_fuzzer terminal_fuzzer terminal_host_fuzzer statesync_fuzzer fragment_fuzzer crypto_fuzzer endif terminal_parser_fuzzer_CPPFLAGS = -I$(top_srcdir)/ @@ -11,3 +11,24 @@ terminal_parser_fuzzer_SOURCES = terminal_parser_fuzzer.cc terminal_fuzzer_CPPFLAGS = -I$(top_srcdir)/ terminal_fuzzer_LDADD = ../terminal/libmoshterminal.a ../util/libmoshutil.a ../statesync/libmoshstatesync.a ../protobufs/libmoshprotos.a $(TINFO_LIBS) $(protobuf_LIBS) terminal_fuzzer_SOURCES = terminal_fuzzer.cc + +# Host-output escape-sequence emulator + renderer (the path a server's output +# actually drives; the older terminal_fuzzer only covers the keystroke path). +terminal_host_fuzzer_CPPFLAGS = -I$(top_srcdir)/ +terminal_host_fuzzer_LDADD = ../terminal/libmoshterminal.a ../util/libmoshutil.a ../statesync/libmoshstatesync.a ../protobufs/libmoshprotos.a $(TINFO_LIBS) $(protobuf_LIBS) +terminal_host_fuzzer_SOURCES = terminal_host_fuzzer.cc + +# Post-decryption protobuf state-sync apply path (client and server halves). +statesync_fuzzer_CPPFLAGS = -I$(top_srcdir)/ $(CRYPTO_CFLAGS) +statesync_fuzzer_LDADD = ../statesync/libmoshstatesync.a ../terminal/libmoshterminal.a ../crypto/libmoshcrypto.a ../protobufs/libmoshprotos.a ../util/libmoshutil.a $(TINFO_LIBS) $(protobuf_LIBS) $(CRYPTO_LIBS) +statesync_fuzzer_SOURCES = statesync_fuzzer.cc + +# Fragment header parse + FragmentAssembly reassembly state machine. +fragment_fuzzer_CPPFLAGS = -I$(top_srcdir)/ $(CRYPTO_CFLAGS) +fragment_fuzzer_LDADD = ../network/libmoshnetwork.a ../crypto/libmoshcrypto.a ../protobufs/libmoshprotos.a ../util/libmoshutil.a $(protobuf_LIBS) $(CRYPTO_LIBS) +fragment_fuzzer_SOURCES = fragment_fuzzer.cc + +# Keyless pre-auth surface: raw datagram -> Session::decrypt (AES-OCB). +crypto_fuzzer_CPPFLAGS = -I$(top_srcdir)/ $(CRYPTO_CFLAGS) +crypto_fuzzer_LDADD = ../crypto/libmoshcrypto.a ../util/libmoshutil.a $(CRYPTO_LIBS) +crypto_fuzzer_SOURCES = crypto_fuzzer.cc diff --git a/src/fuzz/crypto_fuzzer.cc b/src/fuzz/crypto_fuzzer.cc new file mode 100644 index 000000000..f98f84325 --- /dev/null +++ b/src/fuzz/crypto_fuzzer.cc @@ -0,0 +1,32 @@ +/* Fuzzes the KEYLESS, pre-authentication surface: a raw UDP datagram fed + straight into Session::decrypt() with a fixed key the "attacker" does not + know. This is exactly what an internet attacker who cannot forge the OCB tag + can reach. In practice no fuzz input will pass the AES-OCB tag (~2^-128), so + this exercises the reject path and every bit of buffer handling that runs + before and around the tag check: the length arithmetic, the nonce parse, the + memcpy into the fixed receive buffers, and ae_decrypt() over the bundled + AES-OCB implementation. Each such datagram must be rejected with a + Crypto::CryptoException and must never read or write out of bounds -- any + AddressSanitizer report here is a remote, keyless memory-safety bug. */ + +#include +#include +#include + +#include "src/crypto/crypto.h" + +extern "C" int LLVMFuzzerTestOneInput( const uint8_t* data, size_t size ) +{ + /* A fixed all-zero key (canonical 22-char base64). The point is not the key's + secrecy but that decrypt() handles arbitrary ciphertext safely. */ + static const std::string key_b64( 22, 'A' ); + static Crypto::Session session( ( Crypto::Base64Key( key_b64 ) ) ); + + try { + session.decrypt( reinterpret_cast( data ), size ); + } catch ( const Crypto::CryptoException& ) { + /* forged / short / oversized ciphertext: correctly rejected */ + } + + return 0; +} diff --git a/src/fuzz/fragment_fuzzer.cc b/src/fuzz/fragment_fuzzer.cc new file mode 100644 index 000000000..377ef95ea --- /dev/null +++ b/src/fuzz/fragment_fuzzer.cc @@ -0,0 +1,58 @@ +/* Fuzzes fragment reassembly: the wire-format Fragment header parse and the + FragmentAssembly state machine that reassembles an Instruction from a + counterparty's datagrams (parse header -> collect fragments -> uncompress + -> parse protobuf). This is reached right after OCB authentication. + + The input is used two ways: (1) parsed directly as a single wire Fragment, + and (2) chopped into a stream of (fragment_num, final, contents) records fed + to one FragmentAssembly to exercise the dedup / out-of-order-final / + completion logic. Malformed input must be dropped via a + Crypto::CryptoException, not abort. */ + +#include +#include +#include +#include + +#include "src/crypto/crypto.h" +#include "src/network/transportfragment.h" + +extern "C" int LLVMFuzzerTestOneInput( const uint8_t* data, size_t size ) +{ + const char* p = reinterpret_cast( data ); + + /* 1. parse the raw bytes as a single wire Fragment */ + try { + Network::Fragment frag( std::string( p, size ) ); + Network::FragmentAssembly single; + if ( single.add_fragment( frag ) ) { + single.get_assembly(); + } + } catch ( const Crypto::CryptoException& ) { + } + + /* 2. drive one FragmentAssembly with a stream of structured fragments that + all share an id (i.e. one logical packet) */ + try { + Network::FragmentAssembly assembly; + size_t pos = 0; + while ( pos + 4 <= size ) { + const uint16_t num = ( static_cast( data[pos] ) | ( static_cast( data[pos + 1] ) << 8 ) ) + & 0x7fff; + const bool final = data[pos + 2] & 1; + const size_t want = data[pos + 3]; + pos += 4; + const size_t take = std::min( want, size - pos ); + std::string contents( p + pos, take ); + pos += take; + + Network::Fragment frag( 0 /* shared id */, num, final, contents ); + if ( assembly.add_fragment( frag ) ) { + assembly.get_assembly(); + } + } + } catch ( const Crypto::CryptoException& ) { + } + + return 0; +} diff --git a/src/fuzz/statesync_fuzzer.cc b/src/fuzz/statesync_fuzzer.cc new file mode 100644 index 000000000..a96fc4503 --- /dev/null +++ b/src/fuzz/statesync_fuzzer.cc @@ -0,0 +1,38 @@ +/* Fuzzes the post-decryption state-sync input path: the serialized protobuf + "diff" a counterparty sends, applied by the client (Complete::apply_string, + host state) and the server (UserStream::apply_string, user state). This is + the exact attacker-controlled surface reached after a datagram is OCB- + authenticated, reassembled and decompressed. Malformed input is expected to + be dropped via a Crypto::CryptoException; anything else (an abort or another + uncaught exception) is a bug. */ + +#include +#include +#include + +#include "src/crypto/crypto.h" +#include "src/statesync/completeterminal.h" +#include "src/statesync/user.h" + +extern "C" int LLVMFuzzerTestOneInput( const uint8_t* data, size_t size ) +{ + const std::string diff( reinterpret_cast( data ), size ); + + /* client applying host state from the server */ + try { + Terminal::Complete complete( 80, 24 ); + complete.apply_string( diff ); + } catch ( const Crypto::CryptoException& ) { + /* malformed counterparty input, dropped */ + } + + /* server applying user state from the client */ + try { + Network::UserStream user; + user.apply_string( diff ); + } catch ( const Crypto::CryptoException& ) { + /* malformed counterparty input, dropped */ + } + + return 0; +} diff --git a/src/fuzz/terminal_host_fuzzer.cc b/src/fuzz/terminal_host_fuzzer.cc new file mode 100644 index 000000000..c8508b895 --- /dev/null +++ b/src/fuzz/terminal_host_fuzzer.cc @@ -0,0 +1,33 @@ +/* Fuzzes the host-output terminal emulator: the escape/control-sequence byte + stream a server sends to a client, driven through Complete::act(std::string) + -- the path that actually reaches terminaldispatcher, terminalfunctions + (CUP/DCH/ICH/scroll/ECH/...), OSC dispatch (including OSC 8 hyperlinks) and + the framebuffer mutators, and then the new_frame() diff/render. + + The existing terminal_fuzzer feeds bytes as Parser::UserByte, which exercises + the keystroke-translation path instead; this harness covers the emulator and + renderer that a malicious server's output actually drives. Any crash is a + bug. */ + +#include +#include +#include + +#include "src/statesync/completeterminal.h" +#include "src/terminal/terminaldisplay.h" +#include "src/terminal/terminalframebuffer.h" + +extern "C" int LLVMFuzzerTestOneInput( const uint8_t* data, size_t size ) +{ + Terminal::Complete complete( 80, 24 ); + + /* drive the emulator with the fuzzed host byte stream */ + complete.act( std::string( reinterpret_cast( data ), size ) ); + + /* exercise the frame diff/render against a blank reference framebuffer */ + Terminal::Display display( false ); + Terminal::Framebuffer blank( 80, 24 ); + display.new_frame( true, blank, complete.get_fb() ); + + return 0; +}