Skip to content

invoice with a huge expiry crashes or busy-loops lightningd; openchannel_bump on a V1 channel asserts before its own typed errors - #9478

Open
Amperstrand wants to merge 2 commits into
ElementsProject:masterfrom
Amperstrand:fix/rpc-aborts
Open

Amperstrand wants to merge 2 commits into
ElementsProject:masterfrom
Amperstrand:fix/rpc-aborts

Conversation

@Amperstrand

Copy link
Copy Markdown

Two RPC-reachable daemon failures at current master, both triggerable by a single call from any authenticated RPC client (no peer, no funds movement, no unusual config). Two self-contained commits, each carrying its own regression test.

1. invoice with a far-future expiry — abort or 100% CPU busy-loop

invoice's expiry parameter is an unclamped param_u64, and two ranges of far-future values break the daemon in different ways:

  • expiry >= 2^60 needs more bits than push_varlen_field() (common/bolt11.c) can encode in the x field, so bolt11_encode() aborts the whole daemon (FATAL SIGNAL 6). 2^60 - 1, exactly 60 bits, encodes fine.

  • Far below that, the invoice expiration timer breaks: install_expiration_timer() (wallet/invoices.c) arms a timer for MIN(expiry_time) - now, but the timer's nanosecond-grain u64 counter (time_to_grains(): tv_sec * 1e6, TIMER_GRANULARITY 1000) overflows for relative delays beyond ~1.845e13 seconds (~584k years). The wrapped timer reads as already due: trigger_expiration() finds nothing expired, re-arms, and the daemon busy-loops at ~100% CPU inside the timers/sqlite churn, with the RPC reply racing the storm (a wedged-but-alive daemon — visible in a backtrace as timer_expired -> trigger_expiration -> expired_ids -> sqlite3).

Fix: refuse at the parameter stage — expiry >= 2^32 seconds (~136 years) returns JSONRPC2_INVALID_PARAMS ("expiry must be below 2^32 seconds (~136 years)"), keeping a wide margin under both limits. 2^32 - 1 still works and is pinned by the test.

2. openchannel_bump on a channel without an RBF inflight — assert(0 > 0)

json_openchannel_bump() computes the BOLT-2 25/24 feerate ramp and asserts next_feerate_min > last_feerate_perkw BEFORE the channel-state gates. channel_last_funding_feerate() returns 0 for a channel without an in-flight funding transaction — every V1 fundchannel channel — so the assert evaluates 0 > 0 and kills the daemon one screen above the honest typed errors ("Channel not eligible to init RBF" / "No inflight for this channel exists") that were written for exactly this call.

Fix: move the ramp computation below the state gates. The assert is then only reached when an inflight exists (so last_feerate_perkw is nonzero by construction) and the existing typed errors become reachable again.

Alternatives considered (for the expiry side)

  • Param-stage gate (this PR): smallest blast radius, typed error at the RPC boundary, no timer-layer changes. Chosen because both failure modes share the same "absurd input" root and no legitimate invoice needs >136 years.
  • Defensive guard at the timer site: install_expiration_timer() could skip arming when the relative delay exceeds the representable timer range. That would also protect any future caller that feeds a huge delay into the same helper, but it silently leaves invoices unexpired rather than telling the caller their input is absurd, and it touches the timer contract for a single known caller. Happy to add it as belt-and-braces on top if maintainers prefer both.

Tests

  • tests/test_invoices.py::test_invoice_expiry_too_large: exact boundary 2^32 - 1 must still work; 2^32 must return -32602 with the daemon alive (getinfo() asserted on every path).
  • tests/test_opening.py::test_openchannel_bump_no_inflight: well-formed request against a funded V1 channel must return 312 FUNDING_STATE_INVALID (the pre-existing typed error), daemon alive.

On vanilla master both corners die with FATAL SIGNAL 6; with these commits everything passes, including the adjacent suites: test_invoice_expiry, and the real dual-funded RBF flows test_v2_rbf_single / test_v2_rbf_abort_retry (which exercise the reordered ramp on the legitimate path, run under EXPERIMENTAL_DUAL_FUND=1).

Both sites verified present in current master and in v26.06.6.

Cross-implementation note

lnd, eclair and electrum all return typed errors on caller-induced funding/invoice corners; neither peer implementation aborts the process on RPC input.

Checklist

  • The changelog has been updated in the relevant commit(s) according to the guidelines (Changelog-Fixed: trailers).
  • Tests have been added or modified to reflect the changes.
  • Documentation has been reviewed and updated as needed (no API surface change; new typed refusal errors only).
  • Related issues have been listed and linked (none exist upstream for either corner).
  • All PRs must consider how to reverse any persistent changes for tools/lightning-downgrade (no persistent/state changes — refusal and reorder only).

