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

thirdweb A-9

Security Audit

March 13th, 2023

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 Feburary 6th, 2022 to Feburary 15th, 2023.

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
High 1 - - 1
Medium 5 - - 5
Low 1 - - 1
Code Quality 2 - - 2
Gas Optimization 4 - 2 2

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:

We audited the following contracts specificially for the Airdrop audit:

Source Code SHA256
contracts/airdrop/AirdropERC1155.sol

0f8b24f4fedd9fc3a1d26d9cee3983961d988d5a47fc50fec4e81468745460dd

contracts/airdrop/AirdropERC20.sol

63eb7a2e86d0e542c374f1038e8f28a349264f6b26c72464c36920455b1897e1

contracts/airdrop/AirdropERC721.sol

f21672afa3dc281fc2fdeaa1ad8e16f32d6630abbe38916ae1f65a80c7f4d0b2

contracts/interfaces/airdrop/IAirdropERC1155.sol

2af99a66c3f2ae4c418eb6a674ab06dc4e1ebf580e07ceb4f9cd9e83bc4f9857

contracts/interfaces/airdrop/IAirdropERC20.sol

e513059e7e66056276148af682def4739f8d1b779d54c082cb3b2ea2bf133f8c

contracts/interfaces/airdrop/IAirdropERC721.sol

12e8142ab6324916ee6e6c2062d56cf676c004f5513a14841a9494a06fb87ab8

contracts/lib/CurrencyTransferLib.sol

ab7e40d1b333d675e23d9d4a4c70836c508b2e8b890cf1c6f3dc554424d1215d

contracts/openzeppelin-presets/token/ERC20/utils/SafeERC20.sol

569cd0b266ff404aeac1a4266a1535121e47907ef1dcea2d55f4c036a11f758e

contracts/extension/Ownable.sol

d28288423c41e9bcc22c4861dc6901087b7a2500a8415c39580de4f803591005

contracts/extension/PermissionsEnumerable.sol

e11e8f40eb775c0ccc34b746b03a08e584f626b94fdde1396f538c3a76c487d4

contracts/extension/interface/IPermissionsEnumerable.sol

b4958ce026d4c0c93dae5e671aabed246531c448e1e081d0ba6dc590c3377393

We audited the following contracts specificially for the Multichain Registry audit:

Source Code SHA256
contracts/plugin/TWRouter.sol

7984a45907b466554b736367b7ad476ae3c1512d861563ba6b940491acc52827

contracts/plugin/interface/IDefaultPluginSet.sol

b15da519da306fa111231cff65858cd0b9f1c90398a6e2762731ff4313784478

contracts/plugin/interface/IPlugin.sol

0eba4e9a2acbd86a29216da2183a911dc876d239c345a4297caae991e4111e75

contracts/plugin/interface/IPluginRegistry.sol

851c4009315122f065b73fcdaa27f27ed6e9adcd9be30372230d93c88b0e1251

contracts/plugin/interface/ITWRouter.sol

b30554e2957c58ead799b31e89e82da2bbc97ba39ec4ecf07288cc35f48f0953

contracts/registry/TWMultichainRegistry.sol

cc74a1df7e3e61817d85c80e9db6f7d1580ca86aab83c642de3678873271908f

contracts/extension/Initializable.sol

1d352d3edcc93bfd1ce49efa386c1aeaf224309d70a513c6a71dd9f6b52fbb17

contracts/extension/interface/IPermissionsEnumerable.sol

b4958ce026d4c0c93dae5e671aabed246531c448e1e081d0ba6dc590c3377393

contracts/extension/PermissionsEnumerable.sol

e11e8f40eb775c0ccc34b746b03a08e584f626b94fdde1396f538c3a76c487d4

contracts/openzeppelin-presets/utils/EnumerableSet.sol

778d5305652c4eb562b12880cb6cf023d67df24844c15783a0b80fac2e715585

contracts/lib/TWStringSet.sol

bef561777e503ca93c3710cb00c85f0aeb7a1fb7134d8c4078ea668c8b849a92

contracts/extension/interface/IMulticall.sol

e79b52a8ed68d712078780d8acda473e67e2ddd9072a46124b790de0b85ddb68

contracts/extension/Multicall.sol

3ec9119c7840d29e4c61092bbbf8f63e6c5204139490ed44d81bd348005bead8

contracts/lib/TWAddress.sol

e63c27c95600ee1886258f2e588bc6e00a8b6224bfba341c1b05a78baa3755e1

contracts/plugin/DefaultPluginSet.sol

f58c27091037ef6c814886b28b6dcff1749ad09c038b7c624a4fc302c348bf73

contracts/plugin/PluginRegistry.sol

47ad900d613c0f168949339bddb19b1691e0641f71097ae4e564d0fc67241335

contracts/plugin/PluginState.sol

