Thunder Loan

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

`deposit` mints shares for the requested amount without measuring what arrived, so STA — a token the README lists as supported — leaves the pool under-collateralised, and flash loans of it are impossible entirely

Shares are minted against amount, not against the balance the AssetToken actually received

Description

  • deposit decides how many shares to mint from the number the caller passed in, and only afterwards performs the transfer. It never looks at what the AssetToken actually ended up holding.

// src/protocol/ThunderLoan.sol:147-156
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; // @> based on the REQUEST
emit Deposit(msg.sender, token, amount);
@> assetToken.mint(msg.sender, mintAmount); // @> minted first
...
@> token.safeTransferFrom(msg.sender, address(assetToken), amount); // @> whatever arrives, arrives
}
  • For a token whose transfer delivers less than it is told to, those two numbers diverge, and the divergence is permanent: the shares are already outstanding. Claims exceed assets from the first deposit, and the gap widens by 1% of every subsequent deposit.

  • This is not a hypothetical token class here. The README's compatibility section says "Any ERC20 that does not [follow the standard] is only included if it is specified below" and then names STA — 0xa7DE087329BFcda5639247F96140f9DAbe3DeED1. That is Statera: name() returns "Statera", and its transfer/transferFrom burn value * basePercent / 10000 with basePercent = 100, i.e. exactly 1% of every transfer. It is the token that broke Balancer's pools in June 2020 for precisely this reason. The README's Known Issues section reads "None", so there is no acknowledged caveat covering it.

  • redeem compounds the problem, because it also pays out a computed amount rather than a proportional share of what is actually held:

// src/protocol/ThunderLoan.sol:174-177
uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
...
@> assetToken.transferUnderlyingTo(msg.sender, amountUnderlying); // @> full nominal claim, first come first served

Each LP who leaves takes their whole nominal claim out of a pool that was never that large, so the accumulated shortfall lands on whoever is last.

  • The protocol's core product is not merely degraded for STA — it is unusable. transferUnderlyingTo burns 1% on the way out and repay burns 1% on the way back, so the closing balance can never reach startingBalance + fee:

endingBalance = startingBalance - 0.01*amount + 0.99*fee < startingBalance + fee for every amount > 0

Every flashloan of STA therefore reverts at ThunderLoan.sol:212-213, no matter how well funded and well behaved the borrower is. Verified below with a borrower holding 10× the loan size.

Risk

Likelihood:

  • Medium. It requires the owner to list a fee-on-transfer token — but listing STA is not a mistake or an edge case, it is following the project's own documented compatibility list. Once listed, every ordinary deposit by an ordinary LP triggers it, with no attacker involved.

  • Nothing in the contract detects or resists it: there is no balance-delta check anywhere, and setAllowedToken performs no probe of the token's transfer behaviour.

Impact:

  • Liquidity providers lose principal, in an amount that accrues with deposit volume. Measured with two 1,000-token depositors: 2,000 shares outstanding against 1,980 STA actually received. The first LP to leave takes their full 1,000-share claim; the second is left holding a 1,000 claim against 980 actually present.

  • The second LP is not locked out, but they cannot execute a full withdrawal: redeem(theirEntireBalance) reverts, and a reduced redeem returns 970.2 against their 1,000 deposit, leaving 20 shares permanently unbacked. Stated plainly, that is a ~3% haircut, not a total loss.

  • Because the shortfall is only felt by whoever exits last, ordinary withdrawals become a race.

  • Flash loans of STA are impossible. The protocol's headline feature reverts unconditionally for a token it advertises as supported — a complete denial of service for that market, for both borrowers and the liquidity providers who deposited expecting yield.

  • No attacker profits by a matching amount, which is why the impact is loss-of-principal and denial of service rather than theft. Impact Medium × Likelihood Medium ⇒ Medium.

Proof of Concept

Deliberately run on a fresh ThunderLoanUpgraded: its deposit (:146-154) does not contain the updateExchangeRate call, and a fresh deployment has no storage collision, so the exchange rate is exactly 1e18 throughout and the shortfall cannot be attributed to either of those defects. The same code path exists unchanged in ThunderLoan::deposit, where the effects add.

