NFT Contract Security
A typical1 NFT smart contract is a self-sufficient piece of logic processing external inputs, changing its storage state, and outputting events or internal variables values. The contract has only five functionalities related to tokens:
- Minting - token creation (usually requires a MINTER_ROLE)
- Burning - token destruction (usually requires the owner's role)
- Transferring - changing ownership of a token (usually requires the owner's role)
- Event emitting - informing the subscribed entities of the token-related changes.
- Ownership records - persistent storage
All the rest of the contract is responsible for the assets (NFTs) security. The contract's security comprises:
- Access control and
- Protection from user mistakes.
An NFT Contract can be logically split into the following:
- Storage - the global constants, variables, and hashmaps declarations
- Constructor or an initializing function - the initial setters of the key storage values
- Other functions that can be further divided by their visibility into:
external
- called from outside the contract, but not the contract itselfpublic
- the default setting letting anyone, including other contracts, call this functionprivate
- only visible by the contract itselfinternal
- like private, but accessible by the contracts inheriting from this one
Functions can be further divided by whether they interact with the storage values. One type of function calls falls into the read
category, known in conventional programming languages as getters. In Solidity, such functions are usually modified with the view
keyword when a storage value is read without modification and the pure
keyword when the output is calculated in the function body and has nothing to do with the storage. Even though the gas fee for processing such functions can be calculated, an external account controlled by a user is not charged for such requests. However, if contracts perform the same queries, they are charged the gas according to the yellow paper opcodes costs.
Out of 33 functions, an Openzeppelin ERC-721 implementation has 13 view
and no pure
functions:
// public = can be called by anyone
// returns (type) = specifies the return type of the funciton
// view = only reads form a slot, no writing permission
1. function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool)
2. function balanceOf(address owner) public view virtual override returns (uint256)
3. function ownerOf(uint256 tokenId) public view virtual override returns (address)
4. function name() public view virtual override returns (string memory)
5. function symbol() public view virtual override returns (string memory)
6. function tokenURI(uint256 tokenId) public view virtual override returns (string memory)
7. function getApproved(uint256 tokenId) public view virtual override returns (address)
8. function isApprovedForAll(address owner, address operator) public view virtual override returns (bool)
// internal = privatly inherited
9. function _baseURI() internal view virtual returns (string memory)
10. function _ownerOf(uint256 tokenId) internal view virtual returns (address)
11. function _exists(uint256 tokenId) internal view virtual returns (bool)
12. function _isApprovedOrOwner(address spender, uint256 tokenId) internal view virtual returns (bool)
13. function _requireMinted(uint256 tokenId) internal view virtual
Another type of function falls into the write
category. In other programming languages, such functions are called setters. Such functions can modify the storage variables or reset the values in the hashmaps.
Those transactions are the most gas expensive. The very fact that a function was called requires 21,000 gas. The cost becomes higher depending on the number and type of opcodes used in the function. Assigning a variable involves calling the sstore
opcode, which costs 20,000 gas the first time the slot is accessed and 5,000 on every re-write.
The caller of the function pays the gas fees. The caller can be retrieved from the context of the function call, and inside the function can be accessed from msg.sender
. If a function is payable, it is marked payable,
and the sender has to attach some tokens with the call. This amount can be accessed via the function's msg.value
.
In the standard implementation, there are usually five functions that an unprivileged user can call to change the storage:
// public = can be called by anyone
// virtual = allows its inheriting contracts to have a different implementation body
// override = a function with the same signature but different implementation
// Approving
1. function approve(address to, uint256 tokenId) public virtual override
2. function setApprovalForAll(address operator, bool approved) public virtual override
// Transferring
3. function transferFrom (address from, address to, uint256 tokenId) public virtual override
4. function safeTransferFrom(address from, address to, uint256 tokenId) public virtual override
5. function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual override
After looking at the function signatures above, it might seem that since anyone can call those functions, any user can approve or transfer someone else's NFTs to themselves. However, it is not the case. The function bodies have security mechanisms enabled before the token is approved or transferred. The built-in invokes this security mechanism require
function. The function requires at least one parameter that should result in true
or false.
The second parameter is optional and is an error message string. If the condition in the first parameter resolves in false - all the transaction reverts, and no state change happens. The error message will populate the reason parameter to explain what went wrong to the user.
require(<condition>, "Error message");
The ERC-721 contract functions include require calls 18 times:
1. require(owner != address(0), "ERC721: address zero is not a valid owner");
2. require(owner != address(0), "ERC721: invalid token ID");
3. require(to != owner, "ERC721: approval to current owner");
4. require(
_msgSender() == owner || isApprovedForAll(owner, _msgSender()),
"ERC721: approve caller is not token owner or approved for all"
);
5. require(
_isApprovedOrOwner(_msgSender(), tokenId),
"ERC721: caller is not token owner or approved"
); // x2 times
6. require(
_checkOnERC721Received(from, to, tokenId, data),
"ERC721: transfer to non ERC721Receiver implementer");
7. require(
_checkOnERC721Received(address(0), to, tokenId, data),
"ERC721: transfer to non ERC721Receiver implementer"
);
8. require(to != address(0), "ERC721: mint to the zero address");
9. require(!_exists(tokenId), "ERC721: token already minted"); // x2 times
10. require(ERC721.ownerOf(tokenId) == from,
"ERC721: transfer from incorrect owner"); // x2 times
11. require(to != address(0), "ERC721: transfer to the zero address");
12. require(owner != operator, "ERC721: approve to caller"); // x2 times
13. function _requireMinted(uint256 tokenId) internal view virtual{ // x2 times
require(_exists(tokenId), "ERC721: invalid token ID");
}
NFT Contract & Language Vulnerabilities
Here's the list of the known contract and SC language vulnerabilities that are usually eliminated during a smart contract audit and unless missed or neglected by the auditing company or the developers, do not present a real threat to the users at the stage of production implementation.
Contract Vulnerabilities | SC Language Vulnerabilities |
---|---|
- Unsafe mint/transfer/approve op-s - Reentrancy vulnerabilities - Access control failures - Business logic errors - Unlimited Approvals - Trojan Horse NFTs - Not implemented Interfaces - Variable shadowing - Complex modifiers - Oracle manipulation | - Integer overflows / underflows - Precision losses - Unsafe typecasts - Storage collision / broken pointers - Gas miscalculations - Short address param attacks - Access Control related - Signature replay possibility - Code injection via `delegatecalls` - DoS (unexp. reverse, gas limit) - Reentrancy possibility - Insecure randomness |