Foundation contest - hyh's results

Building the new creative economy

General Information

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

Foundation

Findings Distribution

Researcher Performance

Rank: 9/28

Findings: 2

Award: $2,574.75

🌟 Selected for report: 2

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: hyh

Also found by: WatchPug, leastwood, shenwilly

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

1479.1136 USDC - $1,479.11

External Links

Lines of code

https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketReserveAuction.sol#L556-L560

Vulnerability details

Impact

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.

Proof of Concept

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:

https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketReserveAuction.sol#L666-L669

Then, the auction winner can call acceptOffer -> _acceptOffer (or setBuyPrice -> _autoAcceptOffer -> _acceptOffer).

_acceptOffer will try to transfer directly, and then calls _transferFromEscrow:

https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketOffer.sol#L262-L271

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:

https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketOffer.sol#L296-L302

NFTMarketBuyPrice._transferFromEscrow will call super as there is no buy price set:

https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketBuyPrice.sol#L283-L293

Finally, NFTMarketReserveAuction._transferFromEscrow will send the NFT to the winner via _finalizeReserveAuction, not to the user who made the offer:

https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketReserveAuction.sol#L556-L560

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

https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketReserveAuction.sol#L550-L560

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.

Findings Information

🌟 Selected for report: hyh

Also found by: wuwe1

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

1095.6397 USDC - $1,095.64

External Links

Lines of code

https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketCore.sol#L77-L87

Vulnerability details

Impact

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.

Proof of Concept

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:

https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketOffer.sol#L262-L271

_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:

https://github.com/code-423n4/2022-02-foundation/blob/main/contracts/mixins/NFTMarketCore.sol#L77-L87

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):

  • It only impacts NFTs which were thought to be effectively burned as they were transferred to the Market contract with no way to get them back.
  • Presumably the NFTs which get stuck in the Market contract like that were due to user error. So in theory this could have been thought of a sort of a feature - it allows those NFTs to be recovered, which is great for the original creator which will see royalties from future sales again.

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.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter