NFTBridge
60,000 USDC
View results
Submission Details
Severity: low
Valid

Incorrect function signatures in `_callBaseUri` break `baseURI` functionality

Summary

TokenUtil::_callBaseUri is used to get the baseURI of an NFT collection but it uses wrong function signatures, causing it to always return an empty string even if the collection has a baseURI set.

Vulnerability Details

Currently there are two ways for how an NFT's URI can be included in a request when bridging NFTs.

  1. Setting a base URI (uri on L1, base_uri on L2)

  2. Setting an array of token URIs (tokenURIs on L1, ids on L2)

If a collection has set a base URI, the bridge is supposed to take approach 1) as it is more efficient to add one URI from which individual token URIs can be derived instead of adding a large array of URIs to the request.
This also allows the bridging of more NFTs at once, as it is more size efficient.

The retrieving of the baseURI on L1 is handled by TokenUtil::_callBaseUri which is used by TokenUtil::erc721Metadata to retrieve an NFT's metadata.

function _callBaseUri(
address collection
)
internal
view
returns (bool, string memory)
{
bool success;
require(success == false);
uint256 returnSize;
uint256 returnValue;
bytes memory ret;
bytes[2] memory encodedSignatures = [abi.encodeWithSignature("_baseUri()"), abi.encodeWithSignature("baseUri()")]; // [1]
for (uint256 i = 0; i < 2; i++) {
bytes memory encodedParams = encodedSignatures[i];
assembly {
success := staticcall(gas(), collection, add(encodedParams, 0x20), mload(encodedParams), 0x00, 0x20)
if success {
returnSize := returndatasize()
returnValue := mload(0x00)
ret := mload(0x40)
mstore(ret, returnSize)
returndatacopy(add(ret, 0x20), 0, returnSize)
// update free memory pointer
mstore(0x40, add(add(ret, 0x20), add(returnSize, 0x20)))
}
}
if (success && returnSize >= 0x20 && returnValue > 0) {
return (true, abi.decode(ret, (string)));
}
}
return (false, "");
}

Looking at this function, at [1] it uses two hardcoded signatures, _baseUri() and baseUri(), which it calls via a staticcall to get a collection's baseURI.

Now the problem is, that these signatures are incorrect. This can be seen if we look at the OpenZeppelin documentation of ERC721Metadata (https://docs.openzeppelin.com/contracts/2.x/api/token/erc721#ERC721Metadata-baseURI--).
Here we can see that the function signature to get the baseURI of a ERC721Metadata compliant collection is baseURI() and not baseUri().

Impact

This discrepancy in function signatures between the standard and the implementation breaks the whole baseURI functionality of the bridge. More precisely it causes completely compliant NFT collections which in fact use a baseURI to be unable to use that feature of the bridge.
Additionally, this feature being broken decreases the amount of NFTs that can be bridged with one transaction as multiple tokenURIs create a larger request than one baseURI.

Proof of Concept

This PoC will show that the bridge does not use the baseURI of a ERC721Metadata compliant collection.

First please add the following files to apps/blockchain/ethereum/src/token/:

IERC721BridgeableMetadata.sol:

pragma solidity ^0.8.0;
interface IERC721BridgeableMetadata {
function initialize(bytes calldata data) external;
function setBaseURI(string calldata baseURI_) external;
function setTokenURI(uint256 tokenId, string calldata _tokenURI) external;
function mintWithTokenURI(address to, uint256 tokenId, string calldata _tokenURI) external;
function tokenURI(uint256 tokenId) external view returns (string memory);
}

ERC721BridgeableMetadata.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol";
import "./IERC721Bridgeable.sol";
import "../UUPSProxied.sol";
/**
@title ERC721 that can be minted by the bridge.
@dev As this ERC721 must be upgradable, the name and symbol must
be overriden to work correctly, as the constructor can't be called,
but initialization function instead.
*/
contract ERC721BridgeableMetadata is ERC721, UUPSOwnableProxied, IERC721Bridgeable {
// Descriptive name for the token collection.
string private _name;
// Abbreviated name for the token collection.
string private _symbol;
string private __baseURI;
mapping(uint256 => string) private _tokenURIs;
/**
@notice Default constructor, but intialize is used instead.
*/
constructor()
ERC721("", "")
{ }
/**
@notice Initializes the implementation, only callable once.
@param data Data to init the implementation.
*/
function initialize(
bytes calldata data
)
public
onlyInit
{
(string memory n, string memory s) = abi.decode(data, (string, string));
_name = n;
_symbol = s;
_transferOwnership(_msgSender());
}
/**
@inheritdoc IERC721Bridgeable
@dev In this implementation, the owner is the bridge by default. So `onlyOwner`
is enough.
*/
function mintFromBridge(address to, uint256 id)
public
onlyOwner {
_mint(to, id);
}
/**
@inheritdoc IERC721Bridgeable
@dev In this implementation, the owner is the bridge by default. So `onlyOwner`
is enough.
*/
function burnFromBridge(uint256 id)
public
onlyOwner {
_burn(id);
}
/**
@notice base URI for the tokens.
@dev By default empty, or perhaps we can target a default URI?
*/
function _baseURI()
internal
view
override
returns (string memory) {
return __baseURI;
}
function _setTokenURI(uint256 tokenId, string calldata _tokenURI) internal {
_requireMinted(tokenId);
_tokenURIs[tokenId] = _tokenURI;
}
function _setBaseURI(string memory baseURI_) internal {
__baseURI = baseURI_;
}
function baseURI() public view returns (string memory) {
return __baseURI;
}
function setBaseURI(string memory baseURI_) public onlyOwner {
_setBaseURI(baseURI_);
}
function setTokenURI(uint256 tokenId, string calldata _tokenURI) public onlyOwner {
_setTokenURI(tokenId, _tokenURI);
}
function mintWithTokenURI(address to, uint256 tokenId, string calldata _tokenURI) public onlyOwner returns (bool) {
mintFromBridge(to, tokenId);
_setTokenURI(tokenId, _tokenURI);
return true;
}
/**
@notice A descriptive name for the token collection.
@dev `name()` must be overriden as the underlying value
is private, then not accessible for this contract (which extends
the base ERC721 contract).
*/
function name()
public
view
override
returns (string memory)
{
return _name;
}
/**
@notice An abbreviated name for the token collection.
@dev `symbol()` must be overriden as the underlying value
is private, then not accessible for this contract (which extends
the base ERC721 contract).
*/
function symbol()
public
view
override
returns (string memory)
{
return _symbol;
}
}

Then comment out the setUp() function in Bridge.t.sol and add the following imports:

import "../src/token/ERC721BridgeableMetadata.sol";
import "../src/token/IERC721BridgeableMetadata.sol";

In order for us to see the generated request, please add the following lines at the end of Bridge::depositTokens (before emitting the DepositRequestInitiated event):

console.log(req.uri);
console.log(req.tokenURIs.length);

Also add the following import to Bridge.sol:

import { console } from "forge-std/Test.sol";

Now we can finally add the test function test_not_using_baseURI to Bridge.t.sol and execute it with forge test --match-test test_not_using_baseURI -vv.

function test_not_using_baseURI() public {
Users genusers = new Users();
address payable[] memory users = genusers.create(5);
alice = users[0];
bob = users[1];
// "Regular" collections, not controlled by the bridge.
erc721C1 = address(new ERC721BridgeableMetadata());
bytes memory initData = abi.encode("name", "symbol");
IERC721BridgeableMetadata(erc721C1).initialize(initData);
IERC721BridgeableMetadata(erc721C1).setBaseURI("http://example.com/");
IERC721Bridgeable(erc721C1).mintFromBridge(address(this), 1);
IERC721Bridgeable(erc721C1).mintFromBridge(address(this), 2);
snCore = address(new StarknetMessagingLocal());
address impl = address(new Starklane());
bytes memory dataInit = abi.encodeWithSelector(
Starklane.initialize.selector,
abi.encode(
address(this),
snCore,
0x1,
0x2
)
);
bridge = address(new ERC1967Proxy(impl, dataInit));
IStarklane(bridge).enableBridge(true);
uint256[] memory ids = new uint256[](2);
ids[0] = 1;
ids[1] = 2;
IERC721(erc721C1).approve(address(bridge), 1);
IERC721(erc721C1).approve(address(bridge), 2);
IStarklane(bridge).depositTokens{value : 1}(0x10, address(erc721C1), Cairo.snaddressWrap(100), ids, false);
}

This will give us an output looking similar to this:

[PASS] test_not_using_baseURI() (gas: 7052017)
Logs:
2

The empty line before the 2 shows that our uri (baseURI) is an empty string even though we set it in our testcase.
This shows that instead of packing our metadata into one baseURI we add two tokenURIs to the request.

Tools Used

Manual review

Recommended Mitigation

In order for the baseURI feature to work as expected, update the function signatures to include baseURI() or call it directly if the collection supports ERC721Metadata.

Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-baseUri-selector-instead-of-baseURI

Likelyhood: Medium, no token using OZ version 2.X and 3.X will work. Impact: Low, Valid standard token won’t be mint with the URI but owner can use ERC721UriImpl function on the deployed token.

Appeal created

n4nika Submitter
9 months ago
n0kto Lead Judge
9 months ago
n0kto Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-baseUri-selector-instead-of-baseURI

Likelyhood: Medium, no token using OZ version 2.X and 3.X will work. Impact: Low, Valid standard token won’t be mint with the URI but owner can use ERC721UriImpl function on the deployed token.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.