Mastering Solidity Vulnerabilities

Mastering Solidity Vulnerabilities

Enhance your Smart Contract Security

A good blockchain developer can account for a ton of reasons why Smart Contract Security should be taken seriously. The total amount of funds lost due to smart contract hacks totals $2.7 billion- a 1250% increase from 2020, sourced from BanklessTimes here and last updated in 2022.

This shocking report is enough to trigger the importance of Smart Contract Security in the minds of every developer and even the average blockchain user.

Smart Contracts are very much vulnerable to periodic attacks from hackers, who we've seen find weak spots in code and exploit them to their favor time and time again. The best way to repudiate these exploitations in your smart contract as a developer is to understand how they can occur and implement best practices.

This article walks you through the most prevalent ways hackers could breach your code and utilize its vulnerabilities.

Note: Hacker and Attacker will be used interchangeably in this article.

1. ReEntrancies

We start the list off with the old but gold reentrancy attack. A reentrancy attack occurs when an attacker continues to call a function that withdraws ether (ETH) from a smart contract without limitations or consequences. It is otherwise known as a recursive operation.

The Ethereum community got a taste of how merciless this operation can be during The DAO hack on June 17, 2016, which resulted in a total loss of approximately 60 million dollars ($60,000,000) falling into the hands of the hacker.

Here's a fun example to elaborate on the way this works: Let's say there are ten people in a bank. Each of them has one million dollars ($1,000,000) in their accounts and each of them is entitled to withdraw one million only since they own it, but what happens when the bank fails to put measures in place to stop one individual from withdrawing more than one million?

Yes, a reentrancy. It happens when one of those individuals walks into the bank and requests to withdraw their entitled one million from 10 tellers simultaneously.

If Teller 2 has no way to confirm that the individual has already withdrawn the initial million entitled to them from Teller 1 and that their updated balance is now zero (0), Teller 2 will proceed with the request and so will Teller 3 to Teller 10 until the individual has withdrawn all ten million ($10,000,000) from the bank.

Let's say the individual knew that tellers had no way of communicating with each other and that the bank had been counting on the honesty of users to secure funds, so they exploited that weakness and robbed the bank of all the other user's funds.

This is similar to the way it happens with smart contracts as a hacker would repeatedly call a function, knowing fully well that a loophole in the code would allow them to continuously pull funds out of the contract before their balances get updated.

Code example:

