DeFi AMM 회계 버그 및 가상 잔액 캐시 악용

Tip

AWS 해킹 배우기 및 연습하기:HackTricks Training AWS Red Team Expert (ARTE)
GCP 해킹 배우기 및 연습하기: HackTricks Training GCP Red Team Expert (GRTE) Azure 해킹 배우기 및 연습하기: HackTricks Training Azure Red Team Expert (AzRTE)

HackTricks 지원하기

개요

Yearn Finance의 yETH 풀(2025년 11월)은 복잡한 AMMs 내부의 가스 절약용 캐시가 경계 상태 전환 중에 정산되지 않을 경우 어떻게 악용될 수 있는지를 드러냈습니다. 가중 stableswap 풀은 최대 32개의 liquid staking derivatives (LSDs)를 추적하고, 이를 ETH 동등값인 virtual balances (vb_i = balance_i × rate_i / PRECISION)로 변환하여 패킹된 스토리지 배열 packed_vbs[]에 저장합니다. 모든 LP 토큰이 소각되면 totalSupply는 올바르게 0으로 떨어지지만 캐시된 packed_vbs[i] 슬롯들은 엄청난 과거 값을 유지했습니다. 이후의 예금자는 캐시가 유령 유동성을 여전히 보유하고 있었음에도 불구하고 “첫 번째” 유동성 공급자로 취급되었고, 공격자는 약 235 septillion yETH을 단지 16 wei로 민트한 뒤 ≈USD 9M 상당의 LSD 담보를 탈취할 수 있었습니다.

핵심 요소:

  • Derived-state caching: 비싼 오라클 조회를 피하기 위해 가상 잔액을 지속 저장하고 점진적으로 업데이트합니다.
  • Missing reset when supply == 0: remove_liquidity()의 비례 감소가 각 출금 사이클 후 packed_vbs[]에 0이 아닌 잔여값을 남겼습니다.
  • Initialization branch trusts the cache: add_liquidity()_calc_vb_prod_sum()을 호출하고 prev_supply == 0일 때 캐시도 0으로 초기화되었다고 가정하여 단순히 packed_vbs[]읽습니다.
  • Flash-loan financed state poisoning: 예치/출금 루프가 자본 잠금 없이 반올림 잔여를 증폭시켜 ‘첫 예치’ 경로에서 치명적인 과다 민트를 가능하게 했습니다.

캐시 설계 및 경계 처리 누락

취약한 흐름은 아래와 같이 단순화됩니다:

function remove_liquidity(uint256 burnAmount) external {
uint256 supplyBefore = totalSupply();
_burn(msg.sender, burnAmount);

for (uint256 i; i < tokens.length; ++i) {
packed_vbs[i] -= packed_vbs[i] * burnAmount / supplyBefore; // truncates to floor
}

// BUG: packed_vbs not cleared when supply hits zero
}

function add_liquidity(Amounts calldata amountsIn) external {
uint256 prevSupply = totalSupply();
uint256 sumVb = prevSupply == 0 ? _calc_vb_prod_sum() : _calc_adjusted_vb(amountsIn);
uint256 lpToMint = pricingInvariant(sumVb, prevSupply, amountsIn);
_mint(msg.sender, lpToMint);
}

function _calc_vb_prod_sum() internal view returns (uint256 sum) {
for (uint256 i; i < tokens.length; ++i) {
sum += packed_vbs[i]; // assumes cache == 0 for a pristine pool
}
}

Because remove_liquidity() only applied proportional decrements, every loop left fixed-point rounding dust. After ≳10 deposit/withdraw cycles those residues accumulated into extremely large phantom virtual balances while the on-chain token balances were almost empty. Burning the final LP shares set totalSupply to zero yet caches stayed populated, priming the protocol for a malformed initialization.

Exploit playbook (yETH case study)

  1. Flash-loan working capital – 풀을 조작하는 동안 자본을 묶지 않기 위해 Balancer/Aave에서 wstETH, rETH, cbETH, ETHx, WETH 등을 차용한다.
  2. Poison packed_vbs[] – 8개 LSD 자산을 대상으로 예치/인출을 반복한다. 각 부분 인출은 packed_vbs[i] − vb_share를 truncates하여 토큰당 >0의 잔여물을 남긴다. 반복 루프는 실제 잔액이 대체로 상쇄되기 때문에 의심을 유발하지 않으면서 팬텀 ETH 상당 잔액을 부풀린다.
  3. Force supply == 0 – 남아있는 모든 LP 토큰을 소각해 풀이 비어있다고 인식하게 한다. 구현 상의 누락으로 인해 오염된 packed_vbs[]는 그대로 남는다.
  4. Dust-size “first deposit” – 지원되는 LSD 슬롯들에 걸쳐 총 16 wei를 분배해서 보낸다. add_liquidity()prev_supply == 0을 감지하고 _calc_vb_prod_sum()을 실행하며 실제 잔액에서 재계산하지 않고 오래된 캐시를 읽는다. 따라서 민트 계산은 수조 달러가 들어온 것처럼 작동해 ~2.35×10^26 yETH를 발행한다.
  5. Drain & repay – 부풀려진 LP 포지션을 모든 보관된 LSD로 상환하고, Balancer에서 yETH→WETH로 스왑한 뒤 Uniswap v3를 통해 ETH로 변환하고, 플래시론/수수료를 상환한 뒤 이익을 세탁(예: Tornado Cash)한다. 순이익은 약 USD 9M이고 본인 자금은 단지 16 wei만 풀이 접촉했다.

Generalized exploitation conditions

You can abuse similar AMMs when all of the following hold:

  • Cached derivatives of balances (virtual balances, TWAP snapshots, invariant helpers) persist between transactions for gas savings.
  • Partial updates truncate results (floor division, fixed-point rounding), letting an attacker accumulate stateful residues via symmetric deposit/withdraw cycles.
  • Boundary conditions reuse caches instead of ground-truth recomputation, especially when totalSupply == 0, totalLiquidity == 0, or pool composition resets.
  • Minting logic lacks ratio sanity checks (e.g., absence of expected_value/actual_value bounds) so a dust deposit can mint essentially the entire historic supply.
  • Cheap capital is available (flash loans or internal credit) to run dozens of state-adjusting operations inside one transaction or tightly choreographed bundle.

Defensive engineering checklist

  • Explicit resets when supply/lpShares hit zero:
if (totalSupply == 0) {
for (uint256 i; i < tokens.length; ++i) packed_vbs[i] = 0;
}

Apply the same treatment to every cached accumulator derived from balances or oracle data.

  • Recompute on initialization branches – When prev_supply == 0, ignore caches entirely and rebuild virtual balances from actual token balances + live oracle rates.
  • Minting sanity bounds – Revert if lpToMint > depositValue × MAX_INIT_RATIO or if a single transaction mints >X% of historic supply while total deposits are below a minimal threshold.
  • Rounding-residue drains – Aggregate per-token dust into a sink (treasury/burn) so repeated proportional adjustments do not drift caches away from real balances.
  • Differential tests – For every state transition (add/remove/swap), recompute the same invariant off-chain with high-precision math and assert equality within a tight epsilon even after full liquidity drains.

Monitoring & response

  • Multi-transaction detection – Track sequences of near-symmetric deposit/withdraw events that leave the pool with low balances but high cached state, followed by supply == 0. Single-transaction anomaly detectors miss these poisoning campaigns.
  • Runtime simulations – Before executing add_liquidity(), recompute virtual balances from scratch and compare with cached sums; revert or pause if deltas exceed a basis-point threshold.
  • Flash-loan aware alerts – Flag transactions that combine large flash loans, exhaustive pool withdrawals, and a dust-sized final deposit; block or require manual approval.

References

Tip

AWS 해킹 배우기 및 연습하기:HackTricks Training AWS Red Team Expert (ARTE)
GCP 해킹 배우기 및 연습하기: HackTricks Training GCP Red Team Expert (GRTE) Azure 해킹 배우기 및 연습하기: HackTricks Training Azure Red Team Expert (AzRTE)

HackTricks 지원하기