Welcome back to our weekly session on smart contract exploits. This week we will be talking about the Selfdestruct function in Solidity.
Let’s first understand how transfers operate in Solidity programming
- Transfer: Throws exception when an error occurs, and the code will not execute afterward
- Send: The transfer error does not throw an exception and returns true/false. The code will continue to execute.
- call.value().gas: Transfer error does not throw an exception and returns true/false. The code will execute, but call functions for transfer are prone to reentrancy attacks.
All three transfer operations listed above require the target to receive the funds to transfer them to the correct address. However, there’s another way to transfer funds without obtaining the funds first: The Self Destruct function. Selfdestruct is a function in the Solidity smart contract used to delete contracts on the blockchain. When a contract executes a self-destruct operation, the remaining ether on the contract account will be sent to a specified target, and its storage and code are erased. The self-destruct function is a double-edged sword.
// SPDX-License-Identifier: MITpragma solidity ^0.8.10;
contract EtherGame { uint public targetAmount = 7 ether; address public winner;
function deposit() public payable { require(msg.value == 1 ether, “You can only send 1 Ether”);
uint balance = address(this).balance; require(balance <= targetAmount, “Game is over”);
if (balance == targetAmount) { winner = msg.sender; } }
function claimReward() public { require(msg.sender == winner, “Not winner”);
(bool sent, ) = msg.sender.call{value: address(this).balance}(“”); require(sent, “Failed to send Ether”); }}
The EtherGame contract is designed as a game, and let’s call it Lucky Seven for now. Each player deposits one Eth into the contract. Once the balance reaches seven Eth, the player who placed the last Eth is declared the winner and can withdraw all funds in the contract.
The players call the EtherGame.deposit function each time they deposit Eth into the contract. The function will check if the balance in the contract is less than 7. The contract will only continue if the balance is less than 7. The balance is obtained through address().balance, which means all we have to do is change the balance to 7 or greater before a winner is declared. This can be done by forcing the EtherGame contract to accept additional Eth, so the balance is greater than or equal to 7.
Unfortunately, due to the EtherGame.deposit function: require(msg.value == 1 ether, “ You can only send 1 Ether”), this means we can only send one Eth at a time. Since that option is no longer available, let’s move on to the reason we’re here today, The self-destruct function.
The selfdestruct function causes the remaining Eth on the contract to be sent to a specific address. This can be done by creating a malicious contract to target the EtherGame contract with the selfdestruct function. Now we can deposit multiple Eth into the EtherGame contract and bypass the EtherGame.deposit function.
If six players have already deposited one Eth into the contract, we can use the selfdestruct function to force an additional Eth into the contract. This allows us to bypass the EtherGame.deposit function, resulting in an accounting error.
Let’s look at an example of an Attack contract
contract Attack { EtherGame etherGame;
constructor(EtherGame _etherGame) { etherGame = EtherGame(_etherGame); }
function attack() public payable { address payable addr = payable(address(etherGame)); selfdestruct(addr); }
We assigned three roles to this contract
Player 1: Alice
Player 2: Bob
Player 3: Eve (The attacker)
Step 1. The contract EtherGame is deployed
Step 2. Alice decided to play this game. She plans to win by continuously depositing Eths in the contract, so she is guaranteed to win. She finished depositing her six Eth and is getting ready to deposit the final Eth.
Step 3. Eve deploys the malicious contract and passes it to the EtherGame contract.
Step 4. Eve calls Attack.attack and sets msg.value = 1. The function triggers selfdestruct and forces one more Eth into the contract. Now there are 7 Eths in the contract.
Step 5. Now Bob also wants to play the game. He deposits one more Eth into the account, and it fails the balance <=targetAmount check and returns “Game is over.” Eve has successfully paralyzed the EtherGame contract since the game will never produce a winner.
This is what the attack process will look like:
Repair Suggestions
Now that we have an understanding of the Selfdestruct function, We will learn how to prevent and fix them from the perspective of developers and auditors:
As a Developer
We will continue to use the above EtherGame contract. Attackers can exploit this contract since it relies on address(this). balance to acquire the contract’s balance. It can avoid this by setting a variable in balance to increase only after the user deposits Eth into the contract using the EtherGame.deposit function. As a result, as long as the Eth does not appear in other operations, it will not affect our balance, thus preventing accounting errors caused by forced transfers. The code below can be used to repair it.
// SPDX-License-Identifier: MITpragma solidity ^0.8.10;
contract EtherGame { uint public targetAmount = 3 ether; uint public balance; address public winner;
function deposit() public payable { require(msg.value == 1 ether, “You can only send 1 Ether”);
balance += msg.value; require(balance <= targetAmount, “Game is over”);
if (balance == targetAmount) { winner = msg.sender; } }
function claimReward() public { require(msg.sender == winner, “Not winner”);
(bool sent, ) = msg.sender.call{value: balance}(“”); require(sent, “Failed to send Ether”); }}
As an Auditor
As auditors, we need to verify if using address(this).balance will alter the contract’s usual logical flow. Assume that the contract has been tampered with to prevent normal operation. (such as being attacked by selfdestruct). It is also vital to study the actual code during the audit process.