The following disclosure is copied verbatim from a blog post on morehouse.github.io, reproduced here to facilitate discussion.
A critical vulnerability in Eclair versions 0.11.0 and below allows attackers to steal node funds. Users should immediately upgrade to Eclair 0.12.0 or later to protect their funds.
Background
In the Lightning Network, nodes forward payments using contracts called HTLCs (Hash Time-Locked Contracts). To settle a payment, the final recipient reveals a secret piece of data called a preimage. This preimage is passed backward along the payment route, allowing each node to claim their funds from the previous node.
If a channel is forced to close, these settlements can happen on the Bitcoin blockchain. Nodes must watch the blockchain to spot these preimages so they can claim their own funds.
The Preimage Extraction Vulnerability
The vulnerability in Eclair existed in how it monitored the blockchain for preimages during a force close. Eclair would only check for HTLCs that existed in its local commitment transaction — its own current version of the channel’s state. The code incorrectly assumed this local state would always contain a complete list of all possible HTLCs.
However, a malicious channel partner could broadcast an older, but still valid, commitment transaction. This older state could contain an HTLC that the victim’s node had already removed from its own local state. When the attacker claimed this HTLC on-chain with a preimage, the victim’s Eclair node would ignore it because the HTLC wasn’t in its local records, causing the victim to lose the funds.
The original code snippet illustrates the issue:
def extractPreimages(localCommit: LocalCommit, tx: Transaction)(implicit log: LoggingAdapter): Set[(UpdateAddHtlc, ByteVector32)] = {
// ... (code omitted that extracts htlcSuccess and claimHtlcSuccess preimages from tx)
val paymentPreimages = (htlcSuccess ++ claimHtlcSuccess).toSet
paymentPreimages.flatMap { paymentPreimage =>
// we only consider htlcs in our local commitment, because we only care about outgoing htlcs, which disappear first in the remote commitment
// if an outgoing htlc is in the remote commitment, then:
// - either it is in the local commitment (it was never fulfilled)
// - or we have already received the fulfill and forwarded it upstream
localCommit.spec.htlcs.collect {
case OutgoingHtlc(add) if add.paymentHash == sha256(paymentPreimage) => (add, paymentPreimage)
}
}
}
The misleading comment in the code suggests this approach is safe, hiding the bug from a casual review.
Stealing HTLCs
An attacker could exploit this bug to steal funds as follows:
- The attacker
M
opens a channel with the victimB
, creating the following topology:A -- B -- M
. - The attacker routes a payment to themselves along the path
A->B->M
. M
fails the payment by sendingupdate_fail_htlc
followed bycommitment_signed
.B
updates their local commitment and revokes their previous one by sendingrevoke_and_ack
followed bycommitment_signed
.
- At this point,
M
has two valid commitments: one with the HTLC present and one with it removed. - Also at this point,
B
only has one valid commitment with the HTLC already removed.
M
force-closes the channel by broadcasting their older commitment transaction where the HTLC still exists.M
claims the HTLC on the blockchain using the payment preimage.B
sees the on-chain transaction but fails to extract the preimage because the corresponding HTLC is missing from its local commitment.- Because
B
never learned the preimage, it cannot claim the payment fromA
.
When the time limit expires, A
gets a refund, and the victim is left with the loss.
The attacker keeps both the original funds and the payment they claimed on-chain.
The Fix
The solution was to update extractPreimages
to check for HTLCs across all relevant commitment transactions, including the remote and next-remote commitments, not just the local one.
def extractPreimages(commitment: FullCommitment, tx: Transaction)(implicit log: LoggingAdapter): Set[(UpdateAddHtlc, ByteVector32)] = {
// ... (code omitted that extracts htlcSuccess and claimHtlcSuccess preimages from tx)
val paymentPreimages = (htlcSuccess ++ claimHtlcSuccess).toSet
paymentPreimages.flatMap { paymentPreimage =>
val paymentHash = sha256(paymentPreimage)
// We only care about outgoing HTLCs when we're trying to learn a preimage to relay upstream.
// Note that we may have already relayed the fulfill upstream if we already saw the preimage.
val fromLocal = commitment.localCommit.spec.htlcs.collect {
case OutgoingHtlc(add) if add.paymentHash == paymentHash => (add, paymentPreimage)
}
// From the remote point of view, those are incoming HTLCs.
val fromRemote = commitment.remoteCommit.spec.htlcs.collect {
case IncomingHtlc(add) if add.paymentHash == paymentHash => (add, paymentPreimage)
}
val fromNextRemote = commitment.nextRemoteCommit_opt.map(_.commit.spec.htlcs).getOrElse(Set.empty).collect {
case IncomingHtlc(add) if add.paymentHash == paymentHash => (add, paymentPreimage)
}
fromLocal ++ fromRemote ++ fromNextRemote
}
}
This change ensures that Eclair will correctly identify the HTLC and extract the necessary preimage, even if a malicious partner broadcasts an old channel state. The fix was discreetly included in a larger pull request for splicing and released in Eclair 0.12.0.
Discovery
The vulnerability was discovered accidentally during a discussion with Bastien Teinturier, who asked for a second look at the logic in the extractPreimage
function.
Upon review, the attack scenario was identified and reported.
Timeline
- 2025-03-05: Vulnerability reported to Bastien.
- 2025-03-11: Fix merged and Eclair 0.12.0 released.
- 2025-03-21: Agreement on public disclosure in six months.
- 2025-09-23: Public disclosure.
Prevention
In response to the vulnerability report, Bastien sent the following:
This code seems to have been there from the very beginning of eclair, and has not been updated or challenged since then. This is bad, I’m noticing that we lack a lot of unit tests for this kind of scenario, this should have been audited… I’ll spend time next week to check that we have tests for every known type of malicious force-close… Thanks for reporting this, it’s high time we audited that.
As promised, Bastien added a force-close test suite a couple weeks later. Had these tests existed from the start, this vulnerability would have been prevented.
Takeaways
- More robust testing and auditing of Lightning implementations is badly needed.
- Users should keep their node software updated.