6a4b8d272e6625d0a3adfb1bdd2dc5663cc0df840c975cdf13b312946bce5c74

contracts/plugin/Router.sol

5fadc5cc3066582745123007129b568b8fa4427a1187fe4ace9f78ab4a0eec2c

contracts/interfaces/ITWMultichainRegistry.sol

e3332c614ddad11ce40a8a5aa42c4f0c24283cd476fd7226232bffbf51a0aaa0

contracts/extension/interface/IPermissions.sol

333d596baf00c08da55bc1671da3f5df65c4a1d9e8d5639e910d1c23ffb7f980

contracts/extension/interface/IERC2771Context.sol

65462f90d17fcf1befeccedd6f78a372bd0160de16daf3507ffcd639aac38948

contracts/registry/plugin/MultichainRegistryCore.sol

6e3e72c12215c5e5051229528d28216785a715ca5ee34f26dab2f4720f2bfc71

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

H-1

addRecipients adds empty airdrops on subsequent calls

Topic
Protocol Design
Status
Impact
High
Likelihood
High

AirdropERC721.sol, AirdropERC20.sol, and AirdropERC1155.sol, each have the function addRecipients, which with some variance adds airdrop recipients to a mapping, that will be used to issue airdrops. Since there is a lot of data being stored, multiple calls to this function may be required to set all the desired airdrops.

However, on calls after the first, the loop that adds recipients to the mapping is not properly setup, and may not be executed.

(Provided code is from AirdropERC721.sol, but the same issue is present in AirdropERC20.sol and AirdropERC1155.sol)

function addRecipients(AirdropContent[] calldata _contents) external onlyRole(DEFAULT_ADMIN_ROLE) {
    uint256 len = _contents.length;
    require(len > 0, "No payees provided.");

    uint256 currentCount = payeeCount;
    payeeCount += len;

    for (uint256 i = currentCount; i < len; i += 1) {
        airdropContent[i] = _contents[i];
    }

    emit RecipientsAdded(_contents);
}

Since after the first call to addRecipients the payeeCount will be non-zero, and potentially greater than the length of _contents. At the start of the loop i is set to currentCount but the condition i < len expects i to be zero in order to loop through all of _contents.

This leads to some or all of the airdrop recipients to not be included in the drop, while also increasing the payeeCount by number of recipients that were supposed to be added to the airdropContent mapping.

A couple issues arise from this bug:

  • The contract thinks it has more recipients than it does, and will return values that are potentially empty when querying for getAllAirdropPayments, getAllAirdropPaymentsPending, getAllAirdropPaymentsProcessed, getAllAirdropPaymentsFailed, or getAllAirdropPaymentsCancelled. This could lead to problems for contracts that query and use this data, as well as effect front end applications that use it.
  • Calling processPayments would revert when attempting to transfer as the tokenAddress of these missed recipients would be the zero address. The first recipients added can receive their airdrop, if processPayments is called with paymentsToProcess equal to the number of recipients initially added, but after that processPayments will no longer be able to be called.

Remediations to Consider

At the start of the loop set i to be equal to zero, and determine the index to store the recipient in the airdropContent mapping to be currentCount + i.

M-1

Fails for non-compliant ERC20 tokens

Topic
Coding Standards
Status
Impact
Medium
Likelihood
Medium

Some of the well known “ERC20” tokens - such as USDT - don’t fully comply to the ERC-20 standard and don’t return a boolean on e.g. transfer and transferFrom. For such tokens, AirdropERC20::_transferCurrencyWithReturnVal will fail on the following line:

