Contents

Smart Contract Security

Reentrancy

Single-Function Reentrancy

  • // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.27;
    
      contract Vulnerable {
          mapping (address => uint256) private balances;
    
          function withdraw() public {
              uint256 amount = balances[msg.sender];
              (bool success, ) = msg.sender.call{value: amount}("");
              require(success);
              balances[msg.sender] = 0;
          }
      }
    
  • When msg.sender is a smart contract, the withdraw() process may include:

    • update the Ethereum State Trie (i.g. the balances of from and to)
    • excute receive() or fallback() of the smart contract
    • withdraw() is called again during the second step

Cross-Function Reentrancy

  • // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.27;
    
      contract Vulnerable {
          mapping (address => uint256) private balances;
    
          function transfer(address to, uint256 amount) public {
              if (balances[msg.sender] >= amount) {
                  balances[to] += amount;
                  balances[msg.sender] -= amount;
              }
          }
    
          function withdraw() public {
              uint256 amount = balances[msg.sender];
              (bool success, ) = msg.sender.call{value: amount}("");
              require(success);
              balances[msg.sender] = 0;
          }
      }
    
  • When msg.sender is a smart contract, the withdraw() process may include:

    • update the Ethereum State Trie (i.g. the balances of from and to)
    • excute receive() or fallback() of the smart contract
    • transfer() is called during the second step

Cross-Contract Reentrancy

  • import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
    
    contract Bank is ReentrancyGuard {
        mapping (address => uint256) public balances;
    
        function deposit() external payable {
            balances[msg.sender] += msg.value;
        }
    
        function withdraw() public nonReentrant {
            uint256 amount = balances[msg.sender];
            (bool success, ) = payable(msg.sender).call{value: amount}("");
            require(success);
            balances[msg.sender] = 0;
        }
    }
    
    contract BankConsumer {
        Bank private bank;
    
        constructor(address _bank) {
            bank = Bank(_bank);
        }
        function getBalance(address account) public view returns (uint256) {
            return bank.balances(account);
        }
    }
    
  • When msg.sender is a smart contract, the withdraw() process may include:

    • update the Ethereum State Trie (i.g. the balances of from and to)
    • excute receive() or fallback() of the smart contract
    • getBalance() is called and the wrong balance will be returned

Solutions

  • Use transfer

      function withdraw() public {
          uint256 amount = balances[msg.sender];
          // only sends 2300 gas with the external call, which is not enough for sender to call `withdraw()` again.
          payable(msg.sender).transfer(amount);
          balances[msg.sender] = 0;
      }
    
  • Checks-effects-interactions pattern

      function withdraw() public {
          uint256 amount = balances[msg.sender];
          balances[msg.sender] = 0;
          (bool success, ) = msg.sender.call{value: amount}("");
          require(success);
          // balances[msg.sender] = 0;
      }
    
  • Use mutex (openzeppelin ReentrancyGuard)

Arithmetic Overflow / Underflow

  •   function transfer(address _to, uint256 _value) public returns (bool) {
          require(balances[msg.sender] - _value >= 0); // may underflow
          balances[msg.sender] -= _value; // may underflow
          balances[_to] += _value;
          return true;
      }
    
      // _value = 0x8000000000000000000000000000000000000000000000000000000000000000
      // cnt = 0x2
      // amount = 0x0
      function batchTransfer(address[] calldata _receivers, uint256 _value) public returns (bool) {
          uint256 cnt = _receivers.length;
          uint256 amount = _value * cnt; // may overflow
          require(cnt > 0 && cnt <= 20);
          require(_value > 0 && balances[msg.sender] >= amount);
          balances[msg.sender] = balances[msg.sender].sub(amount); // OpenZeppelin SafeMath library is used
          for (uint256 i = 0; i < cnt; i++) {
              balances[_receivers[i]] = balances[_receivers[i]].add(_value);
              Transfer(msg.sender, _receivers[i], _value);
          }
          return true;
      }
    
  • New version EVM will revert when overflow or underflow occurs

  • unchecked can be used to ignore the overflow/underflow check

