Security Audit
Aug 19th, 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 June 18, 2022 to June 29, 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 |
---|---|---|---|---|
High | 1 | - | - | 1 |
Medium | 3 | 1 | 1 | 1 |
Low | 7 | 2 | 4 | 1 |
Code Quality | 5 | 2 | - | 3 |
Informational | 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:
85d1be5546d93ffe1fd1375ceb90af67a4e3210d
Specifically, we audited the following contracts within this repository:
Source Code | SHA256 |
---|---|
contracts/pack/Pack.sol |
|
contracts/extension/ContractMetadata.sol |
|
contracts/extension/Royalty.sol |
|
contracts/extension/Ownable.sol |
|
contracts/extension/Permissions.sol |
|
contracts/extension/PermissionsEnumerable.sol |
|
contracts/extension/TokenBundle.sol |
|
contracts/extension/TokenStore.sol |
|
contracts/extension/interface/IContractMetadata.sol |
|
contracts/extension/interface/IOwnable.sol |
|
contracts/extension/interface/IPermissions.sol |
|
contracts/extension/interface/IPermissionsEnumerable.sol |
|
contracts/extension/interface/IRoyalty.sol |
|
contracts/extension/interface/ITokenBundle.sol |
|
contracts/interfaces/IPack.sol |
|
contracts/interfaces/IWETH.sol |
|
contracts/lib/CurrencyTransferLib.sol |
|
contracts/lib/TWStrings.sol |
|
contracts/openzeppelin-presets/metatx/ERC2771ContextUpgradeable.sol |
|
contracts/openzeppelin-presets/token/ERC20/utils/SafeERC20.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.
supportsInterface()
may prevent ERC721 tokens from transferring to Pack.sol
maxTotalSupply
can be updated to < totalSupply()
platformFeeBps
getClaimTimestamp()
return value
setClaimConditions()
will accept phases that cannot become active
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. |
Three sub-issues are listed below. The main cause for the high severity category is sub-issue #3 – namely, the current Forwarder.sol implementation. Consider updating the implementation to disallow contract calls.
For the other problems with randomness, consider adding an optional flag that hooks into Chainlink, for the bigger projects that want secure randomness.
block.timestamp
to either get a good reward, or skip if a good reward is not possibleThe RNG logic has three parts:
msg.sender
– always knownblockhash of n-1
– known once n-1 is mineddifficulty
– derived in nth blockSpecifically, difficulty
is derived from block.timestamp
, of which a block miner has some influence. Here is the formula for block.difficulty
(source):
block_diff = parent_diff + parent_diff // 2048 * max(1 - (block_timestamp - parent_timestamp) // 10, -99) + int(2**((block.number // 100000) - 2))
Given the above, a miner can submit their own transaction, and tweak the timestamp for either a better reward, or choose to skip the claim in search of a better reward next time.
If a pack gets good traction, validators (post-merge) or miners (pre-merge) will likely attempt to extract value in this way.
Flashbots bundle allows users to group on-chain transactions such that they execute in an all-or-nothing manner. From Flashbot’s website:
Searchers use Flashbots to submit bundles to block builders for inclusion in blocks. Bundles are one or more transactions that are grouped together and executed in the order they are provided. In addition to the searcher's transaction(s) a bundle can also potentially contain other users' pending transactions from the mempool, and bundles can target specific blocks for inclusion as well.
Advanced users can use this to create a bundle with two transactions:
Due to the behavior of Flashbots bundles, if the second tx fails, then the first will fail too:
Unlike broadcasting a transaction which lands on-chain even if the transaction fails, troubleshooting Flashbots bundles is considerably more challenging, since any of the following circumstances will prevent your bundle from landing on chain:
- Transaction failure (ANY within the bundle)
- Incentives (gas price + coinbase transfers) not high enough to offset value of block space
- Competitors paying more for same opportunity
- Bundle received too late to appear in target block
- A validator for target slot not running mev-boost
tx.origin == msg.sender
requirement via a meta-txThirdweb allows a third party to execute transactions on behalf of a user via meta transactions. Packs.sol also implement this feature:
require(isTrustedForwarder(msg.sender) || _msgSender() == tx.origin, "opener cannot be smart contract");
In a meta transaction, a user only needs to create a signature and submit it to a trusted forwarder, having that forwarder execute the transaction in exchange for not directly paying for its gas cost.
Because a forwarder is a contract, any account (EOA or contract) can execute that signed tx on behalf of its user.
This exposes a vulnerability:
msg.sender
as themselves, and calldata for claiming that reward.Since this call is coming from a trusted forwarder, the msg.sender == tx.origin
check is skipped, and the require statement passes.
In Pack.sol, when createPack
is called, an ERC-20 token is minted for each pack to open. However, if any one of these tokens are lost (e.g. sent to an invalid address), then the assets in the Pack contract are permanently locked.
The impact of this in practice depends on the value of the assets escrowed.
Consider setting expiration date on the pack, allowing the creator to recover any unclaimed assets after that point.
In case of unclaimed rewards, either the pack creator will have sold the packs, or will have it in their account to open it themselves. So, we'd rather put onus on the pack owner to redeem it/transfer to correct addresses.
So, perhaps we'll keep this feature for a later release.
In DropERC20.sol’s collectClaimPrice()
:
uint256 totalPrice = (_quantityToClaim * _pricePerToken) / 1 ether;
Here, totalPrice
can be calculated as zero when the product of _quantityToClaim
and _pricePerToken
is smaller than 1 ether
. For example:
This allows a user to claim a small amount of tokens for free (assuming the currency is not NATIVE_TOKEN).
Depending on the perceived value of the dropping ERC-20, an attacker may be motivated to claim this way multiple times in a loop.
Consider requiring totalPrice > 0
when _pricePerToken
is greater than zero.
In ContractPublisher.sol, anyone can claim to have published any contract. While this allows a user or contract to take a trusted publisher address and see what contracts they claimed to have published, they still need to trust the claims of the publisher.
Additionally, a single contract address can be “claimed” by multiple publishers
Consider a design where a contract must approve, in some way, that a publisher has indeed published it, so that ContractPublisher’s on-chain data is more reliable and trustless.
Not fixing (for now).
The issue pointed out requires a design change in the ContractPublisher contract that must be coordinated throughout the thirdweb Release product.
When a user calls openPack()
, the Pack contract sends them one or many assets, depending on the configuration of the pack. However, if one asset transfer fails, then the entire transaction fails, potentially preventing pack opening altogether.
Example case: If a pack contained USDC, and the Pack contract is added to the USDC blacklist, then every bundle containing USDC will revert on attempts to open.
Based on the ASSET_ROLE
code, thirdweb plans to use an asset allow list at some point. Consider using it on launch, to avoid an attacker creating a valuable bundle of assets mixed with their own malicious asset, in attempt to scam others.
Another option to consider is isolating each _transferToken
in a try-catch that emits an Event on throw. This will allow for some record keeping around bad txes. It would also need some sort of collecting from open pack retry feature.
Contract owner will be the end users, and they can toggle ASSET_ROLE as required. Pros & cons will be described in design doc.
When a user calls createPack()
, the Pack contract transfers assets to itself for escrow. However, if one of these assets is a fee-on-transfer ERC-20 token, then the contract will end up with insufficient assets necessary to support opening all packs.
As mentioned in L-1, thirdweb has an allow list ready to use. Consider using it on launch to avoid this issue, or document that these tokens are not supported, or update Pack.sol to perform balance differences by track asset balances on-chain.
Contract owner will be the end users, and they can toggle ASSET_ROLE as required. Pros & cons will be described in design doc.
supportsInterface()
may prevent ERC721 tokens from transferring to Pack.sol
ERC721 contracts that query for ERC721Receiver interface support during transfers will fail to transfer NFTs to Pack.sol, making these NFTs impossible to include in packs.
Note that standard ERC721 implementations (e.g. openzeppelin, solmate contract templates) do not perform this check; and rely only on calling onERC721Received()
post-transfer. This gives this issue a low likelihood.
Consider updating supportsInterface()
to return true for IERC721Receiver
.
Unfortunately it’s common to see accidental transfers of assets to a protocol’s smart contract. If one sends ERC-20, ERC-721, or ERC-1155 tokens to the Pack contract, there is no way to recover them.
Consider a state variable that is set before/after a call to createBundle()
(similar to nonReentrant), so Pack can reject incoming transfers where possible.
Not clear how ERC20 accidental transfers would be rejected.
Any function to recover such tokens might affect existing functionality and token balances in the packs.
Thirdweb ERC2771Context implementation receives an array of trustedForwarder
values on initialization. However, there is no way to remove a trusted forwarder.
Consider adding a function to remove a trusted forwarder, or to set the full list of forwarders, to handle cases where forwarders get lost, expired, or compromised.
Not fixing (for now).
Today, a thirdweb pre-built smart contract deployed by users on the dashboard or via SDKs use the same set of trusted forwarders deployed by thirdweb, or external providers like Biconomy.
The issue pointed out requires a design change across all of thirdweb’s smart contracts; it’s fix will be coordinated across the thirdweb products.
maxTotalSupply
can be updated to < totalSupply()
In DropERC20.sol, setMaxTotalSupply()
allows maxTotalSupply
to be set to any number. If the value is set to <
totalSupply()
all present and future claims will all fail, and a potentially misleading event will be emitted (MaxTotalSupplyUpdated()
), potentially causing unnecessary confusion for users. Consider updating setMaxTotalSupply()
to require a value >= totalSupply()
.
Note that if the intent is to halt minting immediately, setting maxTotalSupply
to present totalSupply()
may suffer from race conditions if additional claims are processed ahead of setMaxTotalSupply()
. In this case, consider calling setClaimConditions()
with an empty _phases
array instead. The latter approach will not revert if additional claims precede its execution.
The purpose here was to achieve pausing of claims by setting maxTotalSupply as less than totalSupply().
The absence of checks is intended to transfer responsibility to contract admin.
The publishContract()
function adds an entry into compilerMetadataUriToPublishedMetadataUris
which is not accounted for during unpublishContract()
.
This may leave orphan compilerMetadataUri
data, causing it to refer to non-existent publishMetadataUri
data. Moreover, getPublishedUriFromCompilerUri()
will return publishMetadataUris
for contracts that are unpublished.
Not fixing (for now).
The issue pointed out requires a design change in the ContractPublisher contract that must be coordinated throughout the thirdweb Release product.
DropERC20.sol and ContractPublisher.sol have very light testing. Consider adding more to more confidently affirm the behavior of the contracts.
setContractUri()
does not emit an event.setPublisherProfileUri()
does not emit an event.Consider adding an event for these changes.
platformFeeBps
The datatype applied to this value varies throughout the contract between uint256
, uint128
, uint64
and uint16
. Consider normalizing to the datatype applied to its storage variable definition of uint128
.
Not fixing (yet).
This fix will be coordinated across the relevant thirdweb smart contracts.
In DropERC20.sol, the import of ERC20PausableUpgradeable.sol
is not used.
getClaimTimestamp()
return value
In DropERC20.sol’s getClaimTimestamp()
, when lastClaimTimestamp
is zero, nextValidClaimTimestamp
should arguably be 0
to indicate that no delay is required, since no prior purchase has occurred.
If this were to be implemented, a minor gas-savings opportunity can be implemented along with it, as the nextValidClaimTimestamp
could be skipped, and line 376 could be simplified to:
require(block.timestamp >= nextValidClaimTimestamp, "cannot claim yet.");
setClaimConditions()
will accept phases that cannot become active
setClaimConditions()
will update claimCondition
without comparing individual _phases[i].startTimestamp
values with respect to block.timestamp
. As a result, it is possible to supply a set of claim conditions which will never execute.
For example, consider calling setClaimCondition
with 4 entries in _phases
, with startTimestamp
values of: 50
, 75
, 100
, 125
. If executed when block.timestamp = 100
, the first two phases will never occur.
This may impact an owner’s intended phases, or mask accidental errors in phase formulation.
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.