Skip to main content

Solidity and Smart Contract Vulnerabilities on Ethereum

Updated on
Dec 29, 2023

9 min read

Overview

Ethereum's daily transaction volume reflects its critical role in the web3 evolution, but it also highlights the potential value at risk if security measures are not practiced. This guide will focus on both the high-level concept of vulnerabilities in smart contracts and show you smart contract vulnerabilities found in Solidity, the primary language for creating smart contracts on EVM-compatible blockchains. We'll also cover the common pitfalls that developers might encounter. By diving into the theory and concepts of Solidity smart contract vulnerabilities, you'll gain insights into best practices and preventative measures.

Let's get started!

What You Will Do


  • Explore the theory behind vulnerabilities in smart contracts
  • Learn about common Solidity smart contract vulnerabilities
  • Take a Quiz to challenge your knowledge

What You Will Need


Smart Contracts Vulnerabilities in Theory

A smart contract can be vulnerable for different reasons. For example, your blockchain application may contain faulty business logic, have insecure code, or have issues with external dependencies and interactions that lead to unexpected behavior. These are all different attack vectors your smart contract may be at risk of if not properly engineered.

At its core, smart contract languages like Solidity are compiled down to bytecode, which is what is deployed on the Ethereum blockchain and what the Ethereum Virtual Machine (EVM) understands. Other smart contract languages like Vyper (which uses Python) were built with some security considerations in mind but at the expense of some features and less developer support. There are also lower-level smart contract languages like Yul, which allow for more optimized and gas-efficient smart contracts but at the expense of complexity. This tradeoff can be a significant factor for developers when choosing a smart contract language, which is why Solidity is still the go-to smart contract language on Ethereum (and other EVM chains).

Another thought to keep in mind is that the smart contract language isn't the only vector of attack and can also depend on blockchain execution designs. We won't cover this in this guide but it's good to keep in mind.

Why Do Hacks Happen?

Before we dive into the technical segment of this guide, let's talk about why hacks happen.

Hacks are a result of a smart contract vulnerability. However, they can still be labeled as multifaceted issues as we described like faulty business logic, insecure code or untended interactions with external factors. While technological improvement and developer experience are fundamental to preventing attacks, understanding the human element - both of the users and attackers - is equally crucial. With that being said, for the remainder of this guide, we'll focus on Solidity vulnerabilities and help you better understand conceptually how to write secure smart contracts in Solidity.

Reentrancy

Reentrancy is one of the most important smart contract vulnerabilities to understand. This vulnerability can be produced both with Solidity and Vyper and occurs when a smart contract function makes an external call to another contract before it finishes executing. If the external contract calls back into the original function, the original function might execute with an inconsistent or unexpected state if written insecurely (we'll explain this more in detail soon). The end result of a successful reentrancy attack is not limited to stolen funds but can also result in unauthorized function calls or contract state changes.

Here's a code block of what vulnerable code might look like:

function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount);

(bool sent, ) = msg.sender.call{value: _amount}(""); // External call before state update
require(sent, "Failed to send Ether");

balances[msg.sender] -= _amount; // State update after the external call
}

In the withdraw function above, the function sends Ether (msg.sender.call) before updating the user's balance. The order of operations is what makes the contract vulnerable to reentrancy.

Mitigating Reentrancy Vulnerabilities

You can use a few solutions to mitigate this type of insecure order of operations (in order of importance):


  1. Checks-Effects-Interactions Pattern: Always update your contract's state (balances, internal variables) before calling external contracts or sending Ether. This pattern applied to the vulnerable code example above would look like:
function withdraw(uint256 _amount) public {
// Checks
require(balances[msg.sender] >= _amount, "Insufficient balance");

// Effects
balances[msg.sender] -= _amount; // Update the state before interacting

// Interactions
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
}

  1. Function Guards: Declare a modifier in your smart contract that acts as a lock for function calls, using a boolean variable to lock and unlock callbacks into your function. If you're using function guards, ideally, you should still use best practices (Checks-Effects), but this is another security layer. However, a function guard to prevent reentrancy would look similar to this:
bool private locked;

modifier noReentrant() {
require(!locked, "No reentrancy allowed!");
locked = true;
_;
locked = false;
}

function withdraw(uint256 _amount) public noReentrant {
...
}

Arithmetic Overflow and Underflow

Most smart contract libraries help manage arithmetic overflow and underflow for you; however, if you are interacting with smart contracts built on older versions of Solidity (e.g., < 0.8.0), look out for this vulnerability.

A simple example of an overflow or underflow would be when a number reverts back to its min/max value. For example, an uint8 value can range from 0 to 255. If your smart contract previously didn't manage the state of this value properly, it could potentially reset back to 0, or vice versa, go to the 255 max value a uint8 can be.