Save as test/PoC_M02.t.sol and run forge test --mt test_M02 -vv:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console2 } from "forge-std/Test.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
/// Statera-style deflationary ERC20: 1% of every transfer is burned.
/// STA (0xa7DE087329BFcda5639247F96140f9DAbe3DeED1) is in the README's supported list.
contract Sta is ERC20 {
constructor() ERC20("Statera", "STA") { }
function mint(address to, uint256 a) external { _mint(to, a); }
function _transfer(address f, address t, uint256 a) internal override {
uint256 b = a / 100; // 1%
super._transfer(f, t, a - b);
_burn(f, b);
}
}
contract P1e18 {
function getPriceOfOnePoolTokenInWeth() external pure returns (uint256) { return 1e18; }
}
contract Fac {
mapping(address => address) s;
function setPool(address t, address p) external { s[t] = p; }
function getPool(address t) external view returns (address) { return s[t]; }
}
/// A fully funded, completely honest borrower.
contract Borrower {
ThunderLoanUpgraded tl;
constructor(ThunderLoanUpgraded _t) { tl = _t; }
function executeOperation(address t, uint256 a, uint256 f, address, bytes calldata)
external returns (bool)
{
IERC20(t).approve(address(tl), a + f);
tl.repay(IERC20(t), a + f);
return true;
}
function go(IERC20 t, uint256 a) external { tl.flashloan(address(this), t, a, ""); }
}
contract PoC_M02 is Test {
address lp1 = makeAddr("lp1");
address lp2 = makeAddr("lp2");
uint256 constant DEP = 1000e18;
function test_M02_staUndercollateralisesAndBreaksFlashLoans() public {
Sta s = new Sta();
Fac f = new Fac();
f.setPool(address(s), address(new P1e18()));
ThunderLoanUpgraded u =
ThunderLoanUpgraded(address(new ERC1967Proxy(address(new ThunderLoanUpgraded()), "")));
u.initialize(address(f));
u.setAllowedToken(IERC20(address(s)), true);
AssetToken a = u.getAssetFromToken(IERC20(address(s)));
for (uint256 i = 0; i < 2; i++) {
address lp = i == 0 ? lp1 : lp2;
s.mint(lp, DEP);
vm.startPrank(lp);
s.approve(address(u), DEP);
u.deposit(IERC20(address(s)), DEP);
vm.stopPrank();
}
console2.log("exchange rate (untouched) :", a.getExchangeRate());
console2.log("shares minted :", a.totalSupply());
console2.log("STA actually received :", s.balanceOf(address(a)));
assertEq(a.getExchangeRate(), 1e18, "rate never moved - not the deposit-rate bug");
assertLt(s.balanceOf(address(a)), a.totalSupply(), "short from day one");
// lp1 exits first and takes their full nominal claim
vm.prank(lp1);
u.redeem(IERC20(address(s)), DEP);
console2.log("lp1 wallet after full exit :", s.balanceOf(lp1));
uint256 left = s.balanceOf(address(a));
console2.log("STA left in AssetToken :", left);
// lp2 cannot execute a full withdrawal...
vm.prank(lp2);
vm.expectRevert(); // ERC20: transfer amount exceeds balance
u.redeem(IERC20(address(s)), DEP);
console2.log("lp2 full redeem : REVERTED");
// ...and a reduced one leaves them short with permanently unbacked shares
vm.prank(lp2);
u.redeem(IERC20(address(s)), left);
console2.log("lp2 partial exit wallet :", s.balanceOf(lp2));
console2.log("lp2 dead shares :", a.balanceOf(lp2));
assertLt(s.balanceOf(lp2), DEP, "lp2 lost principal");
assertGt(a.balanceOf(lp2), 0, "and holds unbacked shares");
// and the protocol's core product does not work for this token at all
Borrower br = new Borrower(u);
s.mint(address(br), 1000e18); // 10x the loan size, so this is not a funding problem
vm.expectRevert(); // ThunderLoan__NotPaidBack
br.go(IERC20(address(s)), 100e18);
console2.log("STA flash loan : REVERTED with a fully funded honest borrower");
}
}

Output:

[PASS] test_M02_staUndercollateralisesAndBreaksFlashLoans() (gas: 9918048)
Logs:
exchange rate (untouched) : 1000000000000000000
shares minted : 2000000000000000000000
STA actually received : 1980000000000000000000
lp1 wallet after full exit : 990000000000000000000
STA left in AssetToken : 980000000000000000000
lp2 full redeem : REVERTED
lp2 partial exit wallet : 970200000000000000000
lp2 dead shares : 20000000000000000000
STA flash loan : REVERTED with a fully funded honest borrower

The exchange rate stays at exactly 1e18 for the whole run, which rules out any contribution from the deposit-rate defect: the shortfall is produced purely by minting against amount instead of against the delivered balance.

Honest limitations

  • This needs the owner to have listed a fee-on-transfer token. I am not claiming the owner is malicious — I am claiming that listing STA is the documented, expected configuration, and the code does not survive it.

  • Part of what each LP loses is the token's own burn on the way out, which is inherent to STA and not the protocol's fault. The protocol-caused loss is specifically the 20 tokens of unbacked claim — 1% of deposit volume — which is what leaves lp2 unable to complete a withdrawal.

  • There is no single actor who profits by a matching amount; the loss is a race between honest LPs. This is loss-of-principal and denial of service, not theft.

Recommended Mitigation

Mint against what actually arrived. Move the transfer above the mint and measure the delta:

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);
+ uint256 balanceBefore = token.balanceOf(address(assetToken));
+ token.safeTransferFrom(msg.sender, address(assetToken), amount);
+ uint256 received = token.balanceOf(address(assetToken)) - balanceBefore;
+ if (received == 0) {
+ revert ThunderLoan__CantBeZero();
+ }
+ uint256 mintAmount = (received * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
+ emit Deposit(msg.sender, token, received);
+ assetToken.mint(msg.sender, mintAmount);
}

redeem should be hardened the same way, so a withdrawal cannot pay out more than the pool's proportional backing:

uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
+ uint256 available = token.balanceOf(address(assetToken));
+ if (amountUnderlying > available) {
+ revert ThunderLoan__NotEnoughTokenBalance(available, amountUnderlying);
+ }

Neither fix makes flash loans work for STA — that requires the settlement check to compare delivered amounts rather than raw balances, or the token to be excluded. Given that a 1%-burn token can never satisfy endingBalance >= startingBalance + fee, the honest resolution is to reject fee-on-transfer tokens at listing time and remove STA from the compatibility list:

function setAllowedToken(IERC20 token, bool allowed) external onlyOwner returns (AssetToken) {
if (allowed) {
...
+ // reject tokens that do not deliver what they are told to
+ uint256 probe = 1e6;
+ token.safeTransferFrom(msg.sender, address(assetToken), probe);
+ if (token.balanceOf(address(assetToken)) != probe) {
+ revert ThunderLoan__TokenNotSupported(token);
+ }

Whichever direction is chosen, the compatibility list in the README and the behaviour of the code have to agree — at the moment they contradict each other.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 12 hours 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!