Try   HackMD

Smart Contract Bug Description

A minipool is funded with 16 ETH from the node operator and 16 ETH from rETH stakers. Upon
withdrawal any incurred losses are taken out of the node operator's share first. If a minipool
ends up with a balance below 16 ETH, an appropriate part of the node operators RPL collateral is
supposed to be sold to make up for the shortfall and protect rETH stakers. This report outlines
how a flawed auction mechanism can be exploited to enrich the exploiter and cause the loss of
rETH staker funds in this scenario.

When a slashing of RPL happens the amount of RPL tokens slashed is based on the shortfall of
the minipool balance compared to the user deposit balance (see here and here).
The slashed tokens are assigned to the RocketAuctionManager by slashRPL(). Whenever it has at least 1 ETH worth of RPL anyone can call createLot() to start an auction. According to the settings, the price starts at 100% of the RPL price and decreases every block until it reaches 50% of the starting price (see getLotPriceAtBlock()).After an auction was started anyone can call placeBid()
and send some ETH with it.

The critical flaw in the mechanism is the way in which bids are settled in claimBid():

​​​​// Get lot price info
​​​​uint256 blockPrice = getLotPriceAtCurrentBlock(_lotIndex);
​​​​uint256 bidPrice = getLotPriceByTotalBids(_lotIndex);
​​​​// Check lot can be claimed from
​​​​require(block.number >= getLotEndBlock(_lotIndex) || bidPrice >= blockPrice, "Lot has not cleared yet");
​​​​// Get & check address bid amount
​​​​uint256 bidAmount = getLotAddressBidAmount(_lotIndex, msg.sender);
​​​​require(bidAmount > 0, "Address has no RPL to claim");
​​​​// Calculate current lot price
​​​​uint256 currentPrice;
​​​​if (bidPrice > blockPrice) { currentPrice = bidPrice; }
​​​​else { currentPrice = blockPrice; }
​​​​// Calculate RPL claim amount
​​​​uint256 rplAmount = calcBase.mul(bidAmount).div(currentPrice); 

getLotPriceByTotalBids() calculates the RPL price as the fraction of total bids and total RPL in the lot. This means thatif someone places a bid for less than the maximum amount(based on the auction price at that time) and then some blocks pass without someone else bidding on the lot, the initial bidder's settling price improves over time. Because there is a gas cost associated with placing and claiming of bids, this leads to a first mover advantage: The first bidder can place a bid for most of the lot and leave just enough over so that it is not profitable for anyone else to bid. Eventually the first bidder will be able to claim the entire lot for a below market rate, resulting in losses for rETH stakers.

Proof of concept/Steps to Reproduce

One could take advantage of this with a simple flashbot bundle. The most reliable way would be to watch the mempool for an appropriate RPL slashing and including it in the bundle, but it's also possible to just wait for the slashig to happen on chain and to submit the bundle then.

Assuming that RocketAuctionManager has less than 1 ETH worth of RPL assigned to it, wait for a slashing that increases the balance to 1 ETH+, then submit the following bundle:

  1. createLot()
  2. placeBid() with an appropriate bid amount < maximum bid amount

Impact

To estimate the impact of this, one has to take into account the gas needed for various
transactions involved (see RocketAuctionManager on Goerli Testnet):

  • createLot(): 250,000+
  • placeBid(): 210,000+
  • claimBid(): 110,000+
  • Swap on Uniswap: 129,000

This means that a potential first mover has to spend a total of ~700k gas to execute the exploit
and then sell the RPL for a profit, while a second bidder would have to spend ~450k gas to place a bid and then sell for a profit.

I considered a simplified model with constant gas price and constant RPL to ETH ratio to gain some intuition:
Let bid_first be the bid amount of the first mover and bid_maxthe maximal possible bid for the lot at the start of the auction. It can be shown that the optimal strategy for a secondary bidder is to bid immediately with bid_second = sqrt(bid_max * bid_first) - bid_first. The profit before gas for a secondary bidder can be calculated as:

​​​​profit_second = bid_max * (1 - sqrt(bid_first/bid_max)) - sqrt(bid_max * bid_first) + bid_first

So the first mover would choose bid_first such that:

​​​​profit_second < 450,000 * gas_price

To make it unprofitable for anyone to bid after them and eventually claim the entire lot for
bid_first.

For example, if a lot has a value of 2 ETH (bid_max = 2) and the gas price is 50 gwei, a first mover could choose bid_first = 1.5982. The best a second bidder could do is bid bid_second = 0.1896 to achieve profit_second just shy of the necessary gas cost. The first mover would purchase RPL at 20% below market value.

Similarly, for bid_max = 1.6 and gas price of 75 gwei, the optimal value is bid_first = 1.1690 (leading to 27% below market) and forbid_max = 5 and a gas price of 50 gwei we get bid_first = 4.3517(13% below market).

In general a smaller lot size and a higher gas price leads to bigger damage relative to the fair value. In reality an exploiter might choose slightly less aggressive first bids to account for variable gas price and RPL to ETH ratio.

Recommendation

Bidders should receive RPL based on the auction price when placeBid() is called, there shouldn't be a price adjustment over time. This would result in a dutch auction. Bidders would end up buying RPL close to market value (after accounting for gas cost).

It seems possible to combine placeBid() and claimBid()into a single transaction in that case, which could lead to gas savings and further improve the efficiency of the mechanism.