deposit() should mint AssetTokens at the current exchange rate, pull in exactly amount, and leave the rate unchanged. The rate should rise only when flash-loan fees are actually paid into the AssetToken, so that totalSupply * rate never exceeds the underlying held; ThunderLoanUpgraded.deposit (L146-L154) does exactly this.
ThunderLoan.deposit mints the depositor's shares (L152), computes a flash-loan fee on the deposited amount (0.3% of its WETH value) and passes it to assetToken.updateExchangeRate (L153-L154), but transfers in only amount (L155), so the fee is never collected. Each deposit therefore raises the value of every outstanding share, the new depositor's included, by tokens that never arrive, and total claims exceed the underlying held. The last LPs to redeem cannot withdraw in full, and a large deposit followed by an immediate redeem moves that unbacked credit out of other LPs' principal.
Likelihood:
It happens on every call to deposit(), with no attacker and no special state. In the PoC's honest-only tests, the shortfall appears after the very first deposit (rate 1.003e18, claim 1003e18 against 1000e18 held).
The theft path is permissionless. deposit() and redeem() are external functions on any allowed token, need no privileged role, timing or reentrancy, and the PoC runs both inside one external call.
The only requirement is capital that is large relative to the pool: to empty it, 0.3% of the deposit's WETH value must exceed the targeted LP balance. That capital can be borrowed for a single transaction from an external flash-loan venue, and the PoC mints it as a stand-in for such a loan. The gain also scales linearly with the TSwap spot price read by getCalculatedFee: in the PoC, doubling the price raised the profit from 297.9e18 to 595.8e18. The cost of moving a real pool's price is not modelled.
Impact:
Direct theft of LP principal. One atomic deposit/redeem of 100,000e18 against an LP's 1000e18 returns a profit of 297.91e18, and profit plus what is left in the pool equals exactly 1000e18, so every token of profit comes out of the LP's principal. With X = 400,000e18, a single transaction leaves 1 wei in the pool and takes essentially all of the LP's 1000e18.
Insolvency without any attacker. After a sole 1000e18 deposit, the LP's redeem(type(uint256).max) reverts with 'ERC20: transfer amount exceeds balance'. With two honest 1000e18 LPs, the first exits with 1004.51e18 and the second can recover only 995.49e18. This breaks the stated invariant that the protocol never loses LP deposits (test/fuzz/Invariant.t.sol L7).
The deficit is stored in the AssetToken's exchange rate, so the planned upgrade to ThunderLoanUpgraded does not remove it. After upgradeTo, the rate is still 1.003e18, claims (~1503e18) still exceed holdings (1500e18), and the second LP's redeem(max) reverts.
test/PoC_TL-02.t.sol (contract PoC_TL02) deploys ThunderLoan behind an ERC1967Proxy with an OpenZeppelin ERC20Mock token and an in-file TSwap pool stand-in whose spot price can be set. Its seven tests show the following. With ThunderLoanUpgraded.deposit as the baseline, the rate stays at 1e18 and the LP gets back exactly 1000e18. With the original deposit: a sole deposit moves the rate to 1.003e18 and redeem(max) reverts; an atomic deposit/redeem takes LP principal 1:1 (297.91e18 at X=100,000e18, and all but 1 wei at X=400,000e18); profit doubles when the spot price doubles; the deficit persists after upgradeTo(ThunderLoanUpgraded); and the second of two honest LPs cannot redeem in full. Run it with forge test --match-path test/PoC_TL-02.t.sol -vv.
File test/PoC_TL-02.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-02.t.sol -vv.
# Summary Exchange rate for asset token is updated on deposit. This means users can deposit (which will increase exchange rate), and then immediately withdraw more underlying tokens than they deposited. # Details Per documentation: > Liquidity providers can deposit assets into ThunderLoan and be given AssetTokens in return. **These AssetTokens gain interest over time depending on how often people take out flash loans!** Asset tokens gain interest when people take out flash loans with the underlying tokens. In current version of ThunderLoan, exchange rate is also updated when user deposits underlying tokens. This does not match with documentation and will end up causing exchange rate to increase on deposit. This will allow anyone who deposits to immediately withdraw and get more tokens back than they deposited. Underlying of any asset token can be completely drained in this manner. # Filename `src/protocol/ThunderLoan.sol` # Permalinks https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/8539c83865eb0d6149e4d70f37a35d9e72ac7404/src/protocol/ThunderLoan.sol#L153-L154 # Impact Users can deposit and immediately withdraw more funds. Since exchange rate is increased on deposit, they will withdraw more funds then they deposited without any flash loans being taken at all. # Recommendations It is recommended to not update exchange rate on deposits and updated it only when flash loans are taken, as per documentation. ```diff function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) { AssetToken assetToken = s_tokenToAssetToken[token]; uint256 exchangeRate = assetToken.getExchangeRate(); uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate; emit Deposit(msg.sender, token, amount); assetToken.mint(msg.sender, mintAmount); - uint256 calculatedFee = getCalculatedFee(token, amount); - assetToken.updateExchangeRate(calculatedFee); token.safeTransferFrom(msg.sender, address(assetToken), amount); } ``` # POC ```solidity function testExchangeRateUpdatedOnDeposit() public setAllowedToken { tokenA.mint(liquidityProvider, AMOUNT); tokenA.mint(user, AMOUNT); // deposit some tokenA into ThunderLoan vm.startPrank(liquidityProvider); tokenA.approve(address(thunderLoan), AMOUNT); thunderLoan.deposit(tokenA, AMOUNT); vm.stopPrank(); // another user also makes a deposit vm.startPrank(user); tokenA.approve(address(thunderLoan), AMOUNT); thunderLoan.deposit(tokenA, AMOUNT); vm.stopPrank(); AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA); // after a deposit, asset token's exchange rate has aleady increased // this is only supposed to happen when users take flash loans with underlying assertGt(assetToken.getExchangeRate(), 1 * assetToken.EXCHANGE_RATE_PRECISION()); // now liquidityProvider withdraws and gets more back because exchange // rate is increased but no flash loans were taken out yet // repeatedly doing this could drain all underlying for any asset token vm.startPrank(liquidityProvider); thunderLoan.redeem(tokenA, assetToken.balanceOf(liquidityProvider)); vm.stopPrank(); assertGt(tokenA.balanceOf(liquidityProvider), AMOUNT); } ```
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.