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

function erc721Metadata returns empty base uri instead of token uris

Summary

When depositing an NFT, during calculating the base uri, if the collection returns empty string "", the protocol considers it as a valid base uri while the correct way is that it considers the token uri instead. This leads to a situation that wrong base uri and token uris are set for the NFT in the struct Request and forwarded to L2. On L2 side, the NFT will be minted (if it is not escrowed) without setting the base uri and token uri.

The root cause of the issue is that during calculating the base uri, returnValue is looking at the memory at the location 0x00 which is holding the offset of returned data, instead of looking at the location 0x20 where the length of returned data is located.

In other words, many NFTs have empty base uri as "", while they have non-empty uri for each token individually as token uri. Due to the explained issue, the protocol assumes that base uri and token uri for such NFTs are empty, while their token uri is not empty.

Vulnerability Details

During calculating the base uri in the function _callBaseUri, if the functions _baseUri() or baseUri() are public/external in the collection, then calling these functions will be successful.

function _callBaseUri(
address collection
)
internal
view
returns (bool, string memory)
{
bool success;
uint256 returnSize;
uint256 returnValue;
bytes memory ret;
bytes[2] memory encodedSignatures = [abi.encodeWithSignature("_baseUri()"), abi.encodeWithSignature("baseUri()")];
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, "");
}

https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L139

If _baseUri() or baseUri() are public/external but their return string is "", it means that the base uri is not available, and their token uri should be used instead. For example, in the following collections, the function baseURi is public, but the state variable _baseURI is not set and is equal to "". In this case the function tokenUri should be used.
https://etherscan.io/address/0xbc0867d688bb604e4da8841066f3e72e5f17df3c#code
https://etherscan.io/address/0x3c3a2080ec9fecee7588838899f98dccf9d91868#readContract#F2
https://etherscan.io/address/0x1e31fe630faf5782f20a187d7d21958b5f3bd699#readContract#F2

function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");
string memory _tokenURI = _tokenURIs[tokenId];
string memory base = baseURI();
// If there is no base URI, return the token URI.
if (bytes(base).length == 0) {
return _tokenURI;
}
// If both are set, concatenate the baseURI and tokenURI (via abi.encodePacked).
if (bytes(_tokenURI).length > 0) {
return string(abi.encodePacked(base, _tokenURI));
}
// If there is a baseURI but no tokenURI, concatenate the tokenID to the baseURI.
return string(abi.encodePacked(base, tokenId.toString()));
}
/**
* @dev Returns the base URI set via {_setBaseURI}. This will be
* automatically added as a prefix in {tokenURI} to each token's URI, or
* to the token ID if no specific URI is set for that token ID.
*/
function baseURI() public view virtual returns (string memory) {
return _baseURI;
}

https://etherscan.io/address/0x16f6e045139011d825286c4569a599ee09274b98#code#F2#L142

When _baseUri() or baseUri on the target collection is called, the returned string data to the Ark protocol will be of format:

0x(32-byte offset)(32-byte length)(data)

for example, the returned string "a" will have the following format:

0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000016100000000000000000000000000000000000000000000000000000000000000

It shows that:

offset is: 0000000000000000000000000000000000000000000000000000000000000020
length is: 0000000000000000000000000000000000000000000000000000000000000001
data is: 6100000000000000000000000000000000000000000000000000000000000000
return size: 96 bytes

In this case, the variables success, returnSize, and returnValue would be: true, 96, and 32. So, the if-clause in the function _callBaseUri will be executed properly:

if (success && returnSize >= 0x20 && returnValue > 0) {
return (true, abi.decode(ret, (string)));
}

https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L166

The issue is here:

When _baseUri() or baseUri on the target collection is called, and they return the string "", the returned string data to the Ark protocol will be:

0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000

It shows that:

offset is: 0000000000000000000000000000000000000000000000000000000000000020
length is: 0000000000000000000000000000000000000000000000000000000000000000
return size: 64 bytes

In this case, the variables success, returnSize, and returnValue would be: true, 64, and 32. So, the if-clause in the function _callBaseUri will be executed. While it should not be executed, because the returned base uri is an empty string, and the token uri should be used instead.

The root cause of this issue is that returnValue (which is responsible to track the length of the returned string) is set to the memory at location 0x00, which is holding the offset of the returned data not the length of the returned string. The offset of the returned data for such a string is always 32. So, the condition returnValue > 0 is always satisfied which is not correct.

returnValue := mload(0x00)

https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L158

As a result of this issue the fields uri and tokenUris would be: "" and new string[](0)

(bool success, string memory _baseUri) = _callBaseUri(collection);
if (success) {
return (c.name(), c.symbol(), _baseUri, new string[](0));
}

https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/token/TokenUtil.sol#L95

if (ctype == CollectionType.ERC721) {
(req.name, req.symbol, req.uri, req.tokenURIs) = TokenUtil.erc721Metadata(
collectionL1,
ids
);
}

https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/ethereum/src/Bridge.sol#L121

On L2 side, during withdrawing, if the token is not escrowed, it would be minted. Since req.uris.len() is set to zero by mistake due to the bug explained, the body of the inner else-clause will be executed, while the body of inner if-clause should have been executed.

if is_escrowed {
IERC721Dispatcher { contract_address: collection_l2 }
.transfer_from(from, to, token_id);
} else {
if (req.uris.len() != 0) {
let token_uri = req.uris[i];
IERC721BridgeableDispatcher { contract_address: collection_l2 }
.mint_from_bridge_uri(to, token_id, token_uri.clone());
} else {
IERC721BridgeableDispatcher { contract_address: collection_l2 }
.mint_from_bridge(to, token_id);
}
}

https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/bridge.cairo#L163

Then, the function mint_from_bridge would be called, instead of mint_from_bridge_uri, and as a result the uri for tokens will not be stored.

#[abi(embed_v0)]
impl ERC721BridgeableImpl of IERC721Bridgeable<ContractState> {
fn mint_from_bridge(ref self: ContractState, to: ContractAddress, token_id: u256) {
assert(
starknet::get_caller_address() == self.bridge.read(),
"ERC721: only bridge can mint"
);
self.erc721._mint(to, token_id);
}
fn mint_from_bridge_uri(ref self: ContractState, to: ContractAddress, token_id: u256, token_uri: ByteArray) {
IERC721Bridgeable::mint_from_bridge(ref self, to, token_id);
self.token_uris.write(token_id, token_uri);
}
}

https://github.com/Cyfrin/2024-07-ark-project/blob/main/apps/blockchain/starknet/src/token/erc721_bridgeable.cairo#L80

In summary:
The NFT collections on L1 that do not have base uri but they have uri for each token individually, when they are bridged to L2, no base uri and no token uri will be set for them.

PoC

In the following test, the contract SampleNFT is deployed where _baseURI is not initialized, instead tokenURI should be used. When running the test test_skipping_token_uri, it is expected that the _tokenURIs[100] be returned as tokenUris[0], but it incorrectly returns "".

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/token/TokenUtil.sol";
import "forge-std/console.sol";
interface ISampleNFT {
function tokenURI(uint256 tokenId) external view returns (string memory);
function baseUri() external view returns (string memory);
}
contract SampleNFT is ISampleNFT {
mapping(uint256 => string) private _tokenURIs;
string private _baseURI;
string public name;
string public symbol;
constructor() {
_tokenURIs[100] = "https://ipfs...";
}
function tokenURI(uint256 tokenId) public view returns (string memory) {
string memory _tokenURI = _tokenURIs[tokenId];
string memory base = baseUri();
// If there is no base URI, return the token URI.
if (bytes(base).length == 0) {
return _tokenURI;
}
// If both are set, concatenate the baseURI and tokenURI (via abi.encodePacked).
if (bytes(_tokenURI).length > 0) {
return string(abi.encodePacked(base, _tokenURI));
}
}
function baseUri() public view returns (string memory) {
return _baseURI;
}
function supportsInterface(
bytes4 interfaceId
) external view returns (bool) {
if (interfaceId == 0xffffffff) {
return false;
} else {
return true;
}
}
}
contract TokenUtilPoCTest is Test {
function test_skipping_token_uri() public {
address toCheck = address(new SampleNFT());
uint256[] memory tokenIds = new uint256[](1);
tokenIds[0] = 100;
(
string memory name,
string memory symbol,
string memory baseUri,
string[] memory tokenUris
) = TokenUtil.erc721Metadata(toCheck, tokenIds);
assert(
keccak256(abi.encodePacked(tokenUris[0])) ==
keccak256(abi.encodePacked(ISampleNFT(toCheck).tokenURI(100)))
);
}
}

Impact

  • Base uri is set incorrectly on L2.

Tools Used

Recommendations

The correct implementation is that 64 bytes from the returned data be stored in the memory from 0x00 to 0x40, and the return value be set to the memory at the location 0x20 where the length of the returned string is stored.

assembly {
- success := staticcall(gas(), collection, add(encodedParams, 0x20), mload(encodedParams), 0x00, 0x20)
+ success := staticcall(gas(), collection, add(encodedParams, 0x20), mload(encodedParams), 0x00, 0x40)
if success {
returnSize := returndatasize()
- returnValue := mload(0x00)
+ returnValue := mload(0x20)
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)));
}

Another solution is to check only the return data size to be larger than 64 bytes (offset + length = 64 byte):

assembly {
- success := staticcall(gas(), collection, add(encodedParams, 0x20), mload(encodedParams), 0x00, 0x20)
+ success := staticcall(gas(), collection, add(encodedParams, 0x20), mload(encodedParams), 0x00, 0x00)
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) {
+ if (success && returnSize > 0x40) {
return (true, abi.decode(ret, (string)));
}
Updates

Lead Judging Commences

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

finding-empty-baseURI-do-not-use-tokenURI

Likelyhood: Low, baseURI is not in the ERC721 standard. Impact: Low, function won’t work when it has to. But URI can be set later.

Support

FAQs

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