Lucene search

K
code423n4Code4renaCODE423N4:2023-10-WILDCAT-FINDINGS-ISSUES-653
HistoryOct 26, 2023 - 12:00 a.m.

Incompatibility with Rebase tokens

2023-10-2600:00:00
Code4rena
github.com
9
rebase tokens
wildcat protocol
vulnerability
user withdrawals
balance accounting
price exchange rate

7.1 High

AI Score

Confidence

High

Lines of code

Vulnerability details

Impact

Borrowers can choose whatever token they want to be the underlying token for a market. The problem comes when those tokens are Rebasing tokens such as Ampleforth. The balances of those tokens are changed (rebased) by a certain algorithm depending on the purpose of the Rebasing token and the market conditions. When lenders initially deposit Rebase tokens in the Wildcat protocol, the amount of that deposit will be stored within the protocol. Based on the internal rebasing of Wildcat the scaledAmound will change based on scaledFactor which increases in order to add APR to the lender deposits, but that doesn’t account for rebasing in the underlying token. At a later stage this may result in wrong user balance accounting. In the case of Ampleforth if the price_exchange_rate of AMPL > 1 2019 USD the market is indicating there is more demand than supply. The Ampleforth protocol automatically and proportionally increases the quantity of tokens in user wallets. This will result in users being able to withdraw more tokens that they are supposed to and eventually the last user to withdraw won’t be able to, as there won’t be enough tokens left in the Wildcat protocol. Similar scenario is presented in the POC

Proof of Concept

Add the following test to WildcatMarketController.t.sol

  function test_rebaseToken() public {
    setUpContracts();

    /// INFO: Mint Rebase token to Alice and Bob
    vm.startPrank(rebaseTokenOwner);
    rebaseToken.transfer(alice, 10e9);
    rebaseToken.transfer(bob, 10e9);
    console.log("");
    console.log("Mint Rebase token to Alice and Bob");
    console.log("Alice rebase token balance: ", rebaseToken.balanceOf(alice));
    console.log("Alice rebase token balance: ", rebaseToken.balanceOf(bob));
    console.log("");
    vm.stopPrank();

    /// INFO: Approve Lenders
    vm.startPrank(borrower);
    address[] memory lenders = new address[](2);
    lenders[0] = alice;
    lenders[1] = bob;
    wildcatMarketController.authorizeLenders(lenders);
    address[] memory markets = new address[](1);
    markets[0] = marketAddress;
    wildcatMarketController.updateLenderAuthorization(alice, markets);
    wildcatMarketController.updateLenderAuthorization(bob, markets);
    vm.stopPrank();

    vm.startPrank(alice);
    rebaseToken.approve(marketAddress, 10e9);
    wildcatMarket.depositUpTo(10e9);
    vm.stopPrank();

    vm.startPrank(bob);
    rebaseToken.approve(marketAddress, 10e9);
    wildcatMarket.depositUpTo(10e9);
    vm.stopPrank();

    /// INFO: Rebase the token
    vm.startPrank(rebaseTokenOwner);
    rebaseToken.rebase(-49_000_000e8);
    vm.stopPrank();

    vm.startPrank(alice);
    wildcatMarket.queueWithdrawal(10e9);
    uint32 expiry = wildcatMarket.currentState().pendingWithdrawalExpiry;
    skip(3601);
    wildcatMarket.executeWithdrawal(alice, expiry);
    vm.stopPrank();

    vm.startPrank(bob);
    wildcatMarket.queueWithdrawal(10e9);
    uint32 expiryNew = wildcatMarket.currentState().pendingWithdrawalExpiry;
    skip(3601);
    wildcatMarket.executeWithdrawal(bob, expiryNew);
    console.log("");
    console.log("Balance of Bob, Alice and Market after Bob and Alice withdrew their rebase token");
    console.log("Alice rebase token balance after withdraw: ", rebaseToken.balanceOf(alice));
    console.log("Bob rebase token balance after withdraw: ", rebaseToken.balanceOf(bob));
    console.log("Market rebase token balance after rebase: ", rebaseToken.balanceOf(marketAddress));
    vm.stopPrank();
  }



Logs:
  Rebase token got deployed:  0xc7183455a4C133Ae270771860664b6B7ec320bB1

  Mint Rebase token to Alice and Bob
  Alice rebase token balance:  10000000000
  Alice rebase token balance:  10000000000


  Balance of Bob, Alice and Market after Bob and Alice withdrew their rebase token
  Alice rebase token balance after withdraw:  10000000000
  Bob rebase token balance after withdraw:  8040000000
  Market rebase token balance after rebase:  0

