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..8eee8dc482b --- /dev/null +++ b/fuzz/src/gossip_discovery.rs @@ -0,0 +1,265 @@ +// 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. + +//! 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 feeding channel announcements, node +//! announcements, channel updates, query messages, and pruning channels and nodes. Both valid +//! and malformed messages are generated to exercise error paths. + +use bitcoin::amount::Amount; +use bitcoin::constants::ChainHash; +use bitcoin::network::Network; +use bitcoin::secp256k1::PublicKey; +use bitcoin::TxOut; + +use lightning::ln::chan_utils::make_funding_redeemscript; +use lightning::ln::msgs::{self, BaseMessageHandler, MessageSendEvent, RoutingMessageHandler}; +use lightning::routing::gossip::{NetworkGraph, NetworkUpdate, NodeId, P2PGossipSync}; +use lightning::routing::utxo::{UtxoLookup, UtxoLookupError, UtxoResult}; +use lightning::util::ser::LengthReadable; +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)), + } + } +} + +#[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 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_pubkey { + () => { + match PublicKey::from_slice(get_slice!(33)) { + Ok(key) => key, + Err(_) => continue, + } + }; + } + + macro_rules! decode_msg { + ($MsgType: path) => {{ + let len_bytes = get_slice!(2); + let msg_len = u16::from_be_bytes(len_bytes.try_into().unwrap()) as usize; + if msg_len == 0 { + continue; + } + let msg_data = get_slice!(msg_len); + let mut reader = &msg_data[..]; + match <$MsgType>::read_from_fixed_length_buffer(&mut reader) { + Ok(msg) => { + assert!(reader.is_empty()); + msg + }, + Err(e) => match e { + msgs::DecodeError::UnknownVersion => continue, + msgs::DecodeError::UnknownRequiredFeature => continue, + msgs::DecodeError::InvalidValue => continue, + msgs::DecodeError::BadLengthDescriptor => continue, + msgs::DecodeError::ShortRead => continue, + msgs::DecodeError::Io(e) => panic!("{:?}", e), + msgs::DecodeError::UnsupportedCompression => continue, + msgs::DecodeError::DangerousValue => continue, + }, + } + }}; + } + + loop { + match get_slice!(1)[0] % 7 { + // Handle a node announcement. + 0 => { + let node_ann = decode_msg!(msgs::NodeAnnouncement); + let Ok(peer_node_id) = node_ann.contents.node_id.as_pubkey() else { + continue; + }; + + match gossip.handle_node_announcement(Some(peer_node_id), &node_ann) { + Ok(_) => { + let graph = network_graph.read_only(); + let node = graph.node(&node_ann.contents.node_id).unwrap(); + let info = node.announcement_info.as_ref().unwrap(); + assert_eq!(info.last_update(), node_ann.contents.timestamp); + }, + Err(_) => {}, + } + }, + // Handle a channel announcement. + 1 => { + let chan_ann = decode_msg!(msgs::ChannelAnnouncement); + let scid = chan_ann.contents.short_channel_id; + let Ok(peer_node_id) = chan_ann.contents.node_id_1.as_pubkey() else { + continue; + }; + let Ok(btc_key1) = chan_ann.contents.bitcoin_key_1.as_pubkey() else { + continue; + }; + let Ok(btc_key2) = chan_ann.contents.bitcoin_key_2.as_pubkey() else { + continue; + }; + + // We conditionally register the funding script in the UTXO set so that valid funding + // script cases are also validated. + if (get_slice!(1)[0] & 1) != 0 { + 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(peer_node_id), &chan_ann) { + Ok(_) => { + let graph = network_graph.read_only(); + let chan = graph.channel(scid).unwrap(); + assert_eq!(chan.node_one, chan_ann.contents.node_id_1); + assert_eq!(chan.node_two, chan_ann.contents.node_id_2); + + assert!(graph.node(&chan_ann.contents.node_id_1).is_some()); + assert!(graph.node(&chan_ann.contents.node_id_2).is_some()); + }, + Err(_) => {}, + } + }, + // Handle a channel update. + 2 => { + let chan_upd = decode_msg!(msgs::ChannelUpdate); + let peer_node_id = get_pubkey!(); + + match gossip.handle_channel_update(Some(peer_node_id), &chan_upd) { + Ok(_) => { + let graph = network_graph.read_only(); + let chan = graph.channel(chan_upd.contents.short_channel_id).unwrap(); + let info = + chan.get_directional_info(chan_upd.contents.channel_flags).unwrap(); + assert_eq!(info.last_update, chan_upd.contents.timestamp); + }, + Err(_) => {}, + } + }, + // Handle query channel range. + 3 => { + let query = decode_msg!(msgs::QueryChannelRange); + let peer_node_id = get_pubkey!(); + + 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); + }, + _ => panic!("Expected SendReplyChannelRange event"), + } + }, + // Handle channel failure network update. + 4 => { + 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. + 5 => { + let peer_node_id = get_pubkey!(); + + network_graph.handle_network_update(&NetworkUpdate::NodeFailure { + node_id: peer_node_id, + is_permanent: true, + }); + + assert!(network_graph + .read_only() + .node(&NodeId::from_pubkey(&peer_node_id)) + .is_none()); + }, + // Remove stale channels and tracking. + 6 => { + 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);