lnwallet/chancloser: validate delivery scripts in the RBF closer
What changed, and why it matters
This commit fixes a bug in LND's cooperative channel-closing code where a peer could supply an invalid, empty, or even money-burning Bitcoin address during a close, and LND would accept it without checking. The fix makes the newer 'RBF' closer always validate the peer's payout address, matching what the older negotiation closer already did. Before, validation only happened if the peer had previously committed to a specific upfront address. Without that, a malicious or buggy peer could have caused funds to be sent to an unspendable or malformed script.
Upgrade to a release containing this commit. Nodes running LND with the RBF cooperative close path should ensure peers cannot supply malformed or unspendable shutdown scripts. Review any channels that were closed using the RBF closer for unexpected delivery scripts.
Security signals we found
Missing input validation on remote-supplied shutdown/delivery script
Empty/zero-length script accepted as valid due to nil-treatment in validator
Mid-negotiation script swap via ClosingComplete not validated
Funds could be directed to provably unspendable scripts (OP_RETURN) or unsupported legacy forms (P2PKH/P2SH)
Fix aligns RBF closer behavior with existing negotiation closer behavior
Evidence from the diff
In lnwallet/chancloser/rbf_coop_transitions.go, validateShutdown previously only called validateShutdownScript when an upfront shutdown script was recorded (fn.MapOption over upfrontAddr). The patch introduces validateRemoteDeliveryScript, which always runs well-formedness checks on the remote delivery script and, if present, enforces exact-match against the upfront script. It also rejects empty scripts explicitly, since the wire format allows zero-length DeliveryAddress and validateShutdownScript treated nil/absent as a no-op. The same helper is now used in ClosingNegotiation.updateAndValidateCloseTerms when a CloserScript is swapped in via ClosingComplete, closing a second bypass. Tests were added covering empty, garbage, OP_RETURN, bare OP_RETURN, P2PKH, and P2SH scripts, all rejected when no upfront address is configured.
Changed components
lnwallet/chancloser/rbf_coop_transitions.goRBF cooperative channel closerClosingNegotiation.updateAndValidateCloseTermsvalidateShutdown / validateRemoteDeliveryScriptInspect captured patch +131 / −14
### lnwallet/chancloser/rbf_coop_test.go
@@ -1466,6 +1466,89 @@ func TestRbfChannelActiveTransitions(t *testing.T) {
)
})
+ // Even when the remote party never committed to an upfront shutdown
+ // script, we should still validate the delivery script they send, and
+ // reject one that isn't a well-formed delivery script.
+ name := "remote_initiated_bad_script_no_upfront_fail"
+ t.Run(name, func(t *testing.T) {
+ // The spec dropped p2pkh and p2sh for co-op closes to keep the
+ // dust calculations uniform, and a delivery script has to be
+ // something we can actually pay to, so none of these are
+ // acceptable even though some of them are perfectly valid
+ // scripts in their own right.
+ badScripts := []struct {
+ name string
+ script lnwire.DeliveryAddress
+ }{
+ {
+ name: "empty",
+ script: lnwire.DeliveryAddress{},
+ },
+ {
+ name: "garbage",
+ script: lnwire.DeliveryAddress(
+ bytes.Repeat([]byte{0xff}, 5),
+ ),
+ },
+ {
+ // Provably unspendable: paying a close output
+ // here would burn the remote party's balance.
+ name: "op_return",
+ script: lnwire.DeliveryAddress(append(
+ []byte{txscript.OP_RETURN, 32},
+ bytes.Repeat([]byte{0xAB}, 32)...,
+ )),
+ },
+ {
+ name: "bare_op_return",
+ script: lnwire.DeliveryAddress(
+ []byte{txscript.OP_RETURN},
+ ),
+ },
+ {
+ name: "p2pkh",
+ script: lnwire.DeliveryAddress(append(append(
+ []byte{
+ txscript.OP_DUP,
+ txscript.OP_HASH160, 20,
+ },
+ bytes.Repeat([]byte{0xAB}, 20)...,
+ ),
+ txscript.OP_EQUALVERIFY,
+ txscript.OP_CHECKSIG,
+ )),
+ },
+ {
+ name: "p2sh",
+ script: lnwire.DeliveryAddress(append(append(
+ []byte{txscript.OP_HASH160, 20},
+ bytes.Repeat([]byte{0xAB}, 20)...,
+ ), txscript.OP_EQUAL)),
+ },
+ }
+
+ for _, badScript := range badScripts {
+ t.Run(badScript.name, func(t *testing.T) {
+ // Note the config carries no remoteUpfrontAddr,
+ // so the only thing standing between the peer's
+ // script and the rest of the close flow is the
+ // delivery-script validation itself.
+ closeHarness := newCloser(t, &harnessCfg{
+ localUpfrontAddr: fn.Some(localAddr),
+ })
+ defer closeHarness.stopAndAssert()
+
+ event := &ShutdownReceived{
+ ShutdownScript: badScript.script,
+ }
+ closeHarness.sendEventAndExpectFailure(
+ ctx, event, ErrInvalidShutdownScript,
+ )
+ closeHarness.assertNoStateTransitions()
+ })
+ }
+ })
+
// When we receive a shutdown, we should transition to the shutdown
// pending state, with the local+remote shutdown addrs known.
t.Run("remote_initiated_close_ok", func(t *testing.T) {
@@ -1731,8 +1814,12 @@ func TestRbfShutdownPendingTransitions(t *testing.T) {
// This will cause a self transition back to ShutdownPending.
closeHarness.assertStateTransitions(&ShutdownPending{})
- // Next, we'll send in a shutdown complete event.
- closeHarness.chanCloser.SendEvent(ctx, &ShutdownReceived{})
+ // Next, we'll send in a shutdown complete event. The script is
+ // incidental to what this test exercises, but a shutdown always
+ // carries one, so we supply the remote party's.
+ closeHarness.chanCloser.SendEvent(ctx, &ShutdownReceived{
+ ShutdownScript: remoteAddr,
+ })
// We should transition to the channel flushing state, then the
// self event to have this state cache he early offer should
@@ -3114,7 +3201,8 @@ func TestNextCloseeNonceStorageFromClosingSig(t *testing.T) {
// updateAndValidateCloseTerms should only validate close terms, not
// update the nonce. The nonce rotation happens in
// LocalOfferSent.ProcessEvent.
- err := negotiation.updateAndValidateCloseTerms(sigEvent, true)
+ env := &Environment{ChainParams: chaincfg.RegressionNetParams}
+ err := negotiation.updateAndValidateCloseTerms(sigEvent, env)
require.NoError(t, err)
// Verify the RemoteCloseeNonce was NOT modified — it should still
### lnwallet/chancloser/rbf_coop_transitions.go
@@ -200,13 +200,32 @@ func validateShutdown(chanThawHeight fn.Option[uint32],
return ErrTaprootShutdownNonceMissing
}
- // Next, we'll verify that the remote party is sending the expected
- // shutdown script.
- return fn.MapOption(func(addr lnwire.DeliveryAddress) error {
- return validateShutdownScript(
- addr, msg.ShutdownScript, &chainParams,
- )
- })(upfrontAddr).UnwrapOr(nil)
+ // Finally, verify the remote party's delivery script. We validate it in
+ // all cases (mirroring the negotiation closer), rather than only when
+ // an upfront shutdown script is on record: passing a nil upfront script
+ // still runs the well-formedness check on the peer's script, and a
+ // non-nil upfront script additionally enforces the exact match.
+ return validateRemoteDeliveryScript(
+ upfrontAddr, msg.ShutdownScript, chainParams,
+ )
+}
+
+// validateRemoteDeliveryScript checks a delivery script the remote party sent
+// us, against any upfront shutdown script we have on record for them. We end up
+// paying to this script, so it has to be present, and it has to be one of the
+// delivery forms we accept. An absent script is rejected here rather than
+// treated as nothing to check.
+func validateRemoteDeliveryScript(upfrontAddr fn.Option[lnwire.DeliveryAddress],
+ script lnwire.DeliveryAddress, chainParams chaincfg.Params) error {
+
+ if len(script) == 0 {
+ return fmt.Errorf("%w: no delivery script",
+ ErrInvalidShutdownScript)
+ }
+
+ return validateShutdownScript(
+ upfrontAddr.UnwrapOr(nil), script, &chainParams,
+ )
}
// ProcessEvent takes a protocol event, and implements a state transition for
@@ -902,7 +921,7 @@ func validateAndExtractSigAndNonce(
// incoming event, and decide if we need to update the remote party's address,
// or reject it if it doesn't include our latest address.
func (c *ClosingNegotiation) updateAndValidateCloseTerms(event ProtocolEvent,
- isTaproot bool) error {
+ env *Environment) error {
assertLocalScriptMatches := func(localScriptInMsg []byte) error {
if !bytes.Equal(
@@ -933,9 +952,19 @@ func (c *ClosingNegotiation) updateAndValidateCloseTerms(event ProtocolEvent,
oldRemoteAddr := c.RemoteDeliveryScript
newRemoteAddr := msg.SigMsg.CloserScript
- // If they're sending a new script, then we'll update to the new
- // one.
+ // If they're sending a new script, then we'll make sure it's
+ // well-formed (and matches any upfront script on record) before
+ // we update to the new one, just as we do for the initial
+ // shutdown script.
if !bytes.Equal(oldRemoteAddr, newRemoteAddr) {
+ err := validateRemoteDeliveryScript(
+ env.RemoteUpfrontShutdown, newRemoteAddr,
+ env.ChainParams,
+ )
+ if err != nil {
+ return err
+ }
+
c.RemoteDeliveryScript = newRemoteAddr
}
@@ -986,7 +1015,7 @@ func (c *ClosingNegotiation) ProcessEvent(event ProtocolEvent, env *Environment,
// At this point, we know its a new signature message. We'll validate,
// and maybe update the set of close terms based on what we receive. We
// might update the remote party's address for example.
- err := c.updateAndValidateCloseTerms(event, env.IsTaproot())
+ err := c.updateAndValidateCloseTerms(event, env)
if err != nil {
return nil, fmt.Errorf("event violates close terms: %w", err)
}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.