When bob quarries for withdraw the 10e9 rebase tokens he deposited and later withdraws them, he receives less than he is supposed to, and Alice effectively stole portion of Bob’s deposit due to wrong accounting. In the above test both Alice and Bob should have been allowed to withdraw a maximum of 9020000000 tokens - usually minus the protocol fees, which in the POC are set to 0.

In order to run the test you have to import the following to WildcatMarketController.t.sol

import {WildcatArchController} from 'src/WildcatArchController.sol';
import {WildcatMarketControllerFactory} from 'src/WildcatMarketControllerFactory.sol';
import {
  MinimumDelinquencyGracePeriod, MaximumDelinquencyGracePeriod, 
  MinimumReserveRatioBips, MaximumReserveRatioBips, MinimumDelinquencyFeeBips, 
  MaximumDelinquencyFeeBips, MinimumWithdrawalBatchDuration, MaximumWithdrawalBatchDuration, 
  MinimumAnnualInterestBips, MaximumAnnualInterestBips } from './shared/TestConstants.sol';
import {WildcatMarketController} from 'src/WildcatMarketController.sol';
import {WildcatSanctionsSentinel, IChainalysisSanctionsList, IWildcatArchController } from 'src/WildcatSanctionsSentinel.sol';
import {WildcatSanctionsEscrow, IWildcatSanctionsEscrow } from 'src/WildcatSanctionsEscrow.sol';
import {SanctionsList} from 'src/libraries/Chainalysis.sol';
import {MockChainalysis, deployMockChainalysis } from './helpers/MockChainalysis.sol';
import {RebaseToken} from './helpers/RebaseToken.sol';

Add the following variables to WildcatMarketController.t.sol

  address public alice = address(123);
  address public bob = address(124);
  address public hacker = address(125);
  address public borrower = address(126);
  address public archOwner = address(127);
  address public jon = address(128);
  address public rebaseTokenOwner = address(129);
  WildcatArchController public wildcatArchController;
  WildcatMarketControllerFactory public wildcatMarketControllerFactory;
  MarketParameterConstraints public constraintsWMC;

  WildcatMarket public wildcatMarket;
  address public marketAddress;

  WildcatMarketController public wildcatMarketController;
  address public marketControllerAddress;
  WildcatSanctionsSentinel internal sentinel;
  RebaseToken public rebaseToken;

Add the following functions to WildcatMarketController.t.sol

function _resetConstraints() internal {
    constraintsWMC = MarketParameterConstraints({
      minimumDelinquencyGracePeriod: MinimumDelinquencyGracePeriod,
      maximumDelinquencyGracePeriod: MaximumDelinquencyGracePeriod,
      minimumReserveRatioBips: MinimumReserveRatioBips,
      maximumReserveRatioBips: MaximumReserveRatioBips,
      minimumDelinquencyFeeBips: MinimumDelinquencyFeeBips,
      maximumDelinquencyFeeBips: MaximumDelinquencyFeeBips,
      minimumWithdrawalBatchDuration: MinimumWithdrawalBatchDuration,
      maximumWithdrawalBatchDuration: MaximumWithdrawalBatchDuration,
      minimumAnnualInterestBips: MinimumAnnualInterestBips,
      maximumAnnualInterestBips: MaximumAnnualInterestBips
    });
  }

  function setUpContracts() public {
    /// INFO: Deploy MockErc20 token and mint tokens
    rebaseToken = new RebaseToken();
    console.log("Rebase token got deployed: ", address(rebaseToken));
    vm.startPrank(rebaseTokenOwner);
    rebaseToken.initialize(rebaseTokenOwner);
    vm.stopPrank();

    vm.startPrank(archOwner);
    /// INFO: Deploy & set up ArchController
    wildcatArchController = new WildcatArchController();
    wildcatArchController.registerBorrower(borrower);

    /// INFO: Set up sentinel
    sentinel = new WildcatSanctionsSentinel(address(wildcatArchController), address(SanctionsList));

    /// INFO: Deploy Factory
    _resetConstraints();
    wildcatMarketControllerFactory = new WildcatMarketControllerFactory(
      address(wildcatArchController),
      address(sentinel),
      constraintsWMC
    );
    wildcatArchController.registerControllerFactory(address(wildcatMarketControllerFactory));
    vm.stopPrank();

    /// INFO: Deploy MarketController and Market
    vm.startPrank(borrower);
    uint128 maxTotalSupply = 100_000e18;
    uint16 annualInterestBips = 1000; // 10%
    uint16 delinquencyFeeBips = 1000; // 10%
    uint32 withdrawalBatchDuration = 3600; // 1 hour
    uint16 reserveRatioBips = 1000; // 10%
    uint32 delinquencyGracePeriod = 3600; // 1 hour

    (marketControllerAddress, marketAddress) = wildcatMarketControllerFactory.deployControllerAndMarket(
      "WildcatTokenR",
      "WCTKNR",
      address(rebaseToken),
      maxTotalSupply,
      annualInterestBips,
      delinquencyFeeBips,
      withdrawalBatchDuration,
      reserveRatioBips,
      delinquencyGracePeriod
    );
    wildcatMarket = WildcatMarket(marketAddress);
    wildcatMarketController = WildcatMarketController(marketControllerAddress);
    vm.stopPrank();
  }