Unexpected Ether

  • contract EtherGame {
        uint256 public payoutMileStone1 = 3 ether;
        uint256 public mileStone1Reward = 2 ether;
        uint256 public payoutMileStone2 = 5 ether;
        uint256 public mileStone2Reward = 3 ether;
        uint256 public finalMileStone = 10 ether;
        uint256 public finalReward = 5 ether;
        mapping(address => uint256) redeemableEther;
    
        // Users pay 0.5 ether. At specific milestones, credit their accounts.
        function play() public payable {
            require(msg.value == 0.5 ether); // each play is 0.5 ether
            uint256 currentBalance = this.balance + msg.value;
            // ensure no players after the game has finished
            require(currentBalance <= finalMileStone);
            // if at a milestone, credit the player's account
            if (currentBalance == payoutMileStone1) {
                redeemableEther[msg.sender] += mileStone1Reward;
            } else if (currentBalance == payoutMileStone2) {
                redeemableEther[msg.sender] += mileStone2Reward;
            } else if (currentBalance == finalMileStone) {
                redeemableEther[msg.sender] += finalReward;
            }
            return;
        }
    
        function claimReward() public {
            // ensure the game is complete
            require(this.balance == finalMileStone);
            // ensure there is a reward to give
            require(redeemableEther[msg.sender] > 0);
            redeemableEther[msg.sender] = 0;
            msg.sender.transfer(redeemableEther[msg.sender]);
        }
    }
    
  • Two ways of forcibly sending ether

    • self-destruct
    • pre-sent ether
      • Anyone can calculate what a contract’s address will be before it is created and send ether to that address.
  • Solutions:

    • don’t use this.balance

Delegatecall

  • Effects of delegatecall:

    • like to copy the function of the logic contract into the current contract and execute it
    • if the called function is not in the logic contract, the fallback() of the logic contract will be executed
  • contract FibonacciLib {
        uint256 public start;
        uint256 public calculatedFibNumber;
    
        function setStart(uint256 _start) public {
            start = _start;
        }
    
        function setFibonacci(uint256 n) public {
            calculatedFibNumber = fibonacci(n);
        }
    
        function fibonacci(uint256 n) internal returns (uint256) {
            if (n == 0) return start;
            else if (n == 1) return start + 1;
            else return fibonacci(n - 1) + fibonacci(n - 2);
        }
    }
    
    contract FibonacciBalance {
        address public fibonacciLibrary;
        uint256 public calculatedFibNumber;
        uint256 public start = 3;
        uint256 public withdrawalCounter;
    
        constructor(address _fibonacciLibrary) {
            fibonacciLibrary = _fibonacciLibrary;
        }
    
        function withdraw() public {
            withdrawalCounter += 1;
            (bool success, ) = fibonacciLibrary.delegatecall(
                abi.encodeWithSignature("setFibonacci(uint256)", withdrawalCounter)
            );
            require(success);
            payable(msg.sender).transfer(calculatedFibNumber * 1 ether);
        }
    
        fallback() external {
            (bool success, ) = fibonacciLibrary.delegatecall(msg.data);
            require(success);
        }
    
        receive() external payable {}
    }
    
    contract Attack {
        uint256 storageSlot0; // corresponds to fibonacciLibrary
        uint256 storageSlot1; // corresponds to calculatedFibNumber
    
        fallback() external {
            storageSlot1 = 0;
            payable(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4).transfer(
                address(this).balance
            ); // all ethers will be taken
        }
    }
    
  • Attack steps:

    • FibonacciBalance.fallback -> FibonacciLib.setStart -> FibonacciBalance.fibonacciLibrary bacomes the address of Attack
    • FibonacciBalance.fallback -> Attack.fallback -> transfer all the balance of FibonacciBalance to 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4

ABI Hash Collisions

  • // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.27;
    
    contract RoyaltyRegistry {
        uint256 constant regularPayout = 0.1 ether;
        uint256 constant premiumPayout = 1 ether;
        mapping (bytes32 => bool) allowedPayouts;
    
        function claimRewards(address[] calldata privileged, address[] calldata regular) external {
            bytes32 payoutKey = keccak256(abi.encodePacked(privileged, regular));
            // hash1 = keccak256(abi.encodePacked([addr1], [addr2, addr3]));
            // hash2 = keccak256(abi.encodePacked([addr1, addr2], [addr3]));
            // notice that hash1 == hash
            // regular users can add themselves to the privileged users' array and receive a larger payout than warranted
            require(allowedPayouts[payoutKey], "Unauthorized claim");
            allowedPayouts[payoutKey] = false;
            _payout(privileged, premiumPayout);
            _payout(regular, regularPayout);
        }
    
        function _payout(address[] calldata users, uint256 reward) internal {
            for(uint i = 0; i < users.length;) {
                (bool success, ) = users[i].call{value: reward}("");
                if (!success) {
                    // more code handling pull payment
                }
                unchecked {
                    ++i;
                }
            }
        }
    }
    

Ambiguous Evaluation Order

  • Expression evaluation

    // SPDX-License-Identifier: MIT
    
    pragma solidity 0.8.27;
    
    contract AmbiguousExpressionEvaluation {
      uint256[] public numbers = [1, 2, 3];
        function arith () public pure returns (uint) {
            uint x = 5;
            return x * x++; // Could be 25 or 30
        }
    }
    
  • Function call

    // SPDX-License-Identifier: MIT
    
    pragma solidity 0.8.27;
    
    contract AmbiguousFunctionCall {
        uint256 public x = 1;
    
        function increment() private  returns (uint256) {
            return x += 1;
        }
    
        function decrement() private returns (uint256) {
            return x -= 1;
        }
    
        function addNumbers(uint256 a, uint256 b) private pure returns (uint256) {
            return a + b;
        }
    
        function testAmbiguity() public returns (uint256) {
            return addNumbers(decrement(), increment()); // Could be (1 + 2) or (1 + 0)
        }
    }
    

