|
| 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. |
0 commit comments