Skip to main content

Command Palette

Search for a command to run...

Guide to Smart Contract Attacks, every beginner should know!

Published
8 min read
Guide to Smart Contract Attacks, every beginner should know!
D

Hello folks ! Myself Deepak . I'm a student and aspiring Blockchain developer.

Overview of attacks, I learnt during initial phase of smart contract security journey. every beginner security researcher should know.

Overview

This writing delves into the foundational security vulnerabilities that beginners in smart contract development need to be aware of.
We'll explore common attack vectors such as reentrancy attacks, integer overflows/underflows, and unchecked external calls, among others. By understanding these basic attack mechanisms, developers can write more secure smart contracts and contribute to the robustness of the blockchain ecosystem.

Whether you're a newcomer to smart contract development or just looking to enhance your security knowledge, this blog will provide you with the essential insights needed to safeguard your contracts.

Following are the list of attacks, which would give a good introduction to smart contract security:

1. Denial of Service:

The below given contract snippet is from a raffle contract and is a best example of DOS attack.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract DoS {
    address[] entrants;

    function enter() public {
        // Check for duplicate entrants
        for (uint256 i; i < entrants.length; i++) {
            if (entrants[i] == msg.sender) {
                revert("You've already entered!");
            }
        }
        entrants.push(msg.sender);
    }
}

The above snippet represent the function, where new entrant tries to enter the raffle, the for loop checks for the already existing entrant and reverts with the message "You've already entered!", else it adds the new entrant to the entrants array.

The problem arises, when the size of the entrants array grows. Every time someone is added to the entrants array, another loop is added to the duplicate check and as a result more gas is consumed.

To summarize, this test deploys the same DoS contract we've been looking at:

vm.prank(warmUpAddress);
    dos.enter();

    uint256 gasStartA = gasleft();
    vm.prank(personA);
    dos.enter();
    uint256 gasCostA = gasStartA - gasleft();

    uint256 gasStartB = gasleft();
    vm.prank(personB);
    dos.enter();
    uint256 gasCostB = gasStartB - gasleft();

    uint256 gasStartC = gasleft();
    vm.prank(personC);
    dos.enter();
    uint256 gasCostC = gasStartC - gasleft();

console2.log("Gas cost A: %s", gasCostA);
console2.log("Gas cost B: %s", gasCostB);
console2.log("Gas cost C: %s", gasCostC);

assert(gasCostC > gasCostB);
assert(gasCostB > gasCostA);

DoS attacks can be very impactful for a protocol. They can inject unfairness and cause interactions to be prohibitively expensive.

2. Reentrancy:

Example contract for reentrancy:

contract ReentrancyVictim {
    mapping(address => uint256) public userBalance;

    function deposit() public payable {
        userBalance[msg.sender] += msg.value;
    }

    function withdrawBalance() public {
        uint256 balance = userBalance[msg.sender];
        // An external call and then a state change!
        // External call
        (bool success,) = msg.sender.call{value: balance}("");
        if (!success) {
            revert();
        }

        // State change
        userBalance[msg.sender] = 0;
    }
}

Following steps will give you clear context of smart contract interaction:

User A (balance 10 ether) can deposit funds

  1. deposit{value: 10 ether}

    userBalance[UserA] = 10 ether
    contract balance = 10 ether
    User A balance = 0 ether

And then withdraw them

  1. withdrawBalance

    userBalance[UserA] = 0 ether
    contract balance = 0 ether
    User A balance = 10 ether

In our withdrawBalance function, we see that the function is making an external call before updating the state of the contract.

What this means, is that an attacker could have that external call be made in such a way that it triggers a call of the withdrawBalance function again (hence - reentrancy).

Following code snippet gives you clear idea of how attacker performs reentrancy attack:

contract ReentrancyAttacker {
    ReentrancyVictim victim;

    constructor(ReentrancyVictim _victim) {
        victim = _victim;
    }

    function attack() public payable {
        victim.deposit{value: 1 ether}();
        victim.withdrawBalance();
    }

    receive() external payable {
        if (address(victim).balance >= 1 ether) {
            victim.withdrawBalance();
        }
    }
}

Consider the above attack contract. Seems pretty benign, but let's walk through what's actually happening.

  1. Attacker calls the attack function which deposits 1 ether, then immediately withdraws it.
function attack() public payable {
        victim.deposit{value: 1 ether}();
        victim.withdrawBalance();
    }
  1. The ReentrancyVictim contract does what's it's supposed to and received the deposit, then processs the withdrawal. During this process the victim contract makes a call to the attacker's contract.

    #NOTE: THIS IS BEFORE OUR BALANCE HAS BEEN UPDATED

(bool success,) = msg.sender.call{value: balance}("");
        if (!success) {
            revert();
        }

What happens when a contract receives value? It's going have it's receive/fallback functions triggered. And what does our Attacker's receive function look like?

receive() external payable {
        if (address(victim).balance >= 1 ether) {
            victim.withdrawBalance();
        }
    }

It calls the withdrawBalance function again! Because our previous withdrawBalance hasn't updated our balance yet, the contract will happily let us withdraw again.. and again .. and again until all funds are drained.

Re-entrancy is a a big deal and it's very impactful when it happens. At it's most minimalistic, re-entrancy generates a loop that continually drains funds from a protocol.

3. Weak Randomness:

The blockchain is deterministic. Using on-chain variables and pseudo random number generation leaves a protocol open to exploits whereby an attacker can predict or manipulate the 'random' value.

