Summary
The depositIntoVault
function fails to update the users balance in the usersToStakes
array, this allows a user to call depositIntoVault
multiple times, depositing their staked amount into the vault and receiving shares for that amount each time.
Vulnerability Details (POC)
as we can see in the function depositIntoVault
:
@external
def depositIntoVault() -> uint256:
"""
@notice Allows users who have staked ETH during the staking period to deposit their ETH
into the WETH Steak vault.
@dev Before depositing into the vault, the raw ETH is converted into WETH.
@return The amount of shares received from the WETH Steak vault.
"""
assert self._hasStakingPeriodEndedAndVaultAddressSet(), STEAK__STAKING_PERIOD_NOT_ENDED_OR_VAULT_ADDRESS_NOT_SET
stakedAmount: uint256 = self.usersToStakes[msg.sender]
assert stakedAmount > 0, STEAK__AMOUNT_ZERO
extcall IWETH(WETH).deposit(value=stakedAmount)
extcall IWETH(WETH).approve(self.vault, stakedAmount)
sharesReceived: uint256 = extcall IWETHSteakVault(self.vault).deposit(stakedAmount, msg.sender)
log DepositedIntoVault(msg.sender, stakedAmount, sharesReceived)
return sharesReceived
The stakedAmount
is used to calculate the amount of shares received but fails to update this state before sending the amount to the vault.
In Steaking.t.sol
add a second user to the setup:
contract SteakingTest is Test, EventsAndErrors {
address public owner;
address public user1;
+ address public user2;
ISteaking public steaking;
MockWETH public weth;
MockWETHSteakVault public wethSteakVault;
function setUp() public {
string memory path = "src/Steaking.vy";
owner = makeAddr("owner");
user1 = makeAddr("user1");
+ user2 = makeAddr("user2");
Then add the sollowing test:
function testDepositIntoVaultCanBeCalledMultipleTimes() public {
uint256 dealAmount = 1 ether;
uint256 dealAmount2 = 2 ether;
vm.deal(user1, dealAmount);
vm.deal(user2, dealAmount2);
_stake(user1, dealAmount, user1);
_stake(user2, dealAmount2, user2);
_endStakingPeriod();
vm.startPrank(owner);
steaking.setVaultAddress(address(wethSteakVault));
vm.stopPrank();
vm.startPrank(user1);
steaking.depositIntoVault();
vm.stopPrank();
vm.startPrank(user1);
steaking.depositIntoVault();
vm.stopPrank();
vm.startPrank(user2);
vm.expectRevert();
steaking.depositIntoVault();
vm.stopPrank();
uint256 wethSteakVaultShares = wethSteakVault.balanceOf(user1);
assertEq(wethSteakVaultShares, dealAmount2);
}
In this scenario 2 users have staked to the protocol.
user1 stakes 1 ETH
user2 stakes 2 ETH
staking period ends
user1 deposits to the vault for a value of 1 ETH
user1 calls deposit again for a value of 1 ETH
user2 calls deposit for a value of 2 ETH, but fails due to the contract only having 1 ETH of balance
Impact
A user can call this function multiple times, denying other users of a claim to their shares.
Tools Used
Foundry, Manual review
Recommendations
Update the state of the users Balance after getting the sharesAmount
value for the deposit.
@external
def depositIntoVault() -> uint256:
"""
@notice Allows users who have staked ETH during the staking period to deposit their ETH
into the WETH Steak vault.
@dev Before depositing into the vault, the raw ETH is converted into WETH.
@return The amount of shares received from the WETH Steak vault.
"""
assert self._hasStakingPeriodEndedAndVaultAddressSet(), STEAK__STAKING_PERIOD_NOT_ENDED_OR_VAULT_ADDRESS_NOT_SET
stakedAmount: uint256 = self.usersToStakes[msg.sender]
+ self.usersToStakes[msg.sender] -= stakedAmount
assert stakedAmount > 0, STEAK__AMOUNT_ZERO
extcall IWETH(WETH).deposit(value=stakedAmount)
extcall IWETH(WETH).approve(self.vault, stakedAmount)
sharesReceived: uint256 = extcall IWETHSteakVault(self.vault).deposit(stakedAmount, msg.sender)
log DepositedIntoVault(msg.sender, stakedAmount, sharesReceived)
return sharesReceived