Lowest-paying findings on Code4rena, Sherlock

·

8 min read

I have listed vulnerabilities that beginners should strive to find, while advanced auditors might not want to spend a lot of time reporting them. To incentivize users, Code4rena and Sherlock use a unique scoring system with the primary goal to reward contestants for finding unique bugs. The award will be shared among those who submitted. The fewer people who find a bug, the more money contestants will be paid. I've used solodit to organize finding with a lot of finders

Use safeTransfer, safeTransferFrom instead of transfer, transferFrom when transferring

Unsafe transfer does guarantee the transfer was fully complete only when it is implemented to fail whenever transfers can't be processed fully. Not all ERC20 implementations behave that way, it isn't required.

Due to that while having unsafe transfer for protocol controlled tokens produces no issues, using it for any external controlled token poses a vulnerability as long as this token implementation do not always fail for unsuccessful transfer, returning false instead, or a token can be upgraded to start behaving this way.

For example, as USDC is an upgradable contract, there is no guarantees that the next implementation will be failing instead of only returning bool success value.

Here is a code example from a contest with this issue, number of finders - 12

  function xTransferToController(
    uint256 _vaultNumber,
    uint256 _amount,
    address _asset,
    uint256 _slippage,
    uint256 _relayerFee
  ) external payable onlyVaults {
    if (homeChain == xControllerChain) {
      IERC20(_asset).transferFrom(msg.sender, xController, _amount);
      IXChainController(xController).upFundsReceived(_vaultNumber);
    } else {
      xTransfer(_asset, _amount, xController, xControllerChain, _slippage, _relayerFee);
      pushFeedbackToXController(_vaultNumber, _relayerFee);
    }
  }

Other similar examples from Solodit: 1 2 3 4 5 6

Use call() instead of transfer() when transferring ETH

The transfer() and send() functions forward a fixed amount of 2300 gas. Historically, it has often been recommended to use these functions for value transfers to guard against reentrancy attacks. However, the gas cost of EVM instructions may change significantly during hard forks which may break already deployed contract systems that make fixed assumptions about gas costs. Also, whenever these is a reentrancy should be checked as well

Here is a code example from a contest with this issue, number of finders - 8

    function _fillQuote(SwapQuote calldata quote) internal returns (uint256 boughtAmount) {
        if (quote.sellToken == quote.buyToken) return 0; // No swap if the tokens are the same.
        if (quote.swapTarget != exchangeProxy) revert Errors.InvalidExchangeProxy();

        // Give `spender` an infinite allowance to spend this contract's `sellToken`.
        if (address(quote.sellToken) != ETH)
            ERC20(address(quote.sellToken)).safeApprove(quote.spender, type(uint256).max);

        uint256 sellAmount = address(quote.sellToken) == ETH
            ? address(this).balance
            : quote.sellToken.balanceOf(address(this));

        // Call the encoded swap function call on the contract at `swapTarget`,
        // passing along any ETH attached to this function call to cover protocol fees.
        (bool success, bytes memory res) = quote.swapTarget.call{ value: msg.value }(quote.swapCallData);
        // if (!success) revert(_getRevertMsg(res));
        if (!success) revert Errors.ZeroExSwapFailed(res);

        // We assume the Periphery does not hold tokens so boughtAmount is always it's balance
        boughtAmount = address(quote.buyToken) == ETH ? address(this).balance : quote.buyToken.balanceOf(address(this));
        sellAmount =
            sellAmount -
            (address(quote.sellToken) == ETH ? address(this).balance : quote.sellToken.balanceOf(address(this)));
        if (boughtAmount == 0 || sellAmount == 0) revert Errors.ZeroSwapAmt();

        // Refund any unspent protocol fees (paid in ether) to the sender.
        uint256 refundAmt = address(this).balance;
        if (address(quote.buyToken) == ETH) refundAmt = refundAmt - boughtAmount;
        payable(msg.sender).transfer(refundAmt);
        emit BoughtTokens(address(quote.sellToken), address(quote.buyToken), sellAmount, boughtAmount);
    }

Other similar examples from Solodit: 1 2 3 4 5

First depositor issue