Add RebaseToken.sol to test/helpers

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "./SafeMathInt.sol";
import "./SafeMath.sol";

contract RebaseToken {
    using SafeMath for uint256;
    using SafeMathInt for int256;
    uint256 public constant DECIMALS = 9;
    uint256 public constant MAX_UINT256 = type(uint256).max;
    uint256 public constant INITIAL_FRAGMENTS_SUPPLY = 50 * 10**6 * 10**DECIMALS;
    string public _name;
    string public _symbol;
    uint8 public _decimals;
    // TOTAL_GONS is a multiple of INITIAL_FRAGMENTS_SUPPLY so that _gonsPerFragment is an integer.
    // Use the highest value that fits in a uint256 for max granularity.
    uint256 public constant TOTAL_GONS = MAX_UINT256 - (MAX_UINT256 % INITIAL_FRAGMENTS_SUPPLY);

    // MAX_SUPPLY = maximum integer < (sqrt(4*TOTAL_GONS + 1) - 1) / 2
    uint256 public constant MAX_SUPPLY = type(uint128).max; // (2^128) - 1

    uint256 public _totalSupply;
    uint256 public _gonsPerFragment;
    mapping(address => uint256) public _gonBalances;

    // This is denominated in Fragments, because the gons-fragments conversion might change before
    // it's fully paid.
    mapping(address => mapping(address => uint256)) public _allowedFragments;

    function initialize(address owner_) public {
        _name = "Ampleforth";
        _symbol = "AMPL";
        _decimals = uint8(DECIMALS);
        _totalSupply = INITIAL_FRAGMENTS_SUPPLY;
        _gonBalances[owner_] = TOTAL_GONS;
        _gonsPerFragment = TOTAL_GONS.div(_totalSupply);
    }

    function rebase(int256 supplyDelta) external returns (uint256)
    {
        if (supplyDelta == 0) {
            return _totalSupply;
        }

        if (supplyDelta < 0) {
            _totalSupply = _totalSupply.sub(uint256(supplyDelta.abs()));
        } else {
            _totalSupply = _totalSupply.add(uint256(supplyDelta));
        }

        if (_totalSupply > MAX_SUPPLY) {
            _totalSupply = MAX_SUPPLY;
        }

        _gonsPerFragment = TOTAL_GONS.div(_totalSupply);
        return _totalSupply;
    }

    /**
     * @return The total number of fragments.
     */
    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }

    function decimals() external view returns (uint256) {
        return _decimals;
    }

    function name() external view returns (string memory) {
        return _name;
    }

    function symbol() external view returns (string memory) {
        return _symbol;
    }
     /**
     * @param who The address to query.
     * @return The balance of the specified address.
     */
    function balanceOf(address who) external view returns (uint256) {
        return _gonBalances[who].div(_gonsPerFragment);
    }

    /**
     * @param who The address to query.
     * @return The gon balance of the specified address.
     */
    function scaledBalanceOf(address who) external view returns (uint256) {
        return _gonBalances[who];
    }

    /**
     * @return the total number of gons.
     */
    function scaledTotalSupply() external pure returns (uint256) {
        return TOTAL_GONS;
    }

    function transfer(address to, uint256 value) external returns (bool) {
        uint256 gonValue = value.mul(_gonsPerFragment);
        _gonBalances[msg.sender] = _gonBalances[msg.sender].sub(gonValue);
        _gonBalances[to] = _gonBalances[to].add(gonValue);
        return true;
    }

    function allowance(address owner_, address spender) external view returns (uint256) {
        return _allowedFragments[owner_][spender];
    }

    function transferFrom(address from, address to, uint256 value) external returns (bool) {
        _allowedFragments[from][msg.sender] = _allowedFragments[from][msg.sender].sub(value);
        uint256 gonValue = value.mul(_gonsPerFragment);
        _gonBalances[from] = _gonBalances[from].sub(gonValue);
        _gonBalances[to] = _gonBalances[to].add(gonValue);
        return true;
    }

    function approve(address spender, uint256 value) external returns (bool) {
        _allowedFragments[msg.sender][spender] = value;
        return true;
    }
}

Add SafeMath.sol to test/helpers

pragma solidity 0.8.20;

/**
 * @title SafeMath
 * @dev Math operations with safety checks that revert on error
 */
