Skip to content

Commit 34bd364

Browse files
authored
Merge pull request #9 from morehouse/eclair_priemage_extraction_exploit
_posts: Eclair: Preimage Extraction Exploit
2 parents b3cf90c + d3c7bcf commit 34bd364

File tree

2 files changed

+128
-0
lines changed

2 files changed

+128
-0
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
---
2+
layout: post
3+
title: "Eclair: Preimage Extraction Exploit"
4+
description: "Discussion of a bug in Eclair's preimage handling that can be exploited to steal funds"
5+
modified: 2025-09-23
6+
tags: [lightning, security, theft, eclair]
7+
categories: [lightning]
8+
image:
9+
feature: eclair_preimage_extraction_exploit_header.png
10+
---
11+
12+
A critical vulnerability in Eclair versions 0.11.0 and below allows attackers to steal node funds.
13+
Users should immediately upgrade to [Eclair 0.12.0](https://github.com/ACINQ/eclair/releases/tag/v0.12.0) or later to protect their funds.
14+
15+
## Background
16+
17+
In the Lightning Network, nodes forward payments using contracts called HTLCs (Hash Time-Locked Contracts).
18+
To settle a payment, the final recipient reveals a secret piece of data called a preimage.
19+
This preimage is passed backward along the payment route, allowing each node to claim their funds from the previous node.
20+
21+
If a channel is forced to close, these settlements can happen on the Bitcoin blockchain.
22+
Nodes must watch the blockchain to spot these preimages so they can claim their own funds.
23+
24+
## The Preimage Extraction Vulnerability
25+
26+
The vulnerability in Eclair existed in how it monitored the blockchain for preimages during a force close.
27+
Eclair would only check for HTLCs that existed in its **local commitment transaction** --- its own current version of the channel's state.
28+
The code incorrectly assumed this local state would always contain a complete list of all possible HTLCs.
29+
30+
However, a malicious channel partner could broadcast an older, but still valid, commitment transaction.
31+
This older state could contain an HTLC that the victim's node had already removed from its own local state.
32+
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.
33+
34+
The original [code snippet](https://github.com/ACINQ/eclair/blob/c7a288b91fc19e89683c531cb3e9f61e59deace9/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala#L1299-L1314) illustrates the issue:
35+
36+
```scala
37+
def extractPreimages(localCommit: LocalCommit, tx: Transaction)(implicit log: LoggingAdapter): Set[(UpdateAddHtlc, ByteVector32)] = {
38+
// ... (code omitted that extracts htlcSuccess and claimHtlcSuccess preimages from tx)
39+
val paymentPreimages = (htlcSuccess ++ claimHtlcSuccess).toSet
40+
paymentPreimages.flatMap { paymentPreimage =>
41+
// we only consider htlcs in our local commitment, because we only care about outgoing htlcs, which disappear first in the remote commitment
42+
// if an outgoing htlc is in the remote commitment, then:
43+
// - either it is in the local commitment (it was never fulfilled)
44+
// - or we have already received the fulfill and forwarded it upstream
45+
localCommit.spec.htlcs.collect {
46+
case OutgoingHtlc(add) if add.paymentHash == sha256(paymentPreimage) => (add, paymentPreimage)
47+
}
48+
}
49+
}
50+
```
51+
52+
The misleading comment in the code suggests this approach is safe, hiding the bug from a casual review.
53+
54+
## Stealing HTLCs
55+
56+
An attacker could exploit this bug to steal funds as follows:
57+
58+
1. The attacker `M` opens a channel with the victim `B`, creating the following topology: `A -- B -- M`.
59+
2. The attacker routes a payment to themselves along the path `A->B->M`.
60+
3. `M` fails the payment by sending `update_fail_htlc` followed by `commitment_signed`. `B` updates their local commitment and revokes their previous one by sending `revoke_and_ack` followed by `commitment_signed`.
61+
- At this point, `M` has two valid commitments: one with the HTLC present and one with it removed.
62+
- Also at this point, `B` only has one valid commitment with the HTLC already removed.
63+
4. `M` force-closes the channel by broadcasting their *older* commitment transaction where the HTLC still exists.
64+
5. `M` claims the HTLC on the blockchain using the payment preimage.
65+
6. `B` sees the on-chain transaction but fails to extract the preimage because the corresponding HTLC is missing from its *local* commitment.
66+
7. Because `B` never learned the preimage, it cannot claim the payment from `A`.
67+
68+
When the time limit expires, `A` gets a refund, and the victim is left with the loss.
69+
The attacker keeps both the original funds and the payment they claimed on-chain.
70+
71+
## The Fix
72+
73+
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.
74+
75+
```scala
76+
def extractPreimages(commitment: FullCommitment, tx: Transaction)(implicit log: LoggingAdapter): Set[(UpdateAddHtlc, ByteVector32)] = {
77+
// ... (code omitted that extracts htlcSuccess and claimHtlcSuccess preimages from tx)
78+
val paymentPreimages = (htlcSuccess ++ claimHtlcSuccess).toSet
79+
paymentPreimages.flatMap { paymentPreimage =>
80+
val paymentHash = sha256(paymentPreimage)
81+
// We only care about outgoing HTLCs when we're trying to learn a preimage to relay upstream.
82+
// Note that we may have already relayed the fulfill upstream if we already saw the preimage.
83+
val fromLocal = commitment.localCommit.spec.htlcs.collect {
84+
case OutgoingHtlc(add) if add.paymentHash == paymentHash => (add, paymentPreimage)
85+
}
86+
// From the remote point of view, those are incoming HTLCs.
87+
val fromRemote = commitment.remoteCommit.spec.htlcs.collect {
88+
case IncomingHtlc(add) if add.paymentHash == paymentHash => (add, paymentPreimage)
89+
}
90+
val fromNextRemote = commitment.nextRemoteCommit_opt.map(_.commit.spec.htlcs).getOrElse(Set.empty).collect {
91+
case IncomingHtlc(add) if add.paymentHash == paymentHash => (add, paymentPreimage)
92+
}
93+
fromLocal ++ fromRemote ++ fromNextRemote
94+
}
95+
}
96+
```
97+
98+
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.
99+
The [fix](https://github.com/ACINQ/eclair/commit/6a8df49a9bf006a0826b828020f551ecb6c7a33e#diff-97779917bce211cd035ebf8f9f265a7ecece4efcd1861c7bab05e0113dd86b06R1306-R1319) was discreetly included in a [larger pull request](https://github.com/ACINQ/eclair/pull/2966) for splicing and released in [Eclair 0.12.0](https://github.com/ACINQ/eclair/releases/tag/v0.12.0).
100+
101+
## Discovery
102+
103+
The vulnerability was discovered accidentally during a discussion with Bastien Teinturier, who asked for a second look at the logic in the `extractPreimage` function.
104+
Upon review, the attack scenario was identified and reported.
105+
106+
### Timeline
107+
108+
- **2025-03-05:** Vulnerability reported to Bastien.
109+
- **2025-03-11:** Fix [merged](https://github.com/ACINQ/eclair/commit/6a8df49a9bf006a0826b828020f551ecb6c7a33e#diff-97779917bce211cd035ebf8f9f265a7ecece4efcd1861c7bab05e0113dd86b06R1306-R1319) and Eclair 0.12.0 released.
110+
- **2025-03-21:** Agreement on public disclosure in six months.
111+
- **2025-09-23:** Public disclosure.
112+
113+
## Prevention
114+
115+
In response to the vulnerability report, Bastien sent the following:
116+
117+
> This code seems to have been there from the very beginning of eclair, and has not been updated or challenged since then.
118+
> This is bad, I'm noticing that we lack a lot of unit tests for this kind of scenario, this should have been audited...
119+
> I'll spend time next week to check that we have tests for every known type of malicious force-close...
120+
> Thanks for reporting this, it's high time we audited that.
121+
122+
As promised, Bastien added a force-close [test suite](https://github.com/ACINQ/eclair/pull/3040) a couple weeks later.
123+
Had these tests existed from the start, this vulnerability would have been prevented.
124+
125+
## Takeaways
126+
127+
- More robust testing and auditing of Lightning implementations is badly needed.
128+
- Users should keep their node software updated.
4.17 MB
Loading

0 commit comments

Comments
 (0)