Security Audit
September 29th, 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 August 23, 2022 to September 2, 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 | 1 | - | - | 1 |
Medium | 3 | - | - | 3 |
Low | 2 | - | 1 | 1 |
Code Quality | 4 | - | - | 4 |
Informational | 1 | - | - | - |
Gas Optimization | 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:
d3f61abc34d930788e09b968affb38fec5762408
Specifically, we audited the following contracts as part of TokenERC20 contract audit:
Contract | SHA256 |
---|---|
contracts/token/TokenERC20.sol |
|
contracts/lib/FeeType.sol |
|
contracts/lib/CurrencyTransferLib.sol |
|
contracts/openzeppelin-presets/metatx/ERC2771ContextUpgradeable.sol |
|
contracts/extension/interface/IPlatformFee.sol |
|
contracts/extension/interface/IPrimarySale.sol |
|
contracts/interfaces/token/ITokenERC20.sol |
|
contracts/interfaces/ITWFee.sol |
|
contracts/interfaces/IThirdwebContract.sol |
|
We audited the following contracts as part of TokenERC721 contract audit:
Contract | SHA256 |
---|---|
contracts/token/TokenERC721.sol |
|
contracts/lib/FeeType.sol |
|
contracts/lib/CurrencyTransferLib.sol |
|
contracts/openzeppelin-presets/metatx/ERC2771ContextUpgradeable.sol |
|
contracts/extension/interface/IPlatformFee.sol |
|
contracts/extension/interface/IPrimarySale.sol |
|
contracts/extension/interface/IRoyalty.sol |
|
contracts/extension/interface/IOwnable.sol |
|
contracts/interfaces/token/ITokenERC721.sol |
|
contracts/interfaces/ITWFee.sol |
|
contracts/interfaces/IThirdwebContract.sol |
|
We audited the following contracts as part of TokenERC1155 contract audit:
Contract | SHA256 |
---|---|
contracts/token/TokenERC1155.sol |
|
contracts/lib/FeeType.sol |
|
contracts/lib/CurrencyTransferLib.sol |
|
contracts/openzeppelin-presets/metatx/ERC2771ContextUpgradeable.sol |
|
contracts/extension/interface/IPlatformFee.sol |
|
contracts/extension/interface/IPrimarySale.sol |
|
contracts/extension/interface/IRoyalty.sol |
|
contracts/extension/interface/IOwnable.sol |
|
contracts/interfaces/token/ITokenERC1155.sol |
|
contracts/interfaces/ITWFee.sol |
|
contracts/interfaces/IThirdwebContract.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.
mintWithSignature()
front-running vulnerability
_safeMint
instead of _mint
platformFeeBps
is not bounded
PrimarySaleRecipient
and platformFeeRecipient
can be set to 0x0
draft-EIP712Upgradeable.sol
draft status
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. |
mintWithSignature()
front-running vulnerability
In TokenERC20, TokenERC721 and TokenERC115, mintWithSignature()
allows the MintRequest’s to
address to be zero:
function mintWithSignature(MintRequest calldata _req, bytes calldata _signature) external payable nonReentrant {
// ...
address receiver = _req.to == address(0) ? _msgSender() : _req.to;
// ...
}
This behavior allows a minter to construct a mint signature such that the sender will receive the tokens, instead of needing to specify the recipient’s address.
However, because the recipient is the sender, an attacker can frontrun a mintWithSignature()
transaction, receiving the tokens and leaving the victim with nothing.
If there is no price, the attacker gets the tokens for free. This is particularly bad when the token has a market price on an AMM, and the contract owner wanted to grant someone new tokens.
If a price is present, the attacker does have to pay it. But the situation is still problematic if the price was a discount, or if the use case is minting for a fixed amount of investment.
Note that this behavior also exists in SignatureDrop.sol.
Consider forcing MintRequest’s to
address to always be defined.
In TokenERC20’s mintWithSignature
function, when mint request is defined with quantity=0
, nothing is minted to the specified to
address but:
minted
mapping).Although an account with a minter role would need to create this mint request, it’s not uncommon for such logic to have bugs, especially in an off-chain context.
Consider adding a check in the verifyRequest
function to revert when quantity is 0.
require(_req.quantity > 0, "quantity is zero");
Also consider to add the above require statement to TokenERC1155. Although this contract doesn’t charge the price as quantity is considered in calculation, it still records the mint request as a success.
In TokenERC20, TokenERC721 and TokenERC115 mint requests can include a price
and a currency
which can be either NATIVE_TOKEN
(e.g. ETH) or some other ERC20 token.
Suppose the currency specified in the request is some ERC20 token and the sender - by mistake - nevertheless sends ether with the call. The function call will not revert, causing the received “native tokens” to be locked inside the contract without recovery.
Consider adding a check to the collectPrice
function which prevents ether being sent to the contract when currency is not set to NATIVE_TOKEN
.
if (_currency == CurrencyTransferLib.NATIVE_TOKEN) {
require(msg.value == _price, "must send total price.");
} else {
require(msg.value == 0, "dont accept ether");
}
_safeMint
instead of _mint
TokenERC721 and TokenERC1155 use _mint
instead of _safeMint
to mint the actual tokens:
_mint(_to, tokenIdToMint);
This can lead to tokens being locked in those contracts with no recovery.
Consider using _safeMint
instead of _mint
to check if a contract recipient can receive (or is at least aware of receiving) ERC721 tokens.
platformFeeBps
is not bounded
In TokenERC20, TokenERC721 and TokenERC115, the initialize
functions don’t have a boundary check for platformFeeBps
.
Also in the setPlatformFeeInfo
function, the only restriction to platformFeeBps
is ≤ MAX_BPS
(10_000). If set to MAX_BPS
or any value close to it (depending on fees), the minting would fail because of underflow in case ThirdWeb fee is applied. See following line:
CurrencyTransferLib.transferCurrency(
_currency,
_msgSender(),
_primarySaleRecipient,
_price - platformFees - twFee // underflow
);
Consider setting a reasonable upper limit for platformFeeBps
.
PrimarySaleRecipient
and platformFeeRecipient
can be set to 0x0
In the initialize()
, setPrimarySaleRecipient()
, and setPlatformFeeInfo()
, in all three token contracts, there is no check for primarySaleRecipient
and platformFeeRecipient
to not have address 0x0.
As a consequence, when using NATIVE_TOKEN (e.g. ETH), ether could be burned to the zero address.
In the case of an ERC20 transfer, the transaction would be reverted (for OpenZeppelin ERC20 tokens).
Consider adding checks to not allow address 0x0 for primarySaleRecipient
and platformFeeRecipient
.
Thirdweb’s signatureMint documentation defines one of the mint request parameters as pricePerToken
.
However, in TokenERC20.sol, the price specified in the mint request is considered the total price instead.
This also creates an inconsistency with TokenERC1155.sol, which uses pricePerToken
as documented.
Consider:
pricePerToken
instead of total price
and adapting collectPrice
function accordingly to include a quantity
parameter, orIn TokenERC20 there is the possibility to pause/unpause token transfers. In contrast, TokenERC721 and TokenERC1155 don’t provide this functionality.
Consider to add pause/unpause capability for token transfers in TokenERC721 and TokenERC1155.
In TokenERC1155's _mintTo
function the minting reverts when the uri
parameter in MintRequest
is empty.
if (bytes(_tokenURI[_tokenId]).length == 0) {
require(bytes(_uri).length > 0, "empty uri.");
_tokenURI[_tokenId] = _uri;
}
In contrast, TokenERC721 doesn’t check for empty uri parameter.
Consider adding this check to TokenERC721 as well.
TokenERC721 and TokenERC1155 define their own NATIVE_TOKEN
constant:
address private constant
NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
However, TokenERC20 uses CurrencyTransferLib.NATIVE_TOKEN
.
The collectPrice()
function is written differently in TokenERC20 from TokenERC721 and TokenERC1155. Instead of passing in a reference to the whole _req
request, it pulls out specific parameters. In addition, TokenERC20’s version has the logic to determine saleRecipient
within mintWithSignature()
instead of collectPrice()
.
Consider updating TokenERC20’s collectPrice()
to be consistent with TokenERC721 and TokenERC1155.
_afterTokenTransfer()
is only overridden in TokenERC20 without any changed behavior.
MAX_BPS
is declared as:
uint128 internal constant
in TokenERC20.uint256 private constant
in TokenERC721 and in TokenERC1155.platformFeeBps
is declared as:
uint128 internal
in TokenERC20.uint128 public
in TokenERC721 and in TokenERC1155.TokenERC721 and TokenERC1155 both inherit from OpenZeppelin’s ReentrancyGuardUpgradeable
and call its __ReentrancyGuard_init()
. However, TokenERC20 does call this initializer, even though it inherits the same contract.
In TokenERC721 and TokenERC1155 function collectPrice
uses memory
keyword for the MintRequest
parameter. Consider using calldata
allocation instead to save gas costs.
In TokenERC721 and TokenERC1155 function collectPrice
uses memory
keyword for the MintRequest
parameter. Consider using calldata
allocation instead to save on gas cost.
draft-EIP712Upgradeable.sol
draft status
See OZ’s statement regarding draft status here: https://docs.openzeppelin.com/contracts/3.x/api/drafts
Scope: VoteERC20.sol and Split.sol
For these two contracts, we opted to do a review instead of a full audit, due there being no test suite and low documentation. However, we were happy to provide suggestions and considerations in this way.
proposalIndex
and proposalId
are usedResolution: Wont Do.
The two common options to store proposals on-chain in DAO proposal systems are:
The former leans more towards decentralization, as anyone can read on-chain data to verify the proposed function calls at any time.
On the other hand, the latter saves on gas costs, as storing function calls on-chain can get expensive.
VoteERC20 takes the approach of both:
propose()
to store all calls in its own proposals variable (approach #1), including the 32 byte hash from the parent OZ contract.Doing both is redundant, which will result in increased gas costs for no benefit. It may also cause confusion, as there are arguably two ids: the 32 byte hash, and the proposal index.
Consider taking one approach or another. If approach #1 is desired, consider using Compound’s Governor Bravo contracts. Otherwise, it may be sufficient that the proposed function calls are emitted in an event.
distribute
functionResolution: Wont Do.
In Split contract, distribute
function loops through each payee address and sends assets performing checks and reverting if any of these calls fails. If one of the payees reverts the transfer the entire distribute
function will revert in every call. This can also be intentionally done by a smart contract address as a payee.
The vulnerability is not that bad, since anyone is able to call release()
for their own account at any time. However, consider documenting the dangers 3rd party contracts or other thirdweb contracts relying on this function.
Also consider not reverting on failed transfers, if atomicity is not important.
Resolution: Updated preset version to reflect OZ changes.
Split contract inherits from PaymentSplitterUpgradeable
. It is not referencing the OpenZeppelin version directly but instead is using a preset version:
import "./openzeppelin-presets/finance/PaymentSplitterUpgradeable.sol";
OpenZeppelin did some slight changes recently on this contract and has added releasable
functions. See here: https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/commit/b392c249e2c72434c438e0e495af1bacbc6cfd4f#diff-ec6a964c766447336ea6842ff3214ac85da5144ccf44d92e100d3d4acfd907c4
Consider to update the preset version to include those latest changes, as releasable()
is a useful function.
Resolution: The ThirdWeb fee was removed.
After calling thirdwebFee.getFeeInfo()
, the Split contract checks to see if their values are not equal to zero. This seems to be inconsistent with the rest of the codebase.
If this check is important, consider reevaluating other parts of the codebase that do not have this check.
Resolution: Removed related comments.
On distribute
function, Split contract has comments referring to an function named _appPay
but it does not seem to exist in the codebase.
function distribute() public virtual {
uint256 count = payeeCount();
for (uint256 i = 0; i < count; i++) {
****// note: `_release` should not fail because payee always has shares, **protected by `_appPay`**
_release(payable(payee(i)));
}
}
function distribute(IERC20Upgradeable token) public virtual {
uint256 count = payeeCount();
for (uint256 i = 0; i < count; i++) {
****// note: `_release` should not fail because payee always has shares, **protected by `_appPay**`
_release(token, payee(i));
}
}
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.