An attacker can prevent liquidation by calling lock
Summary
The chief contract does not allow calling free in the same block that lock is called. When a VoteDelegate (VD) is selected, chief.free is called during liquidation. An attacker can call LSE.lock every block, or simply front-run liquidations, to avoid them.
Vulnerability Detail
Liquidations will revert because dog.bark => LSClipper.kick => LSE.onKick => LSE._selectVoteDelegate => VoteDelegateLike(prevVoteDelegate).free(wad); => chief.free(wad);will revert.
See current chief lines 459 and 448. Note: GitHub has an outdated version.
The liquidator will also have to pay for reverting transactions, which may make liquidations unprofitable.
The cost of the attack is 135,767 gas. It’s $0.8 when gas costs 2 gwei and $3,000/Eth:
0.8 x 5 (blocks per minute) x 60 = $240/hour or $5,760/day.
The attacker may wish to delay the liquidation if they believe the collateral price will increase, avoiding the 15% fee (see onRemove). Delaying liquidation can be economically more profitable, especially for large positions, considering the constant attack cost that does not depend on position size.
If the price of the collateral decreases significantly during the attack, it can create significant bad debt for the system.
An attacker can indefinitely delay the liquidation for $240/hour or deny liquidations by front-running them. This can cause losses for the protocol, as positions can go underwater and create bad debt, depending on the collateral type used.
Code Snippet
PoC
Create test/ALockstakeEngine.sol in the root project directory.
test/ALockstakeEngine.sol
00xSEV
Medium
An attacker can prevent liquidation by calling
lock
Summary
The
chief
contract does not allow callingfree
in the same block thatlock
is called. When a VoteDelegate (VD) is selected,chief.free
is called during liquidation. An attacker can callLSE.lock
every block, or simply front-run liquidations, to avoid them.Vulnerability Detail
Liquidations will revert because
dog.bark
=>LSClipper.kick
=>LSE.onKick
=>LSE._selectVoteDelegate
=>VoteDelegateLike(prevVoteDelegate).free(wad);
=>chief.free(wad);
will revert.See current chief lines 459 and 448. Note: GitHub has an outdated version.
The liquidator will also have to pay for reverting transactions, which may make liquidations unprofitable.
The cost of the attack is 135,767 gas. It’s $0.8 when gas costs 2 gwei and $3,000/Eth: 0.8 x 5 (blocks per minute) x 60 = $240/hour or $5,760/day.
The attacker may wish to delay the liquidation if they believe the collateral price will increase, avoiding the 15% fee (see onRemove). Delaying liquidation can be economically more profitable, especially for large positions, considering the constant attack cost that does not depend on position size.
If the price of the collateral decreases significantly during the attack, it can create significant bad debt for the system.
Similar Issues
(Valid mediums)
Impact
An attacker can indefinitely delay the liquidation for $240/hour or deny liquidations by front-running them. This can cause losses for the protocol, as positions can go underwater and create bad debt, depending on the collateral type used.
Code Snippet
It is based on the
LockstakeEngine.t.sol
setUp
function:block.number
for caching RPC callschief
andpolling
contracts from mainnetVoteDelegateFactory
To see the diff, you can run
git diff
. Note: all other functions exceptsetUp
are removed from the file and the diff.git diff --no-index lockstake/test/LockstakeEngine.t.sol test/ALockstakeEngine.sol
```diff diff --git a/lockstake/test/LockstakeEngine.t.sol b/test/ALockstakeEngine.sol index 83fa75d..ba4f381 100644 --- a/lockstake/test/LockstakeEngine.t.sol +++ b/test/ALockstakeEngine.sol @@ -2,20 +2,32 @@ pragma solidity ^0.8.21; -import "dss-test/DssTest.sol"; -import "dss-interfaces/Interfaces.sol"; -import { LockstakeDeploy } from "deploy/LockstakeDeploy.sol"; -import { LockstakeInit, LockstakeConfig, LockstakeInstance } from "deploy/LockstakeInit.sol"; -import { LockstakeMkr } from "src/LockstakeMkr.sol"; -import { LockstakeEngine } from "src/LockstakeEngine.sol"; -import { LockstakeClipper } from "src/LockstakeClipper.sol"; -import { LockstakeUrn } from "src/LockstakeUrn.sol"; -import { VoteDelegateFactoryMock, VoteDelegateMock } from "test/mocks/VoteDelegateMock.sol"; -import { GemMock } from "test/mocks/GemMock.sol"; -import { NstMock } from "test/mocks/NstMock.sol"; -import { NstJoinMock } from "test/mocks/NstJoinMock.sol"; -import { StakingRewardsMock } from "test/mocks/StakingRewardsMock.sol"; -import { MkrNgtMock } from "test/mocks/MkrNgtMock.sol"; +import "../dss-flappers/lib/dss-test/src//DssTest.sol"; +import "../dss-flappers/lib/dss-test/lib/dss-interfaces/src/Interfaces.sol"; +import { LockstakeDeploy } from "../lockstake/deploy/LockstakeDeploy.sol"; +import { LockstakeInit, LockstakeConfig, LockstakeInstance } from "../lockstake/deploy/LockstakeInit.sol"; +import { LockstakeMkr } from "../lockstake/src/LockstakeMkr.sol"; +import { LockstakeEngine } from "../lockstake/src/LockstakeEngine.sol"; +import { LockstakeClipper } from "../lockstake/src/LockstakeClipper.sol"; +import { LockstakeUrn } from "../lockstake/src/LockstakeUrn.sol"; +import { VoteDelegateFactoryMock, VoteDelegateMock } from "../lockstake/test/mocks/VoteDelegateMock.sol"; +import { GemMock } from "../lockstake/test/mocks/GemMock.sol"; +import { NstMock } from "../lockstake/test/mocks/NstMock.sol"; +import { NstJoinMock } from "../lockstake/test/mocks/NstJoinMock.sol"; +import { StakingRewardsMock } from "../lockstake/test/mocks/StakingRewardsMock.sol"; +import { MkrNgtMock } from "../lockstake/test/mocks/MkrNgtMock.sol"; + +import {VoteDelegateFactory} from "../vote-delegate/src/VoteDelegateFactory.sol"; +import {VoteDelegate} from "../vote-delegate/src/VoteDelegate.sol"; + + +contract DSChiefLike { + DSTokenAbstract public IOU; + DSTokenAbstract public GOV; + mapping(address=>uint256) public deposits; + function free(uint wad) public {} + function lock(uint wad) public {} +} interface CalcFabLike { function newLinearDecrease(address) external returns (address); @@ -29,7 +41,7 @@ interface MkrAuthorityLike { function rely(address) external; } -contract LockstakeEngineTest is DssTest { +contract ALockstakeEngineTest is DssTest { using stdStorage for StdStorage; DssInstance dss; @@ -40,7 +52,7 @@ contract LockstakeEngineTest is DssTest { LockstakeClipper clip; address calc; MedianAbstract pip; - VoteDelegateFactoryMock voteDelegateFactory; + VoteDelegateFactory voteDelegateFactory; NstMock nst; NstJoinMock nstJoin; GemMock rTok; @@ -84,8 +96,13 @@ contract LockstakeEngineTest is DssTest { } } - function setUp() public { - vm.createSelectFork(vm.envString("ETH_RPC_URL")); + // Real contracts for mainnet + address chief = 0x0a3f6849f78076aefaDf113F5BED87720274dDC0; + address polling = 0xD3A9FE267852281a1e6307a1C37CDfD76d39b133; + uint chiefBalanceBeforeTests; + + function setUp() public virtual { + vm.createSelectFork(vm.envString("ETH_RPC_URL"), 20422954); dss = MCD.loadFromChainlog(LOG); @@ -101,7 +118,10 @@ contract LockstakeEngineTest is DssTest { MkrAuthorityLike(mkr.authority()).rely(address(mkrNgt)); vm.stopPrank(); - voteDelegateFactory = new VoteDelegateFactoryMock(address(mkr)); + // voteDelegateFactory = new VoteDelegateFactoryMock(address(mkr)); + voteDelegateFactory = new VoteDelegateFactory( + chief, polling + ); voter = address(123); vm.prank(voter); voteDelegate = voteDelegateFactory.create(); ```Add the following
remappings.txt
to the root project directory.forge test --match-path test/ALSEH7.sol
from root project directorypragma solidity ^0.8.21;
import "./ALockstakeEngine.sol";
contract ALSEH7 is ALockstakeEngineTest { function testLiquidate() external { deal(address(mkr), address(this), 100_000 * 1018, true); address urn = engine.open(0); mkr.approve(address(engine), 100_000 * 10*18); engine.lock(urn, 50_000 1018, 5); engine.draw(urn, address(this), 50_000 * 10**18); engine.selectVoteDelegate(urn, voteDelegate);
}