Security Audit
November 17th, 2022
Version 1.0.0
Presented by 0xMacro
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.
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.
Our understanding of the specification was based on the following sources:
The following source code was reviewed during the audit:
38df19415e92cc9514b55e25102a10ca835ba2d1
63da8c73c5f0b0c911e4dc54e067d6760a360393
Specifically, we audited the following contracts as part of Marketplace contract audit:
Source Code | SHA256 |
---|---|
contracts/marketplace/IMap.sol |
|
contracts/marketplace/IMarketplace.sol |
|
contracts/marketplace/Map.sol |
|
contracts/marketplace/direct-listings/DirectListingsLogic.sol |
|
contracts/marketplace/direct-listings/DirectListingsStorage.sol |
|
contracts/marketplace/english-auctions/EnglishAuctionsLogic.sol |
|
contracts/marketplace/english-auctions/EnglishAuctionsStorage.sol |
|
contracts/marketplace/entrypoint/InitStorage.sol |
|
contracts/marketplace/entrypoint/MarketplaceEntrypoint.sol |
|
contracts/marketplace/extension/ContractMetadata.sol |
|
contracts/marketplace/extension/ContractMetadataStorage.sol |
|
contracts/marketplace/extension/ERC2771Context.sol |
|
contracts/marketplace/extension/ERC2771ContextConsumer.sol |
|
contracts/marketplace/extension/ERC2771ContextStorage.sol |
|
contracts/marketplace/extension/IContext.sol |
|
contracts/marketplace/extension/Permissions.sol |
|
contracts/marketplace/extension/PermissionsEnumerable.sol |
|
contracts/marketplace/extension/PermissionsEnumerableStorage.sol |
|
contracts/marketplace/extension/PermissionsStorage.sol |
|
contracts/marketplace/extension/PlatformFee.sol |
|
contracts/marketplace/extension/PlatformFeeStorage.sol |
|
contracts/marketplace/extension/ReentrancyGuard.sol |
|
contracts/marketplace/extension/ReentrancyGuardStorage.sol |
|
contracts/marketplace/offers/OffersLogic.sol |
|
contracts/marketplace/offers/OffersStorage.sol |
|
We audited the following contracts as part of TieredDrop contract audit:
Source Code | SHA256 |
---|---|
contracts/tiered-drop/TieredDrop.sol |
|
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.
Click on an issue to jump to it, or scroll down to see them all.
setExtension
allows selector clashes enables incorrect select-extension mapping
_validateOwnershipAndApproval
is not needed for creating auctions
We quantify issues in three parts:
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. |
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:
collectAuctionPayout()
and collects 50 WETH.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.
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:
createListing
.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.
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
:
However, in V3, both payouts use the same field: Auction.quantity
:
Auction.quantity
is used and then set to zero (note this does not prevent further payouts, as described in C-1).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.
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:
collectAuctionTokens
on auction #2 until there is no more ID=999 tokens left in the marketplaceTieredDrop 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:
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:
Consider an algorithm that takes out-of-order lazy minting into account, or update the docs to specify this behavior.
setExtension
allows selector clashes enables incorrect select-extension mapping
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.
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.
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).
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:
buyoutBidAmount
of 100.buyoutBidAmount
.Consider:
buyoutBidAmount
instead of the incoming bid amount, orbuyoutBidAmount
.A listing has two sources of truth for pricing:
Listing.currency
and Listing.pricePerToken
.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:
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:
currency
to be an approved currency, orisCurrencyApprovedForListing
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.
_validateOwnershipAndApproval
is not needed for creating auctions
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()
.
In Map.sol, allSelectors
is unused, causing getAllSelectorsRegistered()
to always return an empty array. Consider using or removing this variable and function.
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.
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.
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.
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
In TierdDrop.sol’s getTokensInTier()
, the bytes32 hashOfTier
variable can be pulled outside the loop, as its value only needs to be computed once.
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.