SpankChain LedgerChannel LCOpenTimeout() Malicious-Token Reentrancy Drain (2018-10-06)

  • Name: SpankChain LedgerChannel LCOpenTimeout() malicious-token reentrancy drain
  • Date: 2018-10-06
  • Protocol: SpankChain / Connext payment hub
  • Chain: Ethereum
  • Category: payment-channel reentrancy / malicious-token callback / accounting-before-effects violation
  • Loss: about 166.97 ETH drained, with roughly $4,000 of BOOTY also temporarily immobilized in the contract; user-owned losses were reported at about 34.99 ETH plus 1,271.88 BOOTY before reimbursement
  • Summary: SpankChain’s LedgerChannel.sol let anyone open a channel with an arbitrary ERC-20 token address and later recover the unopened channel through LCOpenTimeout(bytes32 _lcID). That timeout path sent ETH to partyAddresses[0], then called token.transfer(...), and only deleted the channel afterward. The attacker supplied a malicious token contract, set confirmTime = 0, opened a channel whose partyA was the attacker-controlled contract, and used the token’s transfer() hook to re-enter LCOpenTimeout() repeatedly before delete Channels[_lcID] ran. Each recursive call re-read the same undeleted ETH balance and paid it out again.
  • Trigger / entry point:
    • createChannel(bytes32,address,uint256,address,uint256[2]) accepted a caller-chosen _token address and stored it as Channels[_lcID].token.
    • The attacker could set _confirmTime = 0, making LCOpenTimeout() callable immediately after channel creation before the hub could join.
    • LCOpenTimeout() transferred ETH and then invoked Channels[_lcID].token.transfer(...) before deleting the channel record.
    • Because the token contract itself was attacker-controlled, its transfer() implementation could call back into LCOpenTimeout() while the same channel balances still existed.
  • Root cause:
    • SpankChain violated checks-effects-interactions in the unopened-channel refund path.
    • The protocol treated an arbitrary ERC-20 transfer() as a benign payout step even though the token contract was user-supplied code with full ability to call back into LedgerChannel.
    • The same function also coupled channel still exists with ETH is still refundable, so re-entering before delete Channels[_lcID] preserved the payout right.
    • confirmTime = 0 removed the operational buffer that might otherwise have let the hub join or operators intervene before timeout execution.
  • Exploit path:
    1. The attacker funded EOA 0xcf267ea3f1ebae3c29fea0a3253f94f3122c2199 and deployed malicious token / exploit contract 0xc5918a927C4FB83FE99E30d6F66707F4b396900E.
    2. The malicious contract opened a ledger channel against SpankChain’s LedgerChannel contract 0xf91546835f756DA0c10cFa0CDA95b15577b84aA7, naming itself as the token and depositing ETH as the channel’s initial ETH balance.
    3. It set confirmTime = 0, so the channel could be timed out atomically without giving the hub time to join.
    4. The attacker then called LCOpenTimeout(_lcID) in exploit transaction 0x21e9d20b57f6ae60dac23466c8395d47f42dc24628e5a31f224567a2b4effa88.
    5. Inside LCOpenTimeout(), the contract first sent ethBalances[0] back to partyAddresses[0] and then called Channels[_lcID].token.transfer(Channels[_lcID].partyAddresses[0], Channels[_lcID].erc20Balances[0]).
    6. Because token was attacker code, that transfer() re-entered LCOpenTimeout() before delete Channels[_lcID] executed.
    7. Every recursive entry saw the same undeleted channel state and paid the same ETH balance again, draining pooled ETH collateral from the hub contract until recursion stopped.
  • What the attacker needed to be true:
    • The protocol had to accept an arbitrary token contract as a channel asset instead of a tightly allow-listed passive asset surface.
    • LCOpenTimeout() needed to make an external token call before zeroing or deleting the channel state that priced the refund.
    • The attacker’s channel-opening contract needed to be the refunded partyAddresses[0] so the repeated ETH payouts accrued back to attacker-controlled code.
    • confirmTime needed to be low enough—here effectively zero—to let create-and-timeout happen before the intended counterparty could join and change the channel state.
    • The ledger contract needed enough ETH from other users / protocol collateral to make the repeated same-balance refunds worth looping.
  • Technical anatomy worth remembering:
    • This was not just generic reentrancy from call.value(...). The dangerous external edge was a user-selected ERC-20 transfer() invoked during a refund path.
    • The exploit worked because one refund function mixed two different assets: ETH value was paid first, but the re-entry boundary arrived through the later token payout.
    • LCOpenTimeout() was supposed to be a safety valve for unopened channels; instead, it became the cleanest path to replay stale channel balances because delete Channels[_lcID] sat after the external interactions.
    • The operationally small-looking confirmTime parameter mattered. Allowing 0 let the attacker create the exact malicious state and cash it out in the same transaction envelope.
  • Representative transactions and addresses:
    • Victim LedgerChannel contract: 0xf91546835f756DA0c10cFa0CDA95b15577b84aA7
    • Attacker EOA: 0xcf267ea3f1ebae3c29fea0a3253f94f3122c2199
    • Malicious token / exploit contract: 0xc5918a927C4FB83FE99E30d6F66707F4b396900E
    • Representative exploit transaction: 0x21e9d20b57f6ae60dac23466c8395d47f42dc24628e5a31f224567a2b4effa88
  • Response / mitigation:
    • SpankChain took the camsite offline, prepared reimbursements for affected users, and later reported that the attacker returned the stolen ETH by handing over a private key in exchange for a $5,000 bounty plus the 5.5 ETH seed funds used in the exploit.
    • Connext’s joint transparency report says the same counterparty also disclosed a second path for recovering the roughly $4,000 in trapped BOOTY, reclaimed those funds within about an hour, and received a further $4,000 reward.
    • The incident prompted a redeployment of the payment-channel contract with fixes and a broader review by SpankChain, Connext, and Kyokan.
    • The postmortem explicitly called out the decision to skip a professional audit on this more complex payment-channel contract as a process failure.
    • The follow-up review also found adjacent problems beyond the live ETH drain path, including self-channel creation, auto-join behavior around malicious tokens, additional reentrancy surfaces, and arithmetic / dispute-state bugs.