Approval Vulnerabilities

  • Unlimited Approvals

    • super large amount approve(spender, 2^256 - 1)
    • no approval deadline
  • Approval Frontrunning

    • first request approve(spender, x)
    • need to modify the allowance to y, so send the second request approve(spender, y)
    • before the second request is processed, the attacker initiate the transferFrom function to move x tokens
    • after the second request is processed, the attacker can move another y tokens
  • Solutions:

    • OpenZeppelin’s SafeERC20 safeIncreaseAllowance and safeDecreaseAllowance
    • OpenZeppelin’s ERC20Permit permit

Exposed Data

  • All data stored on chain is available to everyone

    • see a simple contract on sepolia testnet

    • access its private variable with following code:

      import { createPublicClient, http } from 'viem';
      import { sepolia } from 'viem/chains';
      
      async function main() {
        const res = await createPublicClient({
          chain: sepolia,
          transport: http('https://sepolia.infura.io/v3/{YOUR INFURA_API_KEY}'),
        }).getStorageAt({
          address: '0x473ecc25c396983bcf7eD005441085eeFA7865E2',
          slot: '0x0',
        });
        console.log(Number(res)); // print 123456
      }
      
      main();
      
  • Solutions:

    • secret data should be encrypted before storing on chain

Transaction Displacement

  • Frontrunning

    • ENS registration
  • Sandwiching attack

    • find a large buying tx, which will increase the target token price
    • buy the target token before the tx is executed
    • sell the target token after the tx is executed
  • Suppression attack

    • issue a series of transactions with a high gas price
    • other transactions can not be processed because of the block gas limit
  • Solutions:

    • commit and reveal

Griefing Attacks

  • // SPDX-License-Identifier: MIT
    
    pragma solidity 0.8.27;
    
    contract DelayedWithdrawal {
        address beneficiary;
        uint256 delay;
        uint256 lastDeposit;
    
        constructor(uint256 _delay) {
            beneficiary = msg.sender;
            lastDeposit = block.timestamp;
            delay = _delay;
        }
    
        modifier checkDelay() {
            require(block.timestamp >= lastDeposit + delay, "Keep waiting");
            _;
        }
    
        // griefing attackers might call this function with minimum value (e.g., 1 wei) to update the lastDeposit
        // preventing the beneficiary from withdrawing the funds
        function deposit() public payable {
            require(msg.value != 0);
            lastDeposit = block.timestamp;
        }
    
        function withdraw() public checkDelay {
            (bool success, ) = beneficiary.call{value: address(this).balance}("");
            require(success, "Transfer failed");
        }
    }
    
  • // SPDX-License-Identifier: MIT
    
    pragma solidity 0.8.27;
    
    contract Relayer {
        mapping (bytes => bool) executed;
        address target;
    
        // the griefing attackers might call `forward` with minimal gas
        // sufficient only to allow the Relayer contract to succeed
        // but causing the external call to revert due to an out-of-gas error
        function forward(bytes memory _data) public {
            require(!executed[_data], "Replay protection");
            // ...
            executed[_data] = true;
            target.call(abi.encodeWithSignature("execute(bytes)", _data));
        }
    }
    

Incorrect Parameter Order

  • / SPDX-License-Identifier: MIT
    
    pragma solidity 0.8.27;
    
    contract Store {
        struct User {
            string nickname;
            string city;
            uint256 age;
        }
    
        // the values of nickname and city are swapped
        User  newUser = User("Shanghai", "Alice", 18);
    }
    

Oracle Manipulation Attacks

  • Replay attacks

    • same-chain replay
      • nonce
    • cross-chain replay
      • chainId
  • Signature malleability

    • if $(r, s)$ is a valid signature, then $(r, - s)$ is also a valid signature

      • transctionId is the hash of the transaction parameters and signature
      • so a different transactionId woudld be created with the signature of $(r, - s)$
    • OpenZeppelin’s ECDSA library

      if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
        return (address(0), RecoverError.InvalidSignatureS);
      }
      

Unprotected Swaps

  • ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
        tokenIn: inToken,
        tokenOut: getTokenisedAddr(_outToken),
        fee: 3000,  // swaps will fail if the pool's fee increases above the 3000 threshold
        recipient: address(this),
        deadline: block.timestamp + 60,
        amountIn: _amount - swapFee,
        amountOutMinimum: 0  // allows for unlimited slippage
    });