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: 13/133
Findings: 4
Award: $589.18
π Selected for report: 0
π Solo Findings: 0
π Selected for report: __141345__
Also found by: Bahurum, Ch_301, Chom, Respx, Trust, datapunk, ronnyx2017
128.9427 USDC - $128.94
When protocols hand out rewards to staked tokens, they must be careful to do so without leaving a large MEV opportunity, otherwise a bot could sandwich the increase of token value by minting shares and immediately redeeming them for a larger underlying amount. By doing this with a large sum of funds, attackers can enjoy a large % of the rewards while minimizing the profits of stakers considerably. xERC4626 addresses this issue and is designed to release staking rewards linearly during some defined cycle length. totalAssets() returns the previous matured assets added to the unlockedRewards from the current cycle:
uint256 unlockedRewards = (lastRewardAmount_ * (block.timestamp - lastSync_)) / (rewardsCycleEnd_ - lastSync_); // reward * cycle elapsed time / cycle total time return storedTotalAssets_ + unlockedRewards;
syncRewards() should be triggered externally and updates the cycle parameters:
uint32 end = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength; β¦ lastSync = timestamp; rewardsCycleEnd = end;
syncRewards() rounds timestamp to the next multiple of rewardsCycleLength and marks that timestamp as end of unlocked rewards. The problem is that timestamp could already be very close to the next rewardsCycleLength multiple, and therefore the release could be extremely sharp (Up to the entire reward amount released in a single block). syncRewards() is permissionless by design, so attacker can wait for timestamp to be close to the target and then call syncRewards(). This will only work if no one else called syncRewards() to update the current cycle.
Attacker can theoretically consume up to the whole reward amount in a single block.
Assume rewardCycleLength = 1000 seconds, current assets = 200 frxETH, current shares = 150 sfrxETH, reward = 20 frxETH, next block timestamp = 19999 1. Attacker calls syncRewards() -> end = ((19999 + 1000) / 1000 ) * 1000 = 20000 2. Attacker mints 150 sfrxETH shares , which costs 200 frxETH 3. Attacker waits 1 block 4. Attacker redeems 150 sfrxETH shares, for half of the total assets = (200 + 200 + 20) / 2 = 210 5. Attacker profits 10 ETH (50% of the reward) from a single block. Staking APY is cut in half. The more attacker deposits, the larger the % of reward consumed.
Manual audit
syncRewards() should add a check: timestamp % rewardsCycleLength <= rewardsCycleLength * SYNC_THRESHOLD / SYNC_THRESHOLD_FACTOR
, with a reasonable threshold. Since syncRewards() is used in beforeWithdraw, it's best to not revert but instead return a false value if we have missed the current cycle.
#0 - FortisFortuna
2022-09-26T17:16:21Z
From @denett syncRewards should be called by us at the beginning of each period, or we need to automatically call it before deposits/withdrawals.
#1 - FortisFortuna
2022-09-26T17:29:48Z
39.1574 USDC - $39.16
depositEther() is used to distribute ETH funds to available validators. If there are more deposits to hand out than there are available validators, the function will revert() and no deposit will be issued. This has unintended consequences. Attacker can launch a grief attack, continually depositing more 32 ETH batches than the number of available validators and selling off the frxETH that was minted. It will freeze the validator matching system. Even if new validators are spun up, attacker can frontrun the depositEther() function and always make sure there is more ETH to distribute than validators. A side effect is that sfrxETH APY will decrease, because frxETH supply increased, but the supply mismatches the actual ETH used to farm validation rewards.
Attacker can disrupt matching of validators and harm the APY of sfrxETH.
Manual audit
Wrap the getNextValidator() call with a try/catch block, and if there are no more available validators, return from the call successfully. This will improve the APY of the system regardless of security considerations.
#0 - FortisFortuna
2022-09-26T01:45:05Z
We plan to keep an eye on the number free validators and have a decent sized buffer of them. Also plan to keep them valid and non-duplicated, same applies to their ordering.
#1 - trust1995
2022-10-01T08:24:28Z
Fortis's comment does not relate at all to the problem described in the report in my opinion. The issue is that it is always possible to brick the deposit function by sending more ETH multiples than there are nodes.
#2 - 0xean
2022-10-12T14:48:48Z
dupe of #17 / #224
π Selected for report: cccz
Also found by: Trust, rotcivegaf, wagmi
393.0581 USDC - $393.06
sfrxETH supports minting and depositing with a signature, which spares the user from approving the sfrxETH vault contract to use their frxETH. The permit function used in mintWithSignature(), verifies the supplied signature matches the fields: 1. Owner - msg.sender 2. Spender - this 3. Amount - either MAX_UINT or previewMint(shares) 4. Deadline - deadline 5. Owner nonce If user does not pass approveMax, which is the security-conscious option, the amount passed to permit is previewMint(shares). However, previewMint(shares) may well return a different amount from the amount used by the user to calculated the signature. previewMint() is supposed to calculate the amount of assets required to be passed to mint shares of the vault, rounded up. If, from the time user created the signature, to the time the signature is verified in mintWithSignature(), the amount changes, the signature check will fail and the TX would revert. The two amounts will likely be different if the protocol is in a unlocking rewards cycle. They could also different if a mint or redeem occurred and due to rounding, the result has changed.
User's call to mintWithSignature could fail through no fault of their own.
1. User would like to mint 100 shares, which are currently worth 110 assets. They sign a permit for 110 assets. 2. Permit signature is validated on the next block. Additional rewards have accrued, and now 100 shares are worth 110.1 assets. The signature fails (previewMint rounded up).
Manual audit
approveMax variable should be a uint256 approveAmount, which is determined only during signature creation on the user's side. It should take into account the current reward cycle and future-proof the transaction with a higher than currently required amount.
#0 - FortisFortuna
2022-09-25T23:38:47Z
Technically correct, though in practice, we will allow user-defined slippage on the UI
#1 - joestakey
2022-09-26T15:31:16Z
Duplicate of #35
#2 - FortisFortuna
2022-09-27T02:08:52Z
We need to be able to pass a min_out
. Mostly an inconvenience to the user rather than an exploit (they are forced to max approve)
Alternatively, we can just remove mintWithSignature
and keep depositWithSignature
and put the min_out
there
#3 - 0xean
2022-10-12T14:11:28Z
closing as dupe of #35
π 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.0172 USDC - $28.02
getNextValidator() is used to get the next available validator to be assigned in depositEther(). The validators array is used as a stack, where addValidator adds a validator to the end of the array and getNextValidator() pops it from there. The consequence is that validators are matched out of order, which makes the system unfair in terms of the time validator has to wait before being assigned. In a possible scenario, the first validators will never be matched because additional validators will cut it in line. Also, because of another bug where if number of deposits available is greater than available validators, no validator is matched, there is less of a chance the early validators would be matched. This is because for them to be matched, there needs to be exactly enough 32 ETH multiples to pop all validators, without overrunning the array. Note that in a decentralized system, users must have the ability to join as legitimate validators, so the matching order must accommodate for both user supplied validators and in-house validators.
Validators may spend an arbitrary amount of time waiting to be matched.
Manual audit.
getNextValidator should pop a validator from the beginning of the array. This could be done efficiently using a Yul snippet, which decrements the array length and stores it in the slot above.
#0 - FortisFortuna
2022-09-26T00:02:08Z
We plan to keep an eye on the number free validators and have a decent sized buffer of them. Also plan to keep them valid and non-duplicated, same applies to their ordering.
#1 - joestakey
2022-09-26T15:43:02Z
In a possible scenario, the first validators will never be matched because additional validators will cut it in line
This would effectively grief these validators. The Readme does mention that the team, or the manager(s) of the OperatorRegistry.sol contract will remove/replace the offending invalid validators
, but here the issue describes what could happen if the team were to act maliciously.
#2 - trust1995
2022-10-01T08:18:23Z
Reopening as @joestakey 's comment seems to be valid.
#3 - FortisFortuna
2022-10-02T02:11:07Z
We are aware of the validator ordering issue, which is why we have some array manipulation functions in there.
#4 - 0xean
2022-10-12T14:51:45Z
Given the admin has complete control of the validator list - I am going to downgrade this to QA. While the FIFO process may not be ideal, I don't see how this qualifies as M.