contract VictimContract{
    mapping(address => uint) balance; // holds user balance

    // function deposits funds for users
    function depositFunds(uint amount) public payable {
        balances[msg.sender] += msg.value;
    }
    // function withdraws funds for users
    function withdrawFunds() public {
        uint amount = balance[msg.sender]; // amount to withdraw
        require(msg.sender.call{value: amount}("");
        balances[msg.sender] = 0; // update user balance
    }
}

The VictimContract() here shows an example of an exploitable Smart Contract. It has two functions: one to help users depositFunds() and one to help users withdrawFunds(). The problem is that the withdrawFunds function does not update the user's balance until line four (4) of the function code.

Because of this, the hacker can keep on withdrawing ether and the contract will only become self-aware of an issue when there is no ether left to drain.

How To Prevent A ReEntrancy

  1. Checks-Effects-Interactions: A Check-Effects-Interaction (CEI) is perhaps the most principal way to prevent a reentrancy as it fixes the issue of updating user balance first.

    It implements the following steps when processing functions:

    • Check conditions required to execute code function,

    • Effect changes in code,

    • Interact with external function.

      Code example:

        contract SafeContract { // safer contract
            mapping(address => uint) balance;
      
            function depositFunds() public payable {
                balance[msg.sender] += msg.value;
            }
      
            function withdrawFunds() public {
                uint amount = balance[msg.sender]; // Check requirement
                balance[msg.sender] = 0; // Effect change
                require(msg.sender.send(amount)); // Interactions
            }
        }
      

      In the code above we notice a slightly different tone in the depositFunds() function of the SafeContract as it has been updated to use the Check-Effects-Interaction.

      The code now checks the balance of the user --> modifies the balance --> and then interacts with the external function that sends the ether to the function caller/user.

  2. Reentrancy Guard: A reentrancy guard puts a halt to the execution of multiple functions at once. It is used on functions that are highly successive to reentrancy attacks to serve as a blockade, preventing an attacker from calling a function multiple times before the function has been successfully executed after the first function call. To simplify the use of reentrancy guards, OpenZeppelin has a library that helps developers import this guard for use into their contracts.

    Code example:

     import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
     contract ReentrancyGuardContract is ReentrancyGuard {
         ... // contract variable logic here
    
         function withdrawFunds() external reentrancyGuard {// protect function
         ... // function logic protected against reentrancy here
         }
     }
    

    In the code example above, a reentrancy guard is imported from OpenZeppelin into our ReentrancyGuard contract and used on the withdrawFunds() function to limit users from calling this function one too many times simultaneously.

2. Improper Visibility

This is perhaps one of the most overlooked vulnerabilities when dealing with smart contract security.

By default, all functions and state variables defined in a contract are public, unless stated otherwise by the developers before hosting the smart contract on Mainnet.

Smart contracts are immutable by nature. Once they're deployed on-chain no one can tamper with the values of the original contract, without having to use a proxy, compared to traditional software that can be maintained at any time by developers. This feature is known to be both good and bad for developers.

When dealing with variables and functions in smart contracts, there are four (4) visibility settings;

  • Private.

  • Internal.

  • External.

  • Public.

If not handled properly these visibilities can leave data stored in your code vulnerable to attackers.

Code example:

contract WrongVisibility {
    string public userSecrets; 

    constructor(string memory _userSecret) {
    userSecrets = _userSecret;
    }
}

In the code example above, userSecrets is saved as a public variable. This means anyone can read the contract and see what secrets the user has stored in the contract through a getter function that returns usersSecrets, breaching the user's privacy.

Improper visibility of functions could also allow a hacker to call a function on a smart contract that they were not originally supposed to have access to. Sensitive functions that control the state of the smart contract or withdraw funds from the contract must have their visibility carefully set by developers to ensure no unauthorized access or manipulation by attackers.

How To Prevent Improper Visibility Attacks

The best way to mitigate an improper visibility attack as a developer is to understand how to explicitly define the visibility of functions and state variables in your smart contract;

  1. Private: Use private for functions that can only be callable from within your smart contract.

  2. Internal: Use internal for functions that should only be callable from within the contract and its derived contracts.

  3. External: Use external for functions that can only be called from outside of your smart contract.

  4. Public: Only use the public visibility for functions that are meant to be publicly accessible and reveal no data that could be harmful to your users if read by an attacker.

3. Denial of Service (DoS)

As the name implies, a Denial of Service vulnerability typically occurs as a kind of malfunction when an attacker spams a smart contract function with requests until the contract locks itself up due to the inability to perform the many requests by the attacker.

Calling a function so many times already consumes excessive gas, if the attacker persists they can easily crash the contract, rendering it useless to other users as the contract will now deny requests from well-meaning users.

Ever had someone request you do so many things at once that you completely lose interest in doing any? That's how a Denial of Service works on a smart contract level.

You can probably already guess how this can be problematic for contracts used for finance systems as users will lose their funds and in the case of voting-based systems, an attack like this would delay decision-making.

How To Prevent Denial Of Service Attacks

  1. Gas Limit: By limiting the amount of gas that could be spent on smart contract functions, a developer can prevent recurring function calls that could cause a Denial of Service attack.

  2. Time frame limits: This is another way to limit how many times a function can be called by the same user within a specific time. Developers should be cautious of functions that shouldn't be processed twice by the same user only seconds apart. Like a voting function. Users should be denied access to voting more than once to prevent users from flooding the function with requests.

  3. Reentrancy guards: Reentrancy guards can also be used to prevent a Denial of Service attack because they limit the recursive calling of smart contract functions.

  4. Gas price discouragement tactics: By implementing mechanisms that monitor and adapt to changes in gas prices, developers can discourage attackers from launching a DoS as higher gas prices could deter them.

4. Lack Of Access Control

Solidity smart contracts are very much capable of limiting access to certain functions. This implementation is called an Access Control. With access control in your code logic, users cannot call functions they weren't meant to, safeguarding several functions from any unwanted caller.

An example of an access controller in real-world terms would be the equivalent of a security guard that limits outsiders from exclusive events.

When limitations to functions are poorly implemented, a smart contract is vulnerable to exploitation by an attacker.

When creating smart contracts especially finance-based smart contracts (DeFi), it is best practice for developers to use access controls to provide an extra step of security in their contract logic, ensuring that users do not lose their funds through exploitation by attackers.

How To Implement Access Control

Use a modifier: A great way to implement Access Control would be to use an in-built modifier function in solidity.

code example:

contract AccessControl {
    address public owner; 
    // declare modifier, set access to owner only!
    modifier onlyOwner() { 
        require(msg.sender == owner, "error: not owner"); // error msg:
        _;
    }

    constructor() {
        owner = msg.sender; // sets owner to deployer of contract
    }
    // implement modifier to limit access control to onlyOwner
    function withdraw() external onlyOwner { 
    ...
    }    
}

The code example above implements access control through the use of a modifier.

Modifiers are the easiest way to control access to your code. You start by defining the terms of the variables and the conditions to be required by the function before it executes any request from a user.

The modifier onlyOwner() tells the withdraw() function to deny the request of any user/address if that address does not match the address of the owner of the contract.

As it is a withdraw function, a developer can prevent attackers from calling the function and withdrawing the funds to themselves.

See also: What is a modifier?

5. Overflows And Underflows Of Integers

Another prime example of Solidity vulnerability can be seen in integers. An integer refers to a number that isn't a fraction. Integers in solidity are whole numbers stored as values for variables.

Solidity primarily uses integers (whole numbers), for operations requiring numerical value since it's designed to be compatible with cryptocurrencies, but there are quite a few workarounds used to represent fractions or floating-point numbers. However, these workarounds are highly discouraged as they could lead to rounding errors.

A rounding error is known as a miscalculation, caused when a number is altered to an integer or another number with slightly fewer decimal points.

Languages like Solidity aren't designed to perfection, this is why developers must be extra careful when writing smart contracts and dealing with integers.

The two types of vulnerabilities that could occur when dealing with integers are;

  • Integer Overflow: When an integer overflows, it is the result of an addition or multiplication surpassing the maximum value attributed to the data type like uint256.

  • Integer Underflow: When it underflows, it's because the result of a subtraction operation being executed in the code goes below the minimum value attributed to the data type.

Code example:

contract ImproperCalculation {
    uint256 public answer;

    function divide(uint256 x, uint256 y) public {
        answer = x / y;
    }
}

The code example above has a divide() function that when called divides x by y. The thing is, if y is larger than x the answer will round down, and if the division of x and y results in a decimal number, it will also round down.

This is just one of the ways integers could be problematic to handle for a developer. It is advisable to use best practices to counter this.

How To Prevent Overflows And Underflows

  1. Use SafeMath Library: If you're writing a smart contract using versions older than version 0.8.0, you're at risk of running into an integer vulnerability. To prevent integer overflows and underflows as a developer, the OpenZeppelin SafeMath library is the safest way to handle these mathematical operations because it comes with checks that handle these overflows and underflows. The SafeMath library also ensures that operations concerning subtractions will never result in a value that is below zero (0), preventing an underflow.

    Code example:

     import "@openzeppelin/contracts/utils/math/SafeMath.sol"
    
     contract SafeMathDemo {
        using SafeMath for uint256; // attribute safe math to uint256 data types
       }
    

    The code example above imports Openzeppelins SafeMath library and uses it in our code for the uint256 data type. This allows our code to use SafeMath functions directly on uint256 variables and avoid an overflow or underflow.

  2. Multiply before Dividing: Another approach to the integer problem commonly used by professional developers would be to first multiply before dividing.

    Below is an example of how this works:

     contract MultiplyBeforeDivide {
         uint256 public answer;
    
         function calculate(uint256 x, uint256 y, uint256 precision) public {
             answer = (x * precision) / y
         }
     }
    

    In the code above we handle the calculation by introducing a new variable called precision that acts as a denominator for the division operation.

    We multiply first before dividing, to retain precision and accuracy from the answer we get.

6. Front Runner Attacks

On the Ethereum blockchain, transactions are very much transparent, in the sense that everyone can see and read them. On top of that transactions are not executed the moment they are made. Instead, they are added to the mempool where they wait for validation before getting added to the blockchain. This usually takes some time.

During this waiting time, a malicious entity who had been observing the transaction could easily create a similar transaction but pay a higher gas fee, placing their transaction above the honest transaction as validators would process theirs first due to the higher incentive.

This is why it's called a front-running attack since the attacker places a similar transaction in front of an honest transaction to profit off the honest transaction.

Front running when it comes to crypto is very much legal, compared to in a traditional stock market, as transactions are not exclusive to a set of people and are very much available for everyone to see, but this doesn't stop it from being an unethical practice.

These attacks are most commonly prevalent in Decentralized Exchanges (DEX) and other kinds of DeFi applications that deal with the exchange of crypto-currencies.

How To Stop A Front Runner

  1. Limit Gas Prices: The best defense a developer has against front-running attacks is to limit just how much can be spent on gas per transaction. This method moderates the amount of gas a user can pay for in their transaction, preventing a front runner from paying an extortionate amount of gas fee to entice miners into front-running an honest transaction.

  2. Use Order Matching Mechanisms: To avoid the potential of a front-running attack, it's best to implement Order Matching Engines that match orders and allow users to purchase and sell at market price.

7. Incompetent Data Feeds

Smart contracts, unfortunately, are unable to communicate with real-world applications. This is done to preserve the decentralized nature of blockchain technology, but it's quite controversial as some Smart Contract applications need to fetch data from the real world to function accurately.

To obtain the data, developers make use of Oracles which help retrieve the required information by communicating with the real world and then feeding that information back into the smart contract like a third-party application.

There are two types of oracles:

  • Centralized Oracles.

  • Decentralized Oracles.

Centralized Oracles fetch their data from a single, trusted source and feed the data back to your smart contract.

Decentralized Oracles are more trustless so they do not fetch from a single source. Rather they pool together information from different independent sources using cryptography, consensus mechanisms, and incentives to ensure the accuracy of data.

Smart Contracts can be vulnerable to exploitation if the oracles used to relay data are inconsistent and wrong. How?

Since Oracles are in charge of supplying information to a smart contract, an attacker could use one to feed the wrong information to a smart contract, throwing off the accuracy of the contract and even resulting in the loss of users' funds.

Here's an example: A smart contract built to help users purchase crypto-currency (ETH), suddenly gets fed the wrong data on the price of Ethereum. Let's say the real price of Ethereum is (hypothetically) $1400, an incorrect data feed or oracle could tamper with this information and display the price as $2000. Users would have lost $600 buying ETH at an incorrect price due to the Oracle.

How To Prevent Incompetent Data Feeds

Decentralize: To avoid incorrect data in your smart contract it's best practice to choose to use a decentralized oracle over a centralized one.

For now, the best decentralized Oracle developers use is Chainlink. Chainlink leads the way in supplying data to smart contracts, however, developers must still be aware of the risk of getting the wrong information fed to their smart contracts.

Chainlink logo

Read more about Chainlink Price Feed here.

Overall Developer Best Practices

The article was tailored to highlight the most common Smart Contract vulnerabilities and how to mitigate them, but there are overall best practices that every developer should keep in mind when building production-ready applications on the blockchain for public use.

  1. Always Test: As a developer, you should get accustomed to writing thorough unit and integration tests for your smart contracts and blockchain application. The purpose of these tests is to attempt to break your code before any attacker could, that way you can spot potential vulnerabilities before you deploy your application to the mainnet.

  2. Implement Emergency Stop Mechanisms: Always code a logic in your smart contract that can help you pause all activities in case of a suspected attack.

  3. Security Audits: Even after testing, you should always audit your code. Smart contract auditing has evolved rapidly over the years, from being a closed community to being an essential trait of every developer. Tools like Mythril and Cyberscan help developers pick up potential vulnerabilities in their smart contracts, making the process easier for devs.

  4. Bug Bounty Programs: If you don't know how to properly audit your code as a developer you can always run a bug bounty program and invite experienced auditors to find potential vulnerabilities in your smart contract and propose the best way to fix them for you. Note: This might be expensive based on who you go to. Open Zeppelin and Cyfrinn offer one of the most top-notch smart contract audits today.

Conclusion

With every new version update, solidity is slowly working its way to perfection, but is not yet there and because of this developers must stay vigilant when developing smart contracts and blockchain applications.

A simple vulnerability could be the reason why your code loses a million dollars in the future and becomes the next most talked about hack, so it's best to always follow these best practices to reduce the potential of these vulnerabilities.

You've come to the end of the article. If you have any questions, leave a comment. If you want more simplified articles like these, follow me for more Blockchain and Web3-related articles.

Click on this link to get in touch with me on my socials. See you next time.