Closed code423n4 closed 1 year ago
0xSorryNotSorry marked the issue as primary issue
A modifier was added to address this specific kind of concern. It is present here
Sidu28 marked the issue as sponsor disputed
GalloDaSballo marked the issue as unsatisfactory: Invalid
Agree with the Sponsor
Hello, I don't think the modifier that was added is adequate.
Here is the proofIsForValidBlockNumber
modifier:
modifier proofIsForValidBlockNumber(uint64 blockNumber) {
require(blockNumber > mostRecentWithdrawalBlockNumber,
"EigenPod.proofIsForValidBlockNumber: beacon chain proof must be for block number after mostRecentWithdrawalBlockNumber");
_;
}
mostRecentWithdrawalBlockNumber
is only updated within the withdrawBeforeRestaking
function. Consequently, if the podOwner refrains from calling the withdrawBeforeRestaking
function, proofIsForValidBlockNumber
will always succeed since the default value of mostRecentWithdrawalBlockNumber
is 0. As a result, proofIsForValidBlockNumber
will generally have no practical significance.
Consider adding a check in proofIsForValidBlockNumber
that mostRecentWithdrawalBlockNumber
is not 0.
Hello, I don't think the modifier that was added is adequate. Here is the
proofIsForValidBlockNumber
modifier:modifier proofIsForValidBlockNumber(uint64 blockNumber) { require(blockNumber > mostRecentWithdrawalBlockNumber, "EigenPod.proofIsForValidBlockNumber: beacon chain proof must be for block number after mostRecentWithdrawalBlockNumber"); _; }
mostRecentWithdrawalBlockNumber
is only updated within thewithdrawBeforeRestaking
function. Consequently, if the podOwner refrains from calling thewithdrawBeforeRestaking
function,proofIsForValidBlockNumber
will always succeed since the default value ofmostRecentWithdrawalBlockNumber
is 0. As a result,proofIsForValidBlockNumber
will generally have no practical significance.Consider adding a check in
proofIsForValidBlockNumber
thatmostRecentWithdrawalBlockNumber
is not 0.
@ChaoticWalrus sorry to keep bothering you, what do you think about this? Do you think this is realistic?
(Btw this should be the last issue standing)
@ChaoticWalrus sorry to keep bothering you, what do you think about this? Do you think this is realistic?
The withdrawBeforeRestaking()
function has its own modifier that prevents it being called after any restaking proofs are verified. It is the hasNeverRestaked
modifier, which is defined here https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/pods/EigenPod.sol#L114-L117 and applied here https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/pods/EigenPod.sol#L454
So the cases are:
1) withdrawBeforeRestaking
is called first, in which case mostRecentWithdrawalBlockNumber
is non-zero
2) verifyWithdrawalCredentialsAndBalance
is called first, in which case the ETH cannot have been withdrawn out of the EigenPod, and withdrawBeforeRestaking
cannot subsequently be called, ever.
So I'm not seeing any need for a modification here.
(Btw this should be the last issue standing)
Thank you, really glad to hear this.
For additional context, the code that prevents withdrawBeforeRestaking
being called after verifyWithdrawalCredentialsAndBalance
also includes these lines https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/pods/EigenPod.sol#L217-L220 which set hasRestaked = true
inside of the verifyWithdrawalCredentialsAndBalance
function.
Hello If the user calls verifyWithdrawalCredential without calling withdrawBeforeRestaking, there would be no restrictions
This is withdrawBeforeRestaking:
function withdrawBeforeRestaking() external onlyEigenPodOwner hasNeverRestaked {
mostRecentWithdrawalBlockNumber = uint32(block.number);
_sendETH(podOwner, address(this).balance);
}
If there is no eth in the eigenpod, there would be no reason to call withdrawBeforeRestaking.
Hello If the user calls verifyWithdrawalCredential without calling withdrawBeforeRestaking, there would be no restrictions
There is no restriction on the oracleBlockNumber
in this case, as you originally stated. I think we agree on that.
If there is no eth in the eigenpod, there would be no reason to call withdrawBeforeRestaking.
Yes, also agree on this.
I suspect we are talking past each other, at least to some degree.
It seems like the general critique falls on the existence of a concept we term 'overcommitments', for which we have an honest watcher assumption and have chosen to not restrict who can call the verifyOvercommittedStake
or verifyAndProcessWithdrawal
. Information on overcommitments is in our documentation, particularly here https://github.com/code-423n4/2023-04-eigenlayer/blob/main/docs/EigenPods.md#merkle-proofs-for-overcommitted-balances
It seems like the general critique falls on the existence of a concept we term 'overcommitments', for which we have an honest watcher assumption and have chosen to not restrict who can call the
verifyOvercommittedStake
orverifyAndProcessWithdrawal
. Information on overcommitments is in our documentation, particularly here https://github.com/code-423n4/2023-04-eigenlayer/blob/main/docs/EigenPods.md#merkle-proofs-for-overcommitted-balances
I agree with this.
But a user would get 32 eth worth of shares for as little as 0 staked eth, and even though there are watchers, there will surely be a delay between when user verifyWithdrawalCredentialsAndBalance
, and when the watcher verifyOvercommitment
. During this delay period, user could be getting rewards, or choose to act maliciously without fear of getting slashed.
I really recommend delaying eigenPodManager.restakeBeaconChainETH(podOwner, REQUIRED_BALANCE_WEI);
by probably 50400 blocks to allow watchers to do their job and to eliminate chances of this occuring
You may also modify proofIsForValidBlockNumber
because I'm not sure that is the intended behaviour.
I agree with this. But a user would get 32 eth worth of shares for as little as 0 staked eth, and even though there are watchers, there will surely be a delay between when user
verifyWithdrawalCredentialsAndBalance
, and when the watcherverifyOvercommitment
. During this delay period, user could be getting rewards, or choose to act maliciously without fear of getting slashed. I really recommend delayingeigenPodManager.restakeBeaconChainETH(podOwner, REQUIRED_BALANCE_WEI);
by probably 50400 blocks to allow watchers to do their job and to eliminate chances of this occuring You may also modifyproofIsForValidBlockNumber
because I'm not sure that is the intended behaviour.
Super. Personally am viewing this more as a difference of opinion at this point, but your suggestion is something we could consider; really appreciate you taking the time to discuss and dig in a bit more.
The one key point that I want to emphasize here -- which contributed significantly to the decisions we've made in the present design -- is that the user you are describing would necessarily have had 32 ETH at some time and gotten slashed down below 31 ETH. Importantly, this means the user has already incurred an ETH loss strictly greater in magnitude than the amount by which they would be briefly 'overcommitted' in EigenLayer. I describe this as there being no 'free lunch' in the system 😄
In most cases, the current design is okay. But I'll suggest that external factors, like a user getting slashed on Beacon chain should have as little negative impact as possible on the system.
Importantly, this means the user has already incurred an ETH loss strictly greater in magnitude than the amount by which they would be briefly 'overcommitted' in EigenLayer. I describe this as there being no 'free lunch' in the system 😄
Yes there may be no 'free lunch' for the user, and most times there is no incentive to do this, but the system could still be negatively impacted.
Per the Sponsors comment:
Caller calls verifyWithdrawalCredentialsAndBalance
first -> They are participating as validator and have a valid balance
They will not be able to call withdrawBeforeRestaking
The flag validatorStatus[validatorIndex]
prevents replay
Caller calls withdrawBeforeRestaking
first -> The block number is increased so older block proofs cannot be used
At this point I would recommend you investigate the finding further and follow up with me or the Sponsor privately if you find something
But with the info I have available, I believe the finding is invalid
Caller calls verifyWithdrawalCredentialsAndBalance first -> They are participating as validator and have a valid balance They will not be able to call withdrawBeforeRestaking.
The caller can actually call verifyWithdrawalCredentialsAndBalance with an invalid balance.
Agree with no free lunch
Also I must put a stop to this dynamic because the submitted finding doesn't have the same information as in the comments
You cannot submit a new finding after the contest is over, the additional info must be contained in the original submission
At this point I would recommend you follow up after the contest if you can write a POC, but I cannot accept the finding as valid due to lack of proof
I have been messaged by the warden privately, and believe that:
overcommitment
is registered (theoretically this can happen instantly, just as much as it can be delayed), meaning that the operator may, for a brief moment have a balance that is not synched with their actual balance, but as a whole, the system is resilient to this desynchFor the above am maintaining the finding as invalid
Lines of code
https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/pods/EigenPod.sol#L175-L226
Vulnerability details
Impact
Attacker would get shares in StrategyManager without staking any real funds. This would allow him to earn rewards or act maliciously without fear of getting slashed.
Proof of Concept
Here is the
verifyWithdrawalCredentialsAndBalance
function: L175-L226EigenPod's
verifyWithdrawalCredentialsAndBalance
function checks if the validator's current balance, which he can withdraw from BeaconChain, is greater or equal to theREQUIRED_BALANCE_GWEI
, then verifies that the withdrawal credentials of the validator at theoracleBlockNumber
points to the EigenPod, then callseigenPodManager.restakeBeaconChainETH
which increments the podOwner's shares inStrategyManager
contract.The problem is that the function only checks that the validator's withdrawalCredentials at an arbitrary
oracleBlockNumber
points to the eigenPod(L189). So the validator is only required to haveA validator can create a pod using EigenManager's
createPod
function, and if he has enough eth(>=REQUIRED_BALANCE_GWEI) at the current block with withdrawalCredentials pointed to any address he wants, he can input a staleoracleBlockNumber
at which his withdrawalCredentials was pointing to the EigenPod, with correspondingproofs
andvalidatorFields
from that block, and the function will pass and shares will be minted for the podOwner. The function still passes when the validator's stake atoracleBlockNumber
with withdrawal credentials pointing to the EigenPod has already been slashed at a later block(oracleBlockNumber
+x). As a result, the validator is not risking any funds because the withdrawal credentials for his currently staked eth is not set to the EigenPod, and the one he staked atoracleBlockNumber
has already been slashed in the past.Since the user now has shares without staking his ETH, He would earn "free" rewards from EigenLayer, or would act maliciously because he did not stake any real value.
Tools Used
Manual Review
Recommended Mitigation Steps
For this issue, I would recommend two fixes to
verifyWithdrawalCredentialsAndBalance
:oracleBlockNumber
. An alternative is to make sureoracleBlockNumber
is not stale, as it was done in verifyOvercommittedStake: