Reach out for an audit or to learn more about Macro
or Message on Telegram

thirdweb A-6

Security Audit

November 17th, 2022

Version 1.0.0

Presented by 0xMacro

Table of Contents

Introduction

This document includes the results of the security audit for thirdweb's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from October 14, 2022 to November 4, 2022.

The purpose of this audit is to review the source code of certain thirdweb Solidity contracts, and provide feedback on the design, architecture, and quality of the source code with an emphasis on validating the correctness and security of the software in its entirety.

Disclaimer: While Macro’s review is comprehensive and has surfaced some changes that should be made to the source code, this audit should not solely be relied upon for security, as no single audit is guaranteed to catch all possible bugs.

Overall Assessment

The following is an aggregation of issues found by the Macro Audit team:

Severity Count Acknowledged Won't Do Addressed
Critical 4 - - 4
High 1 - - 1
Medium 1 - - 1
Low 4 - - 4
Code Quality 3 - 1 2
Gas Optimization 2 - 1 1

thirdweb was quick to respond to these issues.

Specification

Our understanding of the specification was based on the following sources:

Source Code

The following source code was reviewed during the audit:

Specifically, we audited the following contracts as part of Marketplace contract audit:

Contract SHA256
contracts/marketplace/IMap.sol

9ab930008d0bd84c62cf0d986b0bf8ed78f4a086ab789860c1a5b0703754b9fe

contracts/marketplace/IMarketplace.sol

99c8759d3b393428613a910a25c936ac150107fe8e2069459daa976f7b77bdaa

contracts/marketplace/Map.sol

51367c23eb703ce2dd807e743d85013240ba0d715b76b38f309b88df131db802

contracts/marketplace/direct-listings/DirectListingsLogic.sol

8deae2b3840c5fb49995b78750e483f66620c4ac6c5f4485a4311e579869aaa1

contracts/marketplace/direct-listings/DirectListingsStorage.sol

7b2d907a0f1e6e7d85d040e64312d349981111648e462200890ce3b2eb5c7c85

contracts/marketplace/english-auctions/EnglishAuctionsLogic.sol

285add33baf30ca56d6bb0cf23b6a9f28efc5d5cebcc63f40a8ff36f6df08d38

contracts/marketplace/english-auctions/EnglishAuctionsStorage.sol

333c4d7b082f3af984918c2662d000a01f9acd2ad9494debf26b4643fd339c8a

contracts/marketplace/entrypoint/InitStorage.sol

8c4b4626c102014763156be128a4f017a940d95c12a9d067c29f51ca1a6c5bcf

contracts/marketplace/entrypoint/MarketplaceEntrypoint.sol

a71b3d34132bee735967bda33efcfdff28286871a4731ae09648d0868ce26931

contracts/marketplace/extension/ContractMetadata.sol

e749e540e00d26ddc399839f247bc682f26dd7207b62094f76530f2a92b8a57f

contracts/marketplace/extension/ContractMetadataStorage.sol

14d948e13a418e4fc4143afaed847d131bdced85f2de15f802017b7f9d4ee4f7

contracts/marketplace/extension/ERC2771Context.sol

f0267e676d506453bf069834e594a8bc09358118eb9167b3185e188808cc61e2

contracts/marketplace/extension/ERC2771ContextConsumer.sol

3e39b49f49187adc5b0204e948fe6295625497a54fef9f560e95c5a7f59a8fdd

contracts/marketplace/extension/ERC2771ContextStorage.sol

db23bba0849ebe49fe4c0b650908d367f678b27255d2b391234ed80a6211e08a

contracts/marketplace/extension/IContext.sol

0d37a422289e052deaf50cc7ae6c9e4953afc9f3863a47e2a877cce82131cf04

contracts/marketplace/extension/Permissions.sol

f40715d06e060ccb48e32e6c0b70e607fe784929d00c352f8f9221e46b2e9825

contracts/marketplace/extension/PermissionsEnumerable.sol

4eb0f3fdf109d70a97e9a55f7f678bf794cff047e68f8daf3c95a4e19adc1d21

contracts/marketplace/extension/PermissionsEnumerableStorage.sol

c1d03daa525f9ac9ac80ba0843b20dae7f02531ad916a8b63cefdcb3cb50fe53

contracts/marketplace/extension/PermissionsStorage.sol

69906d4f45aaf43e91397bba1e7a82886e5f91976dea73abf14b6a0eadba4072

contracts/marketplace/extension/PlatformFee.sol

8c17f63c3205c9852bfb577435d9e9e5bac8b10db1d8e0fbe10931deff75e70b

contracts/marketplace/extension/PlatformFeeStorage.sol

98c243ad513664a26d28d68911e054ea369fd3b03e235ea2a64382f5c96bd13e

contracts/marketplace/extension/ReentrancyGuard.sol

fef56e40ae4e0ac6cf2e1330b2055b2774a1b4f0441c9ff2a62d454419d01900

contracts/marketplace/extension/ReentrancyGuardStorage.sol

156c48fb3d78a28a3ef5c5078fea4926cec7fe681e06015fcf25867a1b71f31c

contracts/marketplace/offers/OffersLogic.sol

c936fa4eb508ecdf3fb6e29a12fcc358a3a5547a0f370bfd1808d9e467255ecf

contracts/marketplace/offers/OffersStorage.sol

65f28ce5fe81d7be573f2af4cd4a75372c560f6a70ed2155ec043bcfe2041eb4

We audited the following contracts as part of TieredDrop contract audit:

Contract SHA256
contracts/tiered-drop/TieredDrop.sol

4092cc97a7ab7dd98ec2856bcb72c54a28735a57cca29499a9d281b4bcfd6e8e

Note: This document contains an audit solely of the Solidity contracts listed above. Specifically, the audit pertains only to the contracts themselves, and does not pertain to any other programs or scripts, including deployment scripts.

Issue Descriptions and Recommendations

Click on an issue to jump to it, or scroll down to see them all.

Security Level Reference

We quantify issues in three parts:

  1. The high/medium/low/spec-breaking impact of the issue:
    • How bad things can get (for a vulnerability)
    • The significance of an improvement (for a code quality issue)
    • The amount of gas saved (for a gas optimization)
  2. The high/medium/low likelihood of the issue:
    • How likely is the issue to occur (for a vulnerability)
  3. The overall critical/high/medium/low severity of the issue.

This third part – the severity level – is a summary of how much consideration the client should give to fixing the issue. We assign severity according to the table of guidelines below:

Severity Description
(C-x)
Critical

We recommend the client must fix the issue, no matter what, because not fixing would mean significant funds/assets WILL be lost.

(H-x)
High

We recommend the client must address the issue, no matter what, because not fixing would be very bad, or some funds/assets will be lost, or the code’s behavior is against the provided spec.

(M-x)
Medium

We recommend the client to seriously consider fixing the issue, as the implications of not fixing the issue are severe enough to impact the project significantly, albiet not in an existential manner.

(L-x)
Low

The risk is small, unlikely, or may not relevant to the project in a meaningful way.

Whether or not the project wants to develop a fix is up to the goals and needs of the project.

(Q-x)
Code Quality

The issue identified does not pose any obvious risk, but fixing could improve overall code quality, on-chain composability, developer ergonomics, or even certain aspects of protocol design.

(I-x)
Informational

Warnings and things to keep in mind when operating the protocol. No immediate action required.

(G-x)
Gas Optimizations

The presented optimization suggestion would save an amount of gas significant enough, in our opinion, to be worth the development cost of implementing it.

Issue Details

C-1

Marketplace funds can be drained due to lack of auction payout accounting

Topic
Data Integrity
Status
Impact
Critical
Likelihood
High

A seller (auction creator) uses collectAuctionPayout() to collect their payout after the auction closes with a winning bid. The payout is in the currency specified when creating the auction.

In v2, the payout amount was calculated with _winningBid.pricePerToken * _targetListing.quantity. The function then afterwards set each of these values to zero, preventing future payouts.

However, in v3, payout is specified in the dedicated field _winningBid.bidAmount. This means that, although quantity is set to zero like in v2, the _winningBid.bidAmount remains the same. This allows an attacker to call this function as many times as they want, draining all funds of that currency from the Marketplace contract.

