Platform: Code4rena
Start Date: 11/11/2022
Pot Size: $90,500 USDC
Total HM: 52
Participants: 92
Period: 7 days
Judge: LSDan
Total Solo HM: 20
Id: 182
League: ETH
Rank: 16/92
Findings: 6
Award: $1,382.52
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xdeadbeef0x
Also found by: joestakey
1012.6328 USDC - $1,012.63
The burnLPToken
of a Fees & MEV vault allow users to burn LP tokens in exchange of ETH.
Quoting the documentation
Every user has a right to leave the LSD network at anytime. A depositor/staker can simply sell their LP tokens to someone else or burn to redeem ETH or dETH
But there is a check in StakingFundsVault.sol
reverting burnLPToken
unless the Lifecycle of the validator is INITIALS_REGISTERED
180: require( 181: getAccountManager().blsPublicKeyToLifecycleStatus(blsPublicKeyOfKnot) == IDataStructures.LifecycleStatus.INITIALS_REGISTERED, 182: "Cannot burn LP tokens" 183: );
This means once the derivatives have been minted for a validator, a user will not be able to redeem their LP token
for ETH. Even after staking withdrawal, when the lifecycle status has been updated to EXITED
.
High
Add this test to StakingFundsVault.t.sol
, showing that when the lifecycle is EXITED
, the user cannot retrieve their ETH
:
function testBurnLPRevert() public { // @audit - user deposits ETH in vault maxETHDeposit(accountFive, getBytesArrayFromBytes(blsPubKeyOne)); // warp speed ahead vm.warp(block.timestamp + 3 hours); // @audit - Move lifecycle straight to exited vault.accountMan().setLifecycleStatus(blsPubKeyOne, 4); vm.startPrank(accountFive); LPToken token = vault.lpTokenForKnot(blsPubKeyOne); // @audit - The call reverts vm.expectRevert("Cannot burn LP tokens"); vault.burnLPForETH(token, maxStakingAmountPerValidator); vm.stopPrank(); }
You also need to make the following adjustments in the test file:
7: import { 8: TestUtils, 9: MockLSDNFactory, 10: MockLiquidStakingManager, 11: MockAccountManager, +12: MockStakingFundsVault, 13: IDataStructures 14: } from "../utils/TestUtils.sol"; 15: 16: contract StakingFundsVaultTest is TestUtils { 17: 18: address operations = accountOne; 19: 20: MockLiquidStakingManager liquidStakingManager; +21: MockStakingFundsVault vault; //@audit - change to access account manager 22: 23: uint256 maxStakingAmountPerValidator; ... ... 52: function setUp() public { 53: factory = createMockLSDNFactory(); 54: liquidStakingManager = deployDefaultLiquidStakingNetwork(factory, admin); 55: manager = liquidStakingManager; +56: vault = MockStakingFundsVault(payable(address(liquidStakingManager.stakingFundsVault()))); //@audit - vault 57: maxStakingAmountPerValidator = vault.maxStakingAmountPerValidator();
Manual Analysis, Foundry
The vault should allow users to redeem their LP tokens for ETH when the validator lifecycle has come to an end
```solidity 180: require( -181: getAccountManager().blsPublicKeyToLifecycleStatus(blsPublicKeyOfKnot) == IDataStructures.LifecycleStatus.INITIALS_REGISTERED, +181: getAccountManager().blsPublicKeyToLifecycleStatus(blsPublicKeyOfKnot) == (IDataStructures.LifecycleStatus.INITIALS_REGISTERED || IDataStructures.LifecycleStatus.EXITED), 182: "Cannot burn LP tokens" 183: );
#0 - c4-judge
2022-11-21T23:40:55Z
dmvt marked the issue as duplicate of #113
#1 - c4-judge
2022-11-30T12:58:33Z
dmvt marked the issue as satisfactory
#2 - C4-Staff
2022-12-21T05:44:30Z
JeeberC4 marked the issue as duplicate of #176
17.6542 USDC - $17.65
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LPToken.sol#L68 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/SavETHVault.sol#L186
The burnLPToken
of a protected vault allow users to burn LP tokens in exchange of ETH or dETH.
In the case of ETH, ie when the BLS key has not had its derivatives minted yet, the function checks the liquidity is not fresh by checking _lpToken.lastInteractedTimestamp(msg.sender)
140: // before burning, check the last LP token interaction and make sure its more than 30 mins old before permitting ETH withdrawals 141: bool isStaleLiquidity = _lpToken.lastInteractedTimestamp(msg.sender) + 30 minutes < block.timestamp;
This opens a DOS attack vector: a user can prevent another from calling burnLPToken
by transferring them any amount (even 1 gwei worth of LP), as the lastInteractedTimestamp
of the receiver is updated in every LP._transfer()
:
66: function _afterTokenTransfer(address _from, address _to, uint256 _amount) internal override { 67: lastInteractedTimestamp[_from] = block.timestamp; 68: lastInteractedTimestamp[_to] = block.timestamp; //@audit - recipient timestamp updated in every transfer
Note that the same grieving attack can be performed for a user trying to call rotateLPTokens()
.
The same issue is also present in savETHVault
and the Giant pools.
Medium
12 ETH
in a protected vault, receiving LPTokens
burnLPToken()
LPToken.transfer(Alice, 1)
callburnLPToken()
will revert because Alice's liquidity is too fresh.Add this test to SavETHVault.t.sol
, recreating the steps described above:
function testBurnLPDOS() public { // First supply ETH when validator is at initials registered phase savETHVault.accountMan().setLifecycleStatus(blsPubKeyOne, 1); liquidStakingManager.setIsPartOfNetwork(blsPubKeyOne, true); uint256 stakeAmount = 12 ether; vm.deal(accountOne, stakeAmount); vm.deal(accountTwo, stakeAmount); vm.warp(0); //@audit - accountOne and accountTwo both deposit 12ETH in the vault vm.prank(accountOne); savETHVault.depositETHForStaking{value: stakeAmount}(blsPubKeyOne, stakeAmount); vm.prank(accountTwo); savETHVault.depositETHForStaking{value: stakeAmount}(blsPubKeyOne, stakeAmount); LPToken lp = savETHVault.lpTokenForKnot(blsPubKeyOne); assertEq(lp.balanceOf(accountOne), 12 ether); skip(3600); //@audit - going 1 hour forward //@audit - accountTwo transfers 1 gwei worth of LP token to accountOne vm.prank(accountTwo); lp.transfer(accountOne, 1); //@audit - accountOne tries to redeem their LP for ETH // but because of the transfer that just happened, their LPtimestamp is too fresh vm.prank(accountOne); vm.expectRevert("Liquidity is still fresh"); savETHVault.burnLPToken(lp, stakeAmount); }
Manual Analysis, Foundry
Not sure what viable measure can be taken here. Removing lastInteractedTimestamp[_to] = block.timestamp;
from LPtoken._afterTokenTransfer
is not viable, because it be make easy for users to transfer their LPtokens
to another address and circumvent the 30 minutes requirement.
#0 - c4-judge
2022-11-21T23:36:54Z
dmvt marked the issue as duplicate of #49
#1 - c4-judge
2022-11-29T22:42:23Z
dmvt marked the issue as satisfactory
88.5851 USDC - $88.59
Judge has assessed an item in Issue #400 as M risk. The relevant finding follows:
L01 - EOA restriction of wallet representative can be bypassed https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L435
A node operator can call registerBLSPublicKeys() to register a node runner to LSD and create a new smart wallet. The protocol only allows EOAs to be registered as _eoaRepresentative.
The issue is that this can be easily circumvented. As detailed in the isContract() definition by OpenZeppelin:
isContract
will return false for the followingThe operator can then redeploy the same contract to the same address using a CREATE2 call in their factory.
Impact Low
#0 - c4-judge
2022-12-05T10:25:15Z
dmvt marked the issue as duplicate of #187
#1 - c4-judge
2022-12-05T10:25:34Z
dmvt marked the issue as satisfactory
#2 - C4-Staff
2022-12-21T05:53:17Z
JeeberC4 marked the issue as duplicate of #93
123.0349 USDC - $123.03
Judge has assessed an item in Issue #400 as M risk. The relevant finding follows:
L03 - LiquidStakingManager.dao can rug node operators with executeAsSmartWallet() 202: function executeAsSmartWallet( 203: address _nodeRunner, 204: address _to, 205: bytes calldata _data, 206: uint256 _value 207: ) external payable onlyDAO { 208: address smartWallet = smartWalletOfNodeRunner[_nodeRunner]; 209: require(smartWallet != address(0), "No wallet found"); 210: IOwnableSmartWallet(smartWallet).execute( 211: _to, 212: _data, 213: _value 214: ); 215: } executeAsSmartWallet() is meant to enable proxy operations through DAO contracts, but introduces rugging vectors.
Note that as per the docs
LSD Networks can be deployed by any Ethereum Account Despite the name, dao could be an EOA, making the attack even easier to perform.
A malicious dao can wait for a node operator to call registerBLSPublicKeys(), which will transfer amount = 4 ETH * Nvalidators to the smart wallet, then call executeAsSmartWallet() to transfer amount to their own account, rugging the node operator.
A bigger possible attack is rugging the staked ETH:
As per the logic in stake() the ETH staking deposit to the Ethereum Staking contract is performed by the node operator wallet.
When staking withdrawals are enabled, a dao can use executeAsSmartWallet() to withdraw all the staked ETH, rugging all the depositors.
Impact Centralization risk
Tools Used Manual Analysis
#0 - c4-judge
2022-12-05T10:28:14Z
dmvt marked the issue as duplicate of #106
#1 - c4-judge
2022-12-05T10:28:18Z
dmvt marked the issue as satisfactory
88.5851 USDC - $88.59
Judge has assessed an item in Issue #400 as M risk. The relevant finding follows:
L04 - LiquidStakingManager.dao can rug node operators with executeAsSmartWallet() daoCommissionPercentage is used to calculate the portion of node operator network rewards that are sent to dao, when a node runner is calling claimRewardsAsNodeRunner().
There is no timelock to updateDAORevenueCommission(), meaning a dao can call it with _commissionPercentage = MODULO to set daoCommissionPercentage to 100%, effectively stealing all the network rewards from node runners.
Impact Centralization risk
Tools Used Manual Analysis
Mitigation Consider either adding a timelock to updateDAORevenueCommission(), or add an upper boundary to it, so that daoCommissionPercentage is always reasonable.
#0 - c4-judge
2022-12-05T10:29:42Z
dmvt marked the issue as duplicate of #190
#1 - c4-judge
2022-12-05T10:29:49Z
dmvt marked the issue as satisfactory
🌟 Selected for report: 0xSmartContract
Also found by: 0x4non, 0xNazgul, 0xRoxas, 0xdeadbeef0x, 0xmuxyz, 9svR6w, Awesome, Aymen0909, B2, Bnke0x0, CloudX, Deivitto, Diana, Franfran, IllIllI, Josiah, RaymondFam, ReyAdmirado, Rolezn, Sathish9098, Secureverse, SmartSek, Trust, Udsen, a12jmx, aphak5010, brgltd, bulej93, c3phas, ch0bu, chaduke, chrisdior4, clems4ever, cryptostellar5, datapunk, delfin454000, fs0c, gogo, gz627, hl_, immeas, joestakey, lukris02, martin, nogo, oyc_109, pashov, pavankv, peanuts, pedr02b2, rbserver, rotcivegaf, sahar, sakman, shark, tnevler, trustindistrust, zaskoh, zgo
52.0338 USDC - $52.03
A node operator can call registerBLSPublicKeys()
to register a node runner to LSD and create a new smart wallet. The protocol only allows EOAs to be registered as _eoaRepresentative
.
The issue is that this can be easily circumvented. As detailed in the isContract()
definition by OpenZeppelin:
* It is unsafe to assume that an address for which this function returns * false is an externally-owned account (EOA) and not a contract. * * Among others, `isContract` will return false for the following * types of addresses: * * - an externally-owned account * - a contract in construction * - an address where a contract will be created * - an address where a contract lived, but was destroyed
A user can for instance deploy a smart wallet via a factory using CREATE2, call SELFDESTRUCT
on it.
They can then pass the address of that destructed smart contract as _eoaRepresentative
to registerBLSPublicKeys()
.
The operator can then redeploy the same contract to the same address using a CREATE2 call in their factory.
Low
Manual Analysis
A potential mitigation would be to change the logic so that the _eoaRepresentative
would need to call registerBLSPublicKeys()
, and use a msg.sender == tx.origin
check to ensure they are an EOA.
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L435 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L290-L296 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L309-L320
A node operator can call registerBLSPublicKeys()
to register a node runner to LSD and create a new smart wallet. The protocol only allows EOAs to be registered as _eoaRepresentative
.
But in rotateEOARepresentativeOfNodeRunner()
and rotateEOARepresentative()
, there is no such check meaning, _newRepresentative
can be a smart contract.
Note that the error strings here and here and the function NATSPEC comment explicitly indicate the representative should be an EOA.
Low
Manual Analysis
Consider using an Address.isContract()
check in both functions, but as stated in L01 this does not really mitigate the issue properly.
Another option would be use a two-step rotation, with the current representative setting the pending new representative, and the new representative would call the function setting them as the smart wallet representative - allowing the use of msg.sender == tx.origin
to ensure they are an EOA.
LiquidStakingManager.dao
can rug node operators with executeAsSmartWallet()
202: function executeAsSmartWallet( 203: address _nodeRunner, 204: address _to, 205: bytes calldata _data, 206: uint256 _value 207: ) external payable onlyDAO { 208: address smartWallet = smartWalletOfNodeRunner[_nodeRunner]; 209: require(smartWallet != address(0), "No wallet found"); 210: IOwnableSmartWallet(smartWallet).execute( 211: _to, 212: _data, 213: _value 214: ); 215: }
executeAsSmartWallet()
is meant to enable proxy operations through DAO contracts, but introduces rugging vectors.
Note that as per the docs
LSD Networks can be deployed by any Ethereum Account
Despite the name, dao
could be an EOA, making the attack even easier to perform.
A malicious dao
can wait for a node operator to call registerBLSPublicKeys()
, which will transfer amount = 4 ETH * Nvalidators
to the smart wallet, then call executeAsSmartWallet()
to transfer amount
to their own account, rugging the node operator.
A bigger possible attack is rugging the staked ETH:
As per the logic in stake()
the ETH staking deposit to the Ethereum Staking contract is performed by the node operator wallet.
When staking withdrawals are enabled, a dao
can use executeAsSmartWallet()
to withdraw all the staked ETH, rugging all the depositors.
Centralization risk
Manual Analysis
LiquidStakingManager.dao
can rug node operators with executeAsSmartWallet()
daoCommissionPercentage
is used to calculate the portion of node operator network rewards that are sent to dao
, when a node runner is calling claimRewardsAsNodeRunner()
.
There is no timelock to updateDAORevenueCommission()
, meaning a dao
can call it with _commissionPercentage = MODULO
to set daoCommissionPercentage
to 100%, effectively stealing all the network rewards from node runners.
Centralization risk
Manual Analysis
Consider either adding a timelock to updateDAORevenueCommission()
, or add an upper boundary to it, so that daoCommissionPercentage
is always reasonable.
LiquidStakingManager.receive
can lead to user losing fundsThe receive()
function is empty. If an account mistakenly sends ETH
to a LiquidStakingManager
, they have no way to retrieve that amount.
It will later be taken into account for rewards calculation here
Low
Manual Analysis
The LiquidStakingManager.receive()
function should check msg.sender
to ensure the call was triggered by a rightful address to ensure they are staking yield/rewards, and not an arbitrary transfer from a random EOA.
#0 - c4-judge
2022-12-05T10:30:22Z
dmvt marked the issue as grade-b