Carefully observe the below code snippet:

uint256 randomNumber = uint256(keccak256(abi.encodePacked(msg.sender, block.prevrandao, block.timestamp)));

In this declaration we're taking 3 variables:

  • msg.sender

  • block.prevrandao

  • block.timestamp

We're hashing these variables and casting the result as a uint256. The problem exists in that the 3 variables we're deriving our number from are able to be influenced or anticipated such that we can predict what the random number will be.

Multiple ways to manipulate randomness:

i. block.timestamp:

Relying on block.timestamp is risky for a few reasons as node validators/miners have privileges that may give them unfair advantages.

The validator selected for a transaction has the power to:

  • Hold or delay the transaction until a more favorable time

  • Reject the transaction because the timestamp isn't favorable

Timestamp manipulation has become less of an issue on Ethereum, since the merge, but it isn't perfect. Other chains, such as Arbitrum can be vulnerable to several seconds of slippage putting randomness based on block.timestamp at risk.

ii. block.prevrandao:

block.prevrandao was introduced to replace block.difficulty when the merge happened. This is a system to choose random validators.

The security issues using this value for randomness are well enough known that many of them are outlined in the EIP-4399 documentation already.

The security considerations outlined here include:

  • Biasability: The beacon chain RANDAO implementation gives every block proposer 1 bit of influence power per slot. Proposer may deliberately refuse to propose a block on the opportunity cost of proposer and transaction fees to prevent beacon chain randomness (a RANDAO mix) from being updated in a particular slot.

Predictability: Obviously, historical randomness provided by any decentralized oracle is 100% predictable. On the contrary, the randomness that is revealed in the future is predictable up to a limited extent.

iii. msg.sender:

  • Any field controlled by a caller can be manipulated. If randomness is generated from this field, it gives the caller control over the outcome.

  • By using msg.sender we allow the caller the ability to mine for addresses until a favorable one is found, breaking the randomness of the system.

In short, relying on on-chain data to generate random numbers is problematic due to the deterministic nature of the blockchain. The easiest way to mitigate this is to generate random numbers off-chain.

4. Integer Overflow/Underflow:

In Solidity, data types are strictly defined, and each type has a specific range of values it can represent. Unsafe casting occurs when you forcefully convert one data type to another without properly considering the implications of the conversion. This can lead to unexpected behavior, especially when dealing with different sizes of integers, such as converting a larger integer type (e.g., uint256) to a smaller one (e.g., uint64).

Example:

Consider the case where you have a uint256 value representing 20 Ether (expressed as 20e18), which you attempt to cast into a uint64. Since uint64 can only hold values up to 18,446,744,073,709,551,615 (the maximum value for a uint64), the casting operation will cause the value to wrap around, leading to an incorrect, much smaller number.

Preventing Unsafe Casting:

  • Use Appropriate Types: Always use a data type that can safely accommodate the full range of values you intend to work with. For example, prefer uint256 over smaller types unless you have a specific reason to limit the range.

  • Avoid Implicit Casting: Be explicit in your casting and always consider the potential consequences of converting between types.

  • Leverage Safe Math Libraries: Although Solidity 0.8+ has built-in overflow checks, it’s still a good practice to use libraries like OpenZeppelin’s SafeMath to handle complex arithmetic safely.

5. Mishandling of Eth:

While handling ETH in Solidity is a fundamental task for many decentralized applications (dApps), improper handling can lead to serious vulnerabilities, including loss of funds or exploitable security flaws.

1. Implement a fallback or receive function to handle unexpected Ether transfers, and consider adding logic to reject them if the contract is not meant to hold ETH:

receive() external payable {
    revert("Direct payments not accepted");
}

2. Using transfer or send Without Consideration

function sendEther(address payable recipient) external {
    // Using transfer, which only forwards 2300 gas
    recipient.transfer(1 ether);
}

The transfer function forwards only 2300 gas, which might not be sufficient for the recipient's fallback or receive function if it's a contract. If the recipient is a contract with more complex logic, the transfer might fail, causing the transaction to revert.

Better Practice: Use call and check for success:

function sendEther(address payable recipient) external {
    (bool success, ) = recipient.call{value: 1 ether}("");
    require(success, "ETH transfer failed");
}

3. Allowing Unrestricted Ether Deposits

contract EtherSink {
    // Accepts Ether but does nothing with it
    receive() external payable {
        // Ether is accepted without any restrictions or handling
    }
}

This contract accepts Ether without any logic to handle or restrict deposits. This can lead to unintended consequences, such as Ether being locked in the contract with no way to withdraw it.

  1. Hardcoding Ether Values
function payFixedAmount(address payable recipient) external {
    // Sends a fixed amount of 1 ether
    recipient.transfer(1 ether);
}

Hardcoding Ether amounts can be risky, especially if the contract logic or the Ether price changes. This can lead to overpayments or underpayments, depending on the context.

Allow flexibility in the amount:

function payVariableAmount(address payable recipient, uint256 amount) external {
    require(amount > 0, "Amount must be greater than zero");
    (bool success, ) = recipient.call{value: amount}("");
    require(success, "ETH transfer failed");
}

Congratulations! 🎉

You have taken your first step in securing web3.

Thanks for the read! Share with your fellow tech gigs. ↗️

For more such insights on smart contract development and security, connect me on X, Linkedin , and Github.👋

Always welcome for your feedback to improve my work.✨