Skip to content

Latest commit

 

History

History
102 lines (78 loc) · 3.64 KB

052.md

File metadata and controls

102 lines (78 loc) · 3.64 KB

Narrow Opaque Platypus

Medium

Auction Cancellation by Attacker

Auction Cancellation by Attacker.

Summary

An attacker can cancel the auction without losing any funds.

Root Cause

In the StreamEscrow::cancelStream() function, the canceler can recover all of their funds. https://github.com/sherlock-audit/2024-11-nounsdao/blob/main/nouns-monorepo/packages/nouns-contracts/contracts/StreamEscrow.sol#L167

Internal pre-conditions

NounsAuctionHouseV3::immediateTreasuryBPs = 0

External pre-conditions

N/A

Attack Path

When immediateTreasuryBPs = 0, the attacker can acquire the noun at a higher price than any other participant. The attacker calls NounsAuctionHouseV3::settleCurrentAndCreateNewAuction() on time and then immediately invokes StreamEscrow::cancelStream() to recover all of the funds.

Impact

No nouns can be auctioned.

PoC

NounsAuctionHouseV3.sol
325:    function _settleAuction() internal {
            [...]
333:        uint256 amountToSendTreasury = (_auction.amount * immediateTreasuryBPs) / 10_000;
            uint256 amountToStream = _auction.amount - amountToSendTreasury;

            if (amountToSendTreasury > 0) {
                _safeTransferETHWithFallback(owner(), amountToSendTreasury);
            }

            if (amountToStream > 0) {
                streamEscrow.forwardAllAndCreateStream{ value: amountToStream }(_auction.nounId, streamLengthInTicks);
            } else {
                streamEscrow.forwardAll();
            }
            [...]
        }

Here, amountToSendTreasury = 0;

StreamEscrow.sol
167:    function cancelStream(uint256 nounId) public {
            require(isStreamActive(nounId), 'stream not active');

            // transfer noun to treasury
            nounsToken.transferFrom(msg.sender, nounsRecipient, nounId);

            // cancel stream
            streams[nounId].canceled = true;
            Stream memory stream = streams[nounId];
            ethStreamedPerTick -= stream.ethPerTick;
            ethStreamEndingAtTick[stream.lastTick] -= stream.ethPerTick;

            // calculate how much needs to be refunded
180:        uint256 ticksLeft = stream.lastTick - currentTick;
            uint256 amountToRefund = stream.ethPerTick * ticksLeft;
            (bool sent, ) = msg.sender.call{ value: amountToRefund }('');
            require(sent, 'failed to send eth');

            emit StreamCanceled(nounId, amountToRefund, ethStreamedPerTick);
        }

Since cancelStream() is called immediately after createStream() is called, currentTick doesn't increase, allowing the attacker to recover all the funds.

Mitigation

StreamEscrow.sol
167:    function cancelStream(uint256 nounId) public {
            require(isStreamActive(nounId), 'stream not active');

            // transfer noun to treasury
            nounsToken.transferFrom(msg.sender, nounsRecipient, nounId);

            // cancel stream
            streams[nounId].canceled = true;
            Stream memory stream = streams[nounId];
-           ethStreamedPerTick -= stream.ethPerTick;
            ethStreamEndingAtTick[stream.lastTick] -= stream.ethPerTick;
+           ethStreamEndingAtTick[currentTick + 1] += stream.ethPerTick;

            // calculate how much needs to be refunded
-           uint256 ticksLeft = stream.lastTick - currentTick;
+           uint256 ticksLeft = stream.lastTick - currentTick - 1;
            uint256 amountToRefund = stream.ethPerTick * ticksLeft;
            (bool sent, ) = msg.sender.call{ value: amountToRefund }('');
            require(sent, 'failed to send eth');

            emit StreamCanceled(nounId, amountToRefund, ethStreamedPerTick);
        }