RocketPool and Lido Frontrunning Bugfix Review

Immunefi
Immunefi
Published in
6 min readOct 11, 2021

Summary

Whitehat Dmitri Tsumak, founder of StakeWise, submitted a critical vulnerability on October 5th that affected the RocketPool and Lido Finance staking platforms. The vulnerability allowed for the node operator to steal user deposits. The payout for the whitehat was the maximum amount for critical bugs from both projects ($100,000 from each project), resulting in a total payout of $200,000.

The whitehat first disclosed the vulnerability in RocketPool, but after checking another staking service, he noticed the same issue in Lido Finance and submitted another bug report through Immunefi. Bugs in smart contracts are often systemic. A bug that affects one project in many cases affects others.

The vulnerability disclosed to RocketPool also marks one of the fastest ever payouts for a bug bounty to a hacker

Timeline

* October 5th at 3:27 AM GMT+2 report created

* October 5th at 3:35 AM GMT+2 report escalated to the RocketPool team by Immunefi

* October 5th at 3:39 AM GMT+2 bug confirmed and payout agreed to by the hacker

* October 5th at 3:45 AM GMT+2 hacker paid

* October 5th at 4:11 AM GMT+2 Immunefi paid

Lido reacted fast as well. The payout to the hacker was sent on October 9, as it had to be approved via the DAO.

Vulnerability Analysis

RocketPool and Lido are both third-party staking pools for Ethereum 2.0. To understand how third-party staking pools work, we first need to discuss Ethereum 2.0. It is a significant upgrade to the current Ethereum public mainnet, and is designed to accelerate Ethereum’s usage and adoption by improving its performance. The objectives of the new version extend beyond processing transactions at scale, and they also include other design goals, such as improved security and scalability.

Ethereum 2.0 will be changing its consensus model from the current Proof of Work (PoW) to Proof of Stake (PoS). The main difference between PoW and PoS is that there are no miners. Instead, participants, known as validators, can propose new blocks and validate blocks from another validator, but only if they stake 32 ETH by depositing the funds to the official deposit contract. Only then can validators run client nodes to participate in PoS.

Currently, staking is quite expensive. (Ether is $3.5k at the time of writing this article). That means you would need around $112k to be able to stake 32 ETH, compared to approximately $6k at the time when the Deposit contract was first deployed and Beacon Chain launched in 2019.

To overcome the issue of the expensive entry point, third party staking pools were created. They function by pooling together user funds and creating a validator on the Beacon Chain that can earn staking rewards. Lido and RocketPool differ in their exact approaches to accomplishing this, but the underlying idea is similar.

Deposit Contract

To understand the attack vector, we first need to understand the Deposit contract’s deposit function and its four arguments.

- Pubkey: BLS Public key of a validator for Ethereum 2.0

- Withdrawal credentials: BLS Public key for an Ethereum address to which all withdrawals will go. It can be another address of a validator or shard

- Signature: Signed pubkey and withdrawal with BLS private key used for creating Pubkey

- Deposit data root: All three above combined in one structure with the amount to be sent and signed. Hash of that signature is used for data protection against malformatted calldata

Attack Vector

After a malicious node operator would be included in Lido and RocketPool, they would need to generate additional deposit data with withdrawal credentials and minimum deposit value in ETH (depending on the project) for every validator key created. The next steps are crucial for the exploit to happen.

An attacker waits for the 32 ETH to be submitted from the pool to the deposit contract for one of the validators approved in the beginning. When this happens, the malicious node operator frontruns the deposit with previously prepared deposit data with minimal needed deposit value for the same validator bls key by calling deposit() function on the deposit contract.

Above steps, with no need to frontrun on RocketPool (first call deposit() on deposit contract and then call stake() from RocketPoolMiniPoolDelegate.sol), allows for the swap of pool’s withdrawal credentials for an attacker’s withdrawal credentials and most of the staked ETH coming from the pool users instead of attacker’s own.

This happens because of the way how beacon chain clients are implemented.

def process_deposit(state: BeaconState, deposit: Deposit) -> None:
# Verify the Merkle branch
assert is_valid_merkle_branch(
leaf=hash_tree_root(deposit.data),
branch=deposit.proof,
depth=DEPOSIT_CONTRACT_TREE_DEPTH + 1, # Add 1 for the List length mix-in
index=state.eth1_deposit_index,
root=state.eth1_data.deposit_root,
)
# Deposits must be processed in order
state.eth1_deposit_index += 1
pubkey = deposit.data.pubkey
amount = deposit.data.amount
validator_pubkeys = [v.pubkey for v in state.validators]
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=deposit.data.pubkey,
withdrawal_credentials=deposit.data.withdrawal_credentials,
amount=deposit.data.amount,
)
domain = compute_domain(DOMAIN_DEPOSIT) # Fork-agnostic domain since deposits are valid across forks
signing_root = compute_signing_root(deposit_message, domain)
if not bls.Verify(pubkey, signing_root, deposit.data.signature):
return
# Add validator and balance entries
state.validators.append(get_validator_from_deposit(state, deposit))
state.balances.append(amount)
else: # Increase balance by deposit amount
index = ValidatorIndex(validator_pubkeys.index(pubkey))
increase_balance(state, index, amount)

The above code is from Ethereum specs for beacon-chain.

When validators are being added, process_deposit() is being called on the client implementation. Public BLS keys for validators are processed, and if public keys are for new validators, we create new deposit data for that validator. All that happens in this if block.

if pubkey not in validator_pubkeys

In the else block, we see the following:

# Increase balance by deposit amountindex = ValidatorIndex(validator_pubkeys.index(pubkey))increase_balance(state, index, amount)

This means we increase the deposit of the current validator. It’s because the deposit contract only requires 1 ETH at minimum for a deposit to pass. If we don’t have enough ETH and don’t want to miss a spot in the validator queue, we could send the rest later. But the above creates an issue for pooled staking.

A step-by-step guide to exploit the bug is as follows:

1. Frontrun the valid deposit transaction (32 ETH) with our prepared deposit data

2. Malicious deposit data contains the same validator pubkey, minimal deposit for deposit contract (1ETH or 16ETH on RocketPool), and our withdrawal credentials.

if pubkey not in validator_pubkeys on the client side would be called first as it would never be seen before the validator’s key. The second deposit, which we frontrun, would increase our deposit by 32 ETH. Those 32 ETH, as a reminder, came from the pool users.

Vulnerability Fix

RocketPool is currently on testnet and is working on a fix with its auditors before public launch.

Lido has already implemented a temporary fix. Lido decided to temporarily lower staking limits to currently staked amounts. This will result in disallowing the next deposits for currently staked amounts. The team in the meantime will work on the mid-term solution described in this forum post: https://research.lido.fi/t/mitigations-for-deposit-front-running-vulnerability/1239

Acknowledgments

We want to thank Dmitri for a vital find and detailed report. We want to also thank RocketPool and Lido for their swift response and fast handling of the issue and the payout.

If you’d like to start bug hunting, we got you. Check out the Web3 Security Library, and start earning rewards on Immunefi — the leading bug bounty platform for web3 with the world’s biggest payouts.

To report additional vulnerabilities, please see RocketPool and Lido’s bug bounty programs with Immunefi. If you’re interested in protecting your project with a bug bounty, visit the Immunefi services page and fill out the form.

--

--

Immunefi
Immunefi

Immunefi is the premier bug bounty platform for smart contracts, where hackers review code, disclose vulnerabilities, get paid, and make crypto safer.