lightningd: check value overflow for forward amounts
What changed, and why it matters
This commit fixes a crash bug in Core Lightning's payment forwarding code. When processing a hidden (blinded) payment route, the node reads a fee rate from encrypted data. That fee rate is a 32-bit unsigned integer. The old code added it to 1,000,000 using 32-bit arithmetic, so a maliciously large fee rate could wrap around to a tiny value, causing a division-by-zero or other bad arithmetic that crashed the lightningd daemon with a fatal signal. The fix forces the addition to be done in 64-bit arithmetic, preventing the wraparound. The commit also adds tests showing how a crafted blinded path can trigger the issue.
Apply the patch to ensure 64-bit arithmetic is used before any operation involving fee_proportional_millionths. Review other locations where payment_relay fields are used in arithmetic to confirm they are similarly widened. Run the new test_blinded_forward_policy regression test.
Security signals we found
Integer overflow in fee-proportion arithmetic leading to division-by-zero/fatal signal
Blinded path payment_relay field is attacker-controlled and can carry max u32 values
Crash occurs during onion decode of an incoming HTLC (peer_accepted_htlc -> onion_decode -> handle_blinded_forward -> ceil_div)
Fix is a one-line cast to 64-bit before addition
Regression test explicitly uses fee_proportional_millionths=4293967296 to exercise the overflow case
Evidence from the diff
In common/onion_decode.c, handle_blinded_forward() computes the amount to forward for a blinded path using ceil_div((amt - fee_base_msat) * 1000000, 1000000 + fee_proportional_millionths). fee_proportional_millionths is a u32, and the literal 1000000 is an int. On 32-bit or LP64 platforms the sum 1000000 + fee_proportional_millionths could be evaluated in 32-bit unsigned arithmetic, overflowing when fee_proportional_millionths is large (e.g., 4293967296 wraps to ~1000000). The resulting small denominator, combined with a numerator that may also be large, can produce a division overflow/exception (SIGFPE, signal 8) in ceil_div. The patch casts the literal to u64 so the addition is 64-bit. The new test test_blinded_forward_policy constructs a blinded invoice path with fee_proportional_millionths=4293967296 and confirms the node no longer crashes and the payment fails cleanly downstream.
Changed components
common/onion_decode.clightningd/peer_htlcs.c (call site via onion_decode)Blinded path forwarding / payment relay logicInspect captured patch +243 / −2
### common/onion_decode.c
@@ -116,11 +116,10 @@ static bool handle_blinded_forward(const tal_t *ctx,
return false;
}
- /* FIXME: Put these formulae in BOLT 4! */
/* amt_to_forward = ceil((amount_msat - fee_base_msat) * 1000000 / (1000000 + fee_proportional_millionths)) */
/* If these values are crap, that's OK: the HTLC will fail. */
p->amt_to_forward = amount_msat(ceil_div((amt - enc->payment_relay->fee_base_msat) * 1000000,
- 1000000 + enc->payment_relay->fee_proportional_millionths));
+ (u64)1000000 + enc->payment_relay->fee_proportional_millionths));
p->outgoing_cltv = cltv_expiry - enc->payment_relay->cltv_expiry_delta;
return true;
}
### tests/test_pay.py
@@ -7515,3 +7515,245 @@ def test_createproof_include(node_factory, bitcoind):
# Unknown name is an error.
with pytest.raises(RpcError, match=r'Unknown field name'):
l1.rpc.call('createproof', {'invstring': inv, 'include': ['no_such_field']})
+
+
+def test_blinded_forward_policy(node_factory):
+ """Test that an intermediate nodes in a blinded path verify that the
+ encrypted_recipient_data it receives matches its own relay policy."""
+ FEE_PPM = 1000
+ FINAL_AMT = 1000000
+
+ def bad_topology(plugin):
+ """A plugin that sets an arbitary fee for incoming channels. Helper to
+ test encrypted recepient data handling."""
+
+ @plugin.init()
+ def init(configuration, options, plugin):
+ plugin.incoming = None
+
+ @plugin.hook("rpc_command")
+ def on_rpc_command(plugin, rpc_command, **kwargs):
+ if rpc_command["method"] == "listincoming" and plugin.incoming:
+ plugin.log(
+ "Producing fake listincoming with {}".format(plugin.incoming)
+ )
+ return {"return": {"result": {"incoming": [plugin.incoming]}}}
+ return {"result": "continue"}
+
+ @plugin.method("setincoming")
+ def setincoming(plugin, channel):
+ plugin.incoming = channel
+
+ def get_onion(
+ rpc, invoice, blockheight, my_node, entry_point, scid, in_amount, final_amount
+ ):
+ assert len(invoice["invoice_paths"]) == 1
+ assert invoice["invoice_paths"][0]["first_node_id"] == entry_point
+ path = invoice["invoice_paths"][0]["path"]
+ path_key = invoice["invoice_paths"][0]["first_path_key"]
+ assert len(path) == 2
+
+ final_tlvs = TlvPayload()
+ final_tlvs.add_field(2, tu64_encode(final_amount))
+ final_tlvs.add_field(4, tu64_encode(blockheight + 18))
+ final_tlvs.add_field(10, bytes.fromhex(path[1]["encrypted_recipient_data"]))
+ final_tlvs.add_field(18, tu64_encode(final_amount))
+
+ mid_tlvs = TlvPayload()
+ mid_tlvs.add_field(10, bytes.fromhex(path[0]["encrypted_recipient_data"]))
+ mid_tlvs.add_field(12, bytes.fromhex(path_key))
+
+ hops = [
+ {
+ "pubkey": my_node,
+ "payload": serialize_payload_tlv(
+ in_amount, 18 + 6 + 6, scid, blockheight
+ ).hex(),
+ },
+ {"pubkey": entry_point, "payload": mid_tlvs.to_bytes().hex()},
+ {
+ "pubkey": path[1]["blinded_node_id"],
+ "payload": final_tlvs.to_bytes().hex(),
+ },
+ ]
+
+ return rpc.createonion(hops=hops, assocdata=invoice["invoice_payment_hash"])[
+ "onion"
+ ]
+
+ l1, l2 = node_factory.line_graph(
+ 2,
+ wait_for_announce=True,
+ opts=[
+ {"fee-per-satoshi": 0, "fee-base": 0, "dev-allow-localhost": None},
+ {"dev-allow-localhost": None, "fee-per-satoshi": FEE_PPM, "fee-base": 0},
+ ],
+ )
+ l3 = node_factory.get_node(
+ options={"cltv-final": 18, "dev-allow-localhost": None},
+ may_reconnect=True,
+ inline_plugin=bad_topology,
+ )
+ node_factory.join_nodes([l2, l3], announce_channels=False)
+ # Make sure l3 knows about l1-l2, so will add route hint.
+ wait_for(lambda: l3.rpc.listnodes(l1.info["id"]) != {"nodes": []})
+
+ offer = l3.rpc.offer(FINAL_AMT, "test_pay_blindedpath_privchan")
+ l1.rpc.decode(offer["bolt12"])
+
+ chan = only_one(l3.rpc.listpeerchannels(l2.info["id"])["channels"])
+ incoming = {
+ "id": l2.info["id"],
+ "short_channel_id": chan["short_channel_id"],
+ "fee_base_msat": chan["updates"]["remote"]["fee_base_msat"],
+ "fee_proportional_millionths": chan["updates"]["remote"][
+ "fee_proportional_millionths"
+ ],
+ "htlc_min_msat": chan["updates"]["remote"]["htlc_minimum_msat"],
+ "htlc_max_msat": chan["updates"]["remote"]["htlc_maximum_msat"],
+ "cltv_expiry_delta": chan["updates"]["remote"]["cltv_expiry_delta"],
+ "incoming_capacity_msat": chan["receivable_msat"],
+ "public": False,
+ "enabled": True,
+ "peer_features": l2.info["our_features"]["node"],
+ }
+
+ blockheight = l1.rpc.getinfo()["blockheight"]
+ scid = first_scid(l1, l2)
+
+ # l3 sets a payment_relay in the encrypted_recipient_data for l2 that
+ # matches l2's policy
+ fee_ppm = FEE_PPM
+ hop_amt = (fee_ppm * FINAL_AMT) // 1000000 + FINAL_AMT
+ incoming["fee_proportional_millionths"] = fee_ppm
+ l3.rpc.call("setincoming", {"channel": incoming})
+ inv = l1.rpc.fetchinvoice(offer["bolt12"])
+ decoded = l1.rpc.decode(inv["invoice"])
+
+ onion = get_onion(
+ l1.rpc,
+ decoded,
+ blockheight,
+ l1.info["id"],
+ l2.info["id"],
+ scid,
+ hop_amt,
+ FINAL_AMT,
+ )
+
+ l1.rpc.injectpaymentonion(
+ onion=onion,
+ payment_hash=decoded["invoice_payment_hash"],
+ amount_msat=hop_amt,
+ cltv_expiry=blockheight + 18 + 6,
+ partid=1,
+ groupid=0,
+ invstring=inv["invoice"],
+ )
+
+ # l3 sets a payment_relay in the encrypted_recipient_data for l2 that
+ # pays more than l2 asks
+ fee_ppm = 2 * FEE_PPM
+ hop_amt = (fee_ppm * FINAL_AMT) // 1000000 + FINAL_AMT
+ incoming["fee_proportional_millionths"] = fee_ppm
+ l3.rpc.call("setincoming", {"channel": incoming})
+ inv = l1.rpc.fetchinvoice(offer["bolt12"])
+ decoded = l1.rpc.decode(inv["invoice"])
+
+ onion = get_onion(
+ l1.rpc,
+ decoded,
+ blockheight,
+ l1.info["id"],
+ l2.info["id"],
+ scid,
+ hop_amt,
+ FINAL_AMT,
+ )
+
+ l1.rpc.injectpaymentonion(
+ onion=onion,
+ payment_hash=decoded["invoice_payment_hash"],
+ amount_msat=hop_amt,
+ cltv_expiry=blockheight + 18 + 6,
+ partid=1,
+ groupid=0,
+ invstring=inv["invoice"],
+ )
+
+ # l3 sets a payment_relay in the encrypted_recipient_data for l2 that
+ # doesn't pay enough fees to l2, l2 should refuse to forward
+ fee_ppm = FEE_PPM - 1
+ hop_amt = (fee_ppm * FINAL_AMT) // 1000000 + FINAL_AMT
+ incoming["fee_proportional_millionths"] = fee_ppm
+ l3.rpc.call("setincoming", {"channel": incoming})
+ inv = l1.rpc.fetchinvoice(offer["bolt12"])
+ decoded = l1.rpc.decode(inv["invoice"])
+
+ onion = get_onion(
+ l1.rpc,
+ decoded,
+ blockheight,
+ l1.info["id"],
+ l2.info["id"],
+ scid,
+ hop_amt,
+ FINAL_AMT,
+ )
+
+ with pytest.raises(RpcError) as err:
+ l1.rpc.injectpaymentonion(
+ onion=onion,
+ payment_hash=decoded["invoice_payment_hash"],
+ amount_msat=hop_amt,
+ cltv_expiry=blockheight + 18 + 6,
+ partid=1,
+ groupid=0,
+ invstring=inv["invoice"],
+ )
+ assert "onionreply" in err.value.error["data"]
+ # l2 refused: payment_relay fees too low vs its real channel policy
+ l2.daemon.wait_for_log(r"incorrect amount")
+ fwd = only_one(
+ [
+ f
+ for f in l2.rpc.listforwards()["forwards"]
+ if f.get("status") == "local_failed"
+ ]
+ )
+ assert fwd["failreason"] == "WIRE_FEE_INSUFFICIENT"
+
+ # l3 sets a payment_relay in the encrypted_recipient_data for l2 with a big
+ # number,
+ fee_ppm = FEE_PPM
+ hop_amt = (fee_ppm * FINAL_AMT) // 1000000 + FINAL_AMT
+ incoming["fee_proportional_millionths"] = 4293967296
+ l3.rpc.call("setincoming", {"channel": incoming})
+ inv = l1.rpc.fetchinvoice(offer["bolt12"])
+ decoded = l1.rpc.decode(inv["invoice"])
+
+ onion = get_onion(
+ l1.rpc,
+ decoded,
+ blockheight,
+ l1.info["id"],
+ l2.info["id"],
+ scid,
+ hop_amt,
+ FINAL_AMT,
+ )
+
+ with pytest.raises(RpcError) as err:
+ l1.rpc.injectpaymentonion(
+ onion=onion,
+ payment_hash=decoded["invoice_payment_hash"],
+ amount_msat=hop_amt,
+ cltv_expiry=blockheight + 18 + 6,
+ partid=1,
+ groupid=0,
+ invstring=inv["invoice"],
+ )
+ assert "onionreply" in err.value.error["data"]
+ # l2 is fine with it, down the route the last node complains it did not get
+ # the expected amount
+ l3.daemon.wait_for_log(r"final incorrect amount: 234msat in, 1000000msat expected")Why this scored 72/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.