How to hack Smart Contracts?
Smart Contract Vulnerabilities with The Ethernaut Challenges - Part#1
TL;DR: take-aways from hacking smart contracts:
- Be careful with the fallback function because anyone can trigger it. Do not implement important functionality, such as changing the smart contract owner.
- Make sure to test and use security analysis tools to find human errors, such as naming ones.
- "Randomness" on a smart contract is a tricky problem. It's especially common to use the blockhash, which is easily guessable by an attacker. Instead, randomness can be achieved by using an off-chain solution such as an Oracle (e.g., Chainlink VRF).
The Ethernaut challenges are a great way to get started with smart contract vulnerabilities and learn about best practices. Even if you're not a smart contract expert or an auditor, you can learn a lot from completing the challenges. I've been completing the challenges, and this will be a series of blog posts on smart contract security from The Ethernaut Game.
In this post, I want to give an intro on smart contract security and auditing, then go over the first three Ethernaut challenges that cover the following smart contract vulnerabilities:
- Receive Fallback function & receiving ETH into a smart contract
- Defining constructors & human-errors
- Randomness on a smart contract
Introduction
You can not change the smart contract once it's deployed to the blockchain (*worth noting that there are different methods to upgrade the contract). That's why getting it correct the first time is very important. Additionally, since smart contracts are code that interacts with money, they become a target for hacks.
You need to test extensively and beware of the common smart contract vulnerabilities to stay safe from attacks.
Once the smart contract is ready, it should pass a security audit before being published to the mainnet. A smart contract audit is an extensive analysis of the smart contract code. Audits are made to discover security vulnerabilities and issues the contract may have to suggest improvements before launching on mainnet. Here's a great guide on Audit Techniques & Tools 101.
There is a lot to learn and do on Smart Contract Security, and The Ethernaut Game is a great way to get started. You learn by getting hands-on and hacking contracts with security vulnerabilities.
Ethernaut Challenges
The Ethernaut is a Web3/Solidity based game where each level is a smart contract that needs to be 'hacked'. The game is played on the browser and consists of 26 challenges.
Here's the level to walk through the setup. Let's outline the steps over here:
Prerequisites
- Connect to your MetaMask wallet
- Have some Rinkeby Testnet ETH
- Open the browser's developer console
Steps
On each challenge, you'll need to click "Get a new Instance." Once you click the button, approve the popup on your MetaMask and pay for the transaction. This will deploy the smart contract, which you need to hack to pass the challenge.
*Even though you are clicking the button to create a new instance, an Ethernaut account is deploying the smart contract; you are only initiating the transaction. So, you will not be the smart contract owner; instead, an Ethernaut account will.
Open the google developer console; you'll interact with the contact from the console terminal. (Right Click --> Inspect)
You'll need to deploy a smart contract on some challenges. You can deploy it on Remix without any setup. In short, for these challenges, the smart contract will be an intermediary to make the transactions to complete the hack.
1. Receive
*This challenge is called as Fallback in the Ethernaut. We will distinguish between receive and fallback below. Thanks a lot to the feedback from Orkun over here! ๐
You will beat this level if
- you claim ownership of the contract
- you reduce its balance to 0
The challenge's goal is to claim ownership of the smart contract and drain out the funds. The "onlyOwner" modifier used on the withdraw() function will allow us to withdraw the funds if we're the owner.
First, let's cover some key terms that will help to understand the concepts and then have a look at the smart contract code:
- Ethereum has two types of accounts: Externally-owned Accounts (EOA) and smart contract accounts. Smart contracts have their own smart contract addresses and can receive money, just like a user wallet address. You can learn more about the account types over here.
- A Fallback Function on a Solidity Smart Contract is a function that has no name, is public and can not have any parameters. The Fallback function is executed when a function identifier doesn't exist or parameters are not specified for a specific function.
- Receive(): is used as a fallback function if the value sent to the function is greater than 0.
- Solidity modifiers are used to check a condition before executing a function. For example, the onlyOwner() modifier is a frequently used modifier to indicate that only the contract owner can execute the function, such as withdrawing funds.
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0; // specify the version of solidity for the compiler
import '@openzeppelin/contracts/math/SafeMath.sol';
// this is the library to perform mathematical operations safely.
contract Fallback {
using SafeMath for uint256;
// keep track of the addresses and the relative contributions
mapping(address => uint) public contributions;
// keep the owner address of the smart contract
// address type is a solidity variable
address payable public owner;
// constructor is only called once when the contract is deployed
constructor() public {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
// modifiers are added to functions; if the condition is satisfied, the func is executed
modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}
// get the amount a user has sent to the smart contract and store it in the mapping
function contribute() public payable {
// a require statement checks for a condition and if satisfied moves to the next line
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
// get the contribution of a certain user
function getContribution() public view returns (uint) {
return contributions[msg.sender];
}
// get the money out of the smart contract
// only the owner can execute this function
function withdraw() public onlyOwner {
owner.transfer(address(this).balance);
}
// this is the fallback function which has no name and is executed when no other
// function matches
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
There are two conditions:
- msg.value > 0 --> meaning that the value sent must be larger than 0
- contributions[msg.sender] > 0 --> the sender must be contributions mapping. When we satisfy these two conditions, we can become the contract owner and call withdraw().
Solution
Deploy the smart contract.
After you've deployed the contract, you can open the developer console to look at the contract details:
- check the contract address, contract owner and your account address (the player)
- contributions mapping
Call the contribute() function and send some money.
Check the contributions mapping; you should see that there is now an entry for the player account.
Now, we want to call the receive fallback function with a value greater than 0. This will change the contract owner if the two conditions are satisfied.
Since we are in the contributions mapping and sending a message with a value greater than 0, this should satisfy the two conditions.
Once you are the contract owner, call the withdraw() function to get the funds out of the smart contract.
Wohoo! You've just hacked a smart contract! ๐ฅณ๐ฅณ
๐ Takeaway
Be careful with the fallback function on a smart contract, and keep it simple. Anyone can call it, and implementing key logic, such as transferring contract ownership, can create a vulnerability for the contract
2. Fallout
Goal: Claim ownership of the contract below to complete this level.
Let's have a look at the smart contract. I have only added the section we need to solve the challenge.
contract Fallout {
/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
}
In the above code snippet, we have a contract called Fallout and a method that seems to be a constructor.
To recap, A constructor in solidity smart contracts is a function that is executed when the contract is deployed. It is only executed once and is an optional function. A constructor uses the keyword "constructor" or the name of the smart contract itself.
Solution
Before getting to the solution, have a look at the contract owner's address and the player's address:
await contract.owner()
player
In the smart contract code, there is a function called "Fal1out", which looks like a constructor but it actually is not. There is a typo in the name, and instead of an "l", there is a "1."
We can directly call the "Fal1out" function, which will transfer the ownership of the smart contract.
contract.Fal1out()
Now when you check the owner of the contract, it should be your address (the player address):
๐ Takeaway
A small human error allowed us to take control of the smart contract. Thus, creating tests and using audit tools is important to avoid such situations.
3. Coin Flip
This is a coin-flipping game where you need to build up your winning streak by guessing the outcome of a coin flip. To complete this level you'll need to use your psychic abilities to guess the correct outcome 10 times in a row.
We need to know the dice result ten times in a row to complete the challenge.
Let's first cover some key terms and then have a look at the smart contract:
- Blockhash(): it's a global function in solidity that returns the hash of a given block.
- Block.number: global variable that returns the current block number.
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract CoinFlip {
using SafeMath for uint256; // all integers should use the SafeMath Library
uint256 public consecutiveWins; // the frontend can get access to this
// used for determining randomness
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() public {
consecutiveWins = 0;
}
// takes a bool thats the guess of the user
// returns a bool thats true if guess is correct, false otherwise
function flip(bool _guess) public returns (bool) {
// given block, returns the hash
// subtract 1 from the block.number because otherwise it returns the current block
// which has not been mined yet since the block is not mined it will not have a hash.
uint256 blockValue = uint256(blockhash(block.number.sub(1)));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue; // save the blockValue as the lastHash
uint256 coinFlip = blockValue.div(FACTOR); // integer division with a constant number
bool side = coinFlip == 1 ? true : false; // decide on the side based on the number
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
The random number is generated based on the blockhash of the previously mined block. Another contract can copy the same logic to make the same calculation and then call the flip() function with the correct result.
Solution
Copy and paste the smart contract to Remix.
Change the import line to get the SafeMath.sol contract from Github:
import 'https://github.com/OpenZeppelin/openzeppelin-contracts/contracts/utils/math/SafeMath.sol';
Change the version of solidity.
pragma solidity ^0.8.0;
Create a new smart contract on Remix; this will be the attacker's smart contract: CoinFlipAttack.sol.
Here's the code for the attack contract. I have added comments to explain what's going on.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import './CoinFlip.sol'; contract CoinFlipAttack { CoinFlip public victimContract; uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968; constructor(address _victimContractAddress) public { // get the victim contract victimContract = CoinFlip(_victimContractAddress); } // Use the same logic in the coinFlip function to get the same result function flip() public returns (bool) { // best practice is to use safe math for the purpose of the demo I'm using mathematical operations uint256 blockValue = uint256(blockhash(block.number-1)); uint256 coinFlip = (blockValue/FACTOR); bool side = coinFlip == 1 ? true : false; victimContract.flip(side); } }
On the Solidity compiler tab, click compile to compile the CoinFlipAttack contract.
Get the CoinFlip contract address from Ethernaut.
On Remix, change the environment to Injected Metamask. This way, we can deploy the contract from our MetaMask wallet.
Add the CoinFlip address on the deploy interface and deploy the contract. This way, we will set the attacker contract. Next, you'll need to approve the popup on your MetaMask wallet.
In Remix, execute the flip function on the attacker contract. This will flip a coin, but will pre-calculate the result so it should be the correct guess.
Once the transaction is executed, check the consecutive wins on the Ethernaut page.
await contract.consecutiveWins()
Flip the coin correctly 10 times to complete the challenge.
๐ Takeaway
Randomness on a blockchain is a highly complex problem because blockchains are deterministic systems. Thus, true randomness cannot be achieved in solidity. However, there are off-chain resources such as oracle services, the most notable being Chainlink Verifiable Random Functions(VRF) to generate random numbers.
Hope this was helpful. There is much to learn in smart contract auditing and security. I'll be continuing to write about the Ethernaut challenges! ๐
Here are some resources to follow up with:
- Damn Vulnerable DeFi Game by tincho. It's a set of challenges to learn about smart contract security.
- Security Audits from OpenZepplin
- How to Audit a Smart Contract Video by Patrick Collins
Catch you on the next one!