try IERC20(_currency).transferFrom(_from, _to, _amount) returns (bool success_) {

Remediations to Consider

In such cases, we usually recommend using safeTransferFrom from SafeERC20 library. However, in this specific case, library calls can’t be wrapped around try/catch and thus making it difficult to implement a failsafe using try/catch. Instead of using safeTransferFrom, consider using the low level .call method and implement error handling according to your needs.

M-2

resetRecipients() can exceed block gas limit

Topic
Protocol Design
Status
Impact
Medium
Likelihood
Medium

In AirdropERC721.sol, AirdropERC20.sol, and AirdropERC1155.sol, resetRecipients() is called to cancel pending airdrops, and does so by setting processedCount to payeeCount, preventing any unprocessed airdrops from being processed. It also loops through each item canceled and sets its index in the isCancelled mapping to true, allowing getters to determine if an airdrop was canceled.

Looping through and setting the isCancelled mapping for each item is quite costly as it is a separate storage write for each item. In the case where a large airdrop was added using addRecipients() and contained an error, it would cost the owner of the contract a lot of gas to cancel all the items.

It is also possible that if the amount of unprocessed airdrop recipients is very large (~1350 pending recipients), it may be impossible to call resetRecipients(), as it will try to cancel all pending airdrops, causing the transaction cost to exceed the block gas limit.

Remediations to Consider

  • Allow the function to take a parameter determining the number of airdrops to cancel. Doing so would allow canceling large airdrops over multiple transactions without exceeding the block gas limit. It would also allow canceling individual airdrops that may be reverting or griefing, allowing a admin to call processPayments, processing up to the airdrop causing issues, then call resetRecipients() with a value of 1 to cancel the airdrop, then continue processing afterwords, thus preventing having to cancel all airdrops after the issue, and then having to reinsert the old airdrops.
  • Instead of storing a bool for each canceled item in isCancelled, store a range of indexes that represent the canceled items, as cancelation is always done in a sequence. Doing so would allow resetRecipients() to only require 2-3 storage writes, in some cases substantially reducing gas costs.
M-3

processPayments() can be griefed

Topic
Griefing
Status
Impact
Medium
Likelihood
Low

In AirdropERC721.sol, AirdropERC20.sol, and AirdropERC1155.sol, processPayments() airdrops tokens to recipients set prior to the call. For AirdropERC721.sol, and AirdropERC1155.sol, safeTransferFrom is used to transfer their respective tokens, which if the recipient is a contract it calls either onERC721Received, or onERC1155Received, ensuring the contract can properly handle owning tokens, but as a side effect it gives execution control over to the contract, which can execute malicious code. For AirdropERC20.sol, execution control is handed over if the token being sent is the native token of the blockchain, and the recipient is a contract that has implemented a receive() fallback function.

In the case where tokens are sent to a contract that implements one of these hooks maliciously, there is little the contract can do other than consume all the gas provided. If this is done, then calls to processPayments() could cost substantially more than expected, or exceed the block gas limit, preventing calls from executing if processing the malicious recipient.

Currently the resolution would be to call resetRecipients() to clear the pending airdrops, then submit the recipients again by calling addRecipients() with the unprocessed recipients, omitting the griefer. Doing this is rather costly, especially if the griefer is queued before many other recipients.

Remediations to Consider

  • Switch from a push method of transferring tokens, to a pull method. This makes users claim their airdrop and prevents malicious users from preventing others from receiving theirs.
  • Or explicitly pass in the amount of gas for the transfer, ensuring there is enough gas to allow the contract to execute normal operations, but not provide the entire gas of the transaction that can be used maliciously.
M-4

Function clashes can occur between plugins

Topic
Events
Status
Impact
Medium
Likelihood
Medium

PluginState.sol::_addPlugin explicitly checks that a new function selector being added doesn’t already exist in the pluginMetadata mapping - thus avoiding function clashes between different plugins.

require( 
    data.pluginMetadata[_plugin.functions[i].functionSelector].implementation == address(0),
    "PluginState: plugin already exists for function."
);

However, this check is not being done in the _updatePlugin function, allowing to override existing function selectors.

Consider the following scenario:

  1. PluginD is added containing the function d()
  2. PluginE is added containing the function e()
  3. PluginE is updated with the function d().

The last step overrides the mapping for d() pointing to PluginD, so that d() now points to PluginE. As a consequence, TWRouter forwards any calls targeting function d() to PluginE.

Moreover, deleting PluginA at this state would remove the d() function selector from the pluginMetadata mapping, also making the d() function of PluginE unreachable.

Remediations to Consider

Consider adding above require check to the _updatePlugin function as well.

M-5

Meta transactions don’t work

Topic
Spec
Status
Impact
Medium
Likelihood
High

MultichainRegistryCore.sol should allow trusted forwarders to call add(…) and remove(…) on behalf of the msg.sender; as implemented here:

function _msgSender() internal view returns (address sender) {
        if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) {
            // The assembly code is more direct than the Solidity version using `abi.decode`.
            assembly {
                sender := shr(96, calldataload(sub(calldatasize(), 20)))
            }
        } else {
            return msg.sender;
        }
    }

In order to support trusted forwarders - and as seen in the above code - ERC2771Context has to be implemented by TWMultichainRegistry. This can be achieved either via directly inheriting from ERC2771Context, or in the current ThirdWeb setup, by adding ERC2771Context implementation as a plugin.

The current setup involves:

  1. Create ERC2771Context contract with trusted forwarders
  2. Add ERC2771Context contract as a plugin to TWMultichainRegistry.

In step 1, when the ERC2771Context plugin is being created, the constructor stores the forwarders array into storage - using the storage layout of ERC2771Context contract. In step 2, the plugin is added to TWMultichainRegistry, thus forwarding all isTrustedForwarder() calls to the plugin. When e.g. an add(..) call (which forwards to MultichainRegistryCore.add(..)) is now initiated via the proxy, it runs under the proxy’s execution context and uses the proxy’s storage layout. However, the forwarders haven’t been set in the proxy’s storage layout, resulting in the following check to always return false:

IERC2771Context(address(this)).isTrustedForwarder(msg.sender)

Remediations to Consider

Consider implementing the following changed:

  1. Change TWMultichainRegistry to directly inherit from ERC2771Context contract.
  2. Add a function like _setupForwarders to the ERC2771Context contract which adds the trusted forwarders to storage.
  3. Call _setupForwarders from within TWMultichainRegistry::intialize (similar to how _setupRole is called).

By doing so, the trusted forwarders will be correctly stored in the proxy’s storage layout.

L-1

No event emitted within resetRecipients()

Topic
Events
Status
Impact
Low
Likelihood
High

The event PaymentsResetByAdmin is defined in the corresponding interface for all three AirdropERC20/721/1155 contracts:

/// @notice Emitted when pending payments are cancelled, and processed count is reset.
event PaymentsResetByAdmin();

However, it is not being emitted int the resetRecipients function.

Remediations to Consider

Emit the PaymentResetByAdmin event at the end of the resetRecipients function.

Q-1

Unused library SafeERC20

Topic
Quality
Status
Quality Impact
Low

In AirdropERC20.sol, SafeERC20 is imported:

import "../openzeppelin-presets/token/ERC20/utils/SafeERC20.sol";

and referenced here:

using SafeERC20 for IERC20;

Remediations to Consider

remove the above lines as SafeERC20 is not used anywhere in code. Instead use:

import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 
Q-2

Unnecessary use of Clone pattern for MultichainRegistry

Topic
Quality
Status
Quality Impact
Low

TWMultichainRegistry’s purpose is to hold a global registry of deployments and thus intended to be deployed only once by ThirdWeb itself. In this manner, deploying the contract via the Clone pattern only adds complexity and gas costs, without any benefits.

Remediations to Consider

Deploy the contract without using the Clone pattern (and thus having a proxy in front). In this case all the logic for setting up the roles inside the initialize function can be moved to the constructor.

G-1

AirdropContent struct stores redundant data

Topic
Gas Optimization
Status
Wont Do
Gas Savings
High

AirdropERC721.sol, AirdropERC20.sol, and AirdropERC1155.sol each use a variant of AirdropContent to store data in the airdropContent mapping to determine how to process airdrops. Some of this data is necessary, but in the case of both tokenAddress and tokenOwner, this data may be the same for an entire airdrop.

In the case where a collection owner want to airdrop 10k NFTs using AirdropERC721.sol’s addRecipients() to store the data of each recipient, the tokenAddress and tokenOwner would be the same for each entry, causing many unnecessary storage writes.

Remediations to Consider

Instead of storing the tokenAddress and tokenOwner for each airdrop recipient, this data could be stored in a separate mapping, along with a start and end range for the indexes this data applies. Doing so would only require a single write for tokenAddress and tokenOwner per airdrop, substantially reducing the cost of calling addRecipients() when airdropping multiple tokens of the same collection and owner.

G-2

Libraries only used in constructors are added to bytecode

Topic
Gas Optimization
Status
Wont Do
Gas Savings
High

Typically code run in the constructor is not stored in bytecode, and it is expected to be the same when code from a library is only used within the constructor.

However, recently it was found that there is an error causing code from libraries to be added to the bytecode when it is only used in the constructor, adding unnecessary bytecode and increasing deployment costs..

In the case of when TWProxy.sol is deployed, it results with a bytecode size of 668 bytes, but with all references to the Address.sol library removed from the constructor, the deployed bytecode can be brought down to 180 bytes.

Remediations to Consider

Remove references to libraries in constructors if that is the only place the functions are used, to save on deployment costs.

G-3

Use unchecked blocks to reduce gas

Topic
Gas Optimization
Status
Gas Savings
Low

AirdropERC721.sol, AirdropERC20.sol, and AirdropERC1155.sol each use loops for setting recipients and airdropping tokens.

In cases of large airdrops, there are many math operations that run, each checking to make sure no underflow/overflows occur, these checks consume gas and can add up, and are unnecessary if there is no way to underflow or overflow.

Remediations to Consider

Wrap operations that will not overflow/underflow in an unchecked block in order to save gas for users, especially for what is considered to be safe is wrapping i++ in an unchecked block like: unchecked {i++;}..

G-4

Unnecessary to emit airdrop contents

Topic
Gas Optimization
Status
Gas Savings
Low

AirdropERC721.sol, AirdropERC20.sol, and AirdropERC1155.sol emit events when addRecipients(), processPayments() that includes the data of the AirdropContent struct being added/processed for each recipient.

However, since this data is stored on chain, and can be queried for when needed, there is no need to emit this data.

Remediations to Consider

Instead of emitting the contents of an airdrop, emit the indexes of the content.

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.