Reject attempts to advance one-hop blinded forward paths
What changed, and why it matters
This commit fixes a denial-of-service bug in rust-lightning where a maliciously crafted one-hop blinded reply path could cause the node to panic when it tried to respond. The fix rejects paths with too few hops before advancing them, and adds a regression test to confirm no panic occurs.
Upgrade to a rust-lightning release containing this commit. Nodes processing untrusted onion messages or BOLT12 payments are at risk of remote-triggered panic until patched.
Security signals we found
Denial-of-service vector via malformed blinded path
Panic in onion construction due to zero-hop path
Untrusted reply path input validation gap
Regression test confirms fix prevents panic
Evidence from the diff
The patch prevents advancing blinded message and payment paths that have one or fewer hops, because advancing such a path would leave zero hops and later onion construction code would panic. It adds guards in BlindedMessagePath::advance_path_by_one and BlindedPaymentPath::advance_path_by_one, handles the error in outbound payment sending, and adds a regression test for onion message handling.
Changed components
lightning/src/blinded_path/message.rslightning/src/blinded_path/payment.rslightning/src/ln/outbound_payment.rslightning/src/onion_message/functional_tests.rsInspect captured patch +49 / −1
diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs
index 2f67cfd..ce36acd 100644
--- a/lightning/src/blinded_path/message.rs
+++ b/lightning/src/blinded_path/message.rs
@@ -209,6 +209,10 @@ impl BlindedMessagePath {
where
T: secp256k1::Signing + secp256k1::Verification,
{
+ if self.0.blinded_hops.len() <= 1 {
+ // The resulting blinded path has to always be left with at least one hop.
+ return Err(());
+ }
let control_tlvs_ss = node_signer.ecdh(Recipient::Node, &self.0.blinding_point, None)?;
let rho = onion_utils::gen_rho_from_shared_secret(&control_tlvs_ss.secret_bytes());
let encrypted_control_tlvs = &self.0.blinded_hops.get(0).ok_or(())?.encrypted_payload;
diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs
index 5fd608d..c11ee46 100644
--- a/lightning/src/blinded_path/payment.rs
+++ b/lightning/src/blinded_path/payment.rs
@@ -263,6 +263,10 @@ impl BlindedPaymentPath {
where
T: secp256k1::Signing + secp256k1::Verification,
{
+ if self.inner_path.blinded_hops.len() <= 1 {
+ // The resulting blinded path has to always be left with at least one hop.
+ return Err(());
+ }
let (next_node_id, control_tlvs_ss) =
match self.decrypt_intro_payload::<NS>(node_signer).map_err(|_| ())? {
(BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, .. }), ss) => {
diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs
index 24533ba..4b7e5ff 100644
--- a/lightning/src/ln/outbound_payment.rs
+++ b/lightning/src/ln/outbound_payment.rs
@@ -1193,7 +1193,14 @@ impl OutboundPayments {
},
};
if introduction_node_id == our_node_id {
- let _ = path.advance_path_by_one(node_signer, node_id_lookup, secp_ctx);
+ // TODO: Switch this to
+ // Bolt12PaymentError::SendingFailed(RetryableSendFailure::UnpayableInstructions)
+ // once we add it.
+ if let Err(()) = path.advance_path_by_one(node_signer, node_id_lookup, secp_ctx) {
+ let reason = PaymentFailureReason::RouteNotFound;
+ self.abandon_payment(payment_id, reason, pending_events);
+ Err(Bolt12PaymentError::SendingFailed(RetryableSendFailure::RouteNotFound))?
+ }
}
}
}
diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs
index 94536e0..ab3f616 100644
--- a/lightning/src/onion_message/functional_tests.rs
+++ b/lightning/src/onion_message/functional_tests.rs
@@ -846,6 +846,39 @@ fn we_are_intro_node() {
pass_along_path(&nodes);
}
+#[test]
+fn malformed_one_hop_reply_path_does_not_panic_when_we_are_intro_node() {
+ // A reply path may be provided by an untrusted sender. If its only hop is a forward hop for us,
+ // advancing it must reject the path instead of leaving a zero-hop destination behind.
+ let nodes = create_nodes(2);
+ let secp_ctx = Secp256k1::new();
+ let intermediate_nodes =
+ [MessageForwardNode { node_id: nodes[0].node_id, short_channel_id: None }];
+ let valid_reply_path = BlindedMessagePath::new(
+ &intermediate_nodes,
+ nodes[1].node_id,
+ nodes[1].messenger.node_signer.get_receive_auth_key(),
+ MessageContext::Custom(Vec::new()),
+ false,
+ &*nodes[1].entropy_source,
+ &secp_ctx,
+ );
+ let malformed_reply_path = BlindedMessagePath::from_blinded_path(
+ nodes[0].node_id,
+ valid_reply_path.blinding_point(),
+ vec![valid_reply_path.blinded_hops()[0].clone()],
+ );
+
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ nodes[0].messenger.handle_onion_message_response(
+ TestCustomMessage::Pong,
+ Responder::new(malformed_reply_path).respond(),
+ )
+ }));
+ assert!(result.is_ok(), "responding over a malformed reply path must not panic");
+ assert_eq!(result.unwrap(), Err(SendError::BlindedPathAdvanceFailed));
+}
+
#[test]
fn invalid_blinded_path_error() {
// Make sure we error as expected if a provided blinded path has 0 hops.
Why this scored 64/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.