Disclosure: Eclair Preimage Extraction Exploit

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:

  1. The attacker M opens a channel with the victim B, creating the following topology: A -- B -- M.
  2. The attacker routes a payment to themselves along the path A->B->M.
  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.
  • 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.
  1. M force-closes the channel by broadcasting their older commitment transaction where the HTLC still exists.
  2. M claims the HTLC on the blockchain using the payment preimage.
  3. B sees the on-chain transaction but fails to extract the preimage because the corresponding HTLC is missing from its local commitment.
  4. Because B never learned the preimage, it cannot claim the payment from A.

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.
8 Likes

Many thanks for your work on discovering this issue. This gave us an opportunity to do a larger audit of our force-close code, and to decide to frequently spend time re-auditing it. During the early days, I believe there was less discussion between implementations about the test cases that were necessary to handle subtle implementation issues.

We’re trying to do better when introducing new features to the spec: for example for splicing, we’re including test vectors of protocol execution directly in the spec. We also run force-close tests for various edge cases that can be dangerous when doing the initial cross-compatibility tests, which helps share knowledge between implementations: that has already caught bugs before implementations shipped, and I hope this will only get better.

Ideally, having something like lnprototest widely adopted would be more robust, but it’s quite a complex project to bring to a maturity level where implementations can all benefit from it…

9 Likes