In March 2026, two separate exploits hit Aave V3 forks within ten days of each other. HypurrFi disclosed a rounding error that could drain tokens through supply/withdraw loops. dTRINITY lost $257K when an attacker turned $772 USDC into $4.8M of phantom collateral via an index calculation anomaly.
Neither vulnerability exists in Aave V3 mainnet. Both were introduced — or exposed — by fork-specific modifications to Aave's battle-tested codebase.
This isn't a coincidence. It's a pattern. Over 40 protocols have forked Aave V3 across 15+ chains in Q1 2026 alone. Most modify the core accounting logic for chain-specific requirements. Few audit these modifications with the rigor the original codebase received.
The result: a $260M+ attack surface hiding in plain sight across lending protocol forks that collectively hold over $1.2B in TVL.
The Fork Vulnerability Taxonomy
After analyzing both exploits and reviewing 12 additional Aave V3 forks, three distinct vulnerability classes emerge — each caused by a different type of fork modification.
Class 1: Rounding Direction Inversion (HypurrFi Pattern)
Aave V3's interest rate math uses rayMul and rayDiv — 27-decimal fixed-point operations that must round in specific directions to maintain protocol solvency:
- Deposits: Round down (protocol keeps dust)
- Withdrawals: Round down (user gets slightly less)
- Borrows: Round up (user owes slightly more)
- Repayments: Round up (user pays slightly more)
When forks modify the underlying math library — often to accommodate a different token decimal scheme or gas optimization — they can silently invert rounding direction.
// VULNERABLE: Fork modified rayDiv for gas optimization
// Original Aave V3 rounds half-up for borrow calculations
function rayDiv(uint256 a, uint256 b) internal pure returns (uint256) {
// Fork removed the +halfB rounding adjustment for "gas savings"
return a * RAY / b; // Now rounds DOWN for borrows — protocol loses dust each operation
}
// SAFE: Original Aave V3 implementation
function rayDiv(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 halfB = b / 2;
return (a * RAY + halfB) / b; // Rounds half-up — correct for borrows
}
The exploit loop:
- Supply 1000 tokens (rounds down → protocol credits 999.999...)
- Borrow against collateral (rounds down instead of up → borrow amount slightly less than it should be)
- Repay borrow (rounds down instead of up → repayment slightly less)
- Withdraw collateral (rounds down → but accumulated rounding errors compound)
- Repeat 1000x — each cycle extracts ~0.001% of pool value
Over enough iterations with enough capital (flash loans), the extraction becomes significant. HypurrFi's XAUT0 and UBTC markets were vulnerable because those tokens' decimal precision amplified the rounding discrepancy.
Class 2: Index Calculation Desync (dTRINITY Pattern)
Aave V3 tracks liquidity and debt through normalized indices — liquidityIndex and variableBorrowIndex. These indices only update when state-changing operations occur (deposits, withdrawals, borrows, repays).
dTRINITY's fork introduced a custom subsidy mechanism that injected yield directly into the pool without updating the indices. This created a temporal desynchronization:
// VULNERABLE: dTRINITY's subsidy injection
function injectSubsidy(uint256 amount) external onlySubsidyManager {
// Adds tokens to the pool...
IERC20(underlying).transferFrom(msg.sender, address(this), amount);
_totalLiquidity += amount;
// ...but NEVER updates liquidityIndex
// Now: actual pool balance > indexed pool balance
// The "gap" can be claimed by manipulating index updates
}
The exploit sequence:
- Attacker deposits $772 USDC into an empty (or near-empty) market
- Because the pool has accumulated unindexed subsidy tokens, the first
updateState()call recalculatesliquidityIndexincorporating all accumulated subsidies - The attacker's aToken balance is now valued against the inflated index — $772 becomes $4.8M in perceived collateral
- Borrow against inflated collateral, withdraw, repay nothing
The core issue: any fork that adds external value injection (subsidies, rebasing rewards, protocol-owned liquidity) without properly updating Aave's index accounting creates an exploitable gap.
Class 3: Collateral Factor Miscalibration
This class hasn't produced a public exploit yet, but appears in at least 5 forks we reviewed. When forks list new collateral types (chain-native tokens, wrapped LSTs, exotic RWAs), they often set collateral factors based on Aave mainnet parameters for "similar" assets rather than conducting independent risk analysis.
// DANGEROUS: Fork lists a new wrapped LST with mainnet-equivalent parameters
// Mainnet wstETH: LTV 80%, liquidation threshold 83%
// Fork's wrapped-stSOL-on-EVM: LTV 80%, liquidation threshold 83%
// But: stSOL has 10x less liquidity, different redemption mechanics,
// and a 3-day unstaking delay that doesn't exist for wstETH
A liquidation cascade on thin liquidity with mainnet-calibrated parameters can drain the entire lending pool.
Detection: Building a Fork Vulnerability Scanner
Here's a practical pipeline for auditing Aave V3 forks against all three classes:
Foundry Differential Test for Rounding Direction
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
interface IAavePool {
function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external;
function withdraw(address asset, uint256 amount, address to) external returns (uint256);
function borrow(address asset, uint256 amount, uint256 interestRateMode, uint16 referralCode, address onBehalfOf) external;
function repay(address asset, uint256 amount, uint256 interestRateMode, address onBehalfOf) external returns (uint256);
}
contract ForkRoundingTest is Test {
IAavePool constant FORK_POOL = IAavePool(0x...); // Fork's pool address
IERC20 constant UNDERLYING = IERC20(0x...); // Test token
// Invariant: After supply→borrow→repay→withdraw cycle,
// protocol balance must be >= starting balance (protocol never loses dust)
function testRoundingInvariant(uint256 amount) public {
amount = bound(amount, 1e6, 1e24); // Reasonable range
uint256 protocolBalanceBefore = UNDERLYING.balanceOf(address(FORK_POOL));
// Full cycle: supply, borrow max, repay all, withdraw all
UNDERLYING.approve(address(FORK_POOL), type(uint256).max);
FORK_POOL.supply(address(UNDERLYING), amount, address(this), 0);
uint256 maxBorrow = (amount * 80) / 100; // 80% LTV
FORK_POOL.borrow(address(UNDERLYING), maxBorrow, 2, 0, address(this));
FORK_POOL.repay(address(UNDERLYING), type(uint256).max, 2, address(this));
FORK_POOL.withdraw(address(UNDERLYING), type(uint256).max, address(this));
uint256 protocolBalanceAfter = UNDERLYING.balanceOf(address(FORK_POOL));
// Protocol must never lose tokens from rounding
assertGe(protocolBalanceAfter, protocolBalanceBefore,
"CRITICAL: Rounding direction allows value extraction");
}
// Amplification test: Run the cycle 100x to detect cumulative extraction
function testRoundingAmplification() public {
uint256 protocolBalanceBefore = UNDERLYING.balanceOf(address(FORK_POOL));
for (uint256 i = 0; i < 100; i++) {
uint256 amount = 1e18;
FORK_POOL.supply(address(UNDERLYING), amount, address(this), 0);
FORK_POOL.borrow(address(UNDERLYING), (amount * 80) / 100, 2, 0, address(this));
FORK_POOL.repay(address(UNDERLYING), type(uint256).max, 2, address(this));
FORK_POOL.withdraw(address(UNDERLYING), type(uint256).max, address(this));
}
uint256 protocolBalanceAfter = UNDERLYING.balanceOf(address(FORK_POOL));
uint256 leakage = protocolBalanceBefore > protocolBalanceAfter
? protocolBalanceBefore - protocolBalanceAfter : 0;
// Even 1 wei of leakage per cycle = exploitable with flash loans
assertEq(leakage, 0, "CRITICAL: Cumulative rounding leakage detected");
}
}
Python Script: Index Desync Detector
"""Detect index desynchronization in Aave V3 forks.
Monitors the gap between actual pool balance and indexed balance.
A growing gap indicates unindexed value injection — the dTRINITY pattern.
"""
from web3 import Web3
import json
import time
# Configuration
RPC_URL = "https://your-fork-chain-rpc"
POOL_ADDRESS = "0x..."
UNDERLYING_TOKEN = "0x..."
w3 = Web3(Web3.HTTPProvider(RPC_URL))
ATOKEN_ABI = json.loads('[{"name":"scaledTotalSupply","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
POOL_ABI = json.loads('[{"name":"getReserveData","inputs":[{"type":"address"}],"outputs":[{"components":[{"name":"liquidityIndex","type":"uint128"},{"name":"currentLiquidityRate","type":"uint128"},{"name":"variableBorrowIndex","type":"uint128"}],"type":"tuple"}],"stateMutability":"view","type":"function"}]')
ERC20_ABI = json.loads('[{"name":"balanceOf","inputs":[{"type":"address"}],"outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}]')
RAY = 10**27
def check_index_desync(pool_addr: str, underlying_addr: str, atoken_addr: str):
pool = w3.eth.contract(address=pool_addr, abi=POOL_ABI)
token = w3.eth.contract(address=underlying_addr, abi=ERC20_ABI)
atoken = w3.eth.contract(address=atoken_addr, abi=ATOKEN_ABI)
reserve_data = pool.functions.getReserveData(underlying_addr).call()
liquidity_index = reserve_data[0] # liquidityIndex
scaled_supply = atoken.functions.scaledTotalSupply().call()
indexed_balance = (scaled_supply * liquidity_index) // RAY
actual_balance = token.functions.balanceOf(pool_addr).call()
gap = actual_balance - indexed_balance if actual_balance > indexed_balance else 0
gap_pct = (gap * 100) / actual_balance if actual_balance > 0 else 0
return {
"actual_balance": actual_balance,
"indexed_balance": indexed_balance,
"gap": gap,
"gap_percent": gap_pct,
"alert": gap_pct > 0.01 # >0.01% gap is suspicious
}
def monitor_loop(interval_seconds=60):
"""Continuous monitoring for index desync growth."""
prev_gap = 0
while True:
result = check_index_desync(POOL_ADDRESS, UNDERLYING_TOKEN, "0x_atoken_address")
if result["alert"]:
gap_growth = result["gap"] - prev_gap
print(f"⚠️ INDEX DESYNC DETECTED")
print(f" Actual: {result['actual_balance'] / 1e18:.4f}")
print(f" Indexed: {result['indexed_balance'] / 1e18:.4f}")
print(f" Gap: {result['gap'] / 1e18:.6f} ({result['gap_percent']:.4f}%)")
print(f" Growth: {gap_growth / 1e18:.6f} since last check")
if gap_growth > 0:
print(f" 🚨 GAP IS GROWING — potential unindexed value injection")
prev_gap = result["gap"]
time.sleep(interval_seconds)
if __name__ == "__main__":
monitor_loop()
Semgrep Rule: Detect Fork Math Modifications
rules:
- id: aave-fork-math-modification
patterns:
- pattern: |
function rayMul(uint256 $A, uint256 $B) ... {
...
}
- pattern-not: |
function rayMul(uint256 $A, uint256 $B) ... {
...
uint256 $HALF = $B / 2;
...
}
message: >
rayMul implementation missing half-value rounding adjustment.
Aave V3's math library requires specific rounding directions for protocol solvency.
Verify this modification doesn't invert rounding direction for borrows/repayments.
severity: ERROR
languages: [solidity]
metadata:
category: security
references:
- https://github.com/aave/aave-v3-core/blob/master/contracts/protocol/libraries/math/WadRayMath.sol
- id: aave-fork-unindexed-injection
patterns:
- pattern: |
function $FUNC(...) ... {
...
IERC20($TOKEN).transferFrom(..., address(this), $AMOUNT);
...
}
- pattern-not: |
function $FUNC(...) ... {
...
_pool.updateState(...);
...
}
message: >
Token transfer into pool contract without index update.
External value injection without updating liquidityIndex creates
an exploitable gap (dTRINITY pattern). Call updateState() before
or after any balance-changing operation.
severity: WARNING
languages: [solidity]
metadata:
category: security
references:
- https://blocksec.com/blog/weekly-web3-security-incident-roundup-mar-16-mar-22-2026
The Fork Audit Checklist: 10 Questions Every Aave V3 Fork Must Answer
Before deploying an Aave V3 fork, audit teams should verify each of these:
| # | Check | Class | Severity |
|---|---|---|---|
| 1 | Are rayMul/rayDiv rounding directions preserved for all operations? |
Rounding | Critical |
| 2 | Do all external value injections (subsidies, rebases, airdrops) update the liquidity index? | Index Desync | Critical |
| 3 | Has the fork been differentially tested against Aave V3 mainnet with identical inputs? | All | Critical |
| 4 | Are collateral factors independently calibrated for the fork's chain liquidity? | Collateral | High |
| 5 | Does the fork's updateState() function match Aave V3's index update formula exactly? |
Index Desync | Critical |
| 6 | Are there any new external/public functions that modify pool balances? |
Index Desync | High |
| 7 | Have gas optimizations in math libraries been verified to preserve rounding invariants? | Rounding | Critical |
| 8 | Are empty-market first-depositor protections still active? | Rounding | High |
| 9 | Do liquidation functions account for the fork's actual DEX liquidity, not mainnet's? | Collateral | High |
| 10 | Has the fork been fuzz-tested with >100K supply/borrow/repay/withdraw cycles? | All | Critical |
Solana Parallel: Anchor Lending Fork Risks
The same pattern applies to Solana lending forks. When protocols fork Solend, MarginFi, or Kamino, they inherit the same class of risks:
// Solana equivalent: Verify interest index updates on external deposits
// In Anchor lending fork — check that refresh_reserve() is called
// before any balance-modifying operation
pub fn inject_rewards(ctx: Context<InjectRewards>, amount: u64) -> Result<()> {
// VULNERABLE if refresh_reserve() is not called first
// The reserve's cumulative_borrow_rate and deposited_value
// must reflect all accrued interest before adding new value
// ✅ SAFE: Refresh first, then inject
let reserve = &mut ctx.accounts.reserve;
let clock = Clock::get()?;
reserve.accrue_interest(clock.slot)?; // Update indices FIRST
// Now safely add external value
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.source.to_account_info(),
to: ctx.accounts.reserve_liquidity.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
);
token::transfer(cpi_ctx, amount)?;
// Update reserve accounting to include new liquidity
reserve.liquidity.available_amount = reserve.liquidity
.available_amount
.checked_add(amount)
.ok_or(ErrorCode::MathOverflow)?;
Ok(())
}
The Bigger Picture: Fork Security as a Systemic Risk
The Aave V3 fork epidemic reveals a structural problem in DeFi:
Upstream audits don't transfer. Aave V3 mainnet has undergone 15+ audits. Fork modifications invalidate those audits for the changed code paths — but fork teams market "built on audited Aave V3 code" as a security guarantee.
The modification surface is deceptively small. dTRINITY changed ~200 lines of Aave's 50,000+ line codebase. Those 200 lines created a $257K vulnerability. Small diffs can have catastrophic consequences in financial software.
Economic parameters aren't portable. Aave's risk parameters were calibrated for Ethereum mainnet liquidity conditions. Copying them to a chain with 1% of the liquidity creates a silent vulnerability.
Index accounting is the most dangerous code to modify. Both HypurrFi and dTRINITY's vulnerabilities trace to index calculation changes. This is the financial kernel of lending protocols — modify it with extreme caution.
For protocol teams deploying Aave V3 forks: treat every modification as a potential vulnerability. Differential test your fork against Aave V3 mainnet. Independently calibrate risk parameters. And never inject value into a pool without updating the index.
The $260M+ in combined TVL across vulnerable forks is waiting for someone to apply the dTRINITY playbook to the next under-audited fork. Don't let it be yours.
This analysis is part of the DeFi Security Research series. The vulnerability patterns described are based on public exploit post-mortems and disclosed vulnerabilities. All code examples are for educational and defensive purposes.
Top comments (0)