Scope: All in‑scope contracts as defined in the contest scope (commit e8ce05f5530ca965165d41547b289604f873fdf6), including the upgrade from ThunderLoan to ThunderLoanUpgraded.
All findings are reproduced by the PoC test suite in test/audit/ThunderLoanAuditPoC.t.sol (run with forge test --match-path test/audit/ThunderLoanAuditPoC.t.sol -vv).
| # | Severity | Title | File |
|---|---|---|---|
| 1 | High | Storage‑layout collision breaks the upgrade | ThunderLoanUpgraded.sol |
| 2 | High | Flash loan "repaid" by depositing during the callback → free loan + LP insolvency | ThunderLoan.sol / ThunderLoanUpgraded.sol |
| 3 | High | Spot‑price oracle manipulable → flash‑loan fee can be driven to near zero | OracleUpgradeable.sol |
| 4 | High | deposit() inflates exchange rate with notional fee → v1 pool becomes insolvent |
ThunderLoan.sol (v1) |
| 5 | Medium | Fee computed in WETH units but charged in the borrowed token | ThunderLoan.sol |
| 6 | Medium | Removing an allowed token strands LP funds; re‑adding resets the pool | ThunderLoan.sol |
| 7 | Medium | Zero‑fee flash loans revert due to rounding and updateExchangeRate logic |
AssetToken.sol |
Normal behaviour: The proxy upgrade mechanism expects the new implementation to have the same storage layout as the old one. If a state variable is removed or reordered, the proxy will interpret existing storage slots incorrectly, corrupting critical protocol parameters.
Issue: In ThunderLoanUpgraded, the state variable s_feePrecision from v1 is replaced with a public constant FEE_PRECISION. Constants do not occupy storage, so every subsequent variable shifts down by one slot. After the upgrade, s_flashLoanFee reads the old s_feePrecision slot, which holds 1e18 (100 %), instead of the intended 3e15 (0.3 %).
Likelihood:
Certain – the upgrade is planned and will be executed as written. Occurs immediately after the upgrade transaction.
The fee cannot be repaired via initialize() because the proxy is already initialized; only a manual owner call to updateFlashLoanFee can fix it.
Impact:
Every flash loan after the upgrade requires repayment of amount + 100% (i.e., double the loan), making the protocol functionally unusable until the owner intervenes.
The fee engine is broken; LPs receive zero yield because no borrower will pay a 100% fee.
The storage layout corruption persists (no gap), threatening future upgrades.
Explainer: The test deploys a proxy with the v1 implementation, records the fee (0.3%), upgrades to v2, and observes that the fee becomes 1e18 (100%). It also shows that getCalculatedFee for a 10e18 loan returns 10e18 (the fee equals the entire loan amount).
Preserve the storage layout by keeping a placeholder for the removed variable, and add a gap.
Normal behaviour: Flash loans must be repaid (principal + fee) before the transaction ends, verified by checking the pool’s balance after the callback.
Issue: deposit() and redeem() are not blocked during a flash loan. A malicious receiver can, inside executeOperation, call deposit(token, amount + fee) instead of repaying. This increases the pool balance enough to pass the balance check, while the attacker receives new AssetToken shares. After the loan ends, they redeem those shares, effectively stealing the loan principal.
Likelihood:
High – any user can call flashloan with a malicious receiver; deposit is permissionless. Occurs whenever an attacker controls the receiver and deposits during the callback.
Both v1 and v2 are vulnerable, as proven by test02a and test02b.
Impact:
The attacker keeps the entire flash‑loan principal for free.
The pool’s asset backing is reduced while the share supply remains unchanged, making all LPs insolvent (the last LP cannot withdraw).
Attack can be repeated until the pool is drained.
Explainer: LP deposits 100e18. Attacker takes a 10e18 flash loan. In the callback, the receiver calls deposit(10e18 + fee), never repay. The balance check passes because the deposit added exactly the required amount. After the loan ends, the attacker redeems the freshly minted shares. The LP’s full redeem later reverts because the pool is insolvent. The test is run against both the old and upgraded contracts.
Block deposit/redeem while a flash loan is in flight, and/or track explicit debt per loan.
Normal behaviour: The flash‑loan fee is derived from the token’s price in WETH using an oracle.
Issue: The oracle reads a single spot price from the TSwap pool. An attacker can swap a large amount against the pool in the same transaction to temporarily crash the price, making the quoted fee arbitrarily small (even zero, combined with rounding).
Likelihood:
High – manipulation is atomic, cheap, and repeatable. Occurs whenever an attacker has sufficient tokens to move the pool price (can be obtained via a flash swap or the protocol’s own flash loan).
The attacker can also combine with the zero‑fee revert (M‑03) to DoS the protocol.
Impact:
LP yield (the protocol’s core value) can be zeroed at will.
The protocol’s fee revenue becomes unpredictable and manipulable.
Explainer: A pool has 100e18 tokenA and 100e18 WETH. Attacker swaps 900e18 tokenA into the pool, driving the price down >50×. Before manipulation, the fee for a 10e18 loan is 0.3%; after, it is reduced by more than 50×. The test also shows the flash loan executes, but the LP exchange rate increase is >50× less than intended.
Use a TWAP oracle, add a minimum fee floor, and bound the price.
deposit() inflates exchange rate with notional fee → v1 pool becomes insolventNormal behaviour: Deposits should not affect the exchange rate; only fees earned from flash loans should increase it.
Issue: In v1, deposit() calculates a notional fee on the deposit amount and immediately calls updateExchangeRate(calculatedFee) – before the tokens are even transferred. This artificially inflates the exchange rate without any real assets, creating a deficit between the pool balance and total liabilities. The deficit grows with each deposit, eventually making full withdrawals impossible.
Likelihood:
Certain – the v1 code is the currently deployed implementation. Occurs on every deposit.
v2 fixes this, but v1 is live until upgrade.
Impact:
The exchange rate grows faster than the asset balance, causing undercollateralisation.
LPs who redeem late will find the pool unable to honour their full claim – the last withdrawal reverts.
Manipulated oracle prices (H‑01) amplify the inflation.
Explainer: A single deposit of 100e18 tokens into an empty v1 pool should keep the exchange rate at 1. Instead, the notional fee bumps it to 1.003. The LP’s claim now exceeds the pool balance, and a full redeem reverts.
Remove the notional fee bump in deposit (exactly as v2 does).
Normal behaviour: The fee should be a percentage of the loan value, charged in the same token as the loan.
Issue: getCalculatedFee computes the fee in WETH units and returns that value directly, without converting back to the borrowed token’s units. Only tokens priced at exactly 1 WETH are charged correctly. For tokens below 1 WETH, the fee is too low; for tokens above, it is too high.
Likelihood:
Certain – occurs for every flash loan on any token whose price is not exactly 1 WETH. USDC/USDT, for example, are far below 1 WETH on mainnet.
This is a systematic mispricing.
Impact:
LPs are underpaid for most tokens, breaking the yield model.
Borrowers on expensive tokens are overcharged.
The protocol’s fee schedule is inconsistent across assets.
Explainer: TokenB is priced at 0.5 WETH. For a 100e18 loan, the contract charges a fee of (expectedFee * 0.5), i.e., half of the intended 0.3%. The test asserts this undercharge.
Convert the WETH fee back to the token’s units.
Normal behaviour: The owner can add or remove allowed tokens. When removed, LPs should still be able to withdraw.
Issue: redeem is guarded by revertIfNotAllowedToken, which deletes the mapping entry on removal. After delisting, LPs can never redeem. Re‑adding the token deploys a new AssetToken with zero balance and exchange rate 1, orphaning the old pool with all its funds forever.
Likelihood:
Requires an owner action (delisting is a legitimate maintenance operation). Occurs if the owner ever removes a token.
Could also be a griefing vector if the owner is malicious.
Impact:
Permanent loss of all LP principal in the delisted pool.
The owner can wipe historical rates by re‑listing, silently changing economics.
Explainer: LP deposits 100e18. Owner delists token → LP cannot redeem. Owner re‑lists → new AssetToken with zero balance; old pool’s funds are stranded. The test shows the old AssetToken still holds the balance but is inaccessible.
Only allow delisting if the pool is empty, or decouple redeem access from the allowlist.
updateExchangeRate logicNormal behaviour: Flash loans should execute even for very small amounts, and the exchange rate should only update when there is a positive increase.
Issue: updateExchangeRate reverts when fee == 0 (or if the rate does not increase). Due to integer division rounding, any loan with a quoted fee of 0 wei triggers a revert. This can be caused by dust‑sized loans or by oracle manipulation (H‑01). On v1, deposits also call this function, so deposits can be bricked.
Likelihood:
Easy to trigger – any dust loan (e.g., 100 wei) or oracle manipulation makes fee 0. Occurs whenever the calculated fee rounds to zero.
The attacker can also force this to DoS the protocol (combined with H‑01).
Impact:
Flash loans become unavailable for small amounts or when price is manipulated.
v1 deposits can also be DoS’d, trapping LPs.
Overall availability of the protocol is compromised.
Explainer: After funding a pool, an attacker requests a flash loan of 100 wei. The fee calculation yields 0. updateExchangeRate(0) is called and reverts because newExchangeRate == s_exchangeRate. The transaction fails.
Allow zero‑fee updates as a no‑op, but still prevent free loans by enforcing a minimum fee in flashloan.
## Description The thunderloanupgrade.sol storage layout is not compatible with the storage layout of thunderloan.sol which will cause storage collision and mismatch of variable to different data. ## Vulnerability Details Thunderloan.sol at slot 1,2 and 3 holds s_feePrecision, s_flashLoanFee and s_currentlyFlashLoaning, respectively, but the ThunderLoanUpgraded at slot 1 and 2 holds s_flashLoanFee, s_currentlyFlashLoaning respectively. the s_feePrecision from the thunderloan.sol was changed to a constant variable which will no longer be assessed from the state variable. This will cause the location at which the upgraded version will be pointing to for some significant state variables like s_flashLoanFee to be wrong because s_flashLoanFee is now pointing to the slot of the s_feePrecision in the thunderloan.sol and when this fee is used to compute the fee for flashloan it will return a fee amount greater than the intention of the developer. s_currentlyFlashLoaning might not really be affected as it is back to default when a flashloan is completed but still to be noted that the value at that slot can be cleared to be on a safer side. ## Impact 1. Fee is miscalculated for flashloan 1. users pay same amount of what they borrowed as fee ## POC 2 ``` function testFlashLoanAfterUpgrade() public setAllowedToken hasDeposits { //upgrade thunderloan upgradeThunderloan(); uint256 amountToBorrow = AMOUNT * 10; console.log("amount flashloaned", amountToBorrow); uint256 calculatedFee = thunderLoan.getCalculatedFee( tokenA, amountToBorrow ); AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA); vm.startPrank(user); tokenA.mint(address(mockFlashLoanReceiver), amountToBorrow); thunderLoan.flashloan( address(mockFlashLoanReceiver), tokenA, amountToBorrow, "" ); vm.stopPrank(); console.log("feepaid", calculatedFee); assertEq(amountToBorrow, calculatedFee); } ``` Add the code above to thunderloantest.t.sol and run `forge test --mt testFlashLoanAfterUpgrade -vv` to test for the second poc ## Recommendations The team should should make sure the the fee is pointing to the correct location as intended by the developer: a suggestion recommendation is for the team to get the feeValue from the previous implementation, clear the values that will not be needed again and after upgrade reset the fee back to its previous value from the implementation. ##POC for recommendation ``` // function upgradeThunderloanFixed() internal { thunderLoanUpgraded = new ThunderLoanUpgraded(); //getting the current fee; uint fee = thunderLoan.getFee(); // clear the fee as thunderLoan.updateFlashLoanFee(0); // upgrade to the new implementation thunderLoan.upgradeTo(address(thunderLoanUpgraded)); //wrapped the abi thunderLoanUpgraded = ThunderLoanUpgraded(address(proxy)); // set the fee back to the correct value thunderLoanUpgraded.updateFlashLoanFee(fee); } function testSlotValuesFixedfterUpgrade() public setAllowedToken { AssetToken asset = thunderLoan.getAssetFromToken(tokenA); uint precision = thunderLoan.getFeePrecision(); uint fee = thunderLoan.getFee(); bool isflanshloaning = thunderLoan.isCurrentlyFlashLoaning(tokenA); /// 4 slots before upgrade console.log("????SLOTS VALUE BEFORE UPGRADE????"); console.log("slot 0 for s_tokenToAssetToken =>", address(asset)); console.log("slot 1 for s_feePrecision =>", precision); console.log("slot 2 for s_flashLoanFee =>", fee); console.log("slot 3 for s_currentlyFlashLoaning =>", isflanshloaning); //upgrade function upgradeThunderloanFixed(); //// after upgrade they are only 3 valid slot left because precision is now set to constant AssetToken assetUpgrade = thunderLoan.getAssetFromToken(tokenA); uint feeUpgrade = thunderLoan.getFee(); bool isflanshloaningUpgrade = thunderLoan.isCurrentlyFlashLoaning( tokenA ); console.log("????SLOTS VALUE After UPGRADE????"); console.log("slot 0 for s_tokenToAssetToken =>", address(assetUpgrade)); console.log("slot 1 for s_flashLoanFee =>", feeUpgrade); console.log( "slot 2 for s_currentlyFlashLoaning =>", isflanshloaningUpgrade ); assertEq(address(asset), address(assetUpgrade)); //asserting precision value before upgrade to be what fee takes after upgrades assertEq(fee, feeUpgrade); // #POC assertEq(isflanshloaning, isflanshloaningUpgrade); } ``` Add the code above to thunderloantest.t.sol and run with `forge test --mt testSlotValuesFixedfterUpgrade -vv`. it can also be tested with `testFlashLoanAfterUpgrade function` and see the fee properly calculated for flashloan
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.