Open hats-bug-reporter[bot] opened 11 months ago
Another way to mitigate this is to split batchRegisterValidators()
- b75a60d8
to two separate functions (deposit 1 ETH -> deposit 31 ETH) and warn users to verify their withdrawal credentials with clear instructions in docs and code before calling the second function to deposit the remaining ETH.
Hi,
Say you are an attacker for my StakingManager - batchRegisterValidators()
flow,
how can you register your own withdrawal address by front-running my deposit transaction?
The key point here is that you don't have my validator key. The attacker without the valid validator key cannot make valid deposits (to set the withdrawal address).
In apply_deposit
function, see this BLS verification step
if bls.Verify(pubkey, signing_root, signature):
add_validator_to_registry(state, pubkey, withdrawal_credentials, amount)
Hi @seongyun-ko,
Please can you elaborate why do you think bls.Verify()
would fail?
The validator would still sign the initial deposit message containing their key and credentials, that the attacker (a malicious validator) would use to make the front-running deposit.
Thank you, respect your judging work!
Hi @seongyun-ko, Please can you elaborate why do you think
bls.Verify()
would fail? The validator would still sign the initial deposit message containing their key and credentials, that the attacker (a malicious validator) would use to make the front-running deposit.Thank you, respect your judging work!
its because the withdrawal credentials are part of the data that is signed. if you change the WC the signature wont match the data
its because the withdrawal credentials are part of the data that is signed. if you change the WC the signature wont match the data
The idea is that the victim validator exposes their pubKey
(BLS Public key) with the initial deposit. The malicious validator front-runs it and constructs their own valid deposit with their withdrawal credentials and signs via their own signature and the victim's pubKey
with 1 ETH. Since pubKey
validator address is already set: bls.Verify
will be bypassed on the second deposit since the if pubkey not in validator_pubkeys
will be false and the else
statement is executed: aka the validator's balance is incremented by 32 ETH
if pubkey not in validator_pubkeys:
# Verify the deposit signature (proof of possession) which is not checked by the deposit contract
deposit_message = DepositMessage(
pubkey=pubkey,
withdrawal_credentials=withdrawal_credentials,
amount=amount,
)
domain = compute_domain(DOMAIN_DEPOSIT) # Fork-agnostic domain since deposits are valid across forks
signing_root = compute_signing_root(deposit_message, domain)
if bls.Verify(pubkey, signing_root, signature):
add_validator_to_registry(state, pubkey, withdrawal_credentials, amount)
else:
# Increase balance by deposit amount
index = ValidatorIndex(validator_pubkeys.index(pubkey))
increase_balance(state, index, amount)
Sorry if I wasn't clear in my initial explanation.
signature verification will fail if a private key was used that does not belong to the public key
signature verification will fail if a private key was used that does not belong to the public key
How could it possibly fail? The attacker is the one who first registers the public key with their private key aka signature. The public key in fact "will belong to the private key" aka the attacker's since he's the one who first signed it.
Here are the deposit params defined by a tuple:
The signature (S) is the malicious validator's private key over (V, A, W). Verification will return true because the attacker's signature corresponds both to the public key (V) and to their message (A, W).
signature verification will fail if a private key was used that does not belong to the public key
How could it possibly fail? The attacker is the one who first registers the public key with their private key aka signature. The public key in fact "will belong to the private key" aka the attacker's since he's the one who first signed it.
PubKeys and privateKeys are a 1:1 relationship. you derive a pubKey from a private key. to register a pubKey you need to prove that you own its private key. Thats what you do with the signature.
Github username: @0xfuje Twitter username: 0xfuje Submission hash (on-chain): 0xff7675bdcb5c7defa3cd4c84b284ca0ac7420af7bad74a92558bd90119397a42 Severity: high
Description:
Impact
Attacker will gain
32 ether
deposit from usersDescription
An attacker can front-run a deposit for it's deposit data and directly deposit 1 ether to the deposit contract, but change the withdrawal credentials. Because the way deposit is implemented on the beacon chain, the withdrawal address remains the initial one upon second deposit, therefore it is permanently changed to the attacker's address who will gain
32 ether
from the honest depositor.EtherFi - Deposit
There is a two ways to register as a validator in
EtherFi
contracts:StakingManager
-batchRegisterValidators()
to register themselves and deposit32 ether
to the ETH2 deposit contractLiquidityPool
-batchRegisterAsBnftHolder()
->StakingManager
-batchRegisterValidators()
which will deposit1 ether
to the ETH2 deposit contract to set the the validator data and wait for the admin to callbatchApproveRegistration()
to send the remaining31 ether
.The first method is 100% vulnerable to this attack and the attacker will get
32 ETH
for a1 ETH
cost attack. For the second way thebatchApproveRegistration()
comment mentions it's not supposed to be vulnerable to the attack:However the attacker can still front-run
batchRegisterAsBnftHolder()
and get a 1 ETH deposit for each of the user's validators.Consensus Specs - Deposit Contract & Client Implementation
Let's break down how the deposit contract's
deposit()
function works to better understand this attack:consensus-specs/solidity_deposit_contract/deposit_contract.sol
pubKey
: Public BLS key to identify validator for Ethereum 2.0withdrawal_credentials
: Public BLS key for an Ethereum address to which all withdrawals will gosignature
: Signature used for creatingpubKey
deposit_data_root
: The three above parameters combined in one structure, hash of this will be used for data protectionLet's dive into Ethereum Consensus Specs to see what exactly will happen after we deposit:
When a new validator is being added,
process_deposit()
is being called on the client implementation. The function later callsapply_deposit()
which checks if the public key of a validator is already in the list of existing validators: seepubkey not in validator_pubkeys
: if it does not yet exist the system will create a new record for that validator. If thepubkey
already exists we increase the deposit of the current validator. Note that the deposit contract only requires 1 ETH as a minimum deposit.Proof of Concept
test/StakingManager.t.sol
run
forge test --match-test test_DepositFrontRun_0xfuje -vvvv
Recommendation
Consider completely removing
StakingManager
-batchRegisterValidators()
(b75a60d8
) and only allow the second method for deposits (deposit 1 ETH -> approve for 31 ETH) where an oracle confirms that the withdraw credentials for the validators are correct before depositing the remaining 31 ETH. Lido had the same attack vector reported, here's their discussions for mitigation.