오늘은 renzo라는 프로토콜에서 발생한 취약점
renzo는 이더리움의 EigenLayer 위에 구축된 Liquid Restaking 프로토콜로 ETH를 Renzo에 Staking하면 그 대가로 ezETH라는 Liquid Restaking Token을 받음.
타이틀: Pending withdrawals prevent safe removal of collateral assets
pending withdrawals가 먼가
사용자가 ezETH를 ETH로 돌려 받기 위해 출금 요청한 뒤에 아직 완료되지 않고 처리 대기중인 상태임.
작동은 다음과 같은데
- 출금 요청 : 사용자가 앱에서 ezETH -> ETH 출금을 신청
- 대기 기간 (cooldown) : withdrawQueue 버퍼에 자산이 있으면 약 7일, 비어 있으면 10~15일 정도 걸림 이 기간 동안의 상태가 Pending Withdrawal
- claim : 대기 기간이 끝나면 claim을 통해서 자산을 수령할 수 있음
대기 기간이 있는 이유는 ETH가 실제로 밸리데이터에 Staking 되는거라서 이더리움 네트워크 레벨에서 UnStaking 과정을 거쳐야 한다고 함.
그래서 이게 원래 해당 기간에 collateral asset을 못빼는게 아닌가? 했는데
RestakeManger 라는게 있어서 관리자가 Collateral Token을 추가 / 제거 할 수 있다고 함.
문제는 다음과 같음
1. WithdrawQueue 컨트랙트에 withdraw를 호출함.
function withdraw(uint256 _amount, address _assetOut) external nonReentrant {
...
// ezETH를 WithdrawQueue로 전송
IERC20(address(ezETH)).safeTransferFrom(msg.sender, address(this), _amount);
// 현재 TVL로 환매 금액 계산
(, , uint256 totalTVL) = restakeManager.calculateTVLs();
uint256 amountToRedeem = renzoOracle.calculateRedeemAmount(
_amount,
ezETH.totalSupply(),
totalTVL
);
...
// 출금 요청 저장
withdrawRequests[msg.sender].push(
WithdrawRequest(
_assetOut,
withdrawRequestNonce,
amountToRedeem,
_amount,
block.timestamp
)
);
// claimReserve에 예약
claimReserve[_assetOut] += amountToRedeem;
ezETH는 WithdrawQueue 컨트랙트에 잠겨있게 됨.ezETH.totalSupply는 줄어들지 않음
담보 토큰은 claimReserve에 예약만 된 상태임
2. TVL 계산
RestakeMAnager.calculateTVLs를 보면
function calculateTVLs() public view returns (uint256[][] memory, uint256[] memory, uint256) {
...
// collateralTokens 배열을 순회하면서 TVL 계산
uint256 tokenLength = collateralTokens.length;
for (uint256 j = 0; j < tokenLength; ) {
// 각 OD의 전략에 있는 토큰 잔액 조회
uint256 operatorBalance = operatorDelegators[i].getTokenBalanceFromStrategy(
collateralTokens[j]
);
operatorValues[j] = renzoOracle.lookupTokenValue(
collateralTokens[j],
operatorBalance
);
operatorTVL += operatorValues[j];
// WithdrawQueue에 있는 토큰 잔액도 포함 (collateralTokens 배열 기반
if (!withdrawQueueTokenBalanceRecorded) {
totalWithdrawalQueueValue += renzoOracle.lookupTokenValue(
collateralTokens[i],
collateralTokens[j].balanceOf(withdrawQueue)
);
}
...
// WithdrawQueue 잔액을 totalTVL에 합산
totalTVL += (address(withdrawQueue).balance + totalWithdrawalQueueValue);
...
}
로 collateralTokens 배열에 있는 토큰만 TVL에 포함됨.
3. RestakeManager.removeCollateralToken
RestakeManager에서 Collateral Token 제거 과정에서는 아무 조건 검사 없이 바로 제거되게 됨.claimReserve에 해당 토큰이 얼마나 reserve되어 있는지 pending withdrawal이 있는지 확인하지 않음.
4. 제거 이후
제거 이후 calculateTVLs() 가 호출되면 collateralTokens 배열에 해당 토큰이 없으니까 WithdrawQueue에 잠겨있는 토큰 잔액이 TVL에서 빠지게됨.
문제는 아래와 같은데
- WithdrawQueue에는 해당 토큰이 여전히 존재함 (claimReserve에 예약됨)
- 그 토큰을 위해 잠긴 ezETH도 여전히 totalSupply에 포함됨.
결과적으로 deposit()함수에서 새 사용자가 예치할 때
uint256 ezETHToMint = renzoOracle.calculateMintAmount(
totalTVL, // ← 실제보다 낮음 (제거된 토큰 빠짐)
collateralTokenValue,
ezETH.totalSupply() // ← 여전히 pending의 ezETH 포함
);
TVL은 낮아졌는데 ezETH 공급량은 그대로니까 새로운 예치자는 같은 ETH로 더 많은 ezETH를 받게되고 기존 보유자의 지분이 희석됨.
빤대로 환매 시 ezETH당 더 큰 몫을 가져갈 수 있게 되어 남은 담보를 과다하게 인출할 수 있음
claim을 한다 하더라도
function claim(uint256 withdrawRequestIndex) external nonReentrant {
// subtract value from claim reserve for claim asset
claimReserve[_withdrawRequest.collateralToken] -= _withdrawRequest.amountToRedeem;
...
// burn ezETH locked for withdraw request
ezETH.burn(address(this), _withdrawRequest.ezETHLocked);
...
IERC20(_withdrawRequest.collateralToken).transfer(
}
claim 시점에 ezETH가 소각되고 토큰이 빠져나가지만 해당 토큰은 이미 collateralTokens에서 제거된 상태라 TVL 계산에 영향을 주지 않음.
또, removeCollateralToken와 claim사이에 발생한 mint/redeem으로 인한 회계 불일치도 있음
패치
removeCollateralToken에 claimReserve[token] == 0 같이 claimReserve가 없을 때 CollateralToken을 제거할 수 있도록 해야 함.