Mitigating Arithmetic Overflow and Underflow Vulnerabilities


  1. Math Libraries Use libraries like SafeMath in your smart contract to prevent arithmetic overflow and underflow
  2. Function Guards: Declare a modifier in your smart contract to check for underflow or overflow before the state is allowed to be changed

NOTE: Remember to use Solidity version 0.8.0> when compiling new smart contracts

Access Control

Smart contracts may not want all their functions to be called by anyone. Therefore, access control plays a key role in securing your smart contracts. An example of an access control vulnerability could be that a smart contract developer forgot to guard a DAO control function, and therefore, anyone could call this function, potentially changing DAO rules.

Here's a breakdown of how access control issues can manifest in Solidity contracts:

contract UpgradeableContract {
// ...
function updateLogicAddress(address _newAddress) public {
// Should be restricted to owner only
logicAddress = _newAddress;
}
}

In the code example above, anyone can call the updateLogicAddress, potentially upgrading a smart contract's new implementation logic to contain malicious code. Solutions to this potential vulnerability could be as follows.

Mitigating Access Control Vulnerabilities


  1. Function Guards: Create a modifier that checks that the caller is authorized. This can be implemented such as:
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}

Then resolve your original contract as follows:

function updateLogicAddress() public onlyOwner {
// ...
}

  1. Role-based Access Control System (RBAC): Useful when dealing with smart contracts containing complex permissions. Tools like OpenZeppelin provide RBAC systems you can use. In the example below, we import an Ownable contract as set it to the provided initialOwner parameter. This solution removes the need to create your own authentication modifier.
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract MyContract is Ownable {
constructor(address initialOwner) Ownable(initialOwner) {}

function specialThing() public onlyOwner {
// only the owner can call specialThing()!
}
}

Phishing with tx.origin

tx.origin is one of the many built-in Solidity keywords developers can use. tx.origin returns the address of the transaction initiator. For example, if Contract A calls Contract B, the value of tx.origin would be Contract A's address. However, if Contract A calls B and then B calls C, tx.origin will always be the address that originated the transaction (i.e., A). As you can imagine, this could potentially cause an entity to deceive a function call, acting as an owner, for instance.

Mitigation of tx.origin Phishing


  1. Use msg.sender: When possible, use msg.sender instead of tx.origin. msg.sender refers to the immediate caller of the function, providing a more accurate method of authentication, especially in multi-contract calls.

Before:

function transfer(address payable _to, uint _amount) public {
// Vulnerable line: using tx.origin for authentication
require(tx.origin == owner, "Not owner");

(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}

After:

function transfer(address payable _to, uint256 _amount) public {
// Fixed line: using msg.sender for authentication
require(msg.sender == owner, "Not owner");

(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}

Self Destruct (Deprecated)

selfdestruct was a built-in function provided in Solidity that is now deprecated (in 0.8.18>). This deprecation should be one testament to Solidity and its continuous improvement. It allowed smart contracts to be "deleted", effectively setting its account storage and state to 0x while sending any ETH the smart contract held to the recipient specified in the function call.

This vulnerability was the cause of over $1 billion of losses in 2017, where an access control flaw in the Parity Library (a multi-sig wallet provider) lets an attacker take ownership of the library contract. With this control, they can invoke the selfdestruct function and, as a result, leave the funds inaccessible (still to this day).

Frontrunning

Frontrunning occurs when an entity tries to gain economic value from another transaction by sending their transaction with higher gas fees in an attempt to get their transaction mined first (i.e., before the victim's transaction). Frontrunning can be applied to different categories in web3. For instance, it could be in an attempt to take advantage of an arbitrage opportunity in DeFi, front-run an NFT mint opportunity, or any other transaction an entity can simulate beforehand and have the opportunity to take advantage of it and profit.

This isn't a vulnerability to Solidity itself, but it is still good to consider when considering building secure smart contracts. To learn more about frontrunning and how it plays a role in MEV, check out this guide: What is MEV (Maximum Extractable Value) and How to Protect Your Transactions with QuickNode.

Test Your Knowledge

Before you continue solidifying your developer knowledge, test yourself with the quiz below:

🧠Knowledge Check
What is a reentrancy attack in the context of smart contracts?

Additional Resources and Tools


Final Thoughts

As we conclude, remember that the landscape of smart contract development is continually evolving. Continuous learning and staying updated with security advancements and Solidity updates are crucial to becoming a seasoned web3 developer.

Subscribe to our newsletter for more articles and guides on Web3 and blockchain. If you have any questions or need further assistance, please visit the QuickNode Forum. Stay informed and connected by following us on Twitter (@QuickNode) or joining our Discord community.

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide