Building smart contracts for the blockchain is very different from building applications for Web2. First, smart contracts are immutable after they are deployed on the blockchain, while Web2 applications are scalable and loosely coupled. Second, smart contract code has actual monetary value, making security during development even more critical.
Unfortunately, the Web3 space has suffered numerous attacks and tremendous losses as a result of exploited bugs and vulnerabilities, as well as improperly written code.
Vulnerabilities or bugs that exist in a smart contract will spell trouble for anyone who interacts with it after it is deployed on the mainnet.
In this article, I’ll share critical information for improving the security of your smart contract development. I’ll provide detailed instructions to help you identify and prevent common smart contract vulnerabilities and attack vectors.
Jump ahead:
The reentrancy attack is one of the earliest smart contract vulnerabilities; it was first detected on the Ethereum blockchain in 2016.
A smart contract must have a fallback function in order to receive Ether. The fallback function is called by the smart contract that is sending the Ether. If the code of the sending smart contract is not written properly, the receiving smart contract could have an opportunity to exploit the sender.
Here’s an example of an insecure sending smart contract:
// INSECURE mapping (address => uint) private userBalances; function withdrawBalance() public { uint amountToWithdraw = userBalances[msg.sender]; (bool success, ) = msg.sender.call.value(amountToWithdraw)(""); // At this point, the caller's code is executed, and can call withdrawBalance again require(success); userBalances[msg.sender] = 0; }
The code above employs a mapping data structure to keep track of userBalances
that have Ether in this contract. The code resets the value of the userBalances
back to zero after they call the withdrawBalance
function.
This function is vulnerable to reentrancy attacks because it sends Ether to the user before updating the user’s balance in the userBalances
mapping. This is unsafe because it gives the receiver an opportunity to call the withdrawBalance
function again in its fallback function before the user’s balance is updated. In other words, the receiver could withdraw more than they have in the contract.
Here’s a better way to write the smart contract:
mapping (address => uint) private userBalances; function withdrawBalance() public { uint amountToWithdraw = userBalances[msg.sender]; userBalances[msg.sender] = 0; // The user's balance is already 0, so future invocations won't withdraw anything (bool success, ) = msg.sender.call.value(amountToWithdraw)(""); require(success); }
Now let’s look at a more practical example of an insecure smart contract that could fall prey to a reentrancy attack. The code is for an insecure Solidity contract called EtherStore
:
// SPDX-License-Identifier: MIT pragma solidity >=0.4.22 <0.9.0; contract EtherStore { // Mapping Data structure to keep track of balances of users mapping(address => uint256) public balances; // As the name suggests, you can use this function to deposit Ether to the contract function deposit() public payable { balances[msg.sender] += msg.value; } // For withdrawing the Ether stored by users function withdraw() public { // Gets the balance of the user calling the withdraw function uint256 bal = balances[msg.sender]; // Checks if the user/caller has Ether in this contract require(bal > 0); // Transfers the Ether to the user // Vulnerability point (bool sent, ) = msg.sender.call{value: bal}(""); require(sent, "Failed to send Ether"); balances[msg.sender] = 0; } // Helper function to check the balance of this contract function getBalance() public view returns (uint256) { return address(this).balance; } }
This contract enables users to deposit Ether and then withdraw their balance when they please. The contract uses a mapping to track the balances of users, but it makes the mistake of sending Ether out before updating the user’s balance. This makes the contract vulnerable to reentrancy attacks.
A malicious actor could deploy a contract with the address of EtherStore
, exploit the reentrancy vulnerability, and then withdraw more Ether than they originally deposited.
To mitigate against the risk of reentrancy attacks by malicious actors, follow Solidity’s Check-Effect-Interaction pattern when you write the function that sends out Ether from a contract:
Notice that the vulnerable EtherStore
contract does something similar but in the wrong order. It checks if the caller is eligible to use the function, but then skips to the interaction phase without implementing the Effects.
Here’s an example of a secure EtherStore
contract that follows the Check-Effect-Interaction pattern:
// SPDX-License-Identifier: MIT pragma solidity >=0.4.22 <0.9.0; contract EtherStore { // Mapping Data structure to keep track of balances of users mapping(address => uint256) public balances; // As the name suggests, you can use this function to deposit Ether to the contract function deposit() public payable { balances[msg.sender] += msg.value; } // For withdrawing the Ether stored by users function withdraw() public { // Gets the balance of the user calling the withdraw function uint256 bal = balances[msg.sender]; // Check require(bal > 0); // Effect balances[msg.sender] = 0; // Interaction (bool sent, ) = msg.sender.call{value: bal}(""); require(sent, "Failed to send Ether"); } // Helper function to check the balance of this contract function getBalance() public view returns (uint256) { return address(this).balance; } }
Arithmetic overflows and underflows are issues that stem from Solidity itself. Newer versions of Solidity (0.8.0 and higher) can detect this type of error.
The uint
data type in Solidity is used to declare unsigned integers starting at 0. This data type is restricted to certain ranges, including uint8
, uint16
, uint32
, uint64
, uint128,
and uint256
. For example, uint8
represents all unsigned integers starting from 0 to 2^8 – 1 (i.e., 0 to 255).
If we want to use a number higher than 255, we might first consider adopting a uint8
subtype with a wider range. However, this solution would be suboptimal as it would not work for numbers outside the range of the largest uint
subtype.
uint256
represents all unsigned integers from 0 to 2^256-1. If we wanted to represent a uint
out of its range we’d have no alternative uint
type to use. This may not seem like a huge problem. After all, 2^256-1 is a very large number, so the chances of wanting to work with something out of its range are pretty low.
However, if we examine the issue more carefully, we’ll find that the issue is more significant.
Solidity compilers with versions lower than 0.8.0 do not give a warning for using unsigned integers that are out of the range of the uint
type that is in use. Instead, the Solidity compiler does something a bit humorous. It converts the out-of-range number to zero – without warning!
Let’s look at an example.
Let’s say we’re using a uint8
type to store the number 256, which is obviously greater than 2^8-1(255);
uint8 num = 256;
The Solidity compiler would react by overflowing the unsigned integer.
In other words, since uint8
only represents integers from 0 to 255, an unsigned integer greater than this range would reset to the difference between its value and the upper limit of the uint
type (i.e., 256-255 or 1 in this example). The new integer (1, in this case) “overflows” to the first integer of the uint8
range; the range is 0 to 255, so the first integer is 0.
Again, this only happens in Solidity versions lower than 0.8.0.
Now, let’s take a look at a contract with an arithmetic overflow/underflow vulnerability to get a practical understanding of the threat that this type of vulnerability can pose:
// SPDX-License-Identifier: MIT pragma solidity ^0.7.6; contract TimeLock { mapping(address => uint) public balances; mapping(address => uint) public lockTime; function deposit() external payable { balances[msg.sender] += msg.value; lockTime[msg.sender] = block.timestamp + 1 weeks; } function increaseLockTime(uint _secondsToIncrease) public { lockTime[msg.sender] += _secondsToIncrease; } function withdraw() public { require(balances[msg.sender] > 0, "Insufficient funds"); require(block.timestamp > lockTime[msg.sender], "Lock time not expired"); uint amount = balances[msg.sender]; balances[msg.sender] = 0; (bool sent, ) = msg.sender.call{value: amount}(""); require(sent, "Failed to send Ether"); } }
The above code is for a TimeLock
smart contract. This contract is meant to lock the user’s Ether for a duration of one week; it also has the functionality to increase the duration. This is a pretty nice utility for a smart contract. Unfortunately, this contract is vulnerable to overflow/underflow attacks because the withdraw
function logic is dependent on a uint
.
A malicious actor could write code to deposit Ether to the contract and then withdraw a larger amount before the lock period is over by “overflowing” (essentially, resetting) the lock time function to zero.
In Solidity versions higher than 0.8.0, the compiler will detect and adjust for an arithmetic overflow/underflow vulnerability.
If you’re using a Solidity version lower than 0.8.0, you can protect against this vulnerability with the SafeMath library.
Here’s a secure version of our TimeLock
contract using SafeMath
:
// SPDX-License-Identifier: MIT pragma solidity ^0.7.6; library SafeMath { function mul(uint256 a, uint256 b) internal pure returns (uint256) { if (a == 0) { return 0; } uint256 c = a * b; assert(c / a == b); return c; } function div(uint256 a, uint256 b) internal pure returns (uint256) { // assert(b > 0); // Solidity automatically throws when dividing by 0 uint256 c = a / b; // assert(a == b * c + a % b); // There is no case in which this doesn't hold return c; } function sub(uint256 a, uint256 b) internal pure returns (uint256) { assert(b <= a); return a - b; } function add(uint256 a, uint256 b) internal pure returns (uint256) { uint256 c = a + b; assert(c >= a); return c; } } contract TimeLock { using SafeMath for uint; // use the library for uint type mapping(address => uint256) public balances; mapping(address => uint256) public lockTime; function deposit() public payable { balances[msg.sender] = balances[msg.sender].add(msg.value); lockTime[msg.sender] = now.add(1 weeks); } function increaseLockTime(uint256 _secondsToIncrease) public { lockTime[msg.sender] = lockTime[msg.sender].add(_secondsToIncrease); } function withdraw() public { require(balances[msg.sender] > 0); require(now > lockTime[msg.sender]); uint transferValue = balances[msg.sender]; balances[msg.sender] = 0; msg.sender.transfer(transferValue); } }
Race conditions are a pretty general problem in programming and they surface in smart contract development in various shapes and forms. the reentrancy vulnerability we talked about previously is, in fact, a type of race condition in Solidity.
Cross-function race conditions occur in situations in which two or more Solidity functions are trying to access the same state variable for their individual computation before either has the chance to make the update.
Let’s have a look at a Solidity smart contract where this vulnerability is observed:
mapping(address => uint256) public userBalances; /* uses userBalance to transfer funds */ function transfer(address to, uint256 amount) public { if (userBalances[msg.sender] >= amount) { userBalances[to] += amount; userBalances[msg.sender] -= amount; } } /* uses userBalances to withdraw funds */ function withdrawalBalance() public { uint256 amountToWithdraw = userBalances[msg.sender]; // makes external call to the address receiving the ether (bool sent, ) = msg.sender.call{value: amountToWithdraw}(""); require(sent, "Failed to send Ether"); userBalances[msg.sender] = 0; }
In the contract above we have two functions: transfer
and withdrawalBalance
. There is a cross-function bug that could be triggered with this contract.
A bad actor could write a contract to call withdrawal and transfer functions nearly simultaneously. If successful, the attacker would be able to transfer Ether to another address and also withdraw it at the same time.
To understand this vulnerability better, let’s consider an off-chain example. Imagine you have $100 in your bank account and want to make both a transfer and a withdrawal. An example of a race condition occurs when you transfer $50 and withdraw $50 simultaneously before your account balance is updated. In this instance, you end up (at least temporarily) having $50 in your account even though you spent $100. This is referred to as double spending, and it’s an example of the types of issues the blockchain was intended to solve.
As I mentioned earlier, there are some similarities between a cross-function bug and a reentrancy attack. Both of these vulnerabilities are types of race conditions, and both arise from the improper use of external calls.
Another similarity can be found in their solutions. When dealing with race conditions generally in programming, it is advisable to place a lock on the particular variable that is being used for computation until the process is completed. Fortunately, Solidity has the Check-Effect-Interaction pattern which we discussed previously.
To illustrate how the Check-Effect-Interaction pattern works, let’s say a function is supposed to withdraw a particular amount of Ether and the contract in question has a variable to track user balances.
First, we check that the caller is eligible to call this function. Then, in the effect phase, we make changes to the variable before making the external call. Finally, in the interaction phase, we make the external call that sends out Ether.
When implemented correctly, this pattern is enough to stop a cross-function race condition in a smart contract.
Here’s a secure version of the cross-function race conditions contract we examined earlier:
mapping(address => uint256) public userBalances; /* uses userBalance to transfer funds */ function transfer(address to, uint256 amount) public { if (userBalances[msg.sender] >= amount) { userBalances[to] += amount; userBalances[msg.sender] -= amount; return true; } } /* uses userBalances to withdraw funds */ function withdrawalBalance() public { // Not really a check but close enough uint256 amountToWithdraw = userBalances[msg.sender]; // Effect userBalances[msg.sender] = 0; // Interaction (bool sent, ) = msg.sender.call{value: amountToWithdraw}(""); require(sent, "Failed to send Ether"); }
Since Ethereum is a public blockchain, some vulnerabilities that arise are related to race conditions in which malicious actors use the information provided by users to run transactions that benefit themselves at the expense of others.
Each transaction made on Ethereum is sent to the Mempool before it is mined. Miners select the transactions that they want to add to a block and mine to the blockchain. Miners are incentivized to pick transactions with a higher gas price since these transactions translate to greater rewards. Under this scenario, it’s possible for a transaction that came into the Mempool last to be mined first if the gas price of the last transaction is higher than that of the first transaction.
This loophole gives bad actors access to sensitive information from certain transactions and the opportunity to use that information to their benefit before the initial transaction is mined.
To clarify this further, let’s look at an example.
Imagine a contract that was deployed with a certain amount of Ether, and the first person to pass a particular string to a function of that contract gets the Ether that is stored in the contract. User A discovers the string needed to access the Ether in the contract, so they call the function and pass the string. The transaction of User A calling the function must go to the Mempool first, so the input they pass to the function will be visible to everyone who has access to view the Mempool.
Now, imagine that a malicious actor (User B) can view the string that User A used in their transaction. User B runs the same function with that string but with a higher gas price, causing miners to pick User B’s string first. User B looks like the first person to discover the string, and they get the Ether from the contract.
Transaction order dependence attacks are more commonly referred to as front-running. Front-running can take many forms. In another scenario, a contract could be set up so that whoever solves the function receives all the Ether in the contract, but with the stipulation that to run that function the user has to pass a Require
which checks if the string they passed into the function as an input matches the pre-image of the particular hash.
Ordinarily, this would be a good safeguard against an attack, because it’s difficult to calculate the pre-image of a hash. However, a bad actor could monitor the Mempool
for any user with the correct solution, copy the user’s input, and then run the function with a higher gas price in order to get the Ether from the contract.
Another example of front-running is where User A approves User B to spend a certain number of their tokens with the Approve
and TransferFrom
functions, but then later reduces the amount. Now, let’s say User B was monitoring the Mempool and saw User A’s transaction to rescue their allocated number of tokens. User B could send their own transaction (with a higher gas price) to the Mempool, spending the original number of allocated tokens. Since User B’s transaction was mined before User A could reduce their tokens, User B gets the original allocation. Plus, they also receive the reduced number of tokens when User A’s transaction is finally mined!
Front-running, or transaction order dependence, attacks are difficult, but not impossible, to stop.
One method to prevent front-running is to create an upper bound on gas price in the smart contract so that users can’t alter the gas price. However, this strategy only stops normal users since miners can still bypass the upper bound. In many instances, there may be no cause for alarm, since it is difficult for miners to target a single block.
A second method is for a user to employ a commit/reveal scheme. The first transaction a user sends would be encrypted in a particular way (the commit phase. Then, when the transaction gets mined, the user sends another transaction decrypting the information that was sent in the first (the reveal phase). With this method, neither normal users nor miners can pull a front-running attack.
It may be tempting to use the block.timestamp
function to get the current time and then use this data to perform some business logic in your smart contract. However, this is not recommended due to Ethereum’s decentralized nature.
The time provided by the block.timestamp
function is inaccurate and can be manipulated by malicious miners.
Here are some important points to remember about timestamp dependence:
block.timestamp
or blockhash
as a source of randomness, unless you know what you are doing; both of these functions can be influenced by miners to some degreeIn this article, we reviewed several common smart contract vulnerabilities as well as some smart contract development mistakes to avoid: reentrancy, arithmetic overflows and underflows, cross-function race conditions, transaction order dependence, and timestamp dependence.
All of the smart contract examples reviewed in this article were written in Solidity.
If you find this article helpful, please share it with other smart contract developers. Of course, we can’t predict new vulnerabilities that may arise in the future. But, in the meantime, we can mitigate against threats by learning from recent losses in the Web3 space.
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app or site. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.
Modernize how you debug web and mobile apps — Start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
3 Replies to "Smart contract development: Common mistakes to avoid"
Thanks for the good overview, although I cannot quite get the race conditions example. In smart contracts you cannot call anything simultaniously, Transactions and functions calls are atomic, so you cannot put the transfer call within the withdrawal call. Once withdrawal finishes the balance is updated. Please, let me know if I am wrong
It’s somewhat concurrent. The withdrawBalance function starts, then in-between, the transfer function is run. When the transfer function ends, the withdrawBalance function continues and then end. Because the withdrawBalance function hasn’t updated the state, it could give the user more than the coin/money.
Liken it to running a function inside the main function in Rust or Go.
This insightful article highlights common mistakes to avoid in smart contract development – it reinforces the importance of metaverse training to ensure robust and secure implementations.