Pattern tags

  • recovery-only timeout path still held full pooled-ETH authority and therefore deserved the same hardening as a normal withdrawal lane
  • user-chosen token semantics were admitted into a refund path that paid ETH first and only closed authority later
  • one stale unopened-channel balance remained replayable because channel deletion happened after external delivery
  • confirmTime = 0 collapsed the operational buffer and let create-plus-timeout happen atomically before the hub could join

Why the exploit worked

  • LCOpenTimeout() was framed as a recovery valve for channels the hub never joined, but economically it was still a withdrawal function backed by pooled hub ETH. SpankChain treated that path as if it were lower risk than the main settlement flows and therefore let it keep weaker execution ordering.
  • The malicious token did not need exotic ERC-777 behavior. Ordinary ERC20.transfer(...) was already enough callback surface because the contract let users choose the token address and then called that user-supplied code before deleting the channel.
  • The exploit’s profitable core was same unopened-channel balance, replayed several times. ETH left first, but the reentry boundary arrived through the later token refund, so the attacker could keep reopening the stale balance story until the pooled contract ETH was mostly gone.
  • confirmTime = 0 mattered as much as the reentrancy itself. It removed any realistic chance for the designated hub to join the channel and convert the path into the safer joined-channel lifecycle before timeout execution.
  • The broader lesson from the joint review is that SpankChain had several places where helper, timeout, and exceptional flows were trusted on weaker assumptions than the main channel lifecycle. LCOpenTimeout() was simply the one that turned those assumptions into immediate ETH loss first.

