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 ETHdrained, with roughly$4,000of BOOTY also temporarily immobilized in the contract; user-owned losses were reported at about34.99 ETHplus1,271.88 BOOTYbefore reimbursement - Summary: SpankChain’s
LedgerChannel.sollet anyone open a channel with an arbitrary ERC-20 token address and later recover the unopened channel throughLCOpenTimeout(bytes32 _lcID). That timeout path sent ETH topartyAddresses[0], then calledtoken.transfer(...), and only deleted the channel afterward. The attacker supplied a malicious token contract, setconfirmTime = 0, opened a channel whosepartyAwas the attacker-controlled contract, and used the token’stransfer()hook to re-enterLCOpenTimeout()repeatedly beforedelete 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_tokenaddress and stored it asChannels[_lcID].token.- The attacker could set
_confirmTime = 0, makingLCOpenTimeout()callable immediately after channel creation before the hub could join. LCOpenTimeout()transferred ETH and then invokedChannels[_lcID].token.transfer(...)before deleting the channel record.- Because the token contract itself was attacker-controlled, its
transfer()implementation could call back intoLCOpenTimeout()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 intoLedgerChannel. - The same function also coupled
channel still existswithETH is still refundable, so re-entering beforedelete Channels[_lcID]preserved the payout right. confirmTime = 0removed the operational buffer that might otherwise have let the hub join or operators intervene before timeout execution.
- Exploit path:
- The attacker funded EOA
0xcf267ea3f1ebae3c29fea0a3253f94f3122c2199and deployed malicious token / exploit contract0xc5918a927C4FB83FE99E30d6F66707F4b396900E. - The malicious contract opened a ledger channel against SpankChain’s
LedgerChannelcontract0xf91546835f756DA0c10cFa0CDA95b15577b84aA7, naming itself as the token and depositing ETH as the channel’s initial ETH balance. - It set
confirmTime = 0, so the channel could be timed out atomically without giving the hub time to join. - The attacker then called
LCOpenTimeout(_lcID)in exploit transaction0x21e9d20b57f6ae60dac23466c8395d47f42dc24628e5a31f224567a2b4effa88. - Inside
LCOpenTimeout(), the contract first sentethBalances[0]back topartyAddresses[0]and then calledChannels[_lcID].token.transfer(Channels[_lcID].partyAddresses[0], Channels[_lcID].erc20Balances[0]). - Because
tokenwas attacker code, thattransfer()re-enteredLCOpenTimeout()beforedelete Channels[_lcID]executed. - 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.
- The attacker funded EOA
- 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. confirmTimeneeded 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-20transfer()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 becausedelete Channels[_lcID]sat after the external interactions.- The operationally small-looking
confirmTimeparameter mattered. Allowing0let the attacker create the exact malicious state and cash it out in the same transaction envelope.
- This was not just generic reentrancy from
- Representative transactions and addresses:
- Victim
LedgerChannelcontract:0xf91546835f756DA0c10cFa0CDA95b15577b84aA7 - Attacker EOA:
0xcf267ea3f1ebae3c29fea0a3253f94f3122c2199 - Malicious token / exploit contract:
0xc5918a927C4FB83FE99E30d6F66707F4b396900E - Representative exploit transaction:
0x21e9d20b57f6ae60dac23466c8395d47f42dc24628e5a31f224567a2b4effa88
- Victim
- 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,000bounty plus the5.5 ETHseed funds used in the exploit. - Connext’s joint transparency report says the same counterparty also disclosed a second path for recovering the roughly
$4,000in trapped BOOTY, reclaimed those funds within about an hour, and received a further$4,000reward. - 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.
- 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
Pattern tags
recovery-only timeout path still held full pooled-ETH authority and therefore deserved the same hardening as a normal withdrawal laneuser-chosen token semantics were admitted into a refund path that paid ETH first and only closed authority laterone stale unopened-channel balance remained replayable because channel deletion happened after external deliveryconfirmTime = 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 = 0mattered 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 saferjoined-channellifecycle 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()ortransferFrom()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-20is reallyexecutes any caller-supplied contractunless 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
confirmTimecan 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?butwhat 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
- Core recurring pattern: reentrancy.
- Asset-surface companion: interface-conformance-semantic-compatibility-failure — SpankChain treated an arbitrary ERC-20-like token as if transfer semantics were passive enough for a refund path.
- Positive-design companion: actual-receipt-accounting-and-token-semantics-admission-discipline — the sharper design question is why a channel-opening and refund path admitted user-chosen token semantics into pooled-ETH payout logic at all.
- Positive-design companion: temporary-capability-closure-before-external-delivery — the execution-order lesson is that ETH refund authority should have been closed before attacker-controlled token delivery ever happened.
- Positive-design companion: helper-path-invariant-reuse-and-fail-closed-discipline — the lifecycle lesson is that timeout and recovery helpers needed the same invariant closure as the main channel paths instead of a looser callback and admission model.
Control surface
-
The decisive control boundary was the unopened-channel refund path in
LCOpenTimeout(), especially its ordering of ETH payout, attacker-controlled token transfer, and finaldelete Channels[_lcID]. -
More broadly, the protocol let a user choose executable asset code and then called that code from inside the same function that still defined whether pooled ETH remained refundable.
-
Sources:
- https://medium.com/spankchain/we-got-spanked-what-we-know-so-far-d5ed3a0f38fe
- https://medium.com/connext/transparency-report-64c9e58e0a19
- https://raw.githubusercontent.com/crytic/not-so-smart-contracts/master/reentrancy/SpankChain_source_code/SpankChain_Payment.sol
- https://raw.githubusercontent.com/pcaversaccio/reentrancy-attacks/main/README.md
- https://etherscan.io/address/0xf91546835f756DA0c10cFa0CDA95b15577b84aA7
- https://etherscan.io/address/0xc5918a927C4FB83FE99E30d6F66707F4b396900E
- https://etherscan.io/address/0xcf267ea3f1ebae3c29fea0a3253f94f3122c2199
- https://etherscan.io/tx/0x21e9d20b57f6ae60dac23466c8395d47f42dc24628e5a31f224567a2b4effa88
-
Last reviewed: 2026-07-17 UTC