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: 23/92
Findings: 6
Award: $651.11
🌟 Selected for report: 2
🚀 Solo Findings: 0
55.3657 USDC - $55.37
Node runners can withdraw more than 4 ether
from the associated smart wallet and I believe only 4 ether
should be allowed to be refunded to the node runner.
Once this function is called once, bannedBLSPublicKeys[_blsPublicKeyOfKnot] = associatedSmartWallet
, which means that it can only be called once. However, a malicious user can reenter when ether
is received as mapping is updated only after rawExecute
. Note that even though the comment says "refund 4 ether from smart wallet to node runner's EOA", there is no check to verify that _recipient
is not a smart contract address.
LiquidStakingManager.sol#L326-L350
function withdrawETHForKnot(address _recipient, bytes calldata _blsPublicKeyOfKnot) external { require(_recipient != address(0), "Zero address"); require(isBLSPublicKeyBanned(_blsPublicKeyOfKnot) == false, "BLS public key has already withdrawn or not a part of LSD network"); address associatedSmartWallet = smartWalletOfKnot[_blsPublicKeyOfKnot]; require(smartWalletOfNodeRunner[msg.sender] == associatedSmartWallet, "Not the node runner for the smart wallet "); require(isNodeRunnerBanned(nodeRunnerOfSmartWallet[associatedSmartWallet]) == false, "Node runner is banned from LSD network"); require(associatedSmartWallet.balance >= 4 ether, "Insufficient balance"); require( getAccountManager().blsPublicKeyToLifecycleStatus(_blsPublicKeyOfKnot) == IDataStructures.LifecycleStatus.INITIALS_REGISTERED, "Initials not registered" ); // refund 4 ether from smart wallet to node runner's EOA IOwnableSmartWallet(associatedSmartWallet).rawExecute( _recipient, "", 4 ether ); // update the mapping bannedBLSPublicKeys[_blsPublicKeyOfKnot] = associatedSmartWallet; emit ETHWithdrawnFromSmartWallet(associatedSmartWallet, _blsPublicKeyOfKnot, msg.sender); }
Manual Review
Do the update of the mapping bannedBLSPublicKeys[_blsPublicKeyOfKnot] = associatedSmartWallet
before transferring ether
, or add the nonReentrant
modifier to this function.
#0 - c4-judge
2022-11-21T12:01:42Z
dmvt marked the issue as duplicate of #113
#1 - c4-judge
2022-11-21T17:01:43Z
dmvt marked the issue as not a duplicate
#2 - c4-judge
2022-11-21T17:01:48Z
dmvt marked the issue as duplicate of #110
#3 - c4-judge
2022-11-30T12:57:17Z
dmvt marked the issue as nullified
#4 - dmvt
2022-11-30T12:57:30Z
Doesn't pass the burden of proof
#5 - yixxas
2022-12-06T19:09:13Z
Hi @dmvt, would like to seek clarifications on why this report is nullified. I believe this is a valid dupe of #110.
The crux of the issue is explained in the report. Because ether
is transferred via rawExecute
, it gives the attacker the opportunity to reenter at that point. This is possible as the mapping of bannedBLSPublicKeys[_blsPublicKeyOfKnot] = associatedSmartWallet
is updated only after rawExecute
is called. Another important point is also noted here, the fact that _recipient
can be a smart contract and not an EOA as wrongly noted in the comments. Combining all of the above points makes the reentrancy attack possible in withdrawETHForKnot()
.
#6 - c4-judge
2022-12-07T16:09:55Z
dmvt marked the issue as not nullified
#7 - c4-judge
2022-12-07T16:10:00Z
dmvt marked the issue as partial-25
#8 - dmvt
2022-12-07T16:13:26Z
I've upgraded it to 25% credit to match #330, which I upgraded from QA. Both reports are of roughly the same quality. I am giving partial credit and initially nullified the report because the issue as reported:
Doesn't pass the burden of proof
This is my final ruling.
88.5851 USDC - $88.59
User may have withdrawn an amount that results in the remaining withdrawable amount to be less than the MIN_STAKING_AMOUNT
which prevents them from doing withdrawETH()
, especially when they do not have enough ether
to "top up" to make the minimum withdraw.
withdrawETH()
forces users to have at least 0.001 ether
deposited to be able to withdraw.
require(_amount >= MIN_STAKING_AMOUNT, "Invalid amount")
But if for example users deposits 4 ether
, withdraws 3.9995 ether
, has 0.0005 ether
remaining, the user will not be able to make the withdraw.
Manual Review
withdrawETH()
should not require _amount >= MIN_STAKING_AMOUNT
. Recommend this check to be removed.
#0 - c4-judge
2022-11-20T23:59:11Z
dmvt marked the issue as duplicate of #98
#1 - c4-judge
2022-11-20T23:59:16Z
dmvt marked the issue as not a duplicate
#2 - c4-judge
2022-11-21T00:00:06Z
dmvt marked the issue as duplicate of #92
#3 - c4-judge
2022-11-29T23:09:37Z
dmvt marked the issue as satisfactory
88.5851 USDC - $88.59
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L308-L321 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L289-L303 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L435
Node runner can set a smart contract wallet as the smartWalletRepresentative[smartWallet]
address. This breaks the underlying assumption of the protocol that this address must be an EOA, as frequently commented in the contract. This can cause or lead to many unintended effects.
In registerBLSPublicKeys()
, the check is done with require(!Address.isContract(_eoaRepresentative), "Only EOA representative permitted")
. However this check is not done in rotateEOARepresentative()
hence a user can first register a key with an EOA account, then rotate it to a smart contract wallet with rotateEOARepresentative()
to reach the mentioned state.
Manual Review
The same check needs to be done in both rotateEOARepresentative()
and rotateEOARepresentativeOfNodeRunner()
.
#0 - c4-judge
2022-11-21T12:12:28Z
dmvt marked the issue as primary issue
#1 - dmvt
2022-11-21T12:13:16Z
I feel compelled to mention that the mitigation in both this report and #93 is itself a bug, but the underlying reported concern is accurate.
#2 - c4-sponsor
2022-11-28T17:23:58Z
vince0656 marked the issue as sponsor confirmed
#3 - c4-judge
2022-11-30T12:24:54Z
dmvt marked the issue as satisfactory
#4 - C4-Staff
2022-12-21T05:53:33Z
JeeberC4 marked the issue as duplicate of #93
66.4388 USDC - $66.44
A user can be tricked to call withdrawLPTokens()
with a malicious token address that is not part of the LPToken
issued by the lptokenFactory
which can potentially cause them to lose all their giant LP token.
withdrawLPTokens()
does not check that _lpTokens
is a token that is issued by lptokenFactory
. This token can be one that is not registered as part of any BLS public key. A malicious user can use this fact and create a "fake" LPToken
, send it into the contract and trick a user to call this function with this fake token as input.
End result is that users will be transferred this fake token which has no value in the stakehouse ecosystem, but burn their valuable giant LP token.
Manual Review
A check should be added in withdrawLPTokens()
to ensure that tokens being used are created by the lptokenFactory
.
#0 - c4-judge
2022-11-21T00:01:11Z
dmvt marked the issue as duplicate of #98
#1 - c4-judge
2022-11-30T12:33:38Z
dmvt marked the issue as satisfactory
236.9561 USDC - $236.96
The underlying assumption of eoaRepresentative
being an EOA can be untrue. This can cause many unintended effects as the contract comments strongly suggests that this must be an EOA account.
When BLS public key is registered in registerBLSPublicKeys()
, it has the check of
require(!Address.isContract(_eoaRepresentative), "Only EOA representative permitted")
However, this check can be passed even though input is a smart contract if
Address.isContract()
checks for the code length, but during construction code length is 0.Manual Review
It is generally not recommended to enforce an address to be only EOA and AFAIK, this is impossible to enforce due to the aforementioned cases. I recommend the protocol team to take a closer look at this and build the protocol with the assumption that _eoaRepresentative == EOA
.
#0 - c4-judge
2022-11-21T12:21:40Z
dmvt marked the issue as duplicate of #109
#1 - c4-judge
2022-11-21T16:56:29Z
dmvt marked the issue as not a duplicate
#2 - c4-judge
2022-11-21T16:56:42Z
dmvt marked the issue as duplicate of #108
#3 - c4-judge
2022-11-24T09:44:19Z
dmvt marked the issue as selected for report
#4 - c4-sponsor
2022-11-28T17:17:45Z
vince0656 marked the issue as sponsor disputed
#5 - vince0656
2022-11-28T17:18:31Z
Using tx.origin is generally frowned upon
#6 - dmvt
2022-11-30T12:50:25Z
The sponsor confirming that they know it's an issue does not invalidate it as an issue.
#7 - c4-judge
2022-11-30T12:51:08Z
dmvt marked the issue as satisfactory
#8 - trust1995
2022-12-06T21:49:06Z
Report does not explain what "unintended side effects" happen when address is not EOA. In order to be considered a M level report, warden should provide some real impact , otherwise it is a QA level suggestion. Multiple examples of this exact issue judged QA: https://github.com/code-423n4/2022-10-blur-findings/issues/139 https://github.com/code-423n4/2022-10-holograph-findings/issues/63 https://github.com/code-423n4/2021-12-maple-findings/issues/72 @GalloDaSballo
#9 - dmvt
2022-12-07T10:31:18Z
See my response in the post-judging qa discussion.
#10 - C4-Staff
2022-12-21T05:41:24Z
JeeberC4 marked the issue as not a duplicate
#11 - C4-Staff
2022-12-21T05:41:36Z
JeeberC4 marked the issue as primary issue
115.1607 USDC - $115.16
Node runners can have all their stake rewards taken by the DAO as commissions can be set to a 100%.
There is no limits on _updateDAORevenueCommission()
except not exceeding MODULO
, which means it can be set to a 100%.
LiquidStakingManager.sol#L948-L955
function _updateDAORevenueCommission(uint256 _commissionPercentage) internal { require(_commissionPercentage <= MODULO, "Invalid commission"); emit DAOCommissionUpdated(daoCommissionPercentage, _commissionPercentage); daoCommissionPercentage = _commissionPercentage; }
This percentage is used to calculate uint256 daoAmount = (_received * daoCommissionPercentage) / MODULO
in _calculateCommission()
.
Remaining is then caculated with uint256 rest = _received - daoAmount
, and in this case rest = 0
.
When node runner calls claimRewardsAsNodeRunner()
, the node runner will receive 0 rewards.
Manual Review
There should be maximum cap on how much commission DAO can take from node runners.
#0 - c4-judge
2022-11-21T12:28:18Z
dmvt marked the issue as primary issue
#1 - c4-sponsor
2022-11-28T17:11:23Z
vince0656 marked the issue as sponsor disputed
#2 - vince0656
2022-12-02T12:36:14Z
Node runners can see ahead of time what the % commission is and therefore, they can make a decision based on that. However, on reflection, a maximum amount is not a bad idea
#3 - dmvt
2022-12-02T17:17:37Z
I will leave this in place as I think it's a valid concern. If the DAO is compromised (specifically included in scope), the impact is felt immediately and applies to all unclaimed rewards. The node runners can't necessarily see a high fee rate coming in advance.
#4 - c4-judge
2022-12-02T17:19:30Z
dmvt marked the issue as satisfactory
#5 - c4-judge
2022-12-02T17:19:34Z
dmvt marked the issue as selected for report