Detection and audit pivots

  • Treat timeout, refund, rollback, and recovery functions as full withdrawal authority. If the function can release pooled ETH or tokens, review it like a primary settlement path rather than as error-handling glue.
  • Enumerate every user-controlled contract address admitted into the channel lifecycle: token contracts, recipient contracts, and hub / counterparty addresses. Ask which of those addresses can inject code execution into a path that still defines refund rights.
  • Search for mixed-asset payout sequences where one asset is paid before a second asset’s transfer call. Those paths often hide the real callback edge after the economically important value has already started leaving.
  • Fuzz confirmTime, zero-timeout, and same-transaction lifecycle transitions together. Operational buffers that look optional often decide whether an attacker can create the vulnerable state and cash it out atomically.
  • Check whether helper or recovery paths reuse the same invariant set as ordinary close / withdraw flows. If a timeout path keeps weaker ordering, weaker role checks, or broader token admission, it is usually the main bug in a less-visited costume.

Why this incident matters

  • SpankChain is a crisp early example of a protocol letting user-supplied asset code become part of its own withdrawal logic.
  • It shows how timeout, refund, and other recovery-only paths often sit outside the team’s main mental model yet still hold full economic authority.
  • It is also a useful bridge between classic DAO-style reentrancy and later DeFi incidents where “supports any ERC-20” quietly means “executes attacker code mid-settlement.”

Failure-prevention checklist

  • Delete or zero the channel’s refundable balances before any external token or ETH delivery can occur, and restore only through an explicit fail-closed unwind if delivery later fails.
  • Do not let arbitrary user-supplied tokens into pooled-settlement or refund paths. Channel assets should be allow-listed, or token behavior should be wrapped behind a deliberately constrained adapter surface.
  • Separate recovery authority from ordinary settlement authority only if the recovery path is actually narrower; otherwise harden it identically.
  • Enforce non-zero confirmation windows or other lifecycle friction where a protocol’s safety story assumes the hub or counterparty has time to react before a timeout becomes spendable.
  • Make joined, unjoined, timed-out, and closed channel states explicit and one-way so a helper path cannot keep spending the same pre-close balance after authority should already be extinguished.
  • Review payout order across mixed-asset refunds. If one asset leaves before another asset’s transfer call, treat the later call as a reentrancy boundary for the earlier payout too.
  • Reuse the same invariant checks across timeout, dispute, join, deposit, and byzantine-close helpers; exceptional paths should not get a weaker admission or callback model than the main path.
  • Add regression tests with hostile token contracts, self-channel creation attempts, zero-timeout channels, and reentrant callbacks from every asset-transfer edge in the channel lifecycle.

Audit prompts

  • Which functions can release pooled ETH or tokens before a channel is fully joined or fully settled, and do those functions harden external calls the same way the main close path does?
  • Can a user choose any token contract, and if so, what proves that transfer() or transferFrom() on that token is safe enough to run inside refund logic?
  • Does any timeout or recovery path pay one asset, then call attacker-controlled code, and only afterward delete or zero the state that justified the first payout?
  • What lifecycle assumption is encoded in confirmTime, and can an attacker set it to zero or overflow it so that the safety window disappears?
  • Can a user create a channel with themselves, force the hub into an unexpected token, or otherwise move the system into a lifecycle state the offchain software did not expect?
  • If a token callback re-enters today, which exact channel object is still live: balances, membership, timeout status, dispute rights, or all of them?
  • Which helper paths were reviewed only as operational conveniences even though they still controlled pooled funds?
  • What test proves that every path which can emit value also closes the authority that justified that value before control leaves the contract?

Lessons that should already be standard

  • Recovery paths are not operational footnotes. If they can move money, they are settlement code.
  • Supports any ERC-20 is really executes any caller-supplied contract unless the protocol narrows semantics much more aggressively.
  • Mixed-asset payout order matters: the reentrancy boundary may sit on the second asset while the first asset is the one that actually gets stolen.
  • Operational timing parameters such as confirmTime can be security boundaries. A zero or overflowable timeout can turn a lifecycle assumption into an attacker-controlled primitive.
  • The right question is not only is there a reentrancy guard? but what stale authority is still alive when external delivery begins?
  • Helper and timeout flows often carry the weakest review culture and the strongest economic authority at the same time.

Internal linkages

Control surface