The rate update adds two quantities that are not in the same unit, so the credit is too large by a factor of the current exchange rate
Description
-
s_exchangeRate means underlying per share, scaled by 1e18 — that is exactly how deposit (ThunderLoan.sol:150) and redeem (ThunderLoan.sol:174) use it. totalSupply() is therefore a quantity of shares, while fee is a quantity of underlying tokens.
-
The formula adds them together:
function updateExchangeRate(uint256 fee) external onlyThunderLoan {
@> uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
Expanding what the code computes against what it must compute:
code : R' = R * (TS + fee) / TS = R + R * fee / TS
required: TS * R' / 1e18 = TS * R / 1e18 + fee => R' = R + 1e18 * fee / TS
The two agree only while R == 1e18. For every rate above that, the credit is too large by exactly the factor R / 1e18. Note that the author's own worked example in the comment block (:83-88) is computed at rate = 1, which is precisely the one case where the bug is invisible.
The error is self-reinforcing: an over-credit raises R, which makes the next over-credit larger. Book value compounds while the real balance only accrues the fees that were actually paid.
redeem then hands out that book value with no solvency check at all:
uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
...
@> assetToken.transferUnderlyingTo(msg.sender, amountUnderlying);
so the shortfall is not shared — it lands entirely on whoever withdraws last.
AssetToken.sol is not modified by the upgrade, so ThunderLoanUpgraded inherits this verbatim.
Risk
Likelihood:
-
Every flash loan, forever, with no attacker and no unusual conditions. The rate leaves 1e18 on the first fee ever collected, and from the second fee onward every single credit is wrong.
-
Nothing bounds or resets it. s_exchangeRate is private with no setter, and AssetToken is deployed with new (ThunderLoan.sol:234) rather than behind a proxy, so the drift cannot be corrected even by the protocol's own operators.
-
Invisible to the test suite: the project has no redeem test, and test/fuzz/Invariant.t.sol is an empty contract Invariant { } whose stated property — "The protocol should never lose liquidity provider deposits" — was never implemented.
Impact:
-
The protocol becomes progressively insolvent under completely honest operation. Measured on a clean deployment after 100 ordinary flash loans, all properly repaid: the AssetToken holds 2,698.5 tokens while its shares are booked at 2,834.3 — a 5.03% shortfall that no one stole.
-
The loss is a transfer between liquidity providers, decided by exit order. In the run below the LP who exits first collects 1,417.14 tokens — more than their fair half of the 2,698.5 actually held — and the second LP's redeem reverts, leaving them 1,281.37 against an identical book claim. One LP is 68 tokens richer and the other 68 poorer, purely for being second.
-
Because it is order-dependent and irreversible, the rational response for every LP is to withdraw before the others, so the defect converts ordinary withdrawals into a race.
-
No attacker profits directly, which is why I put impact at Medium rather than High. Impact Medium × Likelihood High ⇒ High on the CodeHawks matrix.
Proof of Concept
Deliberately run on a fresh ThunderLoanUpgraded deployment: its deposit (:146-154) does not call updateExchangeRate, and a fresh deployment has no storage collision, so nothing here can be attributed to either of those. The borrower is completely honest — it repays principal plus the full quoted fee through repay() every time.
Save as test/PoC_H05.t.sol and run forge test --mt test_H05 -vv:
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 { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
contract HonestBorrower {
ThunderLoanUpgraded private immutable tl;
constructor(ThunderLoanUpgraded _tl) {
tl = _tl;
}
function executeOperation(address token, uint256 amount, uint256 fee, address, bytes calldata)
external
returns (bool)
{
IERC20(token).approve(address(tl), amount + fee);
tl.repay(IERC20(token), amount + fee);
return true;
}
function borrow(IERC20 token, uint256 amount) external {
tl.flashloan(address(this), token, amount, "");
}
}
contract PoC_H05 is Test {
ThunderLoanUpgraded tl;
ERC20Mock t;
AssetToken asset;
address lp1 = makeAddr("lp1");
address lp2 = makeAddr("lp2");
uint256 constant DEP = 1000e18;
function setUp() public {
MockPoolFactory f = new MockPoolFactory();
t = new ERC20Mock();
f.createPool(address(t));
tl = ThunderLoanUpgraded(address(new ERC1967Proxy(address(new ThunderLoanUpgraded()), "")));
tl.initialize(address(f));
tl.setAllowedToken(t, true);
asset = tl.getAssetFromToken(IERC20(address(t)));
for (uint256 i = 0; i < 2; i++) {
address lp = i == 0 ? lp1 : lp2;
t.mint(lp, DEP);
vm.startPrank(lp);
t.approve(address(tl), DEP);
tl.deposit(IERC20(address(t)), DEP);
vm.stopPrank();
}
}
function _book() internal view returns (uint256) {
return asset.totalSupply() * asset.getExchangeRate() / asset.EXCHANGE_RATE_PRECISION();
}
function test_H05_honestUseMakesTheProtocolInsolvent() public {
HonestBorrower b = new HonestBorrower(tl);
t.mint(address(b), 100_000e18);
console2.log("rate at start (clean) :", asset.getExchangeRate());
console2.log("held at start :", t.balanceOf(address(asset)));
console2.log("book at start :", _book());
for (uint256 i = 1; i <= 100; i++) {
b.borrow(IERC20(address(t)), t.balanceOf(address(asset)));
if (i == 1 || i == 2 || i == 10 || i == 50 || i == 100) {
uint256 held = t.balanceOf(address(asset));
uint256 book = _book();
console2.log("after loan #", i);
console2.log(" held :", held);
console2.log(" book :", book);
console2.log(" deficit :", book > held ? book - held : 0);
console2.log(" deficit bps :", book > held ? (book - held) * 10_000 / held : 0);
}
}
assertGt(_book(), t.balanceOf(address(asset)), "book value exceeds assets after honest use only");
uint256 s1 = asset.balanceOf(lp1);
uint256 s2 = asset.balanceOf(lp2);
vm.prank(lp1);
tl.redeem(IERC20(address(t)), s1);
console2.log("lp1 exits first, receives :", t.balanceOf(lp1));
console2.log("lp2 book claim :", s2 * asset.getExchangeRate() / 1e18);
console2.log("actually left for lp2 :", t.balanceOf(address(asset)));
vm.prank(lp2);
vm.expectRevert();
tl.redeem(IERC20(address(t)), s2);
console2.log("lp2 full redeem : REVERTED");
}
}
Output:
[PASS] test_H05_honestUseMakesTheProtocolInsolvent() (gas: 7554854)
Logs:
rate at start (clean) : 1000000000000000000
held at start : 2000000000000000000000
book at start : 2000000000000000000000
after loan # 1
held : 2006000000000000000000
book : 2006000000000000000000
deficit : 0
deficit bps : 0
after loan # 2
held : 2012018000000000000000
book : 2012036054000000000000
deficit : 18054000000000000
deficit bps : 0
after loan # 10
held : 2060816514142778705470
book : 2061655487226258426000
deficit : 838973083479720530
deficit bps : 4
after loan # 50
held : 2323146761930039742627
book : 2350103990583586864000
deficit : 26957228653547121373
deficit bps : 116
after loan # 100
held : 2698505438733014376614
book : 2834277082949830662000
deficit : 135771644216816285386
deficit bps : 503
lp1 exits first, receives : 1417138541474915331000
lp2 book claim : 1417138541474915331000
actually left for lp2 : 1281366897258099045614
lp2 full redeem : REVERTED
Loan #1 shows a deficit of exactly zero, because the rate is still 1e18 and the two formulas coincide there. From loan #2 onward the gap opens and compounds — this is the signature of the unit error rather than of rounding, which would not grow.
The two liquidity providers hold identical claims of 1,417.14. The first to leave gets all of it; the second is left 135.77 short and cannot execute a full withdrawal.
Recommended Mitigation
Convert the fee into share-space before adding it, so both terms are in the same unit.
function updateExchangeRate(uint256 fee) external onlyThunderLoan {
- // newExchangeRate = oldExchangeRate * (totalSupply + fee) / totalSupply
- uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
+ // `fee` is denominated in the UNDERLYING token; totalSupply() is denominated in shares.
+ // Raising the pool's value by exactly `fee` means:
+ // totalSupply * newRate / 1e18 == totalSupply * oldRate / 1e18 + fee
+ // => newRate = oldRate + fee * EXCHANGE_RATE_PRECISION / totalSupply
+ uint256 supply = totalSupply();
+ if (supply == 0) {
+ revert AssetToken__NoSupply();
+ }
+ uint256 newExchangeRate = s_exchangeRate + (fee * EXCHANGE_RATE_PRECISION) / supply;
if (newExchangeRate <= s_exchangeRate) {
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}
The explicit zero-supply guard is worth adding at the same time: totalSupply() is used as a divisor on the current line too, so an empty pool panics with a division-by-zero rather than a named error.
Two further hardening items, independent of the formula itself:
redeem should never pay out more than the AssetToken holds — add if (amountUnderlying > token.balanceOf(address(assetToken))) revert ... so an accounting error degrades into a clear failure rather than a first-come-first-served race.
Implement the invariant that test/fuzz/Invariant.t.sol already names. totalSupply() * getExchangeRate() / 1e18 <= underlying.balanceOf(assetToken) fails on the second flash loan and would have caught this immediately.