A classic issue with vaults. First depositor can deposit a single wei and then donate to the vault to greatly inflate the share ratio. Due to truncation when converting to shares this can be used to steal funds from later depositors.

Here is a code example from a contest with this issue, number of finders - 41

function addQuote(uint256 baseTokenAmount, uint256 fractionalTokenAmount) public view returns (uint256) {
    uint256 lpTokenSupply = lpToken.totalSupply();
    if (lpTokenSupply > 0) {
        // calculate amount of lp tokens as a fraction of existing reserves
        uint256 baseTokenShare = (baseTokenAmount * lpTokenSupply) / baseTokenReserves();
        uint256 fractionalTokenShare = (fractionalTokenAmount * lpTokenSupply) / fractionalTokenReserves();
        return Math.min(baseTokenShare, fractionalTokenShare);
    } else {
        // if there is no liquidity then init
        return Math.sqrt(baseTokenAmount * fractionalTokenAmount);
    }
}

An attacker can exploit using these steps

  1. Create and add 1 wei baseToken - 1 wei quoteToken to the pair. At this moment, attacker is minted 1 wei LP token because sqrt(1 * 1) = 1

  2. Transfer large amount of baseToken and quoteToken directly to the pair, such as 1e9 baseToken - 1e9 quoteToken. Since no new LP token is minted, 1 wei LP token worths 1e9 baseToken - 1e9 quoteToken.

  3. Normal users add liquidity to pool will receive 0 LP token if they add less than 1e9 token because of rounding division.

Recommended Mitigation Steps

  1. Uniswap V2 solved this problem by sending the first 1000 LP tokens to the zero address. The same can be done in this case i.e. when lpTokenSupply == 0, send the first min liquidity LP tokens to the zero address to enable share dilution.

  2. In add(), ensure the number of LP tokens to be minted is non-zero:

Other similar examples from Solodit: 1 2 3 4 5

Silent overflow

Whenever there is a downcast in the codebase there might be some problems. Solidity will just return result like this uint8(1000000) = 64 Protocols usually use openzeppelin SafeCast library where the same function looks like this

  function toUint8(uint256 value) internal pure returns (uint8) {
        if (value > type(uint8).max) {
            revert SafeCastOverflowedUintDowncast(8, value);
        }
        return uint8(value);
    }

