Race Condition: Shared write access to isLocked allows NFTs with open announcements to become tradeable.
Summary
The LimitOrder and DelayedOrder modules both share competing write access to the isLocked state of a LeverageModule NFT, and rely upon mutually-exclusive sources of truth to decide at what point in time they should gate transferability.
When a caller submits multiple open announcements for a position in parallel, such as one from LimitOrder and another from the DelayedOrder module, the first announcement to terminate will inadvertently leave the affected position in a transferable state, even whilst the competing announcement is still pending finalization.
This undermines a protocol invariant which insists that a LeverageModule position cannot be transferred whilst an active announcement has not been resolved.
Vulnerability Detail
Both the LimitOrder and DelayedOrder modules intend to prevent position tokens from being transferred whilst an announcement intent is in progress:
// Lock the NFT belonging to this position so that it can't be transferred to someone else.
ILeverageModule(vault.moduleAddress(FlatcoinModuleKeys._LEVERAGE_MODULE_KEY)).lock(tokenId);
These protections are implemented for a couple of reasons:
A pending intent adding supplementary margin to a leverage position could still be executed even after the token was traded on secondary marketplaces, resulting in unintentional loss for the original owner.
Trade safety is ensured in that the terms of the underlying collateral cannot be manipulated due to a seller's pre-existing intent after sale has concluded.
However, due to competing writes between LimitOrder and DelayedOrder, these protections can be circumvented:
/// @notice Locks the ERC721 token representing the leverage position.
/// @param _tokenId The ERC721 token ID of the leverage position.
function lock(uint256 _tokenId) public onlyAuthorizedModule {
_lock(_tokenId);
}
/// @notice Unlocks the ERC721 token representing the leverage position.
/// @param tokenId The ERC721 token ID of the leverage position.
function unlock(uint256 tokenId) public onlyAuthorizedModule {
_unlock(tokenId);
}
Tool used
Foundry
Recommendation
There are a couple of recommendations:
For all announcements which result in an unlocked position, ensure the position is not already locked when initially creating the announcement.
Use a shared context between all writers to isLocked to ensure cross-contract synchronization.
cawfree
medium
Race Condition: Shared write access to
isLocked
allows NFTs with open announcements to become tradeable.Summary
The
LimitOrder
andDelayedOrder
modules both share competing write access to theisLocked
state of aLeverageModule
NFT, and rely upon mutually-exclusive sources of truth to decide at what point in time they should gate transferability.When a caller submits multiple open announcements for a position in parallel, such as one from
LimitOrder
and another from theDelayedOrder
module, the first announcement to terminate will inadvertently leave the affected position in a transferable state, even whilst the competing announcement is still pending finalization.This undermines a protocol invariant which insists that a
LeverageModule
position cannot be transferred whilst an active announcement has not been resolved.Vulnerability Detail
Both the
LimitOrder
andDelayedOrder
modules intend to prevent position tokens from being transferred whilst an announcement intent is in progress:These protections are implemented for a couple of reasons:
However, due to competing writes between
LimitOrder
andDelayedOrder
, these protections can be circumvented:Impact
Reduction in trade safety.
Code Snippet
Tool used
Foundry
Recommendation
There are a couple of recommendations:
isLocked
to ensure cross-contract synchronization.Duplicate of #48