Summary
When bridging tokens from L1 to L2, TokenUtil::erc721Metadata
uses the _callBaseUri
function to determine whether a base URI for the collection already exists; if so, the individual token URIs are ignored. However, _callBaseUri
will succeed even if it reads an empty string as the collection's base URI, resulting in a loss of the individual token URIs.
Vulnerability Details
ERC721 NFT contracts may have a public baseUri
function that returns the base URI of the collection, set via _setBaseURI
. The returned string is empty by default (see openzeppelin-contracts/contracts/token/ERC721
/ERC721.sol).
When baseUri
(or _baseUri
) exists and is called by TokenUtil::_callBaseUri()
, the call will succeed even for the empty string case, and the function will return parameters that TokenUtil::erc721Metadata
will take as valid:
function erc721Metadata(address collection, uint256[] memory tokenIds)
internal
view
returns (string memory, string memory, string memory, string[] memory)
{
bool supportsMetadata = ERC165Checker.supportsInterface(collection, type(IERC721Metadata).interfaceId);
if (!supportsMetadata) {
return ("", "", "", new string[](0));
}
IERC721Metadata c = IERC721Metadata(collection);
@>
@> (bool success, string memory _baseUri) = _callBaseUri(collection);
if (success) {
return (c.name(), c.symbol(), _baseUri, new string[](0));
} else {
string[] memory URIs = new string[](tokenIds.length);
for (uint256 i = 0; i < tokenIds.length; i++) {
URIs[i] = c.tokenURI(tokenIds[i]);
}
return (c.name(), c.symbol(), "", URIs);
}
}
When baseUri
function exists and returns an empty string, the empy string will be read by the _callBaseUri
function and the parameters values will be
success == true
_baseUri == ""
The empty string will be used as a valid base URI and will cause individual token URIs to be ignored.
Proof of concept
The fact that _callBaseUri
succeeds when baseUri
function returns an empty string is easily seen by just slightly modifying the WithUnderscoreBaseUri
and/or WithBaseUri
contracts in TokenUtils.t.sol
file as follows:
contract WithUnderscoreBaseUri is IUnderscoreBaseUri {
function _baseUri() external view returns (string memory) {
- return "_baseURI here";
+ return ""; // empty string
}
}
contract WithBaseUri is IBaseUri {
function baseUri() external view returns (string memory) {
- return "_baseURI here";
+ return ""; // empty string
}
}
then, execute the test_with_baseuri
test. It will succeed.
Impact
Impact: High
Likelihood: Medium
This problem arises every time a collection with baseUri
or _baseUri
function returning an empty string is bridged, resulting in loss of the individual token URIs.
Tools Used
Manual Review
Recommendations
To fix this issue, it is recommended to add an additional check for the string returned by _callBaseUri
in the TokenUtil::erc721Metadata
function:
function erc721Metadata(
address collection,
uint256[] memory tokenIds
)
internal
view
returns (string memory, string memory, string memory, string[] memory)
{
bool supportsMetadata = ERC165Checker.supportsInterface(
collection,
type(IERC721Metadata).interfaceId
);
if (!supportsMetadata) {
return ("", "", "", new string[](0));
}
IERC721Metadata c = IERC721Metadata(collection);
// How the URI must be handled.
// if a base URI is already present, we ignore individual URI
// else, each token URI must be bridged and then the owner of the collection
// can decide what to do
(bool success, string memory _baseUri) = _callBaseUri(collection);
- if (success) {
+ if (success && keccak256(abi.encodePacked(_baseUri)) != keccak256(abi.encodePacked(""))) {
return (c.name(), c.symbol(), _baseUri, new string[](0));
}
else {
string[] memory URIs = new string[](tokenIds.length);
for (uint256 i = 0; i < tokenIds.length; i++) {
URIs[i] = c.tokenURI(tokenIds[i]);
}
return (c.name(), c.symbol(), "", URIs);
}
}
A second valid solution is to use the individual URIs even if a base URI exists:
function erc721Metadata(
address collection,
uint256[] memory tokenIds
)
internal
view
returns (string memory, string memory, string memory, string[] memory)
{
bool supportsMetadata = ERC165Checker.supportsInterface(
collection,
type(IERC721Metadata).interfaceId
);
if (!supportsMetadata) {
return ("", "", "", new string[](0));
}
IERC721Metadata c = IERC721Metadata(collection);
- // How the URI must be handled.
- // if a base URI is already present, we ignore individual URI
- // else, each token URI must be bridged and then the owner of the collection
- // can decide what to do
(bool success, string memory _baseUri) = _callBaseUri(collection);
- if (success) {
- return (c.name(), c.symbol(), _baseUri, new string[](0));
- }
- else {
string[] memory URIs = new string[](tokenIds.length);
for (uint256 i = 0; i < tokenIds.length; i++) {
URIs[i] = c.tokenURI(tokenIds[i]);
}
- return (c.name(), c.symbol(), "", URIs);
+ return (c.name(), c.symbol(), _baseUri, URIs);
- }
}