code4rena-2024-03-dittoeth-h01
[H-01] A successfully disputed redemption proposal has still increased the redemption fee base rate; exploit to depeg dUSD
Summary
청산 수수료는 마지막으로 청산 요청한 시점으로부터 지난 시간이 짧을수록 높아진다. 그런데 청산에 이의제기하여 청산을 취소시켜도 청산 요청 시점은 롤백되지 않아 수수료는 여전히 높은 상태로 유지된다. 이로 인해 유저들이 청산을 하지 않도록 유도, 디페깅을 일으킬 수 있다.
Keyword
stablecoin, pegging, defi
Vulnerability
청산 속도를 조절하기 위해 청산 요청 시 청산자가 수수료를 지불해야 한다. 수수료는 청산 성공시 얻을 수 있는 dETH의 n% 만큼이며 일반적으로 이 비율은 baseRate + 0.5% 로 계산된다. baseRate 는 마지막 청산 요청으로부터 지난 시간이 길수록 낮아지고, 전체 발행된 dUSD 중 이번에 청산될 dUSD가 차지하는 비율이 높을수록 높아진다.
청산 요청을 할 때 calculateRedemptionFee 를 호출하여 수수료를 계산하고, 마지막 청산 요청 시점과 baseRate를 현 시점을 기준으로 업데이트 한다. 분쟁 기간에 반대를 하여 청산이 취소되어도 이미 업데이트된 baseRate는 원상복구되지 않는다.
function proposeRedemption(
address asset,
MTypes.ProposalInput[] calldata proposalInput,
uint88 redemptionAmount,
uint88 maxRedemptionFee
) external isNotFrozen(asset) nonReentrant {
...
@> uint88 redemptionFee = calculateRedemptionFee(asset, p.totalColRedeemed, p.totalAmountProposed);
if (redemptionFee > maxRedemptionFee) revert Errors.RedemptionFeeTooHigh();
STypes.VaultUser storage VaultUser = s.vaultUser[Asset.vault][msg.sender];
if (VaultUser.ethEscrowed < redemptionFee) revert Errors.InsufficientETHEscrowed();
@> VaultUser.ethEscrowed -= redemptionFee;
emit Events.ProposeRedemption(p.asset, msg.sender);
}
function calculateRedemptionFee(address asset, uint88 colRedeemed, uint88 ercDebtRedeemed)
internal
returns (uint88 redemptionFee)
{
STypes.Asset storage Asset = s.asset[asset];
uint32 protocolTime = LibOrders.getOffsetTime();
uint256 secondsPassed = uint256((protocolTime - Asset.lastRedemptionTime)) * 1 ether;
uint256 decayFactor = C.SECONDS_DECAY_FACTOR.pow(secondsPassed);
@> uint256 decayedBaseRate = Asset.baseRate.mulU64(decayFactor);
// @dev Calculate Asset.ercDebt prior to proposal
uint104 totalAssetErcDebt = (ercDebtRedeemed + Asset.ercDebt).mulU104(C.BETA);
// @dev Derived via this forumula: baseRateNew = baseRateOld + redeemedLUSD / (2 * totalLUSD)
uint256 redeemedDUSDFraction = ercDebtRedeemed.div(totalAssetErcDebt);
@> uint256 newBaseRate = decayedBaseRate + redeemedDUSDFraction;
@> newBaseRate = LibOrders.min(newBaseRate, 1 ether); // cap baseRate at a maximum of 100%
assert(newBaseRate > 0); // Base rate is always non-zero after redemption
// Update the baseRate state variable
@> Asset.baseRate = uint64(newBaseRate);
Asset.lastRedemptionTime = protocolTime;
@> uint256 redemptionRate = LibOrders.min((Asset.baseRate + 0.005 ether), 1 ether);
@> return uint88(redemptionRate.mul(colRedeemed));
}청산은 dUSD의 가치가 낮아지지 않도록 하기 위한 매커니즘이다. dUSD가 USD보다 낮게 거래되는 경우 페깅을 회복하기 위해서는 청산이 필요하다. 청산 수수료가 지나치게 높으면 청산자에게 손실이 생기며, 이는 즉 누구도 청산을 하지 않게 되어 페깅이 복원되지 않을 것을 의미한다.
다음 공격 시나리오를 생각해보자. 1 dUSD는 1달러와 페깅되는데, 이 시나리오에서는 0.95 달러까지 하락하는 데 걸리는 시간 동안 청산을 방해하면 페깅을 깨트려 공격이 성공한 것으로 보자. (근데 예시는 하락 상태에서 재페깅하는 것을 막는 비용 계산에 가까운 것 같다.) 공격자는 지속적으로 청산을 제안하고 공격자의 다른 계정을 사용해 이의를 제기하여 청산을 취소하는 것을 반복하여 높은 수수료율을 유지할 것이다. 현실적으로 공격에 드는 비용과 공격자가 얻을 수 있는 이익에 대해 생각해보자.
청산 수수료는 redemptionRate * redemptionAmount 이다. 청산자가 청산을 해서 얻을 수 있는 이익은 (1 - price) * redemptionAmount - redemptionRate * redemptionAmount 이다. 만약 redemptionRate >= (1 - price) 이라면 청산자는 청산을 성공해도 이득을 볼 수 없게 된다. 이익이 없다면 아무도 청산을 하지 않을 것이고, 이는 즉 공격자가 수수료율을 이만큼 유지한다면 디페깅을 유도할 수 있다는 것을 의미한다.
청산 요청에 이의를 제기하여 요청이 취소되면 청산자는 청산에 사용한 dUSD를 돌려받는다. 하지만 패널티로 일부를 빼앗겨 이의제기자의 리워드로 제공된다. 하지만 청산자(공격자)와 이의제기자(공격자)가 동일 인물이라면 패널티로 인한 손해는 받지 않는다. 따라서 이는 공격 비용으로 계산하지 않겠다. 하지만 공격자가 청산 요청과 취소를 반복하려면 여전히 청산 요청 수수료를 지불해야 한다. 이 비용이 현실적인지 알아보겠다.
decay 로직(마지막 청산 요청 시간 로직)을 일단 무시하고, a 만큼의 토큰이 청산 요청되면 새로운 baseRate는 baseRateNew = baseRateOld + redeemeddUSD / (2 * totalSupply) 로 계산되므로(계산 공식이 이렇게 정의되었다) 기존 baseRate 보다 a / (2 * totalSupply) 만큼 증가한다. 여기서 totalSupply 는 발행된 전체 dUSD 수를 의미한다.
redemptionRate = baseRate + 0.5% 이므로, 항상 0.5%가 더해진다. 즉, baseRate >= (1 - price) - 0.005 만큼을 유지시키면 디페깅을 유도할 수 있다.
초기 baseRate 가 0이라 가정하고, baseRate >= (1 - price) - 0.005 까지 한 번의 청산 요청으로 baseRate를 올리려면 청산 요청 금액은 a = (1 - price - 0.005) * 2 * totalSupply 이어야 한다. 이만큼을 청산 요청하려면 공격자는 청산 수수료로 (1 - price) * (1 - price - 0.005) * 2 * totalSupply 만큼 지불해야 한다. 만약 dUSD의 가격이 0.95 달러라면 (1 - 0.95) * (1 - 0.95 - 0.005) * 2 * totalSupply, 즉 0.0045 * totalSupply 만큼의 수수료를 지불해야 한다.
담보율이 낮은 숏이 충분히 많지 않다면 한 번에 a 만큼을 청산 요청할 수 없을 것이다. 현실적인 금액을 생각하기 위해 한 번에 a/n 만큼씩, 총 n번 청산 요청과 반대를 반복한다고 하자. 매 요청마다 baseRate는 (a/n) / (2 * totalSupply) 만큼 증가한다. 만약 이를 k번 반복한다 하면 k번째 요청에서 baseRate는 k * (a/n) / (2 * totalSupply) 이고, k번째 요청에서 공격자가 지불해야 하는 청산 요청 수수료는 a/n * (k * (a/n) / (2 * totalSupply) + 0.005) 이다. 총 n번 요청에 드는 수수료의 합은 등차수열의 합이므로 a^2 * (n+1) / (n * 4 * totalSupply) + 0.005 * a 가 된다. n을 무한으로 보내면 대략 a^2/(4* totalSupply) + 0.005 * a 의 청산 수수료를 필요로 한다. a = (1 - price - 0.005) * 2 * totalSupply 를 대입하고, 가격이 0.95 달러라고 하면 약 0.002475 * totalSupply 만큼의 청산 수수료를 필요로 한다. 즉 소액을 여러번 청산/취소하면 공격 비용을 줄일 수 있다.
이제 decay를 적용하여 생각해보자. 기존 baseRate 는 마지막 청산 요청 시간으로부터 더 많은 시간이 지날수록 수수료가 줄어든다. baseRateNew = baseRateOld * decayFactor^지난초 + redeemeddUSD / (2 * totalSupply), 단 decayFactor <= 1 으로 새로운 계산식을 적용한다. 12시간이 지나면 baseRate 가 반감되며, 이를 초단위로 계산할 수 있도록 변환하였다. baseRate에 decayFactor 를 지난 초만큼 곱했을 때, 12시간(43200 초) 후에는 반감되어야 한다. 즉 1/2 = d^43200 이므로 d = (1/2)^(1/43200) 임을 구할 수 있다.
/*
* Half-life of 12h. 12h = 43200 seconds
* (1/2) = d^43200 => d = (1/2)^(1/43200)
*/
uint256 public constant SECONDS_DECAY_FACTOR = 0.9999839550551 ether;
function calculateRedemptionFee(address asset, uint88 colRedeemed, uint88 ercDebtRedeemed)
internal
returns (uint88 redemptionFee)
{
STypes.Asset storage Asset = s.asset[asset];
uint32 protocolTime = LibOrders.getOffsetTime();
uint256 secondsPassed = uint256((protocolTime - Asset.lastRedemptionTime)) * 1 ether; // 지난 초
@> uint256 decayFactor = C.SECONDS_DECAY_FACTOR.pow(secondsPassed); // decayFactor ^ 지난 초
@> uint256 decayedBaseRate = Asset.baseRate.mulU64(decayFactor);
// @dev Calculate Asset.ercDebt prior to proposal
uint104 totalAssetErcDebt = (ercDebtRedeemed + Asset.ercDebt).mulU104(C.BETA);
// @dev Derived via this forumula: baseRateNew = baseRateOld + redeemedLUSD / (2 * totalLUSD)
uint256 redeemedDUSDFraction = ercDebtRedeemed.div(totalAssetErcDebt);
@> uint256 newBaseRate = decayedBaseRate + redeemedDUSDFraction;
newBaseRate = LibOrders.min(newBaseRate, 1 ether); // cap baseRate at a maximum of 100%
assert(newBaseRate > 0); // Base rate is always non-zero after redemption
// Update the baseRate state variable
Asset.baseRate = uint64(newBaseRate);
Asset.lastRedemptionTime = protocolTime;
uint256 redemptionRate = LibOrders.min((Asset.baseRate + 0.005 ether), 1 ether);
return uint88(redemptionRate.mul(colRedeemed));
}계산하기 쉽게 초가 아닌 시간 단위로 생각해보자. 마지막 업데이트 후 t 시간 후 baseRateOld는 (2)^(-t/12) 만큼 곱해져 낮아진다. 공격자의 목표는 baseRate 를 일정값 이상 유지하는 것이다. 시간이 지날수록 baseRate 가 줄어드므로 시간이 지날수록 더 많은 양을 청산해야 baseRate 가 유지될 것이다.
t 시간이 지난 후 baseRate를 이전 시점과 동일하도록 복구하려면 baseRate = baseRate * (2)^(-t/12) + a / (2 * totalSupply) 를 만족하는 a 만큼 청산 요청 해야한다. a = baseRate * (1 - ((2)^(-t/12))) * (2 * totalSupply) 이다. 이만큼을 청산하는 데 필요한 수수료는 (1 - price) * baseRate * (1 - ((2)^(-t/12))) * (2 * totalSupply) 이다. 직전 baseRate 가 (1 - price) - 0.005 이면 수수료는 (1 - price) * ((1 - price) - 0.005) * (1 - ((2)^(-t/12))) * (2 * totalSupply) 이다. 최악의 경우 바로 청산 요청을 해야한다 하면 t → 0 으로 보내면 (아마도) 로피탈의 정리로 인해(lim_(t→0) (1-2^( (-t)/12 ))/t = ln(2)/12) 로, (1 - price) * ((1 - price) - 0.005) * ln(2)/12 * (2 * totalSupply) 만큼의 수수료를 지불해야 한다. 가격이 0.95 라면 최악의 상황에 0.00026 * totalSupply 만큼의 청산 수수료를 지불해야 한다.
마지막으로, 청산 수수료는 청산되는 담보의 양에 비례한다. 청산되는 부채의 양이 baseRate에 영향을 끼친다. 만약 담보가 부족한 숏을 청산한다면 청산되는 담보의 양은 그 숏이 가진 담보만큼만으로 상한이 있지만, 청산되는 부채의 양도 잘라내지는 않는다. 다시 말하면 숏에 남은 담보가 부채보다 적은 경우 청산되는 담보 양은(p.colRedeemed)은 숏의 담보만큼으로 제한된다. 그러나 청산되는 부채의 양(p.amountProposed)은 변화가 없다. 이들의 누적인 p.totalColRedeemed 와 p.totalAmountProposed는 수수료 계산에 사용된다. baseRate 에 영향을 주는 p.totalAmountProposed 는 부채의 누적이고 p.totalColRedeemed 는 사용할 수 있는 담보의 누적(부채보다 적게 잡힐 수 있음)이다. p.totalColRedeemed가 더 적게 잡히므로 예상보다 적은 수수료를 지불하게 된다.
담보가 부족한 숏이 있을 때, 부분적으로 상환하여 담보가 거의 남지 않도록 하면 담보는 거의 0에 가깝지만 부채는 남아있는 숏을 만들 수 있다. 이러면 2개의 숏만으로 공격을 수행하여(하나는 청산 요청에, 하나는 이의제기에 사용) 거의 0에 가까운 청산 수수료를 지불하며 baseRate를 상승시킬 수 있다.
function proposeRedemption(
address asset,
MTypes.ProposalInput[] calldata proposalInput,
uint88 redemptionAmount,
uint88 maxRedemptionFee
) external isNotFrozen(asset) nonReentrant {
if (proposalInput.length > type(uint8).max) revert Errors.TooManyProposals();
MTypes.ProposeRedemption memory p;
p.asset = asset;
STypes.AssetUser storage redeemerAssetUser = s.assetUser[p.asset][msg.sender];
uint256 minShortErc = LibAsset.minShortErc(p.asset);
if (redemptionAmount < minShortErc) revert Errors.RedemptionUnderMinShortErc();
if (redeemerAssetUser.ercEscrowed < redemptionAmount) revert Errors.InsufficientERCEscrowed();
// @dev redeemerAssetUser.SSTORE2Pointer gets reset to address(0) after actual redemption
if (redeemerAssetUser.SSTORE2Pointer != address(0)) revert Errors.ExistingProposedRedemptions();
p.oraclePrice = LibOracle.getPrice(p.asset);
bytes memory slate;
for (uint8 i = 0; i < proposalInput.length; i++) {
p.shorter = proposalInput[i].shorter;
p.shortId = proposalInput[i].shortId;
p.shortOrderId = proposalInput[i].shortOrderId;
// @dev Setting this above _onlyValidShortRecord to allow skipping
STypes.ShortRecord storage currentSR = s.shortRecords[p.asset][p.shorter][p.shortId];
...
// @dev totalAmountProposed tracks the actual amount that can be redeemed. totalAmountProposed <= redemptionAmount
if (p.totalAmountProposed + currentSR.ercDebt <= redemptionAmount) {
@> p.amountProposed = currentSR.ercDebt;
} else {
p.amountProposed = redemptionAmount - p.totalAmountProposed;
// @dev Exit when proposal would leave less than minShortErc, proxy for nearing end of slate
if (currentSR.ercDebt - p.amountProposed < minShortErc) break;
}
/// At this point, the shortRecord passes all checks and will be included in the slate
p.previousCR = p.currentCR;
// @dev Cancel attached shortOrder if below minShortErc, regardless of ercDebt in SR
// @dev All verified SR have ercDebt >= minShortErc so CR does not change in cancelShort()
STypes.Order storage shortOrder = s.shorts[asset][p.shortOrderId];
if (currentSR.status == SR.PartialFill && shortOrder.ercAmount < minShortErc) {
if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder();
LibOrders.cancelShort(asset, p.shortOrderId);
}
@> p.colRedeemed = p.oraclePrice.mulU88(p.amountProposed);
@> if (p.colRedeemed > currentSR.collateral) {
@> p.colRedeemed = currentSR.collateral; // 숏의 담보가 부채보다 적을 시 담보만큼만 p.colRedeemed 에 셋팅 // 하지만 부채의 양 p.amountProposed 에는 변화가 없다.
}
currentSR.collateral -= p.colRedeemed;
currentSR.ercDebt -= p.amountProposed;
@> p.totalAmountProposed += p.amountProposed; // 청산되는 부채의 양 누적
@> p.totalColRedeemed += p.colRedeemed; // 실제로 청산에 사용되는 담보의 양 누적
...
}
...
@> uint88 redemptionFee = calculateRedemptionFee(asset, p.totalColRedeemed, p.totalAmountProposed);
if (redemptionFee > maxRedemptionFee) revert Errors.RedemptionFeeTooHigh();
...
}
function calculateRedemptionFee(address asset, uint88 colRedeemed, uint88 ercDebtRedeemed)
internal
returns (uint88 redemptionFee)
{
STypes.Asset storage Asset = s.asset[asset];
uint32 protocolTime = LibOrders.getOffsetTime();
uint256 secondsPassed = uint256((protocolTime - Asset.lastRedemptionTime)) * 1 ether;
uint256 decayFactor = C.SECONDS_DECAY_FACTOR.pow(secondsPassed);
uint256 decayedBaseRate = Asset.baseRate.mulU64(decayFactor);
// @dev Calculate Asset.ercDebt prior to proposal
@> uint104 totalAssetErcDebt = (ercDebtRedeemed + Asset.ercDebt).mulU104(C.BETA); // p.totalAmountProposed 사용
// @dev Derived via this forumula: baseRateNew = baseRateOld + redeemedLUSD / (2 * totalLUSD)
@> uint256 redeemedDUSDFraction = ercDebtRedeemed.div(totalAssetErcDebt); // p.totalAmountProposed 사용
uint256 newBaseRate = decayedBaseRate + redeemedDUSDFraction;
newBaseRate = LibOrders.min(newBaseRate, 1 ether); // cap baseRate at a maximum of 100%
assert(newBaseRate > 0); // Base rate is always non-zero after redemption
// Update the baseRate state variable
Asset.baseRate = uint64(newBaseRate);
Asset.lastRedemptionTime = protocolTime;
uint256 redemptionRate = LibOrders.min((Asset.baseRate + 0.005 ether), 1 ether);
@> return uint88(redemptionRate.mul(colRedeemed)); // p.totalColRedeemed(청산되는 담보) 사용
}Impact
공격자가 의도적으로 청산 수수료를 높여 청산이 되지 않도록 한다. dUSD가 청산을 통해 페깅을 회복하는 것을 방해하여 완전히 폭락하게 할 수 있다.
Mitigation
이의를 제기하여 성공적으로 청산이 취소되면 청산 수수료가 증가하지 않도록 한다.
Memo
열심히 써놓은 것을 이해해보려고 했는데 뭔가 힘들다… 보고서 중간에 약간 계산이 잘못된 부분이 있는것 같아서 수정했다. 사실 취약점 자체는 코드가 잘못된 것만 보여줘도 납득이 되는데 크리티컬한 이슈임을 증명하기 위해 현실적으로 공격이 가능하다는 것을 보여준 것 같다. 논지는 알겠는데 설명이 맞는건지 모르겠다.
tags: bughunting, dittoeth, smart contract, solidity, solo issue, stablecoin, pegging, defi, severity high