For example:

  • Assume 100 WETH is escrowed in a marketplace contract.
  • An attacker creates an auction selling an ERC-721 token (doesn’t matter which one) with a buyout price of 50 WETH
  • Attacker buys out their the auction, paying 50 WETH to the marketplace contract.
  • Attacker calls collectAuctionPayout() and collects 50 WETH.
  • Attacker continues to call collectAuctionPayout(), receiving 50 WETH each time, until marketplace funds are drained (in this case, 2 additional times).

Consider only allowing a payout if _targetAuction.quantity is zero, or using a separate field (e.g. payoutTimestamp) to track when a seller has received their payout.

C-2

Unsafe listing update allows seller front-run to buyer purchase

Topic
Frontrunning
Status
Impact
Critical
Likelihood
High

In DirectListings, sellers can list their ERC-721 or ERC-1155 tokens for sale and buyers can purchase them directly.

However, a malicious seller front-run a purchase by updating critical fields using updateListing, causing the buyer to receive something different from what was originally listed.

To illustrate, consider the current user flow for a direct, non-reserved, listing:

  1. Seller creates a listing by calling createListing.
  2. Buyer purchases the Token by calling buyFromListing.

In between step 1 and 2, a malicious seller can listen for a buyFromListing transaction and front-run it using updateListing to update tokenId and assetContract before the buyer completes their purchase. These values can be set to anything, including an asset contract that the attacker controls.

Consider disallowing changes to tokenId and assetContract to existing listings.

C-3

Auction creator collecting payout causes buyer to lose their purchased tokens

Topic
Data Integrity
Status
Impact
Critical
Likelihood
High

In a Marketplace contract, after an ERC-1155 auction has completed, 2 actions happen: the auction creator collects their payment, and the bidder collects their token. This is executed in collectAuctionPayout and collectAuctionTokens, respectively.

In V2, a bid had two separate fields: Listing.quantity and Offer.quantityWanted:

  • When an auction creator collects their payout, the former is used and then set to zero, preventing further payouts.
  • When a buyer collects their payout, the latter is used and then set to zero, preventing further payouts.

However, in V3, both payouts use the same field: Auction.quantity:

  • When an auction creator collects their payout, Auction.quantity is used and then set to zero (note this does not prevent further payouts, as described in C-1).
  • When a buyer collects their payout, they don’t receive what they purchased when the auction creator collects their payout before the buyer.

When this happens, a buyer’s purchased tokens are stuck in the Marketplace contract indefinitely.

Consider using a separate field to track bid payout, similar to what was used in V2.

C-4

Auction token collection not tracked enables stealing ERC-1155 tokens

Topic
Data Integrity
Status
Impact
Critical
Likelihood
High

A bidder uses collectAuctionTokens to collect the auction tokens after winning an EnglishAuction.

In V2, a bid a Offer.quantityWanted field. Upon the buyer collecting their payout, this field would be set to zero, preventing further payouts.

However, in V3, no state is modified or checked to prevent multiple payouts from happening. For ERC-1155 auctions, this allows an attacker to receive more tokens than they bought. For example:

  • Suppose a victim auctions an ERC-1155 token, ID for which is 999. This is auction #1.
  • Attacker creates an identical auction, except buyout price is 1 wei of the ERC20 currency; this is auction #2.
  • Attacker buys out their own auction for 1 wei, which transfers the token immediately to attacker’s wallet.
  • Attacker calls collectAuctionTokens on auction #2 until there is no more ID=999 tokens left in the marketplace
  • in this case, attacker collects the token escrowed by auction #1 for free.
H-1

Out of order lazy minting in TieredDrop causes incorrect claims

Topic
Algorithms
Status
Impact
Spec Breaking
Likelihood
High

TieredDrop is an NFT drop contract that allows users to claim with a specified priority of “tiers”.

Thirdweb’s docs for lazyMintWithTier() state:

The same tier can be re-used multiple times, and that does not need to happen on consecutive calls to the function. The contract will always appropriately track what NFTs belong to what tier.

