Platform: Code4rena
Start Date: 22/09/2022
Pot Size: $30,000 USDC
Total HM: 12
Participants: 133
Period: 3 days
Judge: 0xean
Total Solo HM: 2
Id: 165
League: ETH
Rank: 3/133
Findings: 5
Award: $2,123.84
🌟 Selected for report: 3
🚀 Solo Findings: 0
1941.0279 USDC - $1,941.03
sfrxETH.beforeWithdraw
first calls the beforeWithdraw
of xERC4626
, which decrements storedTotalAssets
by the given amount. If the timestamp is greater than the rewardsCycleEnd
, syncRewards
is called. However, the problem is that the assets have not been transfered out yet, meaning asset.balanceOf(address(this))
still has the old value. On the other hand, storedTotalAssets
was already updated. Therefore, the following calculation will be inflated by the amount for which the withdrawal was requested:
uint256 nextRewards = asset.balanceOf(address(this)) - storedTotalAssets_ - lastRewardAmount_;
This has severe consequences:
1.) During the following reward period, lastRewardAmount
is too high, which means that too much rewards are paid out too users who want to withdraw. A user could exploit this to steal the assets of other users.
2.) When syncRewards()
is called the next time, it is possible that the nextRewards
calculation underflows because lastRewardAmount > asset.balanceOf(address(this))
. This is very bad because syncRewards()
will be called in every withdrawal (after the rewardsCycleEnd
) and none of them will succeed because of the underflow. Depositing more also does not help here, it just increases asset.balanceOf(address(this))
and storedTotalAssets
by the same amount, which does not eliminate the underflow.
Note that this bug does not require a malicious user or a targeted attack to surface. It can (and probably will) happen in practice just by normal user interactions with the vault (which is for instance shown in the PoC).
Consider the following test:
function testTotalAssetsAfterWithdraw() public { uint128 deposit = 1 ether; uint128 withdraw = 1 ether; // Mint frxETH to this testing contract from nothing, for testing mintTo(address(this), deposit); // Generate some sfrxETH to this testing contract using frxETH frxETHtoken.approve(address(sfrxETHtoken), deposit); sfrxETHtoken.deposit(deposit, address(this)); require(sfrxETHtoken.totalAssets() == deposit); vm.warp(block.timestamp + 1000); // Withdraw frxETH (from sfrxETH) to this testing contract sfrxETHtoken.withdraw(withdraw, address(this), address(this)); vm.warp(block.timestamp + 1000); sfrxETHtoken.syncRewards(); require(sfrxETHtoken.totalAssets() == deposit - withdraw); }
This is a normal user interaction where a user deposits into the vault, and makes a withdrawal some time later. However, at this point the syncRewards()
within the beforeWithdraw
is executed. Because of that, the documented accounting mistake happens and the next call (in fact every call that will be done in the future) to syncRewards()
reverts with an underflow.
Call syncRewards()
before decrementing storedTotalAssets
, i.e.:
function beforeWithdraw(uint256 assets, uint256 shares) internal override { if (block.timestamp >= rewardsCycleEnd) { syncRewards(); } super.beforeWithdraw(assets, shares); // call xERC4626's beforeWithdraw AFTER }
Then, asset.balanceOf(address(this))
and storedTotalAssets
are still in sync within syncRewards()
.
#0 - FortisFortuna
2022-09-26T15:57:58Z
Does this only occur if all users try to withdraw at the exact same time? If so, this is a known bug by us and the risk would be low in a real-life deployment scenario. We can also let the users know about the ramping of the rewards.
#1 - OpenCoreCH
2022-09-27T18:49:14Z
I do not think that this is a duplicate of #311. #311 (and the other issues that are linked there) describe a recoverable issue where the withdrawal for the last user fails (which was listed as a known issue of xERC4626) until the cycle ends.
The issue here that is described here and demonstrated in the PoC is a non-recoverable sfrxETH-specific issue (because sfrxETH potentially calls syncRewards()
in the beforeWithdraw
function) where withdrawals even fail after the cycle has ended. It also does not require all users to withdraw at the same time.
#2 - FortisFortuna
2022-09-27T20:29:32Z
@OpenCoreCH What about https://github.com/code-423n4/2022-09-frax-findings/issues/24 ?
#3 - OpenCoreCH
2022-09-27T20:32:09Z
@FortisFortuna Good catch did not see that, yes #24 addresses the same issue
#4 - FortisFortuna
2022-09-27T20:33:04Z
@OpenCoreCH I will mark yours as primary because it is better documented.
#5 - corddry
2022-09-29T21:49:19Z
Here's the proposed fix, which instead moves the syncRewards call to a modifier, so that it actually occurs before the withdraw instead of in beforeWithdraw. It also adds it to the other 4626 withdraw/redeem functions. Would appreciate feedback if you have any
https://github.com/FraxFinance/frxETH-public/pull/2/commits/1ec457c7f5faed618971fb29b9bcc6d54453b093
#6 - OpenCoreCH
2022-09-29T21:59:28Z
The modifier is currently missing for mint
and redeem
, is that on purpose? Otherwise, it looks good to me
#7 - corddry
2022-09-29T22:16:39Z
Whoops-- nice catch, added here https://github.com/FraxFinance/frxETH-public/commit/996d528b46d1b2a0ac2e5b8f6d2138ccab8e03f5
No elements are ever deleted in minters_array
within ERC20PermitPermissionedMint
, they are only set to address(0)
. When there are a lot of minters, it is therefore possible that this array will grow too large at one point and all iterations over it will run out of gas. The consequences of this would be quite severe. It would no longer be possible for governance to call removeMinter
, as this function iterates over the whole array and therefore would always run out of gas. A malicious minter could therefore no longer be removed.
There were a lot of fluctuations for the minters during the first 5 years of frxETH because frxETHMinter
was redeployed a lot and the design was changed such that there are multiple minters with different addresses. In total, 600 minters were added, but 550 were deleted again. Because of this, minters_array
contains 600 entries, but 550 of them are address(0)
. A new minter contract is added, but it turns out that the contract has a critical vulnerability, allowing anyone to mint tokens for free. Therefore, governance decides to remove the minter in an emergency proposal. Because of the position of this minter, the call always returns out of gas and removal does not succeed.
In my opinion, there is no reason to keep the indices within the array the same. Therefore, you could also completely delete an element (swap it with the last array element, pop
the array) instead of setting the addresses to zero.
#0 - FortisFortuna
2022-09-25T23:05:58Z
Technically correct, but in practice, the number of minters will always remain low. If it becomes an issue, we can designate one minter as a "pre-minter" that has a batch of tokens minted to it beforehand, then auxiliary contracts can connect to that instead of ERC20PermitPermissionedMint.sol instead.
#1 - 0xean
2022-10-13T21:41:54Z
dupe of #12
39.1574 USDC - $39.16
frxETHMinter.depositEther
always iterates over all deposits that are possible with the current balance ((address(this).balance - currentWithheldETH) / DEPOSIT_SIZE
). However, when a lot of ETH was deposited into the contract / it was not called in a long time, this loop can reach the gas limit. When this happens, no more calls to depositEther
are possible, as it will always run out of gas.
Of course, the probability that such a situation arises depends on the price of ETH. For >1,000 USD it would need require someone to deposit a large amount of money (which can also happen, there are whales with thousands of ETH, so if one of them would decide to use frxETH, the problem can arise). For lower prices, it can happen even for small (in dollar terms) deposits. And in general, the correct functionality of a protocol should not depend on the price of ETH.
Jerome Powell continues to rise interest rates, he just announced the next rate hike to 450%. The crypto market crashes, ETH is at 1 USD. Bob buys 100,000 ETH for 100,000 USD and deposits them into frxETHMinter
. Because of this deposit, numDeposit
within depositEther
is equal to 3125. Therefore, every call to the function runs out of gas and it is not possible to deposit this ETH into the deposit contract.
It should be possible to specify an upper limit for the number of deposits such that progress is possible, even when a lot of ETH was deposited into the contract.
#0 - FortisFortuna
2022-09-26T16:31:57Z
Adding a maxLoops parameter or similar can help mitigate this for sure.
#1 - 0xean
2022-10-11T21:29:22Z
Warden(s) fail to demonstrate how this leads to a loss of funds which would be required for High Severity. This does however lead directly to emergency failover's having to be called to remove the now stuck ETH, and ultimately impairs the functionality and availability of the protocol, so M severity is appropriate.
🌟 Selected for report: Lambda
Also found by: 0x1f8b, 0x5rings, 0xSky, 0xSmartContract, 8olidity, Chom, CodingNameKiki, IllIllI, Ruhum, Sm4rty, brgltd, hansfriese, m9800, magu, pashov, pedroais, peritoflores, prasantgupta52, rokinot, seyni
12.4859 USDC - $12.49
There is a function recoverERC20
to rescue any ERC20 tokens that were accidentally sent to the contract. However, there are tokens that do not return a value on success, which will cause the call to revert, even when the transfer would have been succesful. This means that those tokens will be stuck forever and not recoverable.
Someone accidentally transfers USDT, one of the most commonly used ERC20 tokens, to the contract. Because USDT's transfer does not return a boolean, it will not be possible to recover those tokens and they will be stuck forever.
Use OpenZeppelin's safeTransfer
.
#0 - FortisFortuna
2022-09-25T21:30:25Z
Not really medium risk. Technically you could use safeTransfer, but if someone were to accidentally send something to this contract, it would most likely be either ETH, FRAX, frxETH, or sfrxETH, all of which are transfer compliant.
#1 - 0xean
2022-10-11T21:46:55Z
I think this qualifies as a M risk. Sponsor has created functionality to recover erc20 tokens. Wardens have shown a path to which this functionality does not work correctly.
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
🌟 Selected for report: rotcivegaf
Also found by: 0x040, 0x1f8b, 0x4non, 0xNazgul, 0xSmartContract, 0xf15ers, 8olidity, Aymen0909, B2, Bahurum, Bnke0x0, Ch_301, CodingNameKiki, Deivitto, Diana, Funen, IllIllI, JC, JLevick, KIntern_NA, Lambda, OptimismSec, PaludoX0, RockingMiles, Rolezn, Sm4rty, Soosh, Tagir2003, Tointer, TomJ, Triangle, Trust, V_B, Waze, Yiko, __141345__, a12jmx, ajtra, asutorufos, ayeslick, aysha, bbuddha, bharg4v, bobirichman, brgltd, bytera, c3phas, cryptostellar5, cryptphi, csanuragjain, datapunk, delfin454000, durianSausage, exd0tpy, gogo, got_targ, jag, joestakey, karanctf, ladboy233, leosathya, lukris02, mics, millersplanet, natzuu, neko_nyaa, obront, oyc_109, parashar, peritoflores, rbserver, ret2basic, rokinot, ronnyx2017, rvierdiiev, sach1r0, seyni, sikorico, slowmoses, tnevler, yasir, yongskiws
28.0141 USDC - $28.01
xERC4626
casts timestamps to a uint32
. Because of that, frxETH will only work until February 2106.