Platform: Code4rena
Start Date: 24/02/2022
Pot Size: $75,000 USDC
Total HM: 21
Participants: 28
Period: 7 days
Judge: alcueca
Total Solo HM: 15
Id: 94
League: ETH
Rank: 9/28
Findings: 2
Award: $2,574.75
🌟 Selected for report: 2
🚀 Solo Findings: 0
1479.1136 USDC - $1,479.11
An Offer which is made for an NFT when auction has ended, but its winner hasn't received the NFT yet, can be stolen by this winner as _transferFromEscrow
being called by _acceptOffer
will transfer the NFT to the winner, finalising the auction, while no transfer to the user who made the offer will happen.
This way the auction winner will obtain both the NFT and the offer amount after the fees at no additional cost, at the expense of the user who made the offer.
When an auction has ended, there is a possibility to make the offers for an auctioned NFT as:
makeOffer
checks _isInActiveAuction
:
https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketOffer.sol#L200
_isInActiveAuction
returns false when auctionIdToAuction[auctionId].endTime < block.timestamp
, so makeOffer
above can proceed:
Then, the auction winner can call acceptOffer -> _acceptOffer
(or setBuyPrice -> _autoAcceptOffer -> _acceptOffer
).
_acceptOffer
will try to transfer directly, and then calls _transferFromEscrow
:
If the auction has ended, but a winner hasn't picked up the NFT yet, the direct transfer will fail, proceeding with _transferFromEscrow
in the FNDNFTMarket defined order:
function _transferFromEscrow( address nftContract, uint256 tokenId, address recipient, address seller ) internal override(NFTMarketCore, NFTMarketReserveAuction, NFTMarketBuyPrice, NFTMarketOffer) { super._transferFromEscrow(nftContract, tokenId, recipient, seller); }
NFTMarketOffer._transferFromEscrow will call super as nftContractToIdToOffer
was already deleted:
NFTMarketBuyPrice._transferFromEscrow will call super as there is no buy price set:
Finally, NFTMarketReserveAuction._transferFromEscrow will send the NFT to the winner via _finalizeReserveAuction
, not to the user who made the offer:
The recipient
user who made the offer is not present in this logic, the NFT is being transferred to the auction.bidder
, and the original acceptOffer
will go through successfully.
An attempt to set a buy price from auction winner will lead to auction finalisation, so _buy
cannot be called with a not yet finalised auction, this way the NFTMarketReserveAuction._transferFromEscrow L550-L560 logic is called from the NFTMarketOffer._acceptOffer only:
https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketOffer.sol#L270
is the only user of
This way the fix is to update L556-L560 for the described case as:
Now:
// Finalization will revert if the auction has not yet ended. _finalizeReserveAuction(auctionId, false); // Finalize includes the transfer, so we are done here. return;
To be, we leave the NFT in the escrow and let L564 super call to transfer it to the recipient:
// Finalization will revert if the auction has not yet ended. _finalizeReserveAuction(auctionId, true);
#0 - HardlyDifficult
2022-03-07T12:45:23Z
Yes! This was a great find and a major issue with our implementation. I'm very happy that it was flagged by a few different people, it helps raise our confidence that several wardens really dove into the code.
It was a big miss on our part that this was not thoroughly tested. Our tests for this scenario confirmed the events and payouts, but did not validate the ownership in the end!
The proposed fix is perfect and exactly what we have implemented. This follows the patterns we established well, and actually simplifies the logic here so that things are easier to reason about.
1095.6397 USDC - $1,095.64
If a NFT happens to be in escrow with neither buyPrice, nor auction being initialised for it, there is a way to obtain it for free by any actor via makeOffer
, acceptOffer
combination.
I.e. a malicious user can track the FNDNFTMarket contract and obtain any NFT from it for which there are no buyPrice or auction structures initialised. For example, if a NFT is mistakenly sent to the contract, an attacker can immediately steal it.
This will happen as NFT is being guarded by buyPrice and auction structures only. The severity here is medium as normal usage of the system imply that either one of them is initialised (NFT was sent to escrow as a part of setBuyPrice
or createReserveAuction
, and so one of the structures is present), so this seems to leave only mistakenly sent assets exposed.
An attacker can make a tiny offer with makeOffer
:
https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketOffer.sol#L189
Then call acceptOffer
, which will lead to _acceptOffer
.
Direct NFT transfer will fail in _acceptOffer
as the NFT is being held by the contract and _transferFromEscrow
will be called instead:
_transferFromEscrow
calls will proceed according to the FNDNFTMarket defined order:
function _transferFromEscrow( ... ) internal override(NFTMarketCore, NFTMarketReserveAuction, NFTMarketBuyPrice, NFTMarketOffer) { super._transferFromEscrow(nftContract, tokenId, recipient, seller); }
If there are no corresponding structures, the NFTMarketOffer, NFTMarketBuyPrice and NFTMarketReserveAuction versions of _transferFromEscrow
will pass through the call to NFTMarketCore's plain transfer implementation:
This will effectively transfer the NFT to the attacker, which will pay gas costs and an arbitrary small offer price for it.
Consider adding additional checks to control who can obtain unallocated NFTs from the contract.
Protocol controlled entity can handle such cases manually by initial sender's request.
#0 - HardlyDifficult
2022-03-02T22:13:31Z
Yes, this is an interesting scenario to raise! We do have more than one NFT in the market contract which was transferred directly (vs tracked as part of escrow). So there is some exposure already.
We feel this is a 2 (Med Risk)
:
We have fixed this. We took a slightly different approach than what was recommended, but it should accomplish the same goal. _transferFromEscrow
changed the seller
param to authorizeSeller
. As the call travels through each mixin, we change authorizeSeller
to address(0)
once an escrow manager is able to confirm ownership. Finally when the call reaches NFTMarketCore
it requires that authorizeSeller
is address(0)
before executing the transfer -- this confirms that one or more escrow managers (buy now or auctions) have positively confirmed the seller. So NFTs in the Market without being escrowed now reverts on this line.