sherlock-2025-04-burve-m03
[M-03] Protocol fee resides in the diamond contract can be wrongly sent to users if the underlying vault temporarily disables withdrawal
Summary
연동된 ERC4626 서드파티 볼트가 일시정지 되면 maxWithdraw는 0을 리턴한다(ERC4626 스펙). 이를 고려하지 않아 서드파티 볼트가 일시정지 되었을 때 이자 정산을 잘못 하였고, 유저에게 전송될 용도가 아닌(프로토콜 수수료인) 토큰이 유저에게 전송되었다.
Keyword
pause, erc4626, business logic vul
Vulnerability
유저는 collectEarnings 를 호출하여 자신이 번 이자를 수확해갈 수 있다. 유저가 collectEarnings을 호출할 때마다 먼저 trimAllBalances가 호출되어 볼트에서 발생한 수익을 정산한다. trimAllBalances는 볼트에서 발생한 이자를 출금하여 Reserve라는 특수 Vertex의 볼트로 입금한다.
uint256 residualReal = realBalance - targetReal;
@> vProxy.withdraw(cid, residualReal);
bgtResidual = FullMath.mulDiv(residualReal, bgtValue, value);
@> reserveSharesEarned = ReserveLib.deposit(
vProxy,
self.vid,
residualReal - bgtResidual
);
vProxy.commit();일반적으로 출금된 금액과 입금된 금액은 완전히 동일하므로 실제 토큰의 이동은 없다. (단지 내부적으로 토큰의 소유가 Vertex의 소유로 변경될 뿐) 서드파티에게 수수료를 지불하는 것을 최소화하기 위해 내부적으로 먼저 입출금액을 계산하고, 만약 입금해야 하는 금액과 출금해야 하는 금액이 정확히 동일하다면 서드파티에게는 요청하지 않기 때문이다.
일반적으로 발생한 이자가 Vertex 소유로 이전될 뿐이므로 입금액과 출금액은 정확히 일치, trimAllBalances 실행시 서드파티와의 상호작용은 없어야 한다.
if (assetsToDeposit > 0 && assetsToWithdraw > 0) {
// We can net out and save ourselves some fees.
if (assetsToDeposit > assetsToWithdraw) {
assetsToDeposit -= assetsToWithdraw;
assetsToWithdraw = 0;
} else if (assetsToWithdraw > assetsToDeposit) {
assetsToDeposit = 0;
assetsToWithdraw -= assetsToDeposit;
} else {
// Perfect net!
@> return;
}
}하지만 연동된 서드파티 볼트가 일시정지 상태라면 문제가 발생할 수 있다. 다음은 이자를 출금하기 위해 호출되는 VaultProxyImpl.withdraw 이다. Active 볼트에서 withdrawable를 쿼리한다. 이 함수는 연동된 서드파티의 maxWithdraw 호출값을 그대로 리턴한다. 만약 연동된 서드파티 볼트가 일시정지되어 출금할 수 없는 상태라면 maxWithdraw는 0을 반환한다(ERC4626 스펙에 명시).
function withdraw(
VaultProxy memory self,
ClosureId cid,
uint256 amount
) internal {
// We effectively don't allow withdraws beyond uint128 due to the capping in balance.
uint128 available = self.active.balance(cid, false);
@> uint256 maxWithdrawable = self.active.withdrawable(); // 서드파티의 maxWithdraw 값 리턴 (0)
@> if (maxWithdrawable < available) available = uint128(maxWithdrawable); // 0 으로 설정
if (amount > available) {
@> self.active.withdraw(cid, available); // 0만큼 출금 시도
self.backup.withdraw(cid, amount - available);
} else {
self.active.withdraw(cid, amount);
}
}self.active.withdraw를 호출하여 이자를 출금할 때, active 볼트에 연동된 서드파티 볼트가 일시정지 상태라면 maxWithdraw인 0만큼을 출금 요청할 것이다. 그리고 0만큼을 출금요청하면 서드파티에게 요청을 하지 않고 그대로 리턴한다. 따라서 일시정지된 서드파티와 상호작용을 하지 않아 revert 되지 않고, 조용히 종료된다.
/// Queue up a withdrawal for a given cid.
function withdraw(
VaultPointer memory self,
ClosureId cid,
@> uint256 amount // 0
) internal {
@> if (isNull(self) || amount == 0) return;
if (self.vType == VaultType.E4626) {
getE4626(self).withdraw(self.temp, cid, amount);
} else {
revert VaultTypeUnrecognized(self.vType);
}
}즉 출금액(출금할 이자)은 0이지만 입금액(Vertex로 옮겨지는 이자)은 있으므로, 컨트랙트에 있던 토큰이 서드파티에 예치된다. 원래라면 출금액과 입금액이 일치하여 내부적인 상태만 업데이트 되어야 한다.
위와 같은 행동을 하며 trimAllBalances가 끝나면 다시 collectEarnings의 함수로 돌아온다. ReserveLib.withdraw 를 호출하여 수익을 꺼내간다. ReserveLib.withdraw에서는 위와 마찬가지로 vProxy.withdraw를 호출하고, 서드파티의 maxWithdraw 가 0을 리턴하므로 실제로 서드파티 볼트에서 출금되는 토큰은 없다.
하지만 ReserveLib.withdraw 이후 TransferHelper.safeTransfer를 호출하여 유저에게 토큰을 전송한다. 즉, 컨트랙트에 예치되어 있던 토큰을 유저에게 전송한다.
function collectEarnings(
address recipient,
uint16 closureId
)
external
returns (
uint256[MAX_TOKENS] memory collectedBalances,
uint256 collectedBgt
)
{
ClosureId cid = ClosureId.wrap(closureId);
// Catch up on rehypothecation gains before we claim fees.
@> Store.closure(cid).trimAllBalances();
uint256[MAX_TOKENS] memory collectedShares;
(collectedShares, collectedBgt) = Store.assets().claimFees(
msg.sender,
cid
);
if (collectedBgt > 0)
IBGTExchanger(Store.simplex().bgtEx).withdraw(
recipient,
collectedBgt
);
TokenRegistry storage tokenReg = Store.tokenRegistry();
for (uint8 i = 0; i < MAX_TOKENS; ++i) {
if (collectedShares[i] > 0) {
VertexId vid = VertexLib.newId(i);
// Real amounts.
@> collectedBalances[i] = ReserveLib.withdraw(
vid,
collectedShares[i]
);
@> TransferHelper.safeTransfer(
tokenReg.tokens[i],
recipient,
collectedBalances[i]
);
}
}
}Impact
컨트랙트에 남아있는 있는 토큰은 유저에게 줄 용도가 아니라 프로토콜 수수료로 수집된 것이다. 따라서 이는 유저에게 전송되어서는 안된다. 하지만 프로토콜 수수료가 유저에게 나간다.
Mitigation
볼트가 일시정지된 경우 트랜잭션을 취소시킨다.
Memo
ERC4626 특수 상황 스펙에서 예외 상황을 잘 이끌어냈다.
tags: bughunting, burve, smart contract, solidity, severity medium, solo issue, pause, erc4626, business-logic-vul