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: 9/75
Findings: 1
Award: $2,676.76
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: broccolirob
2676.7554 USDC - $2,676.76
https://github.com/code-423n4/2023-06-stader/blob/7566b5a35f32ebd55d3578b8bd05c038feb7d9cc/contracts/VaultProxy.sol#L20-L36 https://github.com/code-423n4/2023-06-stader/blob/7566b5a35f32ebd55d3578b8bd05c038feb7d9cc/contracts/VaultProxy.sol#L41-L50
When the VaultFactory
contract is deployed and initialized, the initialise
method on the newly created VaultProxy
implementation contract is never called. As such, anyone can call that method and pass in whatever values they want as arguments. One important argument is the _staderConfig
address, which controls where the fallback
function will direct delegatecall
operations. If an attacker passes in a contract that calls selfdestruct
it will be run in the context of the VaultProxy
implementation contract, and will erase all code from that address. Since the clones from the VaultProxy contract merely delegate calls to the implementation address, all subsequent calls for all created vaults from that implementation, will be treated like an EOA and return true
even though calls to functions on that proxy were never executed.
AttackContract
that calls selfdestruct
in its fallback
function.contract AttackContract { function getValidatorWithdrawalVaultImplementation() public view returns(address) { return address(this); } function getNodeELRewardVaultImplementation() public view returns(address) { return address(this); } fallback(bytes calldata _input) external payable returns(bytes memory _output) { selfdestruct(address(0)); } }
initialise
method on the VaultProxy
implementation contract. That address is stored in the vaultProxyImplementation
variable on the VaultFactory
contract. The attacker passes in the address of AttackContract
as the _staderConfig
argument for the initialise
function.VaultProxy
implementation contract, which triggers it's fallback
function. The fallback
function calls staderConfig.getNodeELRewardVaultImplementation()
, and since staderConfig
is set the AttackContract
address, it returns the address of the AttackContract
. delegatecall
runs the fallback function of AttackContract
in its own execution environment. selfdestruct
is called in the execution environment of the VaultProxy
implementation, which erases the code at that address.VaultProxy
implementation contract are now forwarding calls to an implementation address that has no code stored at it. These calls will be treated like calls to an EOA and return true
for success
.Manual Analysis
Prevent the initialise
function from being called on the VaultProxy
implementation contract by inheriting from OpenZeppelin's Initializable
contract, like the system is doing in other contracts. Call the _disableInitializers
function in the constructor and protect initialise
with the initializer
modifier. Alternatively, the initialise
function can be called from the initialize
function of the VaultFactory
contract when the VaultProxy
contract is instantiated.
Access Control
#0 - c4-judge
2023-06-10T10:48:24Z
Picodes marked the issue as duplicate of #167
#1 - c4-judge
2023-06-26T15:49:39Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-06-26T15:51:08Z
Picodes marked the issue as selected for report
#3 - Picodes
2023-07-03T12:13:53Z
Keeping High severity as this seems exploitable to lock funds with no cost as the fallback function is payable
#4 - c4-sponsor
2023-07-04T05:27:01Z
sanjay-staderlabs marked the issue as sponsor confirmed
#5 - sanjay-staderlabs
2023-07-13T04:16:39Z
This is fixed in the code