s_feePrecision into a constant, which deletes a storage slot and shifts s_flashLoanFee onto itThis is a UUPS proxy: the proxy keeps the storage, the implementation only supplies the code. An upgrade is therefore only safe if the new implementation declares its state variables in exactly the same order, so that each name keeps pointing at the slot that already holds its value. The README asks explicitly for this upgrade to be reviewed.
ThunderLoanUpgraded promotes s_feePrecision to constant FEE_PRECISION. Constants live in bytecode, not storage, so the variable that followed it moves up one slot. s_flashLoanFee therefore starts reading the slot that still contains s_feePrecision's value, 1e18 — and 1e18 is exactly the fee denominator, so the fee ratio becomes 1e18 / 1e18 = 1: 100% of the borrowed value.
forge inspect ... storageLayout confirms the slot numbers directly:
initialize is protected by initializer and cannot be re-run after the upgrade, so there is no path that corrects the value on the way through:
The fee then flows straight into the quote every borrower is charged:
Every guard the protocol has passes this value. updateFlashLoanFee only rejects newFee > FEE_PRECISION (:251-256), and 1e18 is exactly at the bound, so nothing anywhere flags the state as invalid.
Likelihood:
Certain, not probable. It is not triggered by an attacker or by a race — it is the deterministic consequence of performing the upgrade the README asks to be reviewed. The moment upgradeTo lands, the fee is wrong for every subsequent call.
Nothing warns. The upgrade transaction succeeds, getFee() returns a plausible-looking 1e18, and the OpenZeppelin upgrade-safety plugin is not used anywhere in the repository, so no tooling is in the way either.
Impact:
Borrowers are charged the entire loan. On a 100-token loan the fee goes from 0.3 to 100 tokens — 333× the intended rate.
This is not merely "flash loans stop working". A receiver contract that approves amount + fee and repays what it is quoted — which is exactly what the project's own test/mocks/MockFlashLoanReceiver.sol does, and the shape every real integrator copies — will hand over its whole balance without any of its own logic noticing. Existing integrations built against the pre-upgrade contract lose funds on their next call.
Anyone whose receiver cannot cover 100% of the loan simply reverts, so the flash-loan product is dead for everyone else. The protocol's only revenue mechanism is destroyed either way.
The owner can repair it with updateFlashLoanFee(3e15), but only after noticing, and only after the borrowers who called first have already paid.
Deploy ThunderLoan behind a proxy exactly as script/DeployThunderLoan.s.sol does, upgrade to ThunderLoanUpgraded, and read the fee back. The raw slot is read with vm.load so the collision is visible directly and not inferred.
Save as test/PoC_H03.t.sol and run forge test --mt test_H03 -vv:
Output:
The fee variable does not change value across the upgrade — it changes which slot it is, and lands on the one holding 1e18.
For completeness, s_currentlyFlashLoaning also shifts, from slot 205 to 204. That one is harmless: a mapping never reads its own slot, only keccak256(key . slot), and every entry is false between transactions anyway. The damage is confined to s_flashLoanFee.
Storage layout must be append-only across an upgrade. Keep the slot occupied and leave the constant out of the sequence.
Two process changes worth making alongside it, because this class of bug is invisible in code review but trivial to catch mechanically:
Add @openzeppelin/upgrades-core (or forge inspect <contract> storageLayout diffed in CI) to the upgrade path — the layout diff above is generated in one command and would have failed the build.
Add a test that upgrades a funded, initialised proxy and asserts that getFee(), owner() and getAssetFromToken() are unchanged afterwards. The repository currently has no such test.
## 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.