@Amperstrand

Copy link
Copy Markdown
Author

Verification update against the v26.06.7 signed release binaries (fresh regtest rig, elementsproject/lightningd:v26.06.7 image, freshly funded V1 channel for the second case), since the source for the emergency release is still embargoed:

  • invoice with expiry >= 2^60 still aborts on 26.06.7FATAL SIGNAL 6 (version v26.06.7) with abort in the backtrace, daemon dead. So the first commit remains needed even on the latest security release.
  • openchannel_bump on a V1 channel is already fixed in 26.06.7: it returns the typed error Can't calculate the next feerate: the last funding feerate recorded for this channel (0) is out of range instead of crashing. Since that fix exists in the release binaries and will presumably reach master when the embargoed source lands, the second commit here is redundant unless you'd like the master-side fix sooner — happy to drop it from this PR if you prefer to let the release fix propagate.

Keeping both commits in place for now; say the word and I'll trim to just the invoice fix.

@Lagrang3

Copy link
Copy Markdown
Collaborator

@Amperstrand, thank you.

Now that you are at it, could you also fix createinvoice? There you can similarly check the expiry bounds
the same way you did with invoice.

@Lagrang3
Lagrang3 self-requested a review September 10, 2026 07:56
@Lagrang3

Copy link
Copy Markdown
Collaborator

@Amperstrand, like you mentioned above. The second commit is not needed, v26.06.7 fixes it already.

@Amperstrand

Copy link
Copy Markdown
Author

Dropped the second commit — v26.06.7's fix supersedes it. The first commit (invoice expiry gate) is unchanged and applies cleanly against current master.

@Amperstrand

Copy link
Copy Markdown
Author

Done — createinvoice applies the same bound now (b179d8f). A crafted bolt11 could carry a huge expiry in past the invoice gate since decode doesn't check signatures; same rejection, same message, checked before the re-encode. The bolt12 path is u32-capped already.

Lagrang3
Lagrang3 previously approved these changes Sep 15, 2026
@Amperstrand

Copy link
Copy Markdown
Author

Liquid was mine: the crafted invoice hardcoded the lnbcrt HRP, but liquid-regtest invoices carry the ert prefix (chainparams), so the string died at the bech32 parse before the bound check. The HRP now comes from a real invoice on the running network; both arms pass locally. The dual-fund cell is a connect 402 in a test this PR doesn't touch — it passed in #9481's green dual-fund run yesterday; if it recurs on the re-run I'll dig in. Both of today's misses were test-matrix coverage on my side — running both network arms on the full touched test file before pushing from now on. Fix pushed (559a006); feel free to push fixups to the branch directly.

@nGoline nGoline left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the three commits. The 2^32 bound is the right one: it sits below both the bolt11 x encode limit and the expiry-timer grain wrap, and matches bolt12's u32 invoice_relative_expiry. Two message/comment corrections inline, plus one doc ask:

  • Please add the cap to the expiry description in doc/schemas/invoice.json (and mention it in doc/schemas/createinvoice.json), then make doc-all.
  • Optional: squash 559a006 into b179d8f.

Happy to approve once those land.

Comment thread lightningd/invoice.c Outdated
Comment thread lightningd/invoice.c Outdated
Comment thread tests/test_invoices.py Outdated
Amperstrand added 2 commits September 17, 2026 11:21
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 microsecond-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).
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. The reachable failure here is the same
expiry-timer overflow the invoice path guards against -- the
bolt11_encode() abort is not reachable (pull_uint() caps a decoded
`x` field at 60 bits). 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 busy-looping.
Signed-off-by: Amperstrand <amperstrand@localhost>
@Amperstrand

Copy link
Copy Markdown
Author

Landed in a rewrite of the branch (SHAs changed — sorry in advance
for any threads that render Outdated):

  • Grain wording is microsecond everywhere: the invoice.c comment,
    the commit message, and the test docstring. TIMER_GRANULARITY is
    1000ns, so the grain count is µs.
  • The createinvoice comment and message now claim only the reachable
    failure: pull_uint() rejects databits > 64 and enforces minimal
    encoding, so a decoded x field cannot exceed 60 bits and the
    bolt11_encode() abort cannot happen from a decoded invoice — the
    expiry-timer wrap is the failure that can. The Changelog line drops
    "crashing" for the same reason.
  • Both schema descriptions mention the cap (invoice.json expiry;
    createinvoice.json invstring, since the expiry rides inside it)
    and the msggen bundle is regenerated.
  • 559a006 folded into the createinvoice commit as requested; the
    branch is rebased onto current master (your docs: rebase, never
    merge — the old merge commit is gone).
    Both arms of tests/test_invoices.py pass locally (regtest +
    liquid-regtest).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants