From d7a5229479d550117c6cd415f3ad542edcd08781 Mon Sep 17 00:00:00 2001 From: Amperstrand Date: Sat, 5 Sep 2026 14:26:03 +0200 Subject: [PATCH 1/2] lightningd: fix crash and busy-loop on invoice with huge expiry invoice's `expiry` parameter is an unclamped param_u64, and far-future values break the daemon in two different ways: - expiry >= 2^60 needs more bits than push_varlen_field() can encode in the bolt11 `x` field, so bolt11_encode() aborts the whole daemon (FATAL SIGNAL 6). - far below that (anywhere past ~584k years), the invoice expiration timer's nanosecond-grain u64 counter overflows: install_expiration_timer() arms a timer that reads as already due, trigger_expiration() finds nothing expired, re-arms, and the daemon busy-loops at 100% CPU with the RPC reply left racing the storm. Refuse at the parameter stage instead: expiry >= 2^32 seconds (~136 years) returns JSONRPC2_INVALID_PARAMS, keeping a wide margin under both limits. 2^32 - 1 still works. Changelog-Fixed: lightningd: fix crash (`FATAL SIGNAL 6`) and a 100% CPU busy-loop when calling `invoice` with an `expiry` too far in the future (now refused above 2^32 seconds). --- lightningd/invoice.c | 11 +++++++++++ tests/test_invoices.py | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lightningd/invoice.c b/lightningd/invoice.c index bb9a1aa54071..e10c8df96e9e 100644 --- a/lightningd/invoice.c +++ b/lightningd/invoice.c @@ -1152,6 +1152,17 @@ static struct command_result *json_invoice(struct command *cmd, return command_fail(cmd, JSONRPC2_INVALID_PARAMS, "dev-routes requires --developer"); + /* Two hard limits on how far in the future an invoice can expire: + * push_varlen_field() can only encode up to 60 bits (larger values + * abort the daemon in bolt11_encode()), and the invoice expiration + * timer overflows its u64 nanosecond-based grain count far below + * that, leaving the expiry check looping forever. 2^32 seconds + * (~136 years) keeps a wide margin under both. */ + if (*expiry >= (u64)1 << 32) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "expiry must be below 2^32 seconds" + " (~136 years)"); + if (strlen(info->label->s) > inv_max_label_len) { return command_fail(cmd, JSONRPC2_INVALID_PARAMS, "Label '%s' over %zu bytes", info->label->s, inv_max_label_len); diff --git a/tests/test_invoices.py b/tests/test_invoices.py index 8ef2d26d7f30..78d8620e17d6 100644 --- a/tests/test_invoices.py +++ b/tests/test_invoices.py @@ -442,6 +442,30 @@ def test_invoice_expiry(node_factory, executor): assert expiry >= start + 1 and expiry <= end + 1 +def test_invoice_expiry_too_large(node_factory): + """An expiry too large to be safe must be refused, not crash or wedge. + + The `x` field is encoded by push_varlen_field(), which can only + express values of up to 60 bits and aborts the whole daemon for + anything larger. Long before that, the invoice expiration timer's + nanosecond-based counter overflows and the expiry check busy-loops + forever, so anything beyond 2^32 seconds (~136 years) is refused. + """ + l1 = node_factory.get_node() + + # The exact boundary still works: 2^32 - 1 is ~136 years of headroom. + ok = l1.rpc.invoice(amount_msat=1000, label='expiry-boundary-ok', + description='boundary', expiry=2**32 - 1) + assert ok['bolt11'] + + # One above the boundary: typed refusal, daemon stays alive. + with pytest.raises(RpcError, match='expiry must be below') as err: + l1.rpc.invoice(amount_msat=1000, label='expiry-too-large', + description='too large', expiry=2**32) + assert err.value.error['code'] == -32602 + assert l1.rpc.getinfo()['id'] + + def test_waitinvoice(node_factory, executor): """Test waiting for one invoice will not return if another invoice is paid. """ From b179d8fba946a3aba4e8f5319986df5fcc6a444f Mon Sep 17 00:00:00 2001 From: Amperstrand Date: Sat, 12 Sep 2026 00:54:54 +0200 Subject: [PATCH 2/2] lightningd: check expiry bounds in createinvoice too A crafted bolt11 can carry expiry >= 2^32 past the invoice RPC's gate: the expiry field is a varint and decode does not require a valid signature, so createinvoice fed b11->expiry straight into invoice creation -- hitting the same bolt11_encode() 60-bit abort and expiry-timer overflow as the invoice path. Same bound, same message, placed before the re-encode. Pointed out in the PR thread. Changelog-Fixed: lightningd: refuse bolt11 `expiry` above 2^32 seconds in `createinvoice` too, instead of crashing or busy-looping. Signed-off-by: Amperstrand --- lightningd/invoice.c | 8 +++++++ tests/test_invoices.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/lightningd/invoice.c b/lightningd/invoice.c index e10c8df96e9e..65b15599fb21 100644 --- a/lightningd/invoice.c +++ b/lightningd/invoice.c @@ -1697,6 +1697,14 @@ static struct command_result *json_createinvoice(struct command *cmd, NULL, chainparams, &hash, &sig, &have_n, &fail); if (b11) { + /* Same bound as the invoice RPC: past 60 bits + * bolt11_encode() below aborts, and the expiry timer + * overflows its u64 nanosecond grain far below that. */ + if (b11->expiry >= (u64)1 << 32) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "expiry must be below 2^32 seconds" + " (~136 years)"); + /* This adds the signature */ char *b11enc = bolt11_encode(cmd, b11, have_n, hsm_sign_b11, cmd->ld); diff --git a/tests/test_invoices.py b/tests/test_invoices.py index 78d8620e17d6..1c4cd7e328e6 100644 --- a/tests/test_invoices.py +++ b/tests/test_invoices.py @@ -1128,3 +1128,51 @@ def test_invoice_maxdesc(node_factory, chainparams): # This should succeed. inv = l1.rpc.invoice(123000, 'test_invoice_maxdesc3', maxdesc) assert l1.rpc.decode(inv['bolt11'])['description'] == maxdesc + + +def test_createinvoice_expiry_too_large(node_factory): + """createinvoice must apply the same expiry bound as invoice. + + A crafted bolt11 can carry an expiry past the invoice RPC's gate: + decode does not verify the signature, so the huge `x` value flows + into invoice creation -- where bolt11_encode() aborts past 60 bits + and the expiry timer busy-loops far below that. Same bound, same + message as the invoice RPC. + """ + import hashlib + import bitstring + from pyln.proto.invoice import tagged, tagged_bytes + from pyln.proto.bech32 import bech32_encode + + def crafted_bolt11(payment_hash, expiry): + """A parseable-but-junk-signed bolt11: createinvoice decodes + without verifying the signature, so the 65 trailing bytes are + filler; every field the parser needs is real.""" + data = bitstring.pack('uint:35', 12345678) + data += tagged_bytes('p', payment_hash) + data += tagged('d', bitstring.BitArray(b'crafted')) + xbits = bitstring.pack('uint:64', expiry)[4:] + while xbits.startswith('0b00000'): + xbits = xbits[5:] + while xbits.len % 5 != 0: + xbits.prepend('0b0') + data += tagged('x', xbits) + data += tagged_bytes('s', bytes(32)) + data += bitstring.BitArray(bytes(65)) + return bech32_encode( + 'lnbcrt', bytes([data[i:i + 5].uint + for i in range(0, data.len, 5)])) + + l1 = node_factory.get_node() + + with pytest.raises(RpcError, match='expiry must be below'): + l1.rpc.createinvoice(crafted_bolt11(bytes(32), 2**32), + 'label', '00' * 32) + + # The exact boundary still works: a matching preimage and the + # largest in-bounds expiry recreate cleanly. + preimage = bytes(range(32)) + ok = l1.rpc.createinvoice( + crafted_bolt11(hashlib.sha256(preimage).digest(), 2**32 - 1), + 'boundary-label', preimage.hex()) + assert ok['bolt11']