library SafeMath {
    /**
     * @dev Multiplies two numbers, reverts on overflow.
     */
    function mul(uint256 a, uint256 b) internal pure returns (uint256) {
        // Gas optimization: this is cheaper than requiring 'a' not being zero, but the
        // benefit is lost if 'b' is also tested.
        // See: https://github.com/OpenZeppelin/openzeppelin-solidity/pull/522
        if (a == 0) {
            return 0;
        }

        uint256 c = a * b;
        require(c / a == b);

        return c;
    }

    /**
     * @dev Integer division of two numbers truncating the quotient, reverts on division by zero.
     */
    function div(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b > 0); // Solidity only automatically asserts when dividing by 0
        uint256 c = a / b;
        // assert(a == b * c + a % b); // There is no case in which this doesn't hold

        return c;
    }

    /**
     * @dev Subtracts two numbers, reverts on overflow (i.e. if subtrahend is greater than minuend).
     */
    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b <= a);
        uint256 c = a - b;

        return c;
    }

    /**
     * @dev Adds two numbers, reverts on overflow.
     */
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a);

        return c;
    }

    /**
     * @dev Divides two numbers and returns the remainder (unsigned integer modulo),
     * reverts when dividing by zero.
     */
    function mod(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b != 0);
        return a % b;
    }
}

Add SafeMathInt.sol to test/helpers

/*
MIT License

Copyright (c) 2018 requestnetwork
Copyright (c) 2018 Fragments, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.20;

/**
 * @title SafeMathInt
 * @dev Math operations for int256 with overflow safety checks.
 */
library SafeMathInt {
    int256 private constant MIN_INT256 = int256(1) << 255;
    int256 private constant MAX_INT256 = ~(int256(1) << 255);

    /**
     * @dev Multiplies two int256 variables and fails on overflow.
     */
    function mul(int256 a, int256 b) internal pure returns (int256) {
        int256 c = a * b;

        // Detect overflow when multiplying MIN_INT256 with -1
        require(c != MIN_INT256 || (a & MIN_INT256) != (b & MIN_INT256));
        require((b == 0) || (c / b == a));
        return c;
    }

    /**
     * @dev Division of two int256 variables and fails on overflow.
     */
    function div(int256 a, int256 b) internal pure returns (int256) {
        // Prevent overflow when dividing MIN_INT256 by -1
        require(b != -1 || a != MIN_INT256);

        // Solidity already throws when dividing by 0.
        return a / b;
    }

    /**
     * @dev Subtracts two int256 variables and fails on overflow.
     */
    function sub(int256 a, int256 b) internal pure returns (int256) {
        int256 c = a - b;
        require((b >= 0 && c <= a) || (b < 0 && c > a));
        return c;
    }

    /**
     * @dev Adds two int256 variables and fails on overflow.
     */
    function add(int256 a, int256 b) internal pure returns (int256) {
        int256 c = a + b;
        require((b >= 0 && c >= a) || (b < 0 && c < a));
        return c;
    }

    /**
     * @dev Converts to absolute value, and fails on overflow.
     */
    function abs(int256 a) internal pure returns (int256) {
        require(a != MIN_INT256);
        return a < 0 ? -a : a;
    }

    /**
     * @dev Computes 2^exp with limited precision where -100 <= exp <= 100 * one
     * @param one 1.0 represented in the same fixed point number format as exp
     * @param exp The power to raise 2 to -100 <= exp <= 100 * one
     * @return 2^exp represented with same number of decimals after the point as one
     */
    function twoPower(int256 exp, int256 one) internal pure returns (int256) {
        bool reciprocal = false;
        if (exp < 0) {
            reciprocal = true;
            exp = abs(exp);
        }

        // Precomputed values for 2^(1/2^i) in 18 decimals fixed point numbers
        int256[5] memory ks = [
            int256(1414213562373095049),
            1189207115002721067,
            1090507732665257659,
            1044273782427413840,
            1021897148654116678
        ];
        int256 whole = div(exp, one);
        require(whole <= 100);
        int256 result = mul(int256(uint256(1) << uint256(whole)), one);
        int256 remaining = sub(exp, mul(whole, one));

        int256 current = div(one, 2);
        for (uint256 i = 0; i < 5; i++) {
            if (remaining >= current) {
                remaining = sub(remaining, current);
                result = div(mul(result, ks[i]), 10**18); // 10**18 to match hardcoded ks values
            }
            current = div(current, 2);
        }
        if (reciprocal) {
            result = div(mul(one, one), result);
        }
        return result;
    }
}

To run the test use forge test -vvv --mt test_rebaseToken

Tools Used

Manual review & Foundry

Recommended Mitigation Steps

Implement a whitelist for allowed tokens.

Assessed type

Other


The text was updated successfully, but these errors were encountered:

All reactions

7.1 High

AI Score

Confidence

High