Here is a code example from a contest with this issue, number of finders - 24

  function buy(uint256[] calldata tokenIds, uint256[] calldata tokenWeights, MerkleMultiProof calldata proof) 
        public
        payable
        returns (uint256 netInputAmount, uint256 feeAmount, uint256 protocolFeeAmount)
    {
        // ~~~ Checks ~~~ //

        // calculate the sum of weights of the NFTs to buy
        uint256 weightSum = sumWeightsAndValidateProof(tokenIds, tokenWeights, proof);

        // calculate the required net input amount and fee amount
        (netInputAmount, feeAmount, protocolFeeAmount) = buyQuote(weightSum);
        ...
        // update the virtual reserves
        virtualBaseTokenReserves += uint128(netInputAmount - feeAmount - protocolFeeAmount); 
        virtualNftReserves -= uint128(weightSum);
        ...

the underflow would cause the invariants of the protocol to be broken, causing it to behave in undefined ways, most likely allowing discount tokens.

Other similar examples from Solodit: 1 2 3

Did not Approve to zero first

Some ERC20 tokens like USDT require resetting the approval to 0 first before being able to reset it to another value.

https://github.com/d-xo/weird-erc20#approval-race-protections

Here is a code example from a contest with this issue, number of finders - 10

  function xTransfer(
    address _token,
    uint256 _amount,
    address _recipient,
    uint32 _destinationDomain,
    uint256 _slippage,
    uint256 _relayerFee
  ) internal {
    require(
      IERC20(_token).allowance(msg.sender, address(this)) >= _amount,
      "User must approve amount"
    );

    // User sends funds to this contract
    IERC20(_token).transferFrom(msg.sender, address(this), _amount);

    // This contract approves transfer to Connext
+   IERC20(_token).approve(address(connext), 0);
    IERC20(_token).approve(address(connext), _amount);

    IERC20(_token).approve(address(connext), _amount);

    IConnext(connext).xcall{value: (msg.value - _relayerFee)}(
      _destinationDomain, // _destination: Domain ID of the destination chain
      _recipient, // _to: address receiving the funds on the destination
      _token, // _asset: address of the token contract
      msg.sender, // _delegate: address that can revert or forceLocal on destination
      _amount, // _amount: amount of tokens to transfer
      _slippage, // _slippage: the maximum amount of slippage the user will accept in BPS (e.g. 30 = 0.3%)
      bytes("") // _callData: empty bytes because we're only sending funds
    );
  }

Other similar examples from Solodit: 1 2

Almost in all contests, this issue is being reported by many wardens. Whenever there is a request to Chainlink Oracle via latestRoundData there are supposed to be checks in a place like the docs say

Here is a code example from a contest with this issue, number of finders - 16

    function getPrice(address _token) external view override returns (uint256) {
        // remap token if possible
        address token = remappedTokens[_token];
        if (token == address(0)) token = _token;

        uint256 maxDelayTime = maxDelayTimes[token];
        if (maxDelayTime == 0) revert NO_MAX_DELAY(_token);

        // try to get token-USD price
        uint256 decimals = registry.decimals(token, USD);
        (, int256 answer, , uint256 updatedAt, ) = registry.latestRoundData(
            token,
            USD
        );
        if (updatedAt < block.timestamp - maxDelayTime)
            revert PRICE_OUTDATED(_token);

        return (answer.toUint256() * 1e18) / 10**decimals;
    }

Here is how it suppose to be implemented

function getPrice(address _token) external view override returns (uint256) {
        // remap token if possible
        address token = remappedTokens[_token];
        if (token == address(0)) token = _token;

        uint256 maxDelayTime = maxDelayTimes[token];
        if (maxDelayTime == 0) revert NO_MAX_DELAY(_token);

        // try to get token-USD price
        uint256 decimals = registry.decimals(token, USD);
        (uint80 roundID, int256 answer, uint256 timestamp, uint256 updatedAt, ) = registry.latestRoundData(
            token,
            USD
        );
        //Solution
        require(updatedAt >= roundID, "Stale price");
        require(timestamp != 0,"Round not complete");
        require(answer > 0,"Chainlink answer reporting 0");

        if (updatedAt < block.timestamp - maxDelayTime)
            revert PRICE_OUTDATED(_token);

        return (answer.toUint256() * 1e18) / 10**decimals;
    }

Other similar examples from Solodit: 1 2 3 4 5 6 7 8 9

The protocol doesn't work correctly with decimals other than the default

In the code, there can be different assumptions about the token's decimals in the code and the documentation. E.x. protocol states that it supports some tokens but using those tokens in the code will result in incorrect behavior.

Here is a code example from a contest with this issue, number of finders - 24

    function changeFeeQuote(uint256 inputAmount) public view returns (uint256 feeAmount, uint256 protocolFeeAmount) {
        // multiply the changeFee to get the fee per NFT (4 decimals of accuracy)
        uint256 exponent = baseToken == address(0) ? 18 - 4 : ERC20(baseToken).decimals() - 4; // underflow - revert
        uint256 feePerNft = changeFee * 10 ** exponent;

        feeAmount = inputAmount * feePerNft / 1e18;
        protocolFeeAmount = feeAmount * Factory(factory).protocolFeeRate() / 10_000;
    }

in case the baseToken is an ERC20, then the exponent is calculated as ERC20(baseToken).decimals() - 4. The main issue here is that if the token decimals are less than 4, then the subtraction will cause an underflow due to Solidity's default checked math, causing the whole transaction to be reverted.

Such tokens with low decimals exist, one major example is GUSD, the Gemini dollar, which has only two decimals. If any of these tokens is used as the base token of a pool, then any call to the change will be reverted, as the scaling of the charge fee will result in an underflow.

Other similar examples from Solodit: 1 2 3 4 5

About 0xVolodya

0xVolodya is an independent smart contract security researcher. Ranked #2 on the 60-day leaderboard and ranked #2 on the 90-day leaderboard at code4rena. Currently available for projects. dm me on Twitter 0xVolodya.