What changed, and why it matters
This change fixes how LND handles certain ping messages from other Lightning nodes. Previously, LND would disconnect from peers that sent pings using a special 'no-reply' range defined in the BOLT 1 protocol spec. Now LND correctly accepts and ignores those pings, keeping the connection alive. This improves compatibility with peers that use these pings as padding, especially during channel re-establishment after reconnecting.
No immediate security response required. This is a protocol compatibility improvement. Operators should upgrade to avoid unnecessary disconnections from spec-compliant peers. No mitigation or workaround is needed beyond normal patch deployment.
Security signals we found
Protocol compatibility fix for BOLT 1 no-reply ping range
Previously caused peer disconnection on spec-compliant pings
Could be triggered by remote peer during channel_reestablish padding
No authentication bypass, memory corruption, or cryptographic weakness introduced
Evidence from the diff
The commit updates ping deserialization and peer handling to comply with BOLT 1’s no-reply ping semantics. lnwire/ping.go no longer rejects pings where NumPongBytes exceeds MaxPongBytes (65531). peer/brontide.go now skips sending a pong reply when NumPongBytes is in the 65532-65535 range, continuing the read loop instead of disconnecting. Tests are added for both decoding and peer-level behavior.
Changed components
lnwire/ping.golnwire/ping_test.gopeer/brontide.gopeer/brontide_test.goInspect captured patch +130 / −4
diff --git a/lnwire/ping.go b/lnwire/ping.go
index 230187b..b864c40 100644
--- a/lnwire/ping.go
+++ b/lnwire/ping.go
@@ -47,10 +47,8 @@ func (p *Ping) Decode(r io.Reader, pver uint32) error {
return err
}
- if p.NumPongBytes > MaxPongBytes {
- return ErrMaxPongBytesExceeded
- }
-
+ // Values above MaxPongBytes are still valid on the wire. Per BOLT 1,
+ // receivers must ignore those pings rather than fail deserialization.
return nil
}
diff --git a/lnwire/ping_test.go b/lnwire/ping_test.go
new file mode 100644
index 0000000..0cc60cf
--- /dev/null
+++ b/lnwire/ping_test.go
@@ -0,0 +1,51 @@
+package lnwire
+
+import (
+ "bytes"
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestPingDecodeAllowsNoReplyPongSizes asserts that ping messages using the
+// BOLT 1 no-reply sentinel range still deserialize successfully.
+func TestPingDecodeAllowsNoReplyPongSizes(t *testing.T) {
+ t.Parallel()
+
+ // Arrange: Pick values from the BOLT 1 no-reply range. These
+ // pings are valid on the wire and should decode successfully
+ // even though they do not require a pong response.
+ testCases := []uint16{65532, 65535}
+
+ for _, numPongBytes := range testCases {
+ numPongBytes := numPongBytes
+ testName := strconv.FormatUint(uint64(numPongBytes), 10)
+
+ t.Run(testName, func(t *testing.T) {
+ // Arrange: Encode a ping carrying a no-reply pong
+ // size together with a small payload so we exercise
+ // the normal wire format.
+ var buf bytes.Buffer
+
+ want := &Ping{
+ NumPongBytes: numPongBytes,
+ PaddingBytes: PingPayload{1, 2, 3},
+ }
+
+ _, err := WriteMessage(&buf, want, 0)
+ require.NoError(t, err)
+
+ // Act: Decode the serialized message through the
+ // standard parser.
+ msg, err := ReadMessage(bytes.NewReader(buf.Bytes()), 0)
+ require.NoError(t, err)
+
+ // Assert: The decoded ping matches the original
+ // input exactly.
+ got, ok := msg.(*Ping)
+ require.True(t, ok)
+ require.Equal(t, want, got)
+ })
+ }
+}
diff --git a/peer/brontide.go b/peer/brontide.go
index 336b88e..c659a02 100644
--- a/peer/brontide.go
+++ b/peer/brontide.go
@@ -2220,6 +2220,13 @@ out:
// the relevant atomic variable.
p.lastPingPayload.Store(msg.PaddingBytes[:])
+ // BOLT 1 requires us to ignore pings requesting 65532
+ // or more pong bytes instead of replying or
+ // disconnecting.
+ if msg.NumPongBytes > lnwire.MaxPongBytes {
+ continue
+ }
+
// Next, we'll send over the amount of specified pong
// bytes.
pong := lnwire.NewPong(p.cfg.PongBuf[0:msg.NumPongBytes])
diff --git a/peer/brontide_test.go b/peer/brontide_test.go
index 10091b6..2a2cb73 100644
--- a/peer/brontide_test.go
+++ b/peer/brontide_test.go
@@ -1073,6 +1073,76 @@ func TestPeerCustomMessage(t *testing.T) {
require.Equal(t, receivedCustomMsg, &receivedCustom.msg)
}
+// TestPeerIgnoresPingWithoutPongReply ensures we keep the connection alive for
+// pings using the BOLT 1 no-reply sentinel range.
+func TestPeerIgnoresPingWithoutPongReply(t *testing.T) {
+ t.Parallel()
+
+ // Arrange: Start a peer using the mock connection so we can
+ // inject incoming pings and observe any outgoing responses.
+ params := createTestPeer(t)
+
+ var (
+ mockConn = params.mockConn
+ alicePeer = params.peer
+ )
+
+ startPeerDone := startPeer(t, mockConn, alicePeer)
+ _, err := fn.RecvOrTimeout(startPeerDone, 2*timeout)
+ require.NoError(t, err)
+
+ writePing := func(msg *lnwire.Ping) {
+ t.Helper()
+
+ var b bytes.Buffer
+ _, err := lnwire.WriteMessage(&b, msg, 0)
+ require.NoError(t, err)
+
+ select {
+ case mockConn.readMessages <- b.Bytes():
+ case <-time.After(timeout):
+ t.Fatal("timeout sending ping to peer")
+ }
+ }
+
+ // Act: Deliver a ping in the BOLT 1 no-reply range.
+ ignoredPayload := []byte{1, 2, 3}
+ writePing(&lnwire.Ping{
+ NumPongBytes: 65535,
+ PaddingBytes: ignoredPayload,
+ })
+
+ // Assert: The peer records the latest ping payload for observability.
+ require.Eventually(t, func() bool {
+ return bytes.Equal(
+ alicePeer.LastRemotePingPayload(), ignoredPayload,
+ )
+ }, timeout, 10*time.Millisecond)
+
+ // Assert: No pong is sent for the no-reply sentinel range.
+ select {
+ case rawMsg := <-mockConn.writtenMessages:
+ t.Fatalf("expected no pong reply, got %x", rawMsg)
+ case <-time.After(100 * time.Millisecond):
+ }
+
+ // Act: Send a normal ping afterward to prove the peer
+ // stayed connected and still handles standard ping/pong
+ // traffic.
+ writePing(&lnwire.Ping{NumPongBytes: 1})
+
+ rawMsg, err := fn.RecvOrTimeout(mockConn.writtenMessages, timeout)
+ require.NoError(t, err)
+
+ msg, err := lnwire.ReadMessage(bytes.NewReader(rawMsg), 0)
+ require.NoError(t, err)
+
+ // Assert: The follow-up ping receives the requested pong reply.
+ pong, ok := msg.(*lnwire.Pong)
+ require.True(t, ok)
+ require.Len(t, pong.PongBytes, 1)
+}
+
// TestUpdateNextRevocation checks that the method `updateNextRevocation` is
// behave as expected.
func TestUpdateNextRevocation(t *testing.T) {
Why this scored 41/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.