Platform: Code4rena
Start Date: 02/06/2023
Pot Size: $100,000 USDC
Total HM: 15
Participants: 75
Period: 7 days
Judge: Picodes
Total Solo HM: 5
Id: 249
League: ETH
Rank: 7/75
Findings: 3
Award: $3,059.30
🌟 Selected for report: 1
🚀 Solo Findings: 0
2753.8636 USDC - $2,753.86
https://github.com/code-423n4/2023-06-stader/blob/main/contracts/Auction.sol#L62-L78
MEV bots may bid all the ongoing auctions before Auction
is paused by frontrunning it with the addBid
function.
This can lead to bots winning all the auctions for a fraction of their price, especially auctions that are almost ended, as no one is able to bid until the contract is unpaused.
No one will be able to bid when the contract is paused, but bots can simply front-run the pause
by looking at the mempool:
function addBid(uint256 lotId) external payable override whenNotPaused { // reject payments of 0 ETH if (msg.value == 0) revert InSufficientETH(); LotItem storage lotItem = lots[lotId]; if (block.number > lotItem.endBlock) revert AuctionEnded(); uint256 totalUserBid = lotItem.bids[msg.sender] + msg.value; if (totalUserBid < lotItem.highestBidAmount + bidIncrement) revert InSufficientBid(); lotItem.highestBidder = msg.sender; lotItem.highestBidAmount = totalUserBid; lotItem.bids[msg.sender] = totalUserBid; emit BidPlaced(lotId, msg.sender, totalUserBid); }
A word about severity; the contract extends PausableUpgradeable
:
contract Auction is IAuction, Initializable, AccessControlUpgradeable, PausableUpgradeable, ReentrancyGuardUpgradeable
Also, it has several functions that have the whenNotPaused
modifier:
function createLot(uint256 _sdAmount) external override whenNotPaused function addBid(uint256 lotId) external payable override whenNotPaused
However, it doesn't have any pause
/unpause
functions that are external
/public
, as they have internal
visibility by default in PausableUpgradeable
; as such, the contract can't be paused with the current implementation.
However, this may change in the future as the contract is upgradeable, so I still consider this issue valid.
Manual review
Consider removing the whenNotPaused
modifier from addBid
, so ongoing auctions may be ended even when the contract is paused.
MEV
#0 - c4-judge
2023-06-12T21:33:00Z
Picodes marked the issue as primary issue
#1 - manoj9april
2023-06-20T07:24:58Z
Bots can win the auction by frontrunning if that ensures a better price than the current bid (which it does) Also this is an duplicate issue of #70
#2 - c4-sponsor
2023-06-20T07:25:33Z
manoj9april marked the issue as sponsor disputed
#3 - Picodes
2023-07-02T23:21:30Z
I don't think this is a duplicate of #70. This issue is specifically about the fact that pausing an auction decreases its time and that MEV bots / the admin could benefit from this. It is a valid issue in the sense that nothing is done to prevent auctions from being closed by a call to pause
, which would break the system.
#4 - c4-judge
2023-07-02T23:23:26Z
Picodes marked the issue as selected for report
#5 - sanjay-staderlabs
2023-07-05T09:34:18Z
Hi @Picodes thanks for pointing it out, it turns out that for the mitigation of issue 383, we have removed PausableUpgradeable
from Auction contract hence removing whenNotPaused modifier from addBid
function. This issue is no more relevant now. Please see if we can close this or lmk what you think.
🌟 Selected for report: Madalad
Also found by: Aymen0909, Bauchibred, Breeje, DadeKuma, Hama, LaScaloneta, Madalad, MohammedRizwan, bin2chen, dwward3n, erictee, etherhood, kutugu, peanuts, piyushshukla, rvierdiiev, saneryee, tallo, turvy_fuzz, whimints
31.7954 USDC - $31.80
https://github.com/code-423n4/2023-06-stader/blob/main/contracts/StaderOracle.sol#L646-L649
latestRoundData
may return stale/wrong data but there aren't any checks to ensure that this happen.
This impacts the balance, supply, and exchange rate that will be used to make withdrawals/deposits.
There are some checks missing from the Chainlink response (round completeness, staleness, price):
function getPORFeedData() internal view returns ( uint256, uint256, uint256 ) { (, int256 totalETHBalanceInInt, , , ) = AggregatorV3Interface(staderConfig.getETHBalancePORFeedProxy()) .latestRoundData(); (, int256 totalETHXSupplyInInt, , , ) = AggregatorV3Interface(staderConfig.getETHXSupplyPORFeedProxy()) .latestRoundData(); return (uint256(totalETHBalanceInInt), uint256(totalETHXSupplyInInt), block.number); }
Issues with Chainlink, such as oracle consensus problems or chain congestion, may result in this contract relying on outdated data.
As these values are used to calculate the exchange rate, it may result in an incorrect price:
function updateERFromPORFeed() external override checkERInspectionMode whenNotPaused { if (!isPORFeedBasedERData) { revert InvalidERDataSource(); } (uint256 newTotalETHBalance, uint256 newTotalETHXSupply, uint256 reportingBlockNumber) = getPORFeedData(); updateWithInLimitER(newTotalETHBalance, newTotalETHXSupply, reportingBlockNumber); }
Manual review
Consider adding the following checks:
(uint80 roundETHBalance, int256 totalETHBalanceInInt, , uint timeStampETHBalance, uint80 answerRoundETHBalance) = AggregatorV3Interface(staderConfig.getETHBalancePORFeedProxy()) .latestRoundData(); (uint80 roundETHSupply, int256 totalETHXSupplyInInt, , uint timeStampETHSupply, uint80 answerRoundETHSupply) = AggregatorV3Interface(staderConfig.getETHXSupplyPORFeedProxy()) .latestRoundData(); require(totalETHBalanceInInt > 0, "Negative Oracle Price"); require(totalETHXSupplyInInt > 0, "Negative Oracle Price"); require(timeStampETHBalance >= block.timestamp, "Stale pricefeed"); require(timeStampETHSupply >= block.timestamp, "Stale pricefeed"); require(roundETHBalance >= answerRoundETHBalance, "Round not complete"); require(roundETHSupply >= answerRoundETHSupply, "Round not complete");
Invalid Validation
#0 - c4-judge
2023-06-09T23:25:25Z
Picodes marked the issue as duplicate of #15
#1 - c4-judge
2023-07-02T10:49:35Z
Picodes marked the issue as satisfactory
🌟 Selected for report: RaymondFam
Also found by: 0xWaitress, 0xhacksmithh, ChrisTina, DadeKuma, LaScaloneta, Rolezn, SAAJ, Sathish9098, T1MOH, bin2chen, btk, catellatech, ernestognw, fatherOfBlocks, hals, hunter_w3b, jaraxxus, matrix_0wl, mgf15, naman1778, niser93, solsaver, turvy_fuzz
273.6431 USDC - $273.64
createLot
createLot
https://github.com/code-423n4/2023-06-stader/blob/main/contracts/Auction.sol#L48-L60
Anyone can create an auction, but they will get nothing in return as their funds will be lost. This function should be callable only by SDCollateral
:
function createLot(uint256 _sdAmount) external override whenNotPaused { lots[nextLot].startBlock = block.number; lots[nextLot].endBlock = block.number + duration; lots[nextLot].sdAmount = _sdAmount; LotItem storage lotItem = lots[nextLot]; if (!IERC20(staderConfig.getStaderToken()).transferFrom(msg.sender, address(this), _sdAmount)) { revert SDTransferFailed(); } emit LotCreated(nextLot, lotItem.sdAmount, lotItem.startBlock, lotItem.endBlock, bidIncrement); nextLot++; }
The penalty amount can only increase, but not decrease. If this function is called on an intended _pubkey
, it won't be possible to decrease it again:
function setAdditionalPenaltyAmount(bytes calldata _pubkey, uint256 _amount) external override { UtilLib.onlyManagerRole(msg.sender, staderConfig); bytes32 pubkeyRoot = UtilLib.getPubkeyRoot(_pubkey); additionalPenaltyAmount[pubkeyRoot] += _amount; emit UpdatedAdditionalPenaltyAmount(_pubkey, _amount); }
If an unintended address is added to the operator whitelist by mistake, it's not possible to remove it before they complete the onboarding process:
function whitelistPermissionedNOs(address[] calldata _permissionedNOs) external override { UtilLib.onlyManagerRole(msg.sender, staderConfig); uint256 permissionedNosLength = _permissionedNOs.length; for (uint256 i = 0; i < permissionedNosLength; i++) { address operator = _permissionedNOs[i]; UtilLib.checkNonZeroAddress(operator); permissionList[operator] = true; emit OperatorWhitelisted(operator); } }
There are multiple payable
functions that are not protected by access control. Anyone can send 1 wei to trigger these functions, which may be used to trick other components that listen to these events.
For example, this event should be emitted only when the withdraw vault completes a payment, but it's callable by anyone:
// payable function for receiving user share from validator withdraw vault function receiveWithdrawVaultUserShare() external payable override { emit WithdrawVaultUserShareReceived(msg.value); }
Anyone can send funds as a deposit, but there isn't an access control to check if they are an operator:
function depositSDAsCollateral(uint256 _sdAmount) external override { address operator = msg.sender; operatorSDBalance[operator] += _sdAmount; if (!IERC20(staderConfig.getStaderToken()).transferFrom(operator, address(this), _sdAmount)) { revert SDTransferFailed(); } emit SDDeposited(operator, _sdAmount); }
When these users try to withdraw their funds and they aren't an operator, their transaction will fail, and their funds will be locked:
function withdraw(uint256 _requestedSD) external override { address operator = msg.sender; uint256 opSDBalance = operatorSDBalance[operator]; -> if (opSDBalance < getOperatorWithdrawThreshold(operator) + _requestedSD) { revert InsufficientSDToWithdraw(opSDBalance); } operatorSDBalance[operator] -= _requestedSD; // cannot use safeERC20 as this contract is an upgradeable contract if (!IERC20(staderConfig.getStaderToken()).transfer(payable(operator), _requestedSD)) { revert SDTransferFailed(); } emit SDWithdrawn(operator, _requestedSD); }
No one can do a deposit unless their assets are greater than the minDeposit
:
function deposit(address _receiver) public payable override whenNotPaused returns (uint256) { uint256 assets = msg.value; if (assets > maxDeposit() || assets < minDeposit()) { revert InvalidDepositAmount(); } uint256 shares = previewDeposit(assets); _deposit(msg.sender, _receiver, assets, shares); return shares; }
However, when the vault is unhealthy, the minDeposit
is zero:
function minDeposit() public view override returns (uint256) { return isVaultHealthy() ? staderConfig.getMinDepositAmount() : 0; }
As such, the previous condition will not revert and the user will be able to call the deposit function without failing.
Consider adding a new check:
if (assets == 0 || assets > maxDeposit() || assets < minDeposit()) { revert InvalidDepositAmount(); }
#0 - c4-judge
2023-06-14T06:48:38Z
Picodes marked the issue as grade-b
#1 - DadeKuma
2023-07-04T11:54:28Z
I had two more issues downgraded to low, so I was wondering if this is worth an A grade now, as all the issues are project-specific, and not generic ones/dups of the bot race
Edit: 5 more issues, not two
#2 - Picodes
2023-07-05T22:46:59Z
Indeed @DadeKuma with the additional downgrading this is worth an A grade.
#3 - c4-judge
2023-07-05T22:47:03Z
Picodes marked the issue as grade-a
#4 - c4-judge
2023-07-13T16:48:36Z
Picodes marked the issue as selected for report
#5 - Picodes
2023-07-13T16:49:48Z
Due to the quality of the downgraded findings, this report stands out as the second best (after #114). As #114 will not be included in the awards, I am giving the selected for report
label to this issue, although it won't be included in the final report.