Platform: Code4rena
Start Date: 15/03/2024
Pot Size: $60,500 USDC
Total HM: 16
Participants: 43
Period: 21 days
Judge: hansfriese
Total Solo HM: 5
Id: 348
League: ETH
Rank: 6/43
Findings: 5
Award: $4,573.16
π Selected for report: 1
π Solo Findings: 0
π Selected for report: nonseodion
Also found by: serial-coder
2108.9448 USDC - $2,108.94
Judge has assessed an item in Issue #266 as 3 risk. The relevant finding follows:
[L-02] Incorrectly executing the updateErcDebt() leads to virtually minting the ercDebt more than necessary
#0 - c4-judge
2024-04-17T06:46:39Z
hansfriese marked the issue as satisfactory
#1 - c4-judge
2024-04-17T06:47:12Z
hansfriese marked the issue as duplicate of #134
π Selected for report: samuraii77
Also found by: samuraii77, serial-coder
1581.7086 USDC - $1,581.71
Judge has assessed an item in Issue #266 as 3 risk. The relevant finding follows:
Incorrect verification of shorter and shortRecordId in RedemptionFacet::claimRemainingCollateral()
#0 - c4-judge
2024-04-11T16:08:36Z
hansfriese marked the issue as duplicate of #104
#1 - c4-judge
2024-04-11T16:08:43Z
hansfriese marked the issue as satisfactory
#2 - hansfriese
2024-04-17T06:36:13Z
I will apply 75% partial credits due to the lack of a detailed impact explanation.
#3 - c4-judge
2024-04-17T06:36:22Z
hansfriese marked the issue as partial-75
π Selected for report: serial-coder
Also found by: unix515
822.4885 USDC - $822.49
https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/RedemptionFacet.sol#L81 https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/RedemptionFacet.sol#L110-L114 https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/RedemptionFacet.sol#L38
The BidOrdersFacet::bidMatchAlgo()
allows a shortOrder
to be partially matched and leave its ercAmount
* price
< minAskEth
due to the DUST_FACTOR
constant (as long as its corresponding shortRecord
is maintaining enough ercDebt
+ shortOrder
's ercAmount
to keep the position >= the minShortErc
threshold).
The redemption process enables redeemers to redeem their ercEscrowed
for ethCollateral
on target shortRecords
. If a shortRecord
was partially filled, the RedemptionFacet::proposeRedemption()
must guarantee that the corresponding shortOrder
maintains the ercAmount
>= minShortErc
. In other words, if the shortOrder
's ercAmount
is less than the minShortErc
threshold, the shortOrder
must be canceled from the order book. Otherwise, the shortRecord
position will be less than the minShortErc
threshold when the order is matched again. Subsequently, the small shortRecord
(short
position) will not incentivize liquidators to liquidate it even if it is liquidable, leaving bad debt to the protocol.
I discovered that the proposeRedemption()
improperly verifies the proposer (redeemer)'s inputted shortOrderId
param, allowing an attacker to specify the shortOrderId
param to another shortOrder
's id that does not correspond to the target redeeming shortRecord
.
The vulnerability can bypass the minShortErc
threshold verification process on the shortOrder
corresponding to the processing shortRecord
, eventually allowing an attacker to leave a small shortRecord
position that disincentivizes liquidators from liquidating the position. Furthermore, the small shortRecord
also disables the redemption mechanism from redeeming it for ethCollateral
.
While processing the proposalInput
param, the proposeRedemption()
will load the shortOrder
from storage according to the attacker-inputted shortOrderId
param (i.e., p.shortOrderId
).
Suppose that the attacker wants to leave their small shortRecord
position to disincentivize liquidators from liquidating it (as well as disabling the redemption mechanism from redeeming it for ethCollateral
). They can place their partially-filled shortRecord
that has the corresponding shortOrder.ercAmount
< minShortErc
to be redeemed via a Sybil account.
To bypass the minShortErc
threshold verification process from canceling their small shortOrder
, the attacker must specify the p.shortOrderId
param to another shortOrder
's id with ercAmount
>= minShortErc
. The manipulated p.shortOrderId
param can bypass the verification process because if the loaded shortOrder.ercAmount
>= minShortErc
, the proposeRedemption()
will not proceed to verify the validity of the shortOrder
.
Hence, the attacker can target their shortRecord
to be redeemed and leave its corresponding shortOrder
with ercAmount
< minShortErc
to keep alive on the order book. Once their small shortOrder
is matched again, it will leave the shortRecord
position less than the minShortErc
threshold, disincentivizing liquidators from liquidating it.
Furthermore, the small shortRecord
(i.e., shortRecord.ercDebt
< minShortErc
) also disables the redemption mechanism from redeeming it for ethCollateral
.
function proposeRedemption( address asset, MTypes.ProposalInput[] calldata proposalInput, uint88 redemptionAmount, uint88 maxRedemptionFee ) external isNotFrozen(asset) nonReentrant { ... for (uint8 i = 0; i < proposalInput.length; i++) { p.shorter = proposalInput[i].shorter; p.shortId = proposalInput[i].shortId; p.shortOrderId = proposalInput[i].shortOrderId; STypes.ShortRecord storage currentSR = s.shortRecords[p.asset][p.shorter][p.shortId]; ... @1 STypes.Order storage shortOrder = s.shorts[asset][p.shortOrderId]; //@audit -- The shortOrder is loaded from storage according to the attacker's p.shortOrderId param @2 if (currentSR.status == SR.PartialFill && shortOrder.ercAmount < minShortErc) { //@audit -- The attacker can leave their target partially-filled shortOrder (corresponding to the redeeming shortRecord) with ercAmount < minShortErc alive by specifying the p.shortOrderId param to another shortOrder's id with ercAmount >= minShortErc @3 if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder(); //@audit -- The root cause is that the proposeRedemption() verifies the loaded shortOrder against the legitimate shortRecordId and shorter params only after the condition in @2 was met LibOrders.cancelShort(asset, p.shortOrderId); } ... } ... }
@1 -- The shortOrder is loaded from storage according to the attacker's p.shortOrderId param
: https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/RedemptionFacet.sol#L110
@2 -- The attacker can leave their target partially-filled shortOrder (corresponding to the redeeming shortRecord) with ercAmount < minShortErc alive by specifying the p.shortOrderId param to another shortOrder's id with ercAmount >= minShortErc
: https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/RedemptionFacet.sol#L111
@3 -- The root cause is that the proposeRedemption() verifies the loaded shortOrder against the legitimate shortRecordId and shorter params only after the condition in @2 was met
: https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/RedemptionFacet.sol#L112
Manual Review
To fix the vulnerability, move out the shortOrder
verification check and execute it immediately after loading the shortOrder
from storage.
function proposeRedemption( address asset, MTypes.ProposalInput[] calldata proposalInput, uint88 redemptionAmount, uint88 maxRedemptionFee ) external isNotFrozen(asset) nonReentrant { ... for (uint8 i = 0; i < proposalInput.length; i++) { p.shorter = proposalInput[i].shorter; p.shortId = proposalInput[i].shortId; p.shortOrderId = proposalInput[i].shortOrderId; STypes.ShortRecord storage currentSR = s.shortRecords[p.asset][p.shorter][p.shortId]; ... STypes.Order storage shortOrder = s.shorts[asset][p.shortOrderId]; + if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder(); if (currentSR.status == SR.PartialFill && shortOrder.ercAmount < minShortErc) { - if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder(); LibOrders.cancelShort(asset, p.shortOrderId); } ... } ... }
Invalid Validation
#0 - c4-pre-sort
2024-04-07T02:32:34Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-04-07T02:32:38Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2024-04-07T02:34:57Z
Inadequate/unstructured proof to support the intended code refactoring.
#3 - c4-judge
2024-04-17T05:38:17Z
hansfriese marked the issue as unsatisfactory: Insufficient proof
#4 - serial-coder
2024-04-17T11:42:17Z
Hi @hansfriese,
Appeal
Please have a second look at the following from the Proof of Concept
section:
While processing the
proposalInput
param, theproposeRedemption()
will load theshortOrder
from storage according to the attacker-inputtedshortOrderId
param (i.e.,p.shortOrderId
).Suppose that the attacker wants to leave their small
shortRecord
position to disincentivize liquidators from liquidating it (as well as disabling the redemption mechanism from redeeming it forethCollateral
). They can place their partially-filledshortRecord
that has the correspondingshortOrder.ercAmount
<minShortErc
to be redeemed via a Sybil account.To bypass the
minShortErc
threshold verification process from canceling their smallshortOrder
, the attacker must specify thep.shortOrderId
param to anothershortOrder
's id withercAmount
>=minShortErc
. The manipulatedp.shortOrderId
param can bypass the verification process because if theloaded shortOrder.ercAmount
>=minShortErc
, theproposeRedemption()
will not proceed to verify the validity of theshortOrder
.Hence, the attacker can target their
shortRecord
to be redeemed and leave its correspondingshortOrder
withercAmount
<minShortErc
to keep alive on the order book. Once their smallshortOrder
is matched again, it will leave theshortRecord
position less than theminShortErc
threshold, disincentivizing liquidators from liquidating it.Furthermore, the small
shortRecord
(i.e.,shortRecord.ercDebt
<minShortErc
) also disables the redemption mechanism from redeeming it forethCollateral
.
To demystify the vulnerability again, please follow the links along:
The shortOrder
is loaded from storage according to the attacker's p.shortOrderId
param.
The attacker can leave their target partially-filled shortOrder
(corresponding to the redeeming shortRecord
) with ercAmount
< minShortErc
alive on the order book by specifying the p.shortOrderId
param to another shortOrder
's id with ercAmount
>= minShortErc
(to bypass the if
statement).
The root cause is that the proposeRedemption()
will verify the loaded shortOrder
against the shortRecordId
and shorter
params only after the condition in Step 2 was met.
Since the attacker already bypassed the condition check in Step 2, Step 3 would not be executed.
Hence, if the attacker points the p.shortOrderId
param to another (fake) shortOrder
's id with ercAmount
>= minShortErc
, they can bypass the validity check of the loaded (fake) shortOrder
, preventing their targeted small shortOrder
(the real shortOrder
corresponding to the redeeming shortRecord
) from being canceled.
Subsequently, the attacker can leave their target small shortOrder
on the order book and when it is matched, its short position (shortRecord
) will be less than theΒ minShortErc
Β threshold that will disincentivize liquidators from liquidating the position even if it is liquidable, leading to the protocol's bad debt. Moreover, the smallΒ shortRecord
Β (i.e.,Β shortRecord.ercDebt
Β <Β minShortErc
) will also disable the redemption mechanism from redeeming it forΒ ethCollateral
.
An attacker can leave small shortOrders
on the order book. As a result, large Bid
orders can run out of gas if the attacker places many small shortOrders
on the order book.
Liquidators are disincentivized from liquidating small shortRecords
because of their small amounts.
Small shortRecords
disable the redemption mechanism from redeeming them forΒ ethCollateral
even if they have poor collateralization.
The snippet below presents how to fix the vulnerability by verifying the validity of the loaded shortOrder
(Step 1) against the shortRecordId
and shorter
params (Step 3) before processing the loaded shortOrder
(Step 2).
This way, an attacker cannot manipulate the p.shortOrderId
param to another (fake) shortOrder
's id.
function proposeRedemption( address asset, MTypes.ProposalInput[] calldata proposalInput, uint88 redemptionAmount, uint88 maxRedemptionFee ) external isNotFrozen(asset) nonReentrant { ... for (uint8 i = 0; i < proposalInput.length; i++) { p.shorter = proposalInput[i].shorter; p.shortId = proposalInput[i].shortId; p.shortOrderId = proposalInput[i].shortOrderId; STypes.ShortRecord storage currentSR = s.shortRecords[p.asset][p.shorter][p.shortId]; ... STypes.Order storage shortOrder = s.shorts[asset][p.shortOrderId]; //@audit -- Step 1 + if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder(); //@audit -- Process Step 3 before Step 2 to fix the vulnerability if (currentSR.status == SR.PartialFill && shortOrder.ercAmount < minShortErc) { //@audit -- Step 2 - if (shortOrder.shortRecordId != p.shortId || shortOrder.addr != p.shorter) revert Errors.InvalidShortOrder(); //@audit -- Step 3 LibOrders.cancelShort(asset, p.shortOrderId); } ... } ... }
#5 - ditto-eth
2024-04-19T15:46:37Z
This is valid, good find
#6 - c4-sponsor
2024-04-19T15:47:18Z
ditto-eth (sponsor) confirmed
#7 - hansfriese
2024-04-21T10:46:24Z
Nice finding.
Medium is appropriate as an attacker can bypass the minShortErc
validation.
#8 - c4-judge
2024-04-21T10:46:29Z
hansfriese removed the grade
#9 - c4-judge
2024-04-21T10:46:37Z
hansfriese marked the issue as satisfactory
#10 - c4-judge
2024-04-21T10:46:50Z
hansfriese marked the issue as selected for report
π Selected for report: klau5
Also found by: 0xSecuri, Bauchibred, Bigsam, Infect3d, falconhoof, nonseodion, serial-coder
42.0292 USDC - $42.03
https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/libraries/LibOracle.sol#L125-L126 https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/libraries/LibOracle.sol#L77
This report raises an issue regarding the lack of a stale price check for the base oracle (ETH/USD price) in the
oracleCircuitBreaker()
only, as the lack of a stale price check for the non-ETH/USD asset oracle (multi-asset oracle) was flagged as a known issue.
The LibOracle::oracleCircuitBreaker()
does not check the stale price for the base oracle (ETH/USD price). Specifically, I'm talking about the condition: "block.timestamp > 2 hours + baseTimeStamp
" (i.e., the 2-hour stale heartbeat as per the docs). Hence, the function cannot verify whether or not the baseChainlinkPrice
is stale.
Consequently, the oracleCircuitBreaker()
will not revert the transaction as expected if the baseChainlinkPrice
is stale. As a result, the protocol's core functions will consume the stale price, harming the protocol's and users' funds.
The oracleCircuitBreaker()
does not check the stale price for the base oracle (ETH/USD price) (i.e., the condition: "block.timestamp > 2 hours + baseTimeStamp
") compared to the baseOracleCircuitBreaker()
.
Without the stale price check mentioned above, the oracleCircuitBreaker()
cannot verify whether the baseChainlinkPrice
is stale. For this reason, the oracleCircuitBreaker()
will not revert the transaction as expected if the baseChainlinkPrice
is stale.
//@audit -- This report raises an issue regarding the lack of a stale price check for the base oracle (ETH/USD price) in the oracleCircuitBreaker() only, as the lack of a stale price check for the non-ETH/USD asset oracle (multi-asset oracle) was flagged as a known issue function oracleCircuitBreaker( uint80 roundId, uint80 baseRoundId, int256 chainlinkPrice, int256 baseChainlinkPrice, uint256 timeStamp, uint256 baseTimeStamp ) private view { @1 bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0 @1 || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0; //@audit -- The oracleCircuitBreaker() lacks checking the stale price for the base oracle (ETH/USD price) (condition: "block.timestamp > 2 hours + baseTimeStamp") if (invalidFetchData) revert Errors.InvalidPrice(); } ... function baseOracleCircuitBreaker( uint256 protocolPrice, uint80 roundId, int256 chainlinkPrice, uint256 timeStamp, uint256 chainlinkPriceInEth ) private view returns (uint256 _protocolPrice) { bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0 @2 || block.timestamp > 2 hours + timeStamp; //@audit -- Whereas the baseOracleCircuitBreaker() already checks that condition as per the docs uint256 chainlinkDiff = chainlinkPriceInEth > protocolPrice ? chainlinkPriceInEth - protocolPrice : protocolPrice - chainlinkPriceInEth; bool priceDeviation = protocolPrice > 0 && chainlinkDiff.div(protocolPrice) > 0.5 ether; ... }
@1 -- The oracleCircuitBreaker() lacks checking the stale price for the base oracle (ETH/USD price) (condition: "block.timestamp > 2 hours + baseTimeStamp")
: https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/libraries/LibOracle.sol#L125-L126
@2 -- Whereas the baseOracleCircuitBreaker() already checks that condition as per the docs
: https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/libraries/LibOracle.sol#L77
Manual Review
Add the stale price check for the base oracle (the condition: block.timestamp > 2 hours + baseTimeStamp
) in the oracleCircuitBreaker()
.
function oracleCircuitBreaker( uint80 roundId, uint80 baseRoundId, int256 chainlinkPrice, int256 baseChainlinkPrice, uint256 timeStamp, uint256 baseTimeStamp ) private view { bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0 - || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0; + || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0 + || block.timestamp > 2 hours + baseTimeStamp; //@audit -- Add the stale price check for the base oracle (ETH/USD price) if (invalidFetchData) revert Errors.InvalidPrice(); }
Oracle
#0 - c4-pre-sort
2024-04-05T21:16:34Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-04-05T21:16:40Z
raymondfam marked the issue as duplicate of #4
#2 - raymondfam
2024-04-05T21:17:07Z
See #4.
#3 - c4-judge
2024-04-12T16:57:54Z
hansfriese marked the issue as unsatisfactory: Out of scope
#4 - serial-coder
2024-04-17T11:36:54Z
Hi @hansfriese,
Appeal
This issue is not a duplicate of #4.
As #4 refers to the missing checks for roundId
, price
, updateTime
, and answeredInRound
, which was flagged as a known issue. This issue refers to an incorrect implementation of the base oracle
(ETH/USD price
) in theΒ oracleCircuitBreaker()
.
To elaborate, the docs (bullet 5) mentioned that the check: "timeStamp + 2 hours < block.timestamp
" (i.e., the 2-hour heartbeat
for the base price
) must be performed to verify the Chainlink data.
Apparently, the baseOracleCircuitBreaker()
correctly checks the base oracle in question (see @2
in the snippet below). However, the sponsor didn't implement this check for the same base oracle in the oracleCircuitBreaker()
(see @1
).
Hence, this issue is not a duplicate of #4, and it raises another point that is a valid issue because the sponsor implemented the use of base oracle
(ETH/USD price
) incorrectly (please refer to the docs (bullet 5)).
//@audit -- This report raises an issue regarding the lack of a stale price check for the base oracle (ETH/USD price) in the oracleCircuitBreaker() only, as the lack of a stale price check for the non-ETH/USD asset oracle (multi-asset oracle) was flagged as a known issue function oracleCircuitBreaker( uint80 roundId, uint80 baseRoundId, int256 chainlinkPrice, int256 baseChainlinkPrice, uint256 timeStamp, uint256 baseTimeStamp ) private view { @1 bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0 @1 || baseRoundId == 0 || baseTimeStamp == 0 || baseTimeStamp > block.timestamp || baseChainlinkPrice <= 0; //@audit -- The oracleCircuitBreaker() lacks checking the stale price for the base oracle (ETH/USD price) (condition: "block.timestamp > 2 hours + baseTimeStamp") if (invalidFetchData) revert Errors.InvalidPrice(); } ... function baseOracleCircuitBreaker( uint256 protocolPrice, uint80 roundId, int256 chainlinkPrice, uint256 timeStamp, uint256 chainlinkPriceInEth ) private view returns (uint256 _protocolPrice) { bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0 @2 || block.timestamp > 2 hours + timeStamp; //@audit -- Whereas the baseOracleCircuitBreaker() already checks that condition as per the docs uint256 chainlinkDiff = chainlinkPriceInEth > protocolPrice ? chainlinkPriceInEth - protocolPrice : protocolPrice - chainlinkPriceInEth; bool priceDeviation = protocolPrice > 0 && chainlinkDiff.div(protocolPrice) > 0.5 ether; ... }
@1 -- The oracleCircuitBreaker() lacks checking the stale price for the base oracle (ETH/USD price) (condition: "block.timestamp > 2 hours + baseTimeStamp")
: https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/libraries/LibOracle.sol#L125-L126
@2 -- Whereas the baseOracleCircuitBreaker() already checks that condition as per the docs
: https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/libraries/LibOracle.sol#L77
#5 - ditto-eth
2024-04-19T15:43:45Z
Agree, this seems like a valid issue. Returned base oracle price could be stale.
#6 - c4-sponsor
2024-04-19T15:47:32Z
ditto-eth (sponsor) confirmed
#7 - c4-judge
2024-04-21T08:13:28Z
hansfriese marked the issue as satisfactory
#8 - c4-judge
2024-04-21T08:13:36Z
hansfriese marked the issue as not a duplicate
#9 - c4-judge
2024-04-21T08:14:00Z
hansfriese marked the issue as selected for report
#10 - c4-judge
2024-04-21T08:15:52Z
hansfriese marked the issue as primary issue
#11 - c4-judge
2024-04-21T08:41:44Z
hansfriese marked the issue as not selected for report
#12 - c4-judge
2024-04-21T08:43:58Z
hansfriese marked the issue as duplicate of #164
#13 - c4-judge
2024-04-21T08:44:18Z
hansfriese marked the issue as partial-50
π Selected for report: hihen
Also found by: 0xSecuri, 0xbepresent, Bigsam, Bube, Infect3d, Pechenite, Rolezn, Topmark, albahaca, caglankaan, cheatc0d3, klau5, nonseodion, oualidpro, pfapostol, serial-coder, slvDev
17.9877 USDC - $17.99
shorter
and shortRecordId
in RedemptionFacet::claimRemainingCollateral()
The RedemptionFacet::claimRemainingCollateral()
incorrectly verifies the shorter
(msg.sender
) and shortRecordId
(the inputted id
) parameters, which can lead to further exploits or unexpected circumstances.
As you can see below, the claimRemainingCollateral()
verifies the shorter
(msg.sender
) and shortRecordId
(the inputted id
) parameters using the &&
operator.
Therefore, the function caller can easily bypass the verification by inputting one of the two logical expressions to be false
. For example, inputting the id
parameter == claimProposal.shortId
.
function claimRemainingCollateral(address asset, address redeemer, uint8 claimIndex, uint8 id) external isNotFrozen(asset) nonReentrant { STypes.AssetUser storage redeemerAssetUser = s.assetUser[asset][redeemer]; if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption(); if (redeemerAssetUser.timeToDispute > LibOrders.getOffsetTime()) revert Errors.TimeToDisputeHasNotElapsed(); // @dev Only need to read up to the position of the SR to be claimed MTypes.ProposalData[] memory decodedProposalData = LibBytes.readProposalData(redeemerAssetUser.SSTORE2Pointer, claimIndex + 1); MTypes.ProposalData memory claimProposal = decodedProposalData[claimIndex]; //@audit -- Incorrect conditional logic (must use the "||" operator instead of the "&&" operator) @> if (claimProposal.shorter != msg.sender && claimProposal.shortId != id) revert Errors.CanOnlyClaimYourShort(); STypes.Asset storage Asset = s.asset[asset]; _claimRemainingCollateral({asset: asset, vault: Asset.vault, shorter: msg.sender, shortId: id}); }
Incorrect conditional logic (must use the "||" operator instead of the "&&" operator)
: https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/RedemptionFacet.sol#L361Manual Review
Use the operator ||
instead of the &&
.
function claimRemainingCollateral(address asset, address redeemer, uint8 claimIndex, uint8 id) external isNotFrozen(asset) nonReentrant { STypes.AssetUser storage redeemerAssetUser = s.assetUser[asset][redeemer]; if (redeemerAssetUser.SSTORE2Pointer == address(0)) revert Errors.InvalidRedemption(); if (redeemerAssetUser.timeToDispute > LibOrders.getOffsetTime()) revert Errors.TimeToDisputeHasNotElapsed(); // @dev Only need to read up to the position of the SR to be claimed MTypes.ProposalData[] memory decodedProposalData = LibBytes.readProposalData(redeemerAssetUser.SSTORE2Pointer, claimIndex + 1); MTypes.ProposalData memory claimProposal = decodedProposalData[claimIndex]; - if (claimProposal.shorter != msg.sender && claimProposal.shortId != id) revert Errors.CanOnlyClaimYourShort(); + if (claimProposal.shorter != msg.sender || claimProposal.shortId != id) revert Errors.CanOnlyClaimYourShort(); STypes.Asset storage Asset = s.asset[asset]; _claimRemainingCollateral({asset: asset, vault: Asset.vault, shorter: msg.sender, shortId: id}); }
updateErcDebt()
leads to virtually minting the ercDebt
more than necessaryThe ExitShortFacet::exitShort()
executes the checkCancelShortOrder()
before the updateErcDebt()
.
Consequently, if the shortRecord
is partially filled and has the ercDebt
less than the minShortErc
threshold, the shortOrder
will be canceled and the ercDebt
will be virtually minted more than necessary to the shortRecord
(to maintain the minShortErc
threshold).
The exitShort()
executes the checkCancelShortOrder()
to check whether the shortRecord
is partially filled. The checkCancelShortOrder()
will cancel the shortOrder
if the shortRecord
is partially filled.
Then, the exitShort()
invokes the updateErcDebt()
to update the ercDebt
of the shortRecord
.
function exitShort( address asset, uint8 id, uint88 buybackAmount, uint80 price, uint16[] memory shortHintArray, uint16 shortOrderId ) external isNotFrozen(asset) nonReentrant onlyValidShortRecord(asset, msg.sender, id) { ... //@audit -- The checkCancelShortOrder() is executed before the updateErcDebt() @1 e.shortOrderIsCancelled = LibSRUtil.checkCancelShortOrder({ @1 asset: asset, @1 initialStatus: short.status, @1 shortOrderId: shortOrderId, @1 shortRecordId: id, @1 shorter: msg.sender @1 }); @1 @1 short.updateErcDebt(e.asset); ... }
@1 -- The checkCancelShortOrder() is executed before the updateErcDebt()
: https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/facets/ExitShortFacet.sol#L158-L166To cancel the shortOrder
, the cancelShort()
is triggered. The function will check if the position has the ercDebt
less than the minShortErc
threshold. If that is the case, the function will spend some of the collateral
to virtually mint the ercDebt
(to be == minShortErc
).
However, since the updateErcDebt()
is executed after the shortOrder
has already been canceled, the cancelShort()
will virtually mint the ercDebt
more than necessary.
Note: the updateErcDebt()
will increase the ercDebt
of the shortRecord. Therefore, if we execute the updateErcDebt()
before, the cancelShort()
will mint the ercDebt
lesser to maintain the minShortErc
threshold.
function cancelShort(address asset, uint16 id) internal { ... if (shortRecord.status == SR.Closed) { ... } else { uint88 minShortErc = uint88(LibAsset.minShortErc(asset)); if (shortRecord.ercDebt < minShortErc) { // @dev prevents leaving behind a partially filled SR is under minShortErc // @dev if the corresponding short is cancelled, then the partially filled SR's debt will == minShortErc @2 uint88 debtDiff = minShortErc - shortRecord.ercDebt; { STypes.Vault storage Vault = s.vault[vault]; @2 uint88 collateralDiff = shortOrder.price.mulU88(debtDiff).mulU88(LibOrders.convertCR(shortOrder.shortOrderCR)); //@audit -- If the shortRecord is partially filled, the shortOrder will be canceled, // and if shortRecord.ercDebt < minShortErc, the collateral will be used to // virtually mint ercDebt (to be == minShortErc) more than expected LibShortRecord.fillShortRecord( asset, shorter, shortRecordId, SR.FullyFilled, @2 collateralDiff, @2 debtDiff, Asset.ercDebtRate, Vault.dethYieldRate ); @2 Vault.dethCollateral += collateralDiff; @2 Asset.dethCollateral += collateralDiff; @2 Asset.ercDebt += debtDiff; // @dev update the eth refund amount @2 eth -= collateralDiff; } // @dev virtually mint the increased debt @2 s.assetUser[asset][shorter].ercEscrowed += debtDiff; } else { shortRecord.status = SR.FullyFilled; } } ... }
@2 -- If the shortRecord is partially filled, the shortOrder will be canceled, and if shortRecord.ercDebt < minShortErc, the collateral will be used to virtually mint ercDebt (to be == minShortErc) more than expected
: https://github.com/code-423n4/2024-03-dittoeth/blob/91faf46078bb6fe8ce9f55bcb717e5d2d302d22e/contracts/libraries/LibOrders.sol#L907-L932Manual Review
Execute the updateErcDebt()
before the checkCancelShortOrder()
.
function exitShort( address asset, uint8 id, uint88 buybackAmount, uint80 price, uint16[] memory shortHintArray, uint16 shortOrderId ) external isNotFrozen(asset) nonReentrant onlyValidShortRecord(asset, msg.sender, id) { ... + short.updateErcDebt(e.asset); e.shortOrderIsCancelled = LibSRUtil.checkCancelShortOrder({ asset: asset, initialStatus: short.status, shortOrderId: shortOrderId, shortRecordId: id, shorter: msg.sender }); - short.updateErcDebt(e.asset); ... }
LibOracle::baseOracleCircuitBreaker()
should not be hardcodedThe hardcoded value of 50% price deviation (0.5 ether
) may be too large when using ETH as a base price reference. Moreover, the fixed % deviation is considered too risky because the protocol's DAO or admin will not be able to update it in production.
Consequently, the check for price deviation in the LibOracle::baseOracleCircuitBreaker()
may not be effective enough to filter out the stale price in production, directly affecting the quality of the oracle price that will be consumed by the core functions of the Ditto
protocol.
The baseOracleCircuitBreaker()
verifies the price reported by Chainlink. If the reported price is invalid or its price deviation when compared to the protocol's cached oracle price is more than 50%, the function will fall back to get Uniswap's TWAP price instead.
However, the baseOracleCircuitBreaker()
uses a hardcoded value of 50% price deviation (0.5 ether
), which may be too large when using ETH as a base price reference. Moreover, the fixed % deviation is considered too risky because the protocol's DAO or admin will not be able to update it in production.
function baseOracleCircuitBreaker( uint256 protocolPrice, uint80 roundId, int256 chainlinkPrice, uint256 timeStamp, uint256 chainlinkPriceInEth ) private view returns (uint256 _protocolPrice) { bool invalidFetchData = roundId == 0 || timeStamp == 0 || timeStamp > block.timestamp || chainlinkPrice <= 0 || block.timestamp > 2 hours + timeStamp; uint256 chainlinkDiff = chainlinkPriceInEth > protocolPrice ? chainlinkPriceInEth - protocolPrice : protocolPrice - chainlinkPriceInEth; @> bool priceDeviation = protocolPrice > 0 && chainlinkDiff.div(protocolPrice) > 0.5 ether; // @dev if there is issue with chainlink, get twap price. Verify twap and compare with chainlink if (invalidFetchData) { ... @> } else if (priceDeviation) { ... } else { ... } }
Manual Review
The % price deviation should be a variable updatable by the protocol's DAO or admin in production.
minShortErc
in the ShortOrdersFacet::createLimitShort()
I noticed the inconsistency in calculating the p.minShortErc
in the ShortOrdersFacet::createLimitShort()
when the cr
param < 1 ether
and when the param >= 1 ether
.
If the cr
param < 1 ether
, the p.minShortErc
has to be modified to be bigger. But, the resulting p.minShortErc
will be bigger than expected. Consequently, a shorter must supply the ercAmount
more than expected to create a limit shortOrder
with the cr
< 1 ether
.
If the cr
param < 1 ether
, p.minShortErc
= LibAsset.minShortErc(asset).mul(1 ether + cr.inv())
.
Suppose the cr
== 0.5 ether
.
p.minShortErc
=LibAsset.minShortErc(asset).mul(1 ether + (1/0.5 ether))
=LibAsset.minShortErc(asset).mul(1 ether + 2 ether)
=LibAsset.minShortErc(asset).mul(3 ether)
As you can see, the resulting multiplier will be 3 ether
instead of 2 ether
(compared to the case cr
param >= 1 ether
).
function createLimitShort( address asset, uint80 price, uint88 ercAmount, MTypes.OrderHint[] memory orderHintArray, uint16[] memory shortHintArray, uint16 shortOrderCR ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant { ... // @dev minShortErc needs to be modified (bigger) when cr < 1 @> p.minShortErc = cr < 1 ether ? LibAsset.minShortErc(asset).mul(1 ether + cr.inv()) : LibAsset.minShortErc(asset); p.eth = price.mul(ercAmount); p.minAskEth = LibAsset.minAskEth(asset); if (ercAmount < p.minShortErc || p.eth < p.minAskEth) revert Errors.OrderUnderMinimumSize(); ... }
Manual Review
The formula for calculating the p.minShortErc
when the cr
param < 1 ether
should be LibAsset.minShortErc(asset).mul(cr.inv())
.
function createLimitShort( address asset, uint80 price, uint88 ercAmount, MTypes.OrderHint[] memory orderHintArray, uint16[] memory shortHintArray, uint16 shortOrderCR ) external isNotFrozen(asset) onlyValidAsset(asset) nonReentrant { ... // @dev minShortErc needs to be modified (bigger) when cr < 1 - p.minShortErc = cr < 1 ether ? LibAsset.minShortErc(asset).mul(1 ether + cr.inv()) : LibAsset.minShortErc(asset); + p.minShortErc = cr < 1 ether ? LibAsset.minShortErc(asset).mul(cr.inv()) : LibAsset.minShortErc(asset); p.eth = price.mul(ercAmount); p.minAskEth = LibAsset.minAskEth(asset); if (ercAmount < p.minShortErc || p.eth < p.minAskEth) revert Errors.OrderUnderMinimumSize(); ... }
#0 - raymondfam
2024-04-08T01:05:14Z
L3: Codehawks' L28: https://www.codehawks.com/report/clm871gl00001mp081mzjdlwc#L-28
3 well-elaborated L.
#1 - c4-pre-sort
2024-04-08T01:05:18Z
raymondfam marked the issue as sufficient quality report
#2 - c4-sponsor
2024-04-09T22:35:09Z
ditto-eth (sponsor) confirmed
#3 - c4-sponsor
2024-04-09T22:35:14Z
ditto-eth marked the issue as disagree with severity
#4 - ditto-eth
2024-04-09T22:35:58Z
this is a pretty big catch. i think L1 can be moved to a medium/high
might be a duplicate of #104
#5 - ditto-eth
2024-04-09T22:41:31Z
l2 can be classified as high. this submission has been the best qa.
might be a duplicate of #134
#6 - c4-judge
2024-04-17T07:25:59Z
hansfriese marked the issue as grade-c
#7 - c4-judge
2024-04-22T13:53:21Z
hansfriese marked the issue as grade-b