Approvals are not being cleared when OwnershipNFT is being transferred by owner. NFT can be sold and later on can be stolen back along with all the deposited tokens.
Proof of Concept
Looking at the OZ ERC721 implementation we will see the following function being called in transferFrom and transfer:
function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) {
address from = _ownerOf(tokenId);
// Perform (optional) operator check
if (auth != address(0)) {
_checkAuthorized(from, auth, tokenId);
}
// Execute the update
if (from != address(0)) {
// Clear approval. No need to re-authorize or emit the Approval event
_approve(address(0), tokenId, address(0), false);
unchecked {
_balances[from] -= 1;
}
}
if (to != address(0)) {
unchecked {
_balances[to] += 1;
}
}
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
return from;
}
As we can see approve with address(0) is called each time, which clears the per token and operator approvals of the previous owner, exactly to prevent the old approvers to steal back the NFT.
However, in OwnershipNFT approvals are not being cleared and will remain even after the position is transferred:
function _transfer(
address _from,
address _to,
uint256 _tokenId
) internal {
_requireAuthorised(_from, _tokenId);
SEAWATER.transferPositionEEC7A3CD(_tokenId, _from, _to);
}
function _requireAuthorised(address _from, uint256 _tokenId) internal view {
// revert if the sender is not authorised or the owner
bool isAllowed =
msg.sender == _from ||
isApprovedForAll[_from][msg.sender] ||
msg.sender == getApproved[_tokenId];
require(isAllowed, "not allowed");
require(ownerOf(_tokenId) == _from, "_from is not the owner!");
}
Note that the Rust code, lib::transfer_position_E_E_C7_A3_C_D in particular, doesn’t manage the approvals, it cares only about adding/removing the position owner:
So approvals are managed only in OwnershipNFT, which is also the nft_manager in lib.rs, and everyone approved by the old owner can do anything with the new owner’s position- to increase, decrease, burn or even transfer it back to himself.
For example, when malicious owner wants to steal from other users he can do the following:
Lists his position for sale, but before that approves himself, or another address that also belongs to him.
Unsuspecting user buys the position and start providing liquidity from it.
The buyer accumulates uncollected fees as well as great amount of token_owed_0 and token_owed_0.
When the old owner is satisfied with the amount that he will receive he executes OwnershipNFT::transferFrom from the approved address.
_requireAuthorised passes because msg.sender == getApproved[_tokenId]
lib::transfer_position_E_E_C7_A3_C_D wipes the buyer as position owner and grants the ownership back to the old owner, who can choose whether to continue using the stolen position or collect it and close it stealing all the liquidity from it.
Tools Used
Manual Review
Recommended Mitigation Steps
In _transfer make sure to clear the approvals for the given token:
function _requireAuthorised(address _from, uint256 _tokenId) internal view {
// revert if the sender is not authorised or the owner
bool isAllowed =
msg.sender == _from ||
isApprovedForAll[_from][msg.sender] ||
msg.sender == getApproved[_tokenId];
+ approve(address(0), _tokenId);
+ setApprovalForAll(address(0), _tokenId);
require(isAllowed, "not allowed");
require(ownerOf(_tokenId) == _from, "_from is not the owner!");
}
Lines of code
https://github.com/code-423n4/2024-08-superposition/blob/main/pkg/seawater/src/lib.rs#L554-L569 https://github.com/code-423n4/2024-08-superposition/blob/main/pkg/sol/OwnershipNFTs.sol#L109-L116
Vulnerability details
Impact
Approvals are not being cleared when
OwnershipNFT
is being transferred by owner. NFT can be sold and later on can be stolen back along with all the deposited tokens.Proof of Concept
Looking at the OZ ERC721 implementation we will see the following function being called in
transferFrom
andtransfer
:ERC721.sol
As we can see approve with
address(0)
is called each time, which clears theper token
andoperator approvals
of the previous owner, exactly to prevent the old approvers to steal back the NFT.However, in
OwnershipNFT
approvals are not being cleared and will remain even after the position is transferred:OwnershipNFT.sol
Note that the
Rust
code,lib::transfer_position_E_E_C7_A3_C_D
in particular, doesn’t manage the approvals, it cares only about adding/removing the position owner:lib.rs
So approvals are managed only in OwnershipNFT, which is also the
nft_manager
inlib.rs
, and everyone approved by the old owner can do anything with the new owner’s position- to increase, decrease, burn or even transfer it back to himself.For example, when malicious owner wants to steal from other users he can do the following:
token_owed_0
andtoken_owed_0
.OwnershipNFT::transferFrom
from the approved address._requireAuthorised
passes becausemsg.sender == getApproved[_tokenId]
lib::transfer_position_E_E_C7_A3_C_D
wipes the buyer as position owner and grants the ownership back to the old owner, who can choose whether to continue using the stolen position or collect it and close it stealing all the liquidity from it.Tools Used
Manual Review
Recommended Mitigation Steps
In
_transfer
make sure to clear the approvals for the given token:Assessed type
ERC721