← index

CLN: Self-Referential Channel Announcement DoS disclosure

An archive of delvingbitcoin.org · view original topic →

· Nishant Bansal · #1 ·

The issue discussed below was originally disclosed at nishantbansal2003.github.io and is reproduced here for discussion.

Core Lightning v25.02 through v26.04 crash on a channel_announcement whose node_id_1 and node_id_2 are the same node. CLN never checks that the two endpoints differ, so gossipd adds the same node to its routing graph twice, trips a no-duplicates assertion, and aborts, taking the whole node down. Because the announcement is written to disk before it is loaded, gossipd reloads it and aborts again on every restart, so the node stays offline until the operator removes the record by hand. The attacker needs only an unspent 2-of-2 output it controls, so the attack costs nothing beyond on-chain fees.

Upgrade to Core Lightning v26.04.1 or later.

Background

Nodes learn the channel graph from BOLT 7 gossip. A channel_announcement identifies a channel by its short_channel_id and names its two endpoints, node_id_1 and node_id_2. It carries four signatures: one from each node, and one from each of the two keys, bitcoin_key_1 and bitcoin_key_2, in the channel’s 2-of-2 funding output. BOLT 7 requires the sender to order the node IDs so that node_id_1 is the lexicographically lesser, which implies they differ, but until this report it placed no matching requirement on the receiver.

CLN’s gossipd validates each announcement, appends it to an on-disk gossip_store, and then loads it into the gossmap, an in-memory routing graph that records each node once, keyed by its node ID.

The vulnerability

When CLN v26.04 receives a channel_announcement, gossmap_manage_channel_announcement() parses it and verifies its signatures, but never checks that the two node IDs differ. A signed announcement with equal node IDs, backed by an unspent 2-of-2 output, therefore passes validation and is written to the gossip_store.

gossipd then loads it into the gossmap with add_channel(), which assumes the two node IDs differ:

static struct gossmap_chan *add_channel(struct gossmap *map, u64 cannounce_off,
                                        size_t msglen, bool *redundant)
{
    /* ... read the two node IDs. */
    n[0] = gossmap_find_node(map, &node_id[0]);
    if (n[0])
        nidx[0] = gossmap_node_idx(map, n[0]);
    else
        nidx[0] = new_node(map);

    n[1] = gossmap_find_node(map, &node_id[1]);
    if (n[1])
        nidx[1] = gossmap_node_idx(map, n[1]);
    else
        nidx[1] = new_node(map);

    /* ... create the channel between nidx[0] and nidx[1]. */
    if (!n[0])
        nodeidx_htable_add(map->nodes,
                           node2ptrint(map->node_arr + nidx[0]));
    if (!n[1])
        nodeidx_htable_add(map->nodes,
                           node2ptrint(map->node_arr + nidx[1]));
    return chan;
}

For each endpoint, the function looks up its node ID in the node table and creates a new node if it isn’t found. New nodes are added to the table only at the end, after the channel is created. So when both endpoints are the same unknown node, the second lookup can’t see the node the first one just created, and creates it again. Adding the second copy then puts a node ID into the table that is already there, tripping the table’s no-duplicates assertion. The failed assertion aborts gossipd, and because lightningd treats that as fatal, the whole node goes down.

Releases before v25.02 also accept the self-referential announcement but don’t crash on it, because their node table allowed duplicates and the second copy was simply added. The table was switched to reject duplicates in v25.02.

The crash survives restarts

The announcement is already in the gossip_store when gossipd aborts. On the next start, gossipd reads the store back, loads the same announcement, and aborts again. The node can’t start until the operator removes the record from the gossip_store by hand, and upgrading doesn’t remove a record that is already stored.

The attack

An attacker completes the BOLT 8 handshake and init exchange with the victim as an ordinary peer. It then does the following:

  1. Fund a 2-of-2 P2WSH output whose two keys the attacker controls, so it looks like any Lightning funding output.
  2. Craft a channel_announcement for the output’s short_channel_id with a fresh node ID, not yet in the victim’s graph, as both endpoints, then sign it with that node’s key and the two funding keys.
  3. Once the output is 6 blocks deep, send the announcement to the victim.

All four signatures are valid and the output is unspent, so CLN accepts the announcement, writes it to the gossip_store, and aborts while loading it. The funds stay in an output the attacker controls, so the attack costs only on-chain fees.

The attacker also doesn’t need to connect to every victim. Nodes that relay such announcements instead of rejecting them, such as CLN releases before v25.02, can carry them to vulnerable nodes the attacker can’t connect to directly.

Observed impact

The node aborts on every restart until the operator removes the record. While it is down, it can’t watch the chain, so it can miss a peer broadcasting a revoked commitment or an HTLC it must claim before it expires, putting funds at risk. After the operator removes the record, the attacker can resend the same announcement at no further cost for as long as the output stays unspent.

The fix

PR #9082, merged 2026-04-24 and released in v26.04.1, adds a check, right after parsing, that rejects any channel_announcement whose node_id_1 isn’t lexicographically less than node_id_2:

const char *gossmap_manage_channel_announcement(
    const tal_t *ctx, struct gossmap_manage *gm,
    const u8 *announce TAKES, const struct node_id *source_peer TAKES,
    const struct amount_sat *known_amount)
{
    /* ... parse the announcement. */
    if (!(node_id_cmp(&node_id_1, &node_id_2) < 0)) {
        return tal_fmt(ctx, "node_id_1 must be the lesser node id! 1=%s, 2=%s",
                       fmt_node_id(tmpctx, &node_id_1),
                       fmt_node_id(tmpctx, &node_id_2));
    }
    /* ... verify the signatures and queue the funding output lookup. */
    return NULL;
}

Requiring node_id_1 to be strictly less rejects equal node IDs as well as misordered ones, so the announcement is never stored and never reaches the routing graph. BOLT 7 PR #1333, prompted by this report, adds the same requirement for the receiver to the spec.

Discovery

This bug was found with a gossip state-machine fuzz target for Core Lightning, later proposed in PR #9115. The target feeds gossipd both valid and malformed messages and simulates the chain lookup, so announcements that pass validation are stored and loaded into the routing graph as on a real node. A self-referential channel_announcement generated by the fuzzer tripped the no-duplicates assertion, and reproducing it on a regtest node showed that the crash repeats on every restart.

Timeline

Takeaways

· ramofewa · #2 ·

The regression test should cover both live gossip and replay from gossip_store; testing only the graph insertion path would miss the persistent-crash loop. A useful invariant here is that every accepted announcement remains safe to load after a restart, including records written by older versions. The fuzz target described seems well suited to preserving that property as the parser and graph code evolve.

· sagetaya · #3 ·

A useful regression test would exercise both graph states: the duplicated node ID is absent before the announcement, and it already exists. The first reaches the duplicate insertion path described here; the second checks that rejection doesn’t depend on whether the node was previously learned. It’d also be worth asserting the malformed announcement never reaches gossip_store, then restarting from that store to catch persistence-order regressions.