crashing a lightning node with a flood of pings
I found a critical DoS vulnerability in Core Lightning (CLN) while writing my own BOLT8 implementation. If I flood the node with ping messages asking for the largest possible pong and then never read the TCP buffer, the node keeps queueing the pong messages until it runs out of memory and gets OOM-killed.
Background
Every Lightning node speaks an encrypted peer-to-peer protocol defined in BOLT 8: messages are framed and encrypted with the Noise protocol, and each frame is at most 65,535 bytes. In Core Lightning (CLN) this transport lives in a dedicated daemon, connectd, which multiplexes one TCP connection per peer and shuffles messages between the peer and the per-channel subdaemons.
Most messages are just forwarded: connectd decrypts them and hands them to the subdaemon that owns that channel. But a few never reach a subdaemon at all. connectd builds the reply itself, right there on the connection, and sends it back. Those are the ones I'll call locally handled:
ping(BOLT 1). Apingcarries anum_pong_bytesfield. The receiver must reply with apongmessage padded to exactly that many bytes, up to a maximum of 65,531. It is a liveness probe, and the reply is generated entirely by the receiver.query_channel_range(BOLT 7). A gossip query asking "give me the channels in this block range." The receiver builds and streams back a reply coveringfirst_blocknumthroughfirst_blocknum + number_of_blocks. (query_short_channel_idsis handled the same way, though a 65,535-byte frame caps how much you can ask for in one request.)
Both are handled inside connectd, and in both the sender picks how large the reply is.
The Unbounded Output Queue
connectd is not supposed to read as fast as a peer can send. The read loop reads one message and then parks itself on a wake token, &peer->peer_in, instead of immediately reading the next one.
/* Wait for them to wake us */
peer->peer_in_lastmsg = type;
peer->peer_in_lasttime = ;
return ;
It stays asleep until something wakes that token. In the vulnerable code that wake came from the subdaemon side: once write_to_subd had drained a subdaemon's queue, it woke the read loop again.
/* Nothing to send? */
if
This is textbook backpressure, but the resource it protects is the subdaemon, not the socket. connectd reads no faster than the subdaemons consume, which is enough as long as every message the read loop parks on is on its way to one. The locally handled ones are not: connectd answers them itself, onto the peer's own outgoing queue, and after answering one the read loop did not park at all.
/* If we swallow this, just try again. */
if
return ; /* read the NEXT message immediately */
next_read reads again right away. It never touches &peer->peer_in, so nothing at all limits how fast a peer can make connectd generate replies.
The messages where the peer sizes that reply all sit behind this shortcut:
/* We handle pings and gossip messages. */
static bool else if else if else if
And each ping produces a reply that the sender sizes, allocated and enqueued on the outgoing queue:
if
Put together, the loop against a non-reading peer becomes:
- Read a
pingasking for a 65,531-bytepong. - Build the
pong, enqueue it onpeer_outq. return next_read(...), immediately read the nextping. No wait on the write side.- The peer never reads, so the write side never drains
peer_outq. But the read loop never checks; it keeps looping as fast as it can decrypt.
The outgoing queue grows without bound. query_channel_range with number_of_blocks set to U32::MAX does the same thing and worse, piling a whole chain's worth of gossip replies onto the same queue from one small request.
Crashing the Node
The exploit needs no funded channel and no valid gossip. It only needs to complete the Noise handshake and then refuse to read:
- Alice, the attacker, completes the BOLT 8 handshake with Bob, the victim.
- Alice sends a flood of
pingmessages, each withnum_pong_bytes = 65531(or a flood ofquery_channel_rangewithnumber_of_blocks = U32::MAX). - Alice never reads from the socket.
- Bob's
connectdanswers every message, appending a largepong(or gossip reply) to the per-peer outgoing queue, and immediately reads the next request because local handling skips the read gate. - The queue grows until
connectdexhausts RAM and swap, and the node is killed by the OOM killer.
In testing on a 2-core VM with 2 GB RAM and 2 GB swap, a single connection was enough to take the node down; a hundred simulated peers did it faster. The victim needs no open channel with the attacker, so the attack surface is every reachable node on the network.
The Fix
The fix has two parts. The first routes locally handled messages back through the gate: instead of reading the next message immediately, connectd nudges the write side and then parks on &peer->peer_in, exactly like a subdaemon-bound message.
/* If we swallow this, just try again. */
if
...
/* Wait for them to wake us */
peer->peer_in_lastmsg = type;
out:
peer->peer_in_lasttime = ;
return ;
That alone would deadlock the connection. The only thing that ever woke &peer->peer_in was write_to_subd, and a locally handled message never reaches a subdaemon, so the read loop would park and never be woken again. The second part gives the gate a socket-side waker:
if
Now the read loop sleeps after queuing a reply and does not read again until the writer has drained peer_outq and woken it. Against a peer that never reads, the writer never drains, so reading stalls after a single queued reply. peer_outq can no longer grow past roughly one message, and the flood can no longer drive the node to OOM.
It landed as PR #8525.
Discovery
I was learning BOLT 8 by implementing it, writing my own library for the Noise handshake and transport. Once it could talk to a real node, the obvious next step was to point it at one and see how the P2P layer held up under traffic no well-behaved peer would ever send. Running a CLN node in regtest on a small VM, I scripted 100 peers that completed the handshake and then spammed ping messages with num_pong_bytes = 65531 while deliberately reading nothing back. The node consumed all RAM and swap and was OOM-killed. The same crash reproduced with query_channel_range set to the maximum block range, and even with a single peer, which pointed at the real cause: the outgoing per-peer queue was never being bounded when the peer refused to drain it.
Lessons Learned
Backpressure only counts if every path goes through it. CLN had the gate and the gate worked, but it was coupled to subdaemon delivery, and the handful of messages connectd answers by itself never touch a subdaemon. One shortcut around one gate, on the messages where the sender picks the reply size, was enough to OOM the node once the peer stopped draining its socket.
Timeline
- 2025-08-25: Vulnerability reported privately to Rusty Russell.
- 2025-09-02: Fix merged as PR #8525 and released as the last change in Core Lightning
v25.09. - 2026-08-25: Public disclosure.