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
fromandto) - excute
receive()orfallback()of the smart contract withdraw()is called again during the second step
- update the Ethereum State Trie (i.g. the balances of
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()orfallback()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()orfallback()of the smart contract getBalance()is called and the wrong balance will be returned
Solutions
-
Use
transferfunction 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
-
uncheckedcan 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
- don’t use
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.fibonacciLibrarybacomes the address of AttackFibonacciBalance.fallback->Attack.fallback-> transfer all the balance ofFibonacciBalanceto0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
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
- super large amount
-
Approval Frontrunning
- first request
approve(spender, x) - need to modify the allowance to
y, so send the second requestapprove(spender, y) - before the second request is processed, the attacker initiate the
transferFromfunction to movextokens - after the second request is processed, the attacker can move another
ytokens
- first request
-
Solutions:
- OpenZeppelin’s SafeERC20
safeIncreaseAllowanceandsafeDecreaseAllowance - OpenZeppelin’s ERC20Permit
permit
- OpenZeppelin’s SafeERC20
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
Signature-related Attacks
-
Replay attacks
- same-chain replay
nonce
- cross-chain replay
chainId
- same-chain replay
-
Signature malleability
-
if $(r, s)$ is a valid signature, then $(r, - s)$ is also a valid signature
transctionIdis the hash of the transaction parameters and signature- so a different
transactionIdwoudld 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 });