This allows an “out of order” lazy mint. For example, if there are three tiers, X, Y, and Z, then the following is a valid mint pattern:

  • Mint 10 X
  • Mint 10 Y
  • Mint 30 Z
  • Mint 10 X

This creates 20 X tokens, 10 Y tokens, and 30 Z tokens available to mint.

When claiming, the docs state:

As a result, the claimer receives the specified total no. of NFTs, and the particular tiers of NFTs distributed to the claimer are determined by the provided order of priority of tiers.

However, when claiming, the NFTs distributed are also determined by the order of lazy minting.

Taking the above example, this means if a user were to mint 25 tokens with tier priority [Y, X] (note Y having priority over X), then they would receive:

  • 10 from tier Y (ids 10-19)
  • 10 from tier Z (ids 20-29)
  • 5 from tier X (ids 0-4)

Consider an algorithm that takes out-of-order lazy minting into account, or update the docs to specify this behavior.

M-1

setExtension allows selector clashes enables incorrect select-extension mapping

Topic
Upgradability
Status
Impact
High
Likelihood
Low

Map’s setExtension() creates a mapping from a function selector to a contract address that implements that function. This allows MarketplaceEntrypoint to delegate calls to any number of other approved contracts, achieving the same core goals as EIP-2535.

However, setExtension() can overwrite an existing mapping, since it does not validate whether the selector is already assigned. This can silently cause problems if/when thirdweb decides to deploy a contract with a function selector that clashes with a previous, already-deployed function selector.

Consider splitting setExtension into two functions: addExtension and replaceExtension. Similar to EIP-2535, this will help catch scenarios where two or more extensions implement the same function selector.

L-1

Create listing start timestamp accuracy

Topic
Data Integrity
Status
Impact
Medium
Likelihood
Low

DirectListings’s createListing() allows startTimestamp to be within 60 minutes behind the current block timestamp. This allows a newly created listing to be recorded as having started potentially up to an hour ago.

While this poses no risk for thirdweb’s current contracts, it is in fact a data inaccuracy. If other contracts (thirdweb or 3rd party) or off-chain software were to use this value for business logic, the inaccuracy could affect calculations or introduce vulnerabilities.

Consider updating startTimestamp to be the current block timestamp upon listing create to maintain data integrity.

L-2

Creating listings with start time in the past can cause dead listings

Topic
Input Validation
Status
Impact
Low
Likelihood
Low

DirectListings’s createListing() allows startTimestamp to be within 60 minutes behind the current block timestamp. This means that a listing could potentially end as soon as it is created.

Consider requiring that endTimestamp is not in the past, with or without a buffer (e.g. must be 5 minutes before end).

L-3

Buyer can overpay when using buyout option

Topic
User Experience
Status
Impact
Low
Likelihood
Low

In EnglishAuctions, a bidder can use the buy out option to directly purchase a token from the auction. The buy out price is defined as: "The total bid amount for which the bidder can directly purchase the auctioned items and close the auction as a result."

However, the buyer can bid higher than the buy out option and instead of paying the buy out amount, they pay their bid amount.

A quick illustration of this:

  1. The auction that a bidder is interested in has a buyoutBidAmount of 100.
  2. Bidder bids 120, surpassing the buyoutBidAmount.
  3. Instead of paying 100, they end up paying 120. The bidder overpays.

Consider:

  • Forcing the collection amount to be the buyoutBidAmount instead of the incoming bid amount, or
  • Reverting if the incoming bid amount is greater than the buyoutBidAmount.
L-4

Off-chain application might block itself from buying listing

Topic
Data Model
Status
Impact
Low
Likelihood
Low

A listing has two sources of truth for pricing:

  1. Listing.currency and Listing.pricePerToken.
  2. DirectListingsStorage.isCurrencyApprovedForListing and DirectListingsStorage.currencyPriceForListing.

Although approveCurrencyForListing() disallows approving a currency that is currently the listing’s currency, it’s still possible to enter a state where a currency is set in both places. For example:

  1. Create a listing with WETH as the currency.
  2. Approve DAI as a currency with a price of 100 per token.
  3. Call updateListing() to update the listing to DAI with a price of 50 per token.

After this, the listing now has two DAI prices.

In buyFromListing(), the isCurrencyApprovedForListing price takes precedence. If a contract or off-chain application were to use Listing.pricePerToken in this scenario, it would always revert.

Consider:

  1. Disallowing updating a listing’s currency to be an approved currency, or
  2. Using isCurrencyApprovedForListing as a single source of truth.

If (b) is considered, also consider combining isCurrencyApprovedForListing and currencyPriceForListing into a compacted struct – reducing uint256 to e.g. uint252 – to reduce storage slot usage.

Q-1

_validateOwnershipAndApproval is not needed for creating auctions

Topic
Extra Code
Status
Quality Impact
Low

As part of creating a new auction, _validateOwnershipAndApproval() is called to validate whether the msg.sender has ownership over the token(s) and granted approvals to the marketplace.

However it is not needed because _transferAuctionTokens() will be called as part of creating the auction and it will revert if the owner does not own the tokens and/or has not granted the marketplace an approval.

Consider removing _validateOwnershipAndApproval().

Q-2

Unused state variable

Topic
Unused Code
Status
Quality Impact
Low

In Map.sol, allSelectors is unused, causing getAllSelectorsRegistered() to always return an empty array. Consider using or removing this variable and function.

Q-3

Minting is always all-or-nothing

Topic
Use Cases
Status
Wont Do
Quality Impact
Medium

During TieredDrop.sol’s claimWithSignature(), the following require statement dictates that the full requested quantity must be available:

require(remaningToDistribute == 0, "Insufficient tokens in tiers.");

However, this may give some users a poor experience. For example, if a user attempts to claim 10 tokens during a heated drop event, but the number of available falls to 9, the user will get zero tokens even though they may be happy with 9.

Consider allowing partial quantity fills, perhaps via a boolean or minimum amount parameter.

Response by thirdweb

Not fixing — for now. This is a great suggestion and we’ll have a larger eng. discussion for incorporating this across our contracts which distribute NFTs.

G-1

Struct compaction

Topic
Data Model
Status
Wont Do
Gas Savings
Low

In LazyMintWithTier.sol, struct TokenRange's two fields could reasonably be compacted to (uint128, uint128) from (uint256, uint256), to save a cold SSTORE on mint and a cold SLOAD on use.

Response by thirdweb

Not fixing. Given that thirdweb’s smart contracts serve a wide NFT user base, we’re open to users issuing tokenIds greater than type(uint128).max

G-2

Loop variables

Topic
Redundant Computation
Status
Gas Savings
Low

In TierdDrop.sol’s getTokensInTier(), the bytes32 hashOfTier variable can be pulled outside the loop, as its value only needs to be computed once.

Disclaimer

Macro makes no warranties, either express, implied, statutory, or otherwise, with respect to the services or deliverables provided in this report, and Macro specifically disclaims all implied warranties of merchantability, fitness for a particular purpose, noninfringement and those arising from a course of dealing, usage or trade with respect thereto, and all such warranties are hereby excluded to the fullest extent permitted by law.

Macro will not be liable for any lost profits, business, contracts, revenue, goodwill, production, anticipated savings, loss of data, or costs of procurement of substitute goods or services or for any claim or demand by any other party. In no event will Macro be liable for consequential, incidental, special, indirect, or exemplary damages arising out of this agreement or any work statement, however caused and (to the fullest extent permitted by law) under any theory of liability (including negligence), even if Macro has been advised of the possibility of such damages.

The scope of this report and review is limited to a review of only the code presented by the thirdweb team and only the source code Macro notes as being within the scope of Macro’s review within this report. This report does not include an audit of the deployment scripts used to deploy the Solidity contracts in the repository corresponding to this audit. Specifically, for the avoidance of doubt, this report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. In this report you may through hypertext or other computer links, gain access to websites operated by persons other than Macro. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such websites’ owners. You agree that Macro is not responsible for the content or operation of such websites, and that Macro shall have no liability to your or any other person or entity for the use of third party websites. Macro assumes no responsibility for the use of third party software and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.