Frax Ether Liquid Staking contest - Trust's results

A liquid ETH staking derivative designed to uniquely leverage the Frax Finance ecosystem.

General Information

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

Frax Finance

Findings Distribution

Researcher Performance

Rank: 13/133

Findings: 4

Award: $589.18

🌟 Selected for report: 0

πŸš€ Solo Findings: 0

Findings Information

🌟 Selected for report: __141345__

Also found by: Bahurum, Ch_301, Chom, Respx, Trust, datapunk, ronnyx2017

Labels

bug
duplicate
2 (Med Risk)
3 (High Risk)
disagree with severity
sponsor confirmed
syncRewards sniping

Awards

128.9427 USDC - $128.94

External Links

Lines of code

LOC: https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/sfrxETH.sol#L26

Vulnerability details

Description

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.

Impact

Attacker can theoretically consume up to the whole reward amount in a single block.

Proof of Concept

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.

Tools Used

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

Findings Information

🌟 Selected for report: Lambda

Also found by: 0x52, Bahurum, Bnke0x0, KIntern_NA, Respx, Soosh, TomJ, Trust, V_B, lukris02, rbserver, rotcivegaf, yixxas

Labels

bug
duplicate
2 (Med Risk)
sponsor confirmed
depositEther OOG

Awards

39.1574 USDC - $39.16

External Links

Lines of code

https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L129-L136

Vulnerability details

Description

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.

Impact

Attacker can disrupt matching of validators and harm the APY of sfrxETH.

Tools Used

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

Findings Information

🌟 Selected for report: cccz

Also found by: Trust, rotcivegaf, wagmi

Labels

bug
duplicate
2 (Med Risk)
primary issue
sponsor acknowledged
mintWithSignature slippage

Awards

393.0581 USDC - $393.06

External Links

Lines of code

https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/sfrxETH.sol#L84

Vulnerability details

Description

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.

Impact

User's call to mintWithSignature could fail through no fault of their own.

Proof of Concept

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).

Tools Used

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

Lines of code

https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/OperatorRegistry.sol#L140-L141

Vulnerability details

Description

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.

Impact

Validators may spend an arbitrary amount of time waiting to be matched.

Tools Used

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.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter