Merge pull request #11482 from Roasbeef/pub/unsigned-acked-wrap
What changed, and why it matters
This patch fixes a database bug in the Lightning Network Daemon (LND) where very long lists of unsigned, acknowledged channel updates could be silently truncated when saved to disk. Because the storage format used a 16-bit counter, lists longer than 65,535 entries would have their count wrapped around to zero, causing updates to be lost after a restart. The fix rejects oversized lists before writing and also detects and rejects corrupted/truncated records when loading.
Treat as a security-relevant bug fix and include in the next maintenance release. Operators should upgrade nodes that handle high-volume channels; no immediate rotation of secrets is required unless abnormal channel force-closures are observed.
Security signals we found
Integer truncation / wrap-around in serialization format (uint16 count)
Silent data loss on restart due to undersized length field
Defensive deserialization with count-modulo validation
Runtime pre-condition check before state-mutating revocation
Release notes explicitly describe the issue as a bug fix for silent update loss
Evidence from the diff
The channeldb/chanstate serialization for LogUpdates writes the list length as a uint16. Previously, if len(logUpdates) exceeded MaxUint16, the cast to uint16 silently wrapped (e.g., 65536 became 0), and deserialization would read zero entries, dropping all updates. The patch adds an explicit size check in SerializeLogUpdates, a runtime guard in RevokeCurrentCommitment using the in-memory tip index, and a defensive deserialization path in channeldb.deserializeLogUpdates that detects trailing bytes from wrapped counts and validates the total count modulo 2^16. Tests cover both the new rejection behavior and recovery of the wrapped-count case.
Changed components
lnwallet/channel.go RevokeCurrentCommitment / getUnsignedAckedUpdateschanstate/kv_commitment.go SerializeLogUpdateschanneldb/channel.go deserializeLogUpdateschanneldb/chanstate log-update persistence formatInspect captured patch +218 / −12
### channeldb/channel.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
+ "math"
"net"
"github.com/btcsuite/btcd/btcec/v2"
@@ -1120,8 +1121,38 @@ func serializeLogUpdates(w io.Writer, logUpdates []LogUpdate) error {
}
// deserializeLogUpdates deserializes a list of updates from a stream.
-func deserializeLogUpdates(r io.Reader) ([]LogUpdate, error) {
- return cstate.DeserializeLogUpdates(r)
+func deserializeLogUpdates(r *bytes.Reader) ([]LogUpdate, error) {
+ updates, err := cstate.DeserializeLogUpdates(r)
+ if err != nil || r.Len() == 0 {
+ return updates, err
+ }
+
+ // Older versions wrote the complete list after a wrapped uint16 count.
+ // Recover those entries so a reconnect cannot silently drop a settle.
+ declaredCount := len(updates)
+ for r.Len() > 0 {
+ var update LogUpdate
+ if err := cstate.ReadElements(
+ r, &update.LogIndex, &update.UpdateMsg,
+ ); err != nil {
+ return nil, fmt.Errorf(
+ "unable to read trailing log update: %w", err,
+ )
+ }
+
+ updates = append(updates, update)
+ }
+
+ if len(updates) <= math.MaxUint16 ||
+ len(updates)%(math.MaxUint16+1) != declaredCount {
+
+ return nil, fmt.Errorf(
+ "log update count mismatch: declared %d, found %d",
+ declaredCount, len(updates),
+ )
+ }
+
+ return updates, nil
}
// AppendRemoteCommitChain appends a new CommitDiff to the remote party's
### channeldb/log_updates_test.go
@@ -0,0 +1,52 @@
+package channeldb
+
+import (
+ "bytes"
+ "encoding/binary"
+ "math"
+ "testing"
+
+ "github.com/lightningnetwork/lnd/chanstate"
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeserializeWrappedLogUpdates(t *testing.T) {
+ var buf bytes.Buffer
+ require.NoError(t, binary.Write(&buf, binary.BigEndian, uint16(0)))
+
+ fee := &lnwire.UpdateFee{FeePerKw: 1000}
+ for i := 0; i <= math.MaxUint16; i++ {
+ var update lnwire.Message = fee
+ if i == 0 {
+ update = &lnwire.UpdateFulfillHTLC{ID: 7}
+ }
+
+ require.NoError(t, chanstate.WriteElements(
+ &buf, uint64(i), update,
+ ))
+ }
+
+ updates, err := deserializeLogUpdates(bytes.NewReader(buf.Bytes()))
+ require.NoError(t, err)
+ require.Len(t, updates, math.MaxUint16+1)
+ require.Equal(t, uint64(0), updates[0].LogIndex)
+ require.IsType(t, &lnwire.UpdateFulfillHTLC{}, updates[0].UpdateMsg)
+ require.Equal(
+ t, uint64(math.MaxUint16), updates[len(updates)-1].LogIndex,
+ )
+}
+
+func TestDeserializeLogUpdatesRejectsTrailingData(t *testing.T) {
+ _, err := deserializeLogUpdates(bytes.NewReader([]byte{0, 0, 1}))
+ require.ErrorContains(t, err, "unable to read trailing log update")
+
+ var buf bytes.Buffer
+ require.NoError(t, binary.Write(&buf, binary.BigEndian, uint16(0)))
+ require.NoError(t, chanstate.WriteElements(
+ &buf, uint64(1), &lnwire.UpdateFee{FeePerKw: 1000},
+ ))
+
+ _, err = deserializeLogUpdates(bytes.NewReader(buf.Bytes()))
+ require.ErrorContains(t, err, "log update count mismatch")
+}
### chanstate/kv_commitment.go
@@ -5,6 +5,7 @@ import (
"encoding/binary"
"fmt"
"io"
+ "math"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd/fn/v2"
@@ -513,6 +514,11 @@ func extractCommitTlvData(c *ChannelCommitment) commitTlvData {
// SerializeLogUpdates serializes provided list of updates to a stream.
func SerializeLogUpdates(w io.Writer, logUpdates []LogUpdate) error {
+ if len(logUpdates) > math.MaxUint16 {
+ return fmt.Errorf("too many log updates: %d exceeds %d",
+ len(logUpdates), math.MaxUint16)
+ }
+
numUpdates := uint16(len(logUpdates))
if err := binary.Write(w, byteOrder, numUpdates); err != nil {
return err
### chanstate/kv_commitment_log_test.go
@@ -0,0 +1,38 @@
+package chanstate
+
+import (
+ "bytes"
+ "math"
+ "testing"
+
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSerializeLogUpdatesCountLimit(t *testing.T) {
+ updates := make([]LogUpdate, math.MaxUint16+1)
+ fee := &lnwire.UpdateFee{FeePerKw: 1000}
+ for i := range updates {
+ updates[i] = LogUpdate{
+ LogIndex: uint64(i),
+ UpdateMsg: fee,
+ }
+ }
+
+ var buf bytes.Buffer
+ require.NoError(t, SerializeLogUpdates(
+ &buf, updates[:math.MaxUint16],
+ ))
+
+ decoded, err := DeserializeLogUpdates(bytes.NewReader(buf.Bytes()))
+ require.NoError(t, err)
+ require.Len(t, decoded, math.MaxUint16)
+ require.Equal(
+ t, uint64(math.MaxUint16-1), decoded[len(decoded)-1].LogIndex,
+ )
+
+ buf.Reset()
+ err = SerializeLogUpdates(&buf, updates)
+ require.ErrorContains(t, err, "too many log updates")
+ require.Zero(t, buf.Len())
+}
### docs/release-notes/unreleased/11482.md
@@ -0,0 +1,6 @@
+# Bug Fixes
+
+* The channel database now [rejects oversized unsigned acked update
+ logs](https://github.com/lightningnetwork/lnd/pull/11482) instead of
+ truncating the entry count on write, and validates the record on load so
+ a restart cannot silently drop previously acknowledged channel updates.
### lnwallet/channel.go
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
+ "math"
"slices"
"sync"
@@ -3730,15 +3731,13 @@ func (lc *LightningChannel) createCommitDiff(newCommit *commitment,
// getUnsignedAckedUpdates returns all remote log updates that we haven't
// signed for yet ourselves.
-func (lc *LightningChannel) getUnsignedAckedUpdates() []channeldb.LogUpdate {
+func (lc *LightningChannel) getUnsignedAckedUpdates(
+ lastLocalCommitted uint64) []channeldb.LogUpdate {
+
// Fetch the last remote update that we have signed for.
lastRemoteCommitted :=
lc.commitChains.Remote.tail().messageIndices.Remote
- // Fetch the last remote update that we have acked.
- lastLocalCommitted :=
- lc.commitChains.Local.tail().messageIndices.Remote
-
// We'll now run through the remote update log to locate the items that
// we haven't signed for yet. This will be the set of items we need to
// restore if we reconnect in order to produce the signature that the
@@ -5853,6 +5852,19 @@ func (lc *LightningChannel) RevokeCurrentCommitment() (*lnwire.RevokeAndAck,
lc.Lock()
defer lc.Unlock()
+ // Check the next tail before changing the in-memory commitment chain. A
+ // larger list cannot be written to the existing uint16 disk format.
+ lastLocalCommitted := lc.commitChains.Local.tip().messageIndices.Remote
+ unsignedAckedUpdates := lc.getUnsignedAckedUpdates(
+ lastLocalCommitted,
+ )
+ if len(unsignedAckedUpdates) > math.MaxUint16 {
+ return nil, nil, nil, fmt.Errorf(
+ "too many unsigned acked updates: %d exceeds %d",
+ len(unsignedAckedUpdates), math.MaxUint16,
+ )
+ }
+
revocationMsg, err := lc.generateRevocation(lc.currentHeight)
if err != nil {
return nil, nil, nil, err
@@ -5871,11 +5883,6 @@ func (lc *LightningChannel) RevokeCurrentCommitment() (*lnwire.RevokeAndAck,
chainTail := lc.commitChains.Local.tail()
newCommitment := chainTail.toDiskCommit(lntypes.Local)
- // Get the unsigned acked remotes updates that are currently in memory.
- // We need them after a restart to sync our remote commitment with what
- // is committed locally.
- unsignedAckedUpdates := lc.getUnsignedAckedUpdates()
-
finalHtlcs, err := lc.channelState.UpdateCommitment(
newCommitment, unsignedAckedUpdates,
)
### lnwallet/channel_log_limit_test.go
@@ -0,0 +1,66 @@
+package lnwallet
+
+import (
+ "math"
+ "testing"
+
+ "github.com/btcsuite/btcd/wire/v2"
+ "github.com/lightningnetwork/lnd/channeldb"
+ "github.com/lightningnetwork/lnd/lntypes"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRevokeRejectsTooManyUnsignedAckedUpdates(t *testing.T) {
+ channel, _, err := CreateTestChannels(
+ t, channeldb.SingleFunderTweaklessBit,
+ )
+ require.NoError(t, err)
+
+ for i := 0; i <= math.MaxUint16; i++ {
+ channel.updateLogs.Remote.appendUpdate(&paymentDescriptor{
+ EntryType: FeeUpdate,
+ LogIndex: uint64(i),
+ Amount: 1000,
+ })
+ }
+
+ channel.commitChains.Local.addCommitment(&commitment{
+ messageIndices: lntypes.Dual[uint64]{
+ Remote: math.MaxUint16 + 1,
+ },
+ })
+
+ oldTail := channel.commitChains.Local.tail()
+ oldHeight := channel.currentHeight
+ _, _, _, err = channel.RevokeCurrentCommitment()
+ require.ErrorContains(t, err, "too many unsigned acked updates")
+ require.Same(t, oldTail, channel.commitChains.Local.tail())
+ require.Equal(t, oldHeight, channel.currentHeight)
+}
+
+func TestRevokeAllowsMaxUnsignedAckedUpdates(t *testing.T) {
+ channel, _, err := CreateTestChannels(
+ t, channeldb.SingleFunderTweaklessBit,
+ )
+ require.NoError(t, err)
+
+ // Fill the acked update window to the largest list the on-disk uint16
+ // count can express. The revocation must still go through.
+ for i := range math.MaxUint16 {
+ channel.updateLogs.Remote.appendUpdate(&paymentDescriptor{
+ EntryType: FeeUpdate,
+ LogIndex: uint64(i),
+ Amount: 1000,
+ })
+ }
+
+ channel.commitChains.Local.addCommitment(&commitment{
+ txn: wire.NewMsgTx(2),
+ messageIndices: lntypes.Dual[uint64]{
+ Remote: math.MaxUint16,
+ },
+ })
+
+ _, _, _, err = channel.RevokeCurrentCommitment()
+ require.NoError(t, err)
+}Why this scored 66/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.