From d29150d158fb0d03de43850ffd7fe73b6858a048 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Sat, 21 Mar 2026 00:17:31 +0530 Subject: [PATCH] fuzz: add fuzz target for P2PGossipSync gossip message handling Signed-off-by: Nishant Bansal --- fuzz/src/bin/gen_target.sh | 1 + fuzz/src/bin/gossip_discovery_target.rs | 133 +++++++ fuzz/src/gossip_discovery.rs | 462 ++++++++++++++++++++++++ fuzz/src/lib.rs | 1 + fuzz/targets.h | 1 + 5 files changed, 598 insertions(+) create mode 100644 fuzz/src/bin/gossip_discovery_target.rs create mode 100644 fuzz/src/gossip_discovery.rs diff --git a/fuzz/src/bin/gen_target.sh b/fuzz/src/bin/gen_target.sh index b4f0c7a12b9..fd308a1f10e 100755 --- a/fuzz/src/bin/gen_target.sh +++ b/fuzz/src/bin/gen_target.sh @@ -29,6 +29,7 @@ GEN_TEST fromstr_to_netaddress GEN_TEST feature_flags GEN_TEST lsps_message GEN_TEST fs_store +GEN_TEST gossip_discovery GEN_TEST msg_accept_channel msg_targets:: GEN_TEST msg_announcement_signatures msg_targets:: diff --git a/fuzz/src/bin/gossip_discovery_target.rs b/fuzz/src/bin/gossip_discovery_target.rs new file mode 100644 index 00000000000..960ba80ec8c --- /dev/null +++ b/fuzz/src/bin/gossip_discovery_target.rs @@ -0,0 +1,133 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +// This file is auto-generated by gen_target.sh based on target_template.txt +// To modify it, modify target_template.txt and run gen_target.sh instead. + +#![cfg_attr(feature = "libfuzzer_fuzz", no_main)] +#![cfg_attr(rustfmt, rustfmt_skip)] + +#[cfg(not(fuzzing))] +compile_error!("Fuzz targets need cfg=fuzzing"); + +#[cfg(not(hashes_fuzz))] +compile_error!("Fuzz targets need cfg=hashes_fuzz"); + +#[cfg(not(secp256k1_fuzz))] +compile_error!("Fuzz targets need cfg=secp256k1_fuzz"); + +extern crate lightning_fuzz; +use lightning_fuzz::gossip_discovery::*; +use lightning_fuzz::utils::test_logger; + +#[cfg(feature = "afl")] +#[macro_use] extern crate afl; +#[cfg(feature = "afl")] +fn main() { + fuzz!(|data| { + gossip_discovery_test(&data, test_logger::DevNull {}); + }); +} + +#[cfg(feature = "honggfuzz")] +#[macro_use] extern crate honggfuzz; +#[cfg(feature = "honggfuzz")] +fn main() { + loop { + fuzz!(|data| { + gossip_discovery_test(&data, test_logger::DevNull {}); + }); + } +} + +#[cfg(feature = "libfuzzer_fuzz")] +#[macro_use] extern crate libfuzzer_sys; +#[cfg(feature = "libfuzzer_fuzz")] +fuzz_target!(|data: &[u8]| { + gossip_discovery_test(data, test_logger::DevNull {}); +}); + +#[cfg(feature = "stdin_fuzz")] +fn main() { + use std::io::Read; + + // On macOS, panic=abort causes the process to send SIGABRT which can leave it + // stuck in an uninterruptible state due to the ReportCrash daemon. Using + // process::exit in a panic hook avoids this by terminating cleanly. + #[cfg(target_os = "macos")] + std::panic::set_hook(Box::new(|panic_info| { + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!("{}\n{}", panic_info, std::backtrace::Backtrace::force_capture()); + let _ = std::io::stderr().flush(); + std::process::exit(1); + })); + + let mut data = Vec::with_capacity(8192); + std::io::stdin().read_to_end(&mut data).unwrap(); + gossip_discovery_test(&data, lightning_fuzz::utils::test_logger::Stdout {}); +} + +#[test] +fn run_test_cases() { + use std::fs; + use std::io::Read; + use lightning_fuzz::utils::test_logger::StringBuffer; + + use std::sync::{atomic, Arc}; + { + let data: Vec = vec![0]; + gossip_discovery_test(&data, test_logger::DevNull {}); + } + let mut threads = Vec::new(); + let threads_running = Arc::new(atomic::AtomicUsize::new(0)); + if let Ok(tests) = fs::read_dir("test_cases/gossip_discovery") { + for test in tests { + let mut data: Vec = Vec::new(); + let path = test.unwrap().path(); + fs::File::open(&path).unwrap().read_to_end(&mut data).unwrap(); + threads_running.fetch_add(1, atomic::Ordering::AcqRel); + + let thread_count_ref = Arc::clone(&threads_running); + let main_thread_ref = std::thread::current(); + threads.push((path.file_name().unwrap().to_str().unwrap().to_string(), + std::thread::spawn(move || { + let string_logger = StringBuffer::new(); + + let panic_logger = string_logger.clone(); + let res = if ::std::panic::catch_unwind(move || { + gossip_discovery_test(&data, panic_logger); + }).is_err() { + Some(string_logger.into_string()) + } else { None }; + thread_count_ref.fetch_sub(1, atomic::Ordering::AcqRel); + main_thread_ref.unpark(); + res + }) + )); + while threads_running.load(atomic::Ordering::Acquire) > 32 { + std::thread::park(); + } + } + } + let mut failed_outputs = Vec::new(); + for (test, thread) in threads.drain(..) { + if let Some(output) = thread.join().unwrap() { + println!("\nOutput of {}:\n{}\n", test, output); + failed_outputs.push(test); + } + } + if !failed_outputs.is_empty() { + println!("Test cases which failed: "); + for case in failed_outputs { + println!("{}", case); + } + panic!(); + } +} diff --git a/fuzz/src/gossip_discovery.rs b/fuzz/src/gossip_discovery.rs new file mode 100644 index 00000000000..f0e1424fc5d --- /dev/null +++ b/fuzz/src/gossip_discovery.rs @@ -0,0 +1,462 @@ +//! Test that no series of gossip messages received from peers can result in a crash. We do this +//! by standing up a `P2PGossipSync` with a `NetworkGraph` and a mock UTXO lookup, then reading +//! bytes from the fuzz input to denote actions such as connecting peers, feeding channel +//! announcements, node announcements, channel updates, or query messages. Both valid and +//! malformed messages are generated to exercise error paths. + +use bitcoin::amount::Amount; +use bitcoin::constants::ChainHash; +use bitcoin::hashes::{sha256d, Hash}; +use bitcoin::network::Network; +use bitcoin::secp256k1::ecdsa::Signature; +use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, SecretKey}; +use bitcoin::TxOut; + +use lightning::ln::chan_utils::make_funding_redeemscript; +use lightning::ln::msgs::BaseMessageHandler; +use lightning::ln::msgs::MessageSendEvent; +use lightning::ln::msgs::RoutingMessageHandler; +use lightning::ln::msgs::UnsignedGossipMessage; +use lightning::ln::msgs::{ + ChannelAnnouncement, ChannelUpdate, Init, NodeAnnouncement, QueryChannelRange, + UnsignedChannelAnnouncement, UnsignedChannelUpdate, UnsignedNodeAnnouncement, +}; +use lightning::routing::gossip::{NetworkGraph, NetworkUpdate, NodeAlias, NodeId, P2PGossipSync}; +use lightning::routing::utxo::{UtxoLookup, UtxoLookupError, UtxoResult}; +use lightning::types::features::{ChannelFeatures, NodeFeatures}; +use lightning::util::ser::Writeable; +use lightning::util::wakers::Notifier; + +use crate::utils::test_logger; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +struct FuzzUtxoLookup { + utxos: Mutex>, +} + +impl FuzzUtxoLookup { + fn new() -> Arc { + Arc::new(Self { utxos: Mutex::new(HashMap::new()) }) + } + + fn register(&self, scid: u64, txout: TxOut) { + self.utxos.lock().unwrap().insert(scid, txout); + } +} + +impl UtxoLookup for FuzzUtxoLookup { + fn get_utxo( + &self, _chain_hash: &ChainHash, short_channel_id: u64, + _async_completion_notifier: Arc, + ) -> UtxoResult { + let utxos = self.utxos.lock().unwrap(); + match utxos.get(&short_channel_id) { + Some(txout) => UtxoResult::Sync(Ok(txout.clone())), + None => UtxoResult::Sync(Err(UtxoLookupError::UnknownTx)), + } + } +} + +#[derive(Clone)] +struct Peer { + ln_secret: SecretKey, + btc_secret: SecretKey, + node_id: PublicKey, +} + +impl Peer { + fn new(ln_secret: SecretKey, btc_secret: SecretKey) -> Self { + let node_id = PublicKey::from_secret_key(&Secp256k1::signing_only(), &ln_secret); + Self { ln_secret, btc_secret, node_id } + } +} + +fn sign_gossip_message(msg: &UnsignedGossipMessage, node_secret: &SecretKey) -> Signature { + let msg_hash = Message::from_digest(sha256d::Hash::hash(&msg.encode()[..]).to_byte_array()); + Secp256k1::signing_only().sign_ecdsa(&msg_hash, node_secret) +} + +#[inline] +fn do_test(data: &[u8], out: Out) { + let logger = Arc::new(test_logger::TestLogger::new("".to_owned(), out)); + + let network = Network::Bitcoin; + let network_graph = Arc::new(NetworkGraph::new(network, Arc::clone(&logger))); + let utxo_lookup = FuzzUtxoLookup::new(); + let gossip = Arc::new(P2PGossipSync::new( + Arc::clone(&network_graph), + Some(Arc::clone(&utxo_lookup)), + Arc::clone(&logger), + )); + + let mut peers: Vec = Vec::new(); + + let mut read_pos = 0; + macro_rules! get_slice { + ($len: expr) => {{ + let slice_len = $len as usize; + if data.len() < read_pos + slice_len { + return; + } + read_pos += slice_len; + &data[read_pos - slice_len..read_pos] + }}; + } + + macro_rules! get_secret_key { + () => { + match SecretKey::from_slice(get_slice!(32)) { + Ok(key) => key, + Err(_) => return, + } + }; + } + + macro_rules! get_peer { + () => {{ + if peers.is_empty() { + continue; + } + + peers[(get_slice!(1)[0] as usize) % peers.len()].clone() + }}; + } + + // Mutates/malforms the field based on input bytes to test validation failure path. + macro_rules! mutate_field { + () => { + get_slice!(1)[0] % 2 == 0 + }; + } + + loop { + match get_slice!(1)[0] % 8 { + // Connect a new peer. + 0 => { + let btc_secret = get_secret_key!(); + let ln_secret = get_secret_key!(); + let peer = Peer::new(ln_secret, btc_secret); + + let init = Init { + features: gossip.provided_init_features(peer.node_id), + networks: None, + remote_network_address: None, + }; + gossip.peer_connected(peer.node_id, &init, true).unwrap(); + + let events = gossip.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + match &events[0] { + MessageSendEvent::SendGossipTimestampFilter { node_id, msg } => { + assert_eq!(*node_id, peer.node_id); + assert_eq!(msg.chain_hash, ChainHash::using_genesis_block(network)); + assert_eq!(msg.timestamp_range, u32::max_value()); + }, + _ => panic!("Expected SendGossipTimestampFilter event"), + } + + peers.push(peer); + }, + // Handle a node announcement. + 1 => { + let peer = get_peer!(); + let timestamp = u32::from_be_bytes(get_slice!(4).try_into().unwrap()); + let excess_data_large = get_slice!(1)[0] % 2 == 0; + let excess_data = if excess_data_large { vec![0u8; 1025] } else { Vec::new() }; + let excess_addr_large = get_slice!(1)[0] % 2 == 0; + let excess_address_data = + if excess_addr_large { vec![0u8; 1025] } else { Vec::new() }; + + let mut unsigned = UnsignedNodeAnnouncement { + features: NodeFeatures::empty(), + timestamp, + node_id: NodeId::from_pubkey(&peer.node_id), + rgb: [0u8; 3], + alias: NodeAlias([0u8; 32]), + addresses: Vec::new(), + excess_address_data, + excess_data, + }; + + if mutate_field!() { + if let Ok(pk) = PublicKey::from_slice(get_slice!(33)) { + unsigned.node_id = NodeId::from_pubkey(&pk); + } + } + + let sig = sign_gossip_message( + &UnsignedGossipMessage::NodeAnnouncement(&unsigned), + &peer.ln_secret, + ); + let mut ann = NodeAnnouncement { signature: sig, contents: unsigned }; + + if mutate_field!() { + if let Ok(s) = Signature::from_compact(get_slice!(64)) { + ann.signature = s; + } + } + + match gossip.handle_node_announcement(Some(peer.node_id), &ann) { + Ok(should_relay) => { + assert_eq!(should_relay, !excess_data_large && !excess_addr_large); + + let graph = network_graph.read_only(); + let node_id = NodeId::from_pubkey(&peer.node_id); + let node = graph.node(&node_id).unwrap(); + let info = node.announcement_info.as_ref().unwrap(); + assert_eq!(info.last_update(), timestamp); + }, + Err(_) => {}, + } + }, + // Handle a channel announcement. + 2 => { + let scid = u64::from_be_bytes(get_slice!(8).try_into().unwrap()); + let peer1 = get_peer!(); + let peer2 = get_peer!(); + + let secp = Secp256k1::signing_only(); + let btc_key1 = PublicKey::from_secret_key(&secp, &peer1.btc_secret); + let btc_key2 = PublicKey::from_secret_key(&secp, &peer2.btc_secret); + + let excess_data_large = get_slice!(1)[0] % 2 == 0; + let excess_data = if excess_data_large { vec![0u8; 1025] } else { Vec::new() }; + + let mut unsigned = UnsignedChannelAnnouncement { + features: ChannelFeatures::empty(), + chain_hash: ChainHash::using_genesis_block(network), + short_channel_id: scid, + node_id_1: NodeId::from_pubkey(&peer1.node_id), + node_id_2: NodeId::from_pubkey(&peer2.node_id), + bitcoin_key_1: NodeId::from_pubkey(&btc_key1), + bitcoin_key_2: NodeId::from_pubkey(&btc_key2), + excess_data, + }; + + if mutate_field!() { + if let Ok(pk) = PublicKey::from_slice(get_slice!(33)) { + unsigned.node_id_1 = NodeId::from_pubkey(&pk); + } + } + if mutate_field!() { + if let Ok(pk) = PublicKey::from_slice(get_slice!(33)) { + unsigned.node_id_2 = NodeId::from_pubkey(&pk); + } + } + if mutate_field!() { + if let Ok(pk) = PublicKey::from_slice(get_slice!(33)) { + unsigned.bitcoin_key_1 = NodeId::from_pubkey(&pk); + } + } + if mutate_field!() { + if let Ok(pk) = PublicKey::from_slice(get_slice!(33)) { + unsigned.bitcoin_key_2 = NodeId::from_pubkey(&pk); + } + } + if mutate_field!() { + unsigned.chain_hash = + ChainHash::from(<&[u8; 32]>::try_from(get_slice!(32)).unwrap()); + } + + let msg = UnsignedGossipMessage::ChannelAnnouncement(&unsigned); + let node_sig1 = sign_gossip_message(&msg, &peer1.ln_secret); + let btc_sig1 = sign_gossip_message(&msg, &peer1.btc_secret); + let node_sig2 = sign_gossip_message(&msg, &peer2.ln_secret); + let btc_sig2 = sign_gossip_message(&msg, &peer2.btc_secret); + + let mut ann = ChannelAnnouncement { + node_signature_1: node_sig1, + node_signature_2: node_sig2, + bitcoin_signature_1: btc_sig1, + bitcoin_signature_2: btc_sig2, + contents: unsigned, + }; + + if mutate_field!() { + if let Ok(s) = Signature::from_compact(get_slice!(64)) { + ann.node_signature_1 = s; + } + } + if mutate_field!() { + if let Ok(s) = Signature::from_compact(get_slice!(64)) { + ann.node_signature_2 = s; + } + } + if mutate_field!() { + if let Ok(s) = Signature::from_compact(get_slice!(64)) { + ann.bitcoin_signature_1 = s; + } + } + if mutate_field!() { + if let Ok(s) = Signature::from_compact(get_slice!(64)) { + ann.bitcoin_signature_2 = s; + } + } + + if !mutate_field!() { + let script_pubkey = make_funding_redeemscript(&btc_key1, &btc_key2).to_p2wsh(); + utxo_lookup.register( + scid, + TxOut { value: Amount::from_sat(1_000_000), script_pubkey }, + ); + } + + match gossip.handle_channel_announcement(Some(peer1.node_id), &ann) { + Ok(should_relay) => { + assert_eq!(should_relay, !excess_data_large); + + let graph = network_graph.read_only(); + let chan = graph.channel(scid).unwrap(); + assert_eq!(chan.node_one, NodeId::from_pubkey(&peer1.node_id)); + assert_eq!(chan.node_two, NodeId::from_pubkey(&peer2.node_id)); + + let node1_id = NodeId::from_pubkey(&peer1.node_id); + let node2_id = NodeId::from_pubkey(&peer2.node_id); + assert!(graph.node(&node1_id).is_some()); + assert!(graph.node(&node2_id).is_some()); + }, + Err(_) => {}, + } + }, + // Handle a channel update. + 3 => { + let scid = u64::from_be_bytes(get_slice!(8).try_into().unwrap()); + let fee_rate = u32::from_be_bytes(get_slice!(4).try_into().unwrap()); + let base_fee = u32::from_be_bytes(get_slice!(4).try_into().unwrap()); + let timestamp = u32::from_be_bytes(get_slice!(4).try_into().unwrap()); + let msg_flags = get_slice!(1)[0]; + let chan_flags = get_slice!(1)[0]; + let cltv_expiry_delta = u16::from_be_bytes(get_slice!(2).try_into().unwrap()); + let htlc_minimum_msat = u64::from_be_bytes(get_slice!(8).try_into().unwrap()); + let htlc_maximum_msat = u64::from_be_bytes(get_slice!(8).try_into().unwrap()); + + let peer = get_peer!(); + + let excess_data_large = get_slice!(1)[0] % 2 == 0; + let excess_data = if excess_data_large { vec![0u8; 1025] } else { Vec::new() }; + + let mut unsigned = UnsignedChannelUpdate { + chain_hash: ChainHash::using_genesis_block(network), + short_channel_id: scid, + timestamp, + message_flags: msg_flags, + channel_flags: chan_flags, + cltv_expiry_delta, + htlc_minimum_msat, + htlc_maximum_msat, + fee_base_msat: base_fee, + fee_proportional_millionths: fee_rate, + excess_data, + }; + + if mutate_field!() { + unsigned.chain_hash = + ChainHash::from(<&[u8; 32]>::try_from(get_slice!(32)).unwrap()); + } + + let sig = sign_gossip_message( + &UnsignedGossipMessage::ChannelUpdate(&unsigned), + &peer.ln_secret, + ); + let mut update = ChannelUpdate { signature: sig, contents: unsigned }; + + if mutate_field!() { + if let Ok(s) = Signature::from_compact(get_slice!(64)) { + update.signature = s; + } + } + + match gossip.handle_channel_update(Some(peer.node_id), &update) { + Ok(_) => { + let graph = network_graph.read_only(); + let chan = graph.channel(scid).unwrap(); + let info = chan.get_directional_info(chan_flags).unwrap(); + assert_eq!(info.last_update, timestamp); + }, + Err(_) => {}, + } + }, + // Handle query channel range. + 4 => { + let first_block = u32::from_be_bytes(get_slice!(4).try_into().unwrap()); + let num_blocks = u32::from_be_bytes(get_slice!(4).try_into().unwrap()); + + let peer = get_peer!(); + + let mut query = QueryChannelRange { + chain_hash: ChainHash::using_genesis_block(network), + first_blocknum: first_block, + number_of_blocks: num_blocks, + }; + + if mutate_field!() { + query.chain_hash = + ChainHash::from(<&[u8; 32]>::try_from(get_slice!(32)).unwrap()); + } + + let _ = gossip.handle_query_channel_range(peer.node_id, query); + + // handle_query_channel_range always enqueues at least one + // SendReplyChannelRange event regardless of success or failure. + let events = gossip.get_and_clear_pending_msg_events(); + assert!(!events.is_empty()); + for event in &events { + match event { + MessageSendEvent::SendReplyChannelRange { node_id, msg } => { + assert_eq!(*node_id, peer.node_id); + assert!(msg.sync_complete || events.len() > 1); + }, + _ => panic!("Expected SendReplyChannelRange event"), + } + } + // The last reply must have sync_complete set. + match events.last().unwrap() { + MessageSendEvent::SendReplyChannelRange { msg, .. } => { + assert!(msg.sync_complete); + }, + _ => unreachable!(), + } + }, + // Handle channel failure network update. + 5 => { + let scid = u64::from_be_bytes(get_slice!(8).try_into().unwrap()); + + network_graph.handle_network_update(&NetworkUpdate::ChannelFailure { + short_channel_id: scid, + is_permanent: true, + }); + + assert!(network_graph.read_only().channel(scid).is_none()); + }, + // Handle node failure network update. + 6 => { + let peer = get_peer!(); + let node_id = NodeId::from_pubkey(&peer.node_id); + + network_graph.handle_network_update(&NetworkUpdate::NodeFailure { + node_id: peer.node_id, + is_permanent: true, + }); + + assert!(network_graph.read_only().node(&node_id).is_none()); + }, + // Remove stale channels and tracking. + 7 => { + let time_unix = u64::from_be_bytes(get_slice!(8).try_into().unwrap()); + network_graph.remove_stale_channels_and_tracking_with_time(time_unix); + }, + _ => unreachable!(), + } + } +} + +pub fn gossip_discovery_test(data: &[u8], out: Out) { + do_test(data, out); +} + +#[no_mangle] +pub extern "C" fn gossip_discovery_run(data: *const u8, datalen: usize) { + do_test(unsafe { std::slice::from_raw_parts(data, datalen) }, test_logger::DevNull {}); +} diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index 582fa346c54..5f429ea2c3b 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -31,6 +31,7 @@ pub mod chanmon_deser; pub mod feature_flags; pub mod fromstr_to_netaddress; pub mod full_stack; +pub mod gossip_discovery; pub mod indexedmap; pub mod invoice_deser; pub mod invoice_request_deser; diff --git a/fuzz/targets.h b/fuzz/targets.h index 921439836af..ef8e899b178 100644 --- a/fuzz/targets.h +++ b/fuzz/targets.h @@ -22,6 +22,7 @@ void fromstr_to_netaddress_run(const unsigned char* data, size_t data_len); void feature_flags_run(const unsigned char* data, size_t data_len); void lsps_message_run(const unsigned char* data, size_t data_len); void fs_store_run(const unsigned char* data, size_t data_len); +void gossip_discovery_run(const unsigned char* data, size_t data_len); void msg_accept_channel_run(const unsigned char* data, size_t data_len); void msg_announcement_signatures_run(const unsigned char* data, size_t data_len); void msg_channel_reestablish_run(const unsigned char* data, size_t data_len);