Smart contract audits enable developers to provide a thorough analysis of smart contract sets. The main goal of a smart contract audit is to detect and eliminate vulnerabilities. A good smart contract audit examines and comments on a project’s smart contract code, presented to the project’s developers.
One key aspect to remember when writing smart contracts is to keep it simple, as added complexity increases the likelihood of errors. Simplicity in smart contract design is effective in instances where the smart contract system performs a limited set of functions for a predefined period of time. The use of prewritten tools is encouraged (e.g., creating a random number generator), and OpenZeppelin’s Solidity library provides patterns that enable the reusability of secure code.
The security audit of a contract has to start from the development stage, adding new, thorough tests when new attack vectors are discovered. There are pitfalls that have to be considered when programming smart contracts on the Ethereum blockchain, such as how timestamps can be imprecise, with miners influencing the execution time of a transaction. External smart contract calls have to be extremely vetted, as malicious code can be executed and control flow can be changed.
In this article, we’ll go through a step-by-step guide to auditing smart contracts. We’ll cover two important points: the necessary steps to audit a smart contract and generate documentation, as well as common attack vectors on the Ethereum blockchain.
- Getting started
- Structure of a smart contract audit
- Example audit: NFT upload
- Studying common attack vectors
Getting started
First, we’ll audit a smart contract for bulk-uploading NFTs. A key component of auditing smart contracts is the process of investigating aspects of the code to find bugs, vulnerabilities, and risks before deployment to the Ethereum mainnet.
A smart contract audit is not a 100 percent guarantee that the contract won’t exhibit bugs or vulnerabilities. It does, however, guarantee that the smart contract is secure and has been reviewed by an expert.
Structure of a smart contract audit
A smart contract audit report is expected to contain a variety of items, listed below, including details about identified vulnerabilities, a disclaimer, and suggested remediations.
- Disclaimer: This section is important for stating that the audit is not a legally binding document and provides no guarantee
- Overview of the audit: A brief look at the contract and the best practices that have been observed in its creation
- Attacks carried out on the contract: Outlines the attacks that have been carried out on the contract, ensuring its security
- Critical-level vulnerabilities: Outline critical vulnerabilities found in the contract, such as a bug that allows attackers to steal currency
- Medium-level vulnerabilities: Vulnerabilities that could damage the contract but with a limitation
- Low-level vulnerabilities: Issues that don’t affect the contract
- Inspecting the code line by line: Analysis of the lines of code with potential improvements
Example audit: NFT upload
The code we’re evaluating can be seen below and is also located in this GitHub gist.
// SPDX-License-Identifier: GPL-3.0 /** !Disclaimer! These contracts have been used to create tutorials, and was created for the purpose to teach people how to create smart contracts on the blockchain. please review this code on your own before using any of the following code for production. */ pragma solidity >=0.7.0 <0.9.0; import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; /// @title Contract to deploy NFTs to the opensea blockchain contract RetroNFT is ERC721Enumerable, Ownable { using Strings for uint256; /// URI to read metadata of images to be deployed string public baseURI; /// expected file extensuon to be contained in URI string public baseExtension = ".json"; /// cost of individual NFTs in collection uint256 public cost = 0.033 ether; /// Maximum supply of NFTs to be deployed by contract uint256 public maxSupply = 10000; /// maximum amount to be minted uint256 public maxMintAmount = 300; bool public paused = false; /// allowed addresses mapping(address => bool) public whitelisted; constructor( string memory _name, string memory _symbol, string memory _initBaseURI ) ERC721(_name, _symbol) { setBaseURI(_initBaseURI); mint(msg.sender, 220); } // set URI which contains created images, likely a pinata CID. function _baseURI() internal view virtual override returns (string memory) { return baseURI; } /// @dev Creates tokens of token type `id`, and assigns them to `to` /// `to` cannot be a zero address function mint(address _to, uint256 _mintAmount) public payable { uint256 supply = totalSupply(); require(!paused); require(_mintAmount > 0); require(_mintAmount <= maxMintAmount); require(supply + _mintAmount <= maxSupply); if (msg.sender != owner()) { if(whitelisted[msg.sender] != true) { require(msg.value >= cost * _mintAmount); } } for (uint256 i = 1; i <= _mintAmount; i++) { _safeMint(_to, supply + i); } } /// function which provides a basic access control mechanism /// where there is an account (an owner) that can be granted access to specific functions function walletOfOwner(address _owner) public view returns (uint256[] memory) { uint256 ownerTokenCount = balanceOf(_owner); uint256[] memory tokenIds = new uint256[](ownerTokenCount); for (uint256 i; i < ownerTokenCount; i++) { tokenIds[i] = tokenOfOwnerByIndex(_owner, i); } return tokenIds; } function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { require( _exists(tokenId), "ERC721Metadata: URI query for nonexistent token" ); string memory currentBaseURI = _baseURI(); return bytes(currentBaseURI).length > 0 ? string(abi.encodePacked(currentBaseURI, tokenId.toString(), baseExtension)) : ""; } //only owner function setCost(uint256 _newCost) public onlyOwner { cost = _newCost; } function setmaxMintAmount(uint256 _newmaxMintAmount) public onlyOwner { maxMintAmount = _newmaxMintAmount; } function setBaseURI(string memory _newBaseURI) public onlyOwner { baseURI = _newBaseURI; } function setBaseExtension(string memory _newBaseExtension) public onlyOwner { baseExtension = _newBaseExtension; } function pause(bool _state) public onlyOwner { paused = _state; } function whitelistUser(address _user) public onlyOwner { whitelisted[_user] = true; } function removeWhitelistUser(address _user) public onlyOwner { whitelisted[_user] = false; } function withdraw() public payable onlyOwner { /// implementing transfer instead of call.value (bool os, ) = payable(owner()).call{value: address(this).balance}(""); require(os); } }
Disclaimer
Smart contracts are deployed and executed on the Ethereum blockchain. An audit cannot explicitly guarantee that the smart contract will forever be secure, since changes on the Ethereum platform could create new attack vectors that affect the smart contract. This document is not meant to serve as a warranty about the safety or utility of the code contained in this smart contract and is designed for discussion purposes.
Overview
The project contains only one file, NftUpload.sol
, composed of 114 lines of code written in Solidity. Relevant functions and state variables are commented on based on the natspec documentation.
The purpose of this smart contract is to enable the mass deployment of NFT tokens to the Opensea network.
Implemented attacks
Reentrancy attack
A reentrancy attack is a destructive attack on a Solidity smart contract. Reentrancy attacks highlight the dangers of calling external contracts, as the external contract makes a recursive call back to the original function and changes the data that the calling function is expecting.
This contract implements the call.value
in the withdraw
function, which can be called recursively to extract the Ether stored on the contract. This poses a medium-level vulnerability if the contract was interacting with untrusted smart contracts and can be prevented by updating the balance
of the sender before sending the Ether.
The best practice is to implement transfer()
instead of call.value()
. There’s no risk of reentrancy attacks using this since the transfer function only allows the use of 23,000 gas and can only be used for an event to log data and throw on failure.
Short address attack
A short address attack affects ERC-20 tokens. In this, the service preparing the data for token transfers assumes that users will input 20-byte-long addresses, but the length of the addresses is not actually checked.
In this attack, the user generates an Ethereum address with a trailing 0. Ethereum addresses are generated randomly and, on average, will take 256 tries to get an address with a trailing 0.
The next step in this attack is finding an exchange wallet that contains 256,000 tokens. The attacker buys 1,000 tokens by removing the last zero from the wallet address. Then, a request for withdrawal of 1,000 tokens is created using the generated address.
If the buy function doesn’t check the length of the address of the sender, Ethereum’s virtual machine will add zeros to the transaction until the address is complete. The virtual machine will return 256,000 for every 1,000 tokens bought, a bug that arises from the Ethereum virtual machine.
The audited contract isn’t vulnerable to this attack, as it’s not an ERC-20 token.
Vulnerability review and inspection
No critical vulnerabilities were identified in this contract. On line 139, the call.value
should be replaced with the transfer()
function to prevent reentrancy attacks. Additionally, a specific line-by-line inspection is below:
See line 12 below. A version pragma is specified from which the compiler knows the version of Solidity:
pragma solidity >=0.7.0 <0.9.0;
Next, we’ll look at line 45. To maximize resources, the loop should be taken out and minting should be done only after deployment is complete:
>constructor( string memory _name, string memory _symbol, string memory _initBaseURI ) ERC721(_name, _symbol) { setBaseURI(_initBaseURI); mint(msg.sender, 220); }
On line 66, add error messages to the require
statement to pinpoint which mint reverts:
if (msg.sender != owner()) { if(whitelisted[msg.sender] != true) { require(msg.value >= cost * _mintAmount); } }
Finally, on line 77, an extra mapping should be added, (uint256 => boolean)
isTokenFullyMinted
to perform pseudo-minting to check that tokens have been minted:
function walletOfOwner(address _owner) public view returns (uint256[] memory) { uint256 ownerTokenCount = balanceOf(_owner); uint256[] memory tokenIds = new uint256[](ownerTokenCount); for (uint256 i; i < ownerTokenCount; i++) { tokenIds[i] = tokenOfOwnerByIndex(_owner, i); } return tokenIds; }
Summary of audit
The code is clear and well commented. The mechanism to deploy and mint is quite simple and shouldn’t bring major issues.
My final recommendation is to pay more attention to the visibility of the functions and look into implementing the ERC-721 extension for bulk minting.
Studying common attack vectors
In the words of George Santayana, “To know your future you must know your past.” I bring up this quote to highlight that one of the best ways to prevent attacks on a smart contract is to be aware of existing attacks. The SWC registry provides a collection of classifications that contain a list of known attacks to date. The SWC registry provides an SWC identifier (ID), title, and list of code-related samples.
There are common attack factors that a smart contract auditor must look into, such as access control issues, integer overflows and underflows, and reentrancy vulnerabilities (for DApps written in Solidity).
Smart contracts in the decentralized finance space are especially vulnerable to front-running attacks. In a front-running attack, a bot preempts a transaction while it is being packaged. The bot sets a higher gas cost to complete the transaction at a preferential rate before the attacked transaction is processed. Such attacks are possible due to the transaction-based nature of the blockchain. The most popular form of front-running attack is known as a sandwich attack.
What is a sandwich attack?
A sandwich attack is a front-running technique and common attack vector on decentralized exchanges running the automated market maker mechanism. In a sandwich attack, the predator finds a pending transaction on the blockchain P2P network and attempts to surround the transaction by placing one order before the transaction (front-running) and one order after it (back-running). The goal of this attack is to manipulate the price of an asset as a result of buying and selling.
Sandwich attacks are possible because all blockchain transactions can be openly observed in the mempool. Once the attack bot notices a pending transaction of a victim exchanging asset X for asset Y, the victim is front-run by buying asset Y.
Once the transaction is identified, the bot initiates a transaction, sets a higher gas fee, and successfully jumps ahead of the user’s normal transaction under the gas competition mechanism. The bot immediately launches another sell trade, completed after the victim’s normal trade.
As a developer working on smart contracts in the DeFi sector, one potential solution to protect users from sandwich attacks is to deal with the transaction openness of the blockchain by encrypting information so that bots can’t process it.
There are currently proposals to use zk-SNARKs, a zero-knowledge-proof technique, to achieve encryption. This strategy is not mature enough yet but is being actively discussed in the community.
Conclusion
Smart contract audits are especially necessary for DApps. A project having a smart contract isn’t an indication of value, but it is of great importance. I encourage developers to continue learning and improving knowledge of latest updates when it comes to contract security and best practices.
The post A developer’s guide to smart contract security audits appeared first on LogRocket Blog.
from LogRocket Blog https://ift.tt/FvSCh0A
via Read more