Basics of smart contracts: operation and security
What is a smart contract
Smart contracts and intelligent contracts, like any contract, define the terms of an agreement. However, what makes a contract “smart” is that the terms are set and enforced as code running on the Internet, in a blockchain network, rather than as a document drawn up with the help of a lawyer.
Smart contracts extend the basic idea of cryptocurrencies, namely sending and receiving money without a “trusted intermediary.” And since they run on a blockchain such as Ethereum, they guarantee security, reliability, and availability without any restrictions.
Practical application of smart contracts
Smart contracts allow developers to create a wide range of decentralized programs and tokens, which serve as securities in the virtual world. They are used for many tasks, from cryptocurrency exchanges and new financial instruments to logistics and gaming. Smart contracts are stored in the blockchain, just like any crypto transaction.
From the invention of smart contracts till today, Ethereum has been the most popular platform for smart contracts, but many other platforms can also execute them. These alternative blockchains include but not limited to Solana, EOS, Neo, Tezos, Tron, Polkadot and Algorand. Smart contracts are written in various programming languages: Solidity, Web Assembly, Michelson, Rust, Vyper, Yul, DAML, JavaScript, Move, Bitcoin Script, Golang, C/C++, C#, Haskell, Clarity, etc.
Anyone can create and deploy smart contracts on the blockchain, and also manage their execution within predefined limits. The smart contract code is open and can be publicly verified. This means that any interested party can see exactly what logic a smart contract follows when receiving or sending digital assets.
In Ethereum or other networks, the code of each smart contract is permanently stored in the blockchain. This allows the interested party to analyse not only the contract code to verify its functionality but also its current state and execution history.
The Ethereum network consists of many so-called nodes, i.e. devices that ensure the operation of the blockchain. Nodes come in many types, the most important of which is the full node. Each full node stores a copy of all existing smart contracts and their current state, along with the blockchain and transaction data. When a smart contract receives funds from a user, its code is executed by all nodes in the network to reach a consensus on the outcome of the transaction. This is what allows smart contracts to operate securely without any central governing body, even when users make complex financial transactions with unknown parties.
To perform transactions in Ethereum, you need to pay a commission, the amount of which depends on the complexity of the transaction and its urgency. To assess the complexity of a transaction, the concept of “gas” is used, which denotes the calculations necessary to execute the corresponding contract code. The commission is calculated based on the amount of “gas” multiplied by its current cost, with the addition of a priority rate. The size of the priority rate is chosen independently and affects how soon the transaction will be included in the block and considered successfully completed. Thus, users can save by paying lower fees for non-urgent transactions.
Once deployed on the blockchain, smart contracts generally cannot be changed, even by their creator, although there are exceptions to this rule. This immutability helps ensure that smart contracts cannot be censored, blocked, deleted, or repudiated. Below is an example of a simple smart contract in Solidity.
pragma solidity ^0.8.9;
contract Inbox {
string public message;
constructor(string memory initialMessage) {
message = initialMessage;
}
function setMessage(string memory newMessage) public {
message = newMessage;
}
function getMessage() public view returns(string) {
return message;
}
}
Security of smart contracts
Despite the high resilience of blockchain distributed network algorithms to hacking, cases where users lose huge amounts of funds due to the actions of attackers still occur. This is caused by errors in the development of smart contracts, so-called vulnerabilities.
For example, Ronin Network, a blockchain network associated with the popular game Axie Infinity, lost 625 million USD in a hacking attack.
Another well-known case was called “DAO Hack”. The hacker who discovered the vulnerability stole 3.6 million Ether (60 million USD) by using a fallback feature in the code that had the “Reentrancy” vulnerability.
In these examples, attackers used the openness and transparency of smart contract algorithms. After checking the source code of the contracts, hackers found vulnerabilities and successfully exploited them.
In a general case, two registries developed by crypto-enthusiasts and security experts are used to classify smart contract vulnerabilities:
- The Smart Contract Weakness Classification Registry (SWC Registry) is an implementation of the weakness classification scheme proposed in EIP-1470 (Ethereum Improvement Proposal). It is partially consistent with the terminology and structure used in the Common Weakness Enumeration (CWE), while describing multiple vulnerabilities specific to smart contracts.
- Decentralized Application Security Project (or DASP) Top 10 of 2018 – the project is an initiative of NCC Group. This is an open collaborative project to join the efforts in identifying smart contract vulnerabilities by the crypto enthusiast community.
Smart contract’s vulnerability types
1) Reentrancy. The Reentrancy attack is one of the most destructive attacks in Solidity smart contracts. A reentrancy attack occurs when a function makes an external call to another untrusted contract. The unreliable contract then makes a recursive call to the original function in an attempt to exhaust all funds. If a contract fails to update its state before sending funds, an attacker can continuously call the withdrawal function to deplete the contract’s funds.
Sending money in the blockchain is a longer operation (minutes) than calls to smart contract functions (fractions of a second). This difference creates a condition for multiple calls to the vulnerable smart contract, which does not have time to understand that it has already sent money, so it executes the command to send it to the same address many times.
Example of a reentrancy attack scenario:
The Reentrancy attack is implemented using two smart contracts: the victim’s contract (A) containing the vulnerable function, and the attacker’s contract (B).
- Contract A calls the function of receiving funds in contract B – receivefunds.
- Then contract B immediately calls contract A again, while the functions of transferring funds (sendfunds) and updating the balance (updatebalance) have not yet been completed. This step is performed as quickly as possible and the maximum number of times.
The callback function of contract B calls contract A repetitively to abuse the call order of the updatebalance function, which updates the balance of the smart contract. Thus, the callback function has time to call the checkbalance and sendfunds functions several times before the updatebalance function is executed.
Below is an example of a smart contract vulnerable to Reentrancy.
contract EtherStore {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0);
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
The attacker will call the widthdraw() function until the contract balance is exhausted, that is, until it becomes zero.
2) Arithmetic Overflow and Underflow. In simple words, an overflow is when a uint (unsigned integer) number exceeds its size in bytes. Then the next increment will return the variable to its initial state. For example, the largest number that a uint8 type consisting of 8 bits can store is 11111111 in binary (2^8 – 1 = 255 in decimal). Here is the example of code:
uint8 balance = 255;
balance++;
If you execute this code, the “balance” will be 0. This is a simple example of an overflow. Adding 1 to the binary 11111111 resets it to 00000000.
In the reverse case, subtracting 1 from a uint8 number that equals 0 will change the value to 255.
Such overflows without proper checks lead to unexpected values of variables, and therefore to unexpected consequences during the smart contract’s operation, up to the complete loss of the money it manages.
3) Source of Randomness.
Temporal component. Timestamps have historically been used for various needs, such as generating pseudo-random numbers, locking funds for a certain period of time, and for other time-dependent contract state functions. Hackers have the ability to modify timestamps, which can be quite dangerous if timestamps are used at critical points in a smart contract.
Using the blockhash function. Using the blockhash function is similar to relying on a timestamp. This feature is not recommended for critical components for the same reason as the timestamp dependency, since hackers can manipulate these features and change withdrawals to their advantage. This is especially noticeable when the block hash is used as a source of randomness. Below is an example of a vulnerable function:
function guess(uint _guess) public {
uint answer = uint(
keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))
);
}
The attack can be successfully executed if an attacker simply copies the function code into a custom contract, deploys it to the blockchain network, and executes the copied function to generate a pseudo-random value. The acquired result can be sent in response to the original contract.
4) Denial of Service. There are many ways to break a smart contract and interrupt its normal operation. There is no general method of finding such vulnerabilities since each contract has its own logic of operation. Despite this, we can describe several attack vectors that can lead to the exploitation of DoS (denial of service) vulnerabilities.
Looping through externally accessible mappings or arrays. Typically, this vulnerability occurs in scenarios where the contract owner distributes tokens to investors using distribute() functions, as shown in the example below:
function distribute() public {
require(msg.sender == owner); // only owner
for(uint i = 0; i < investors.length; i++) {
transferToken(investors[i],investorTokens[i]);
}
}
In this example, the loop is executed for each element of the array. Since the size of the array can be artificially increased, an attacker can create too many accounts. At a certain moment, the size of the array will increase so much that the entire amount of gas will be spent on the loop. This will make further execution of the algorithm and normal operation of the contract impossible.
5) Unchecked Call Return Value. The basic idea behind this type of vulnerability is that the contract lacks validation of the return value in the message after the outer contract is invoked. As a result, the contract will continue running even if another contract that was called by the first contract responded incorrectly. This may lead to unpredictable behaviour of the algorithm in the future. Solidity uses several low-level call methods that work with raw addresses: call, callcode, delegatecall, and send.
contract Lotto {
bool public payedOut = false;
address public winner;
uint public winAmount;
// ... extra functionality here
function sendToWinner() public {
require(!payedOut);
winner.send(winAmount);
payedOut = true;
}
function withdrawLeftOver() public {
require(payedOut);
msg.sender.send(this.balance);
}
}
In the example above, the error exists when send is used without validating the response. If the winner’s transaction fails, this allows paidOut to be set to true (regardless of whether Ether was sent or not). In this case, another participant can withdraw the winner’s rewards using the withdrawLeftOver function.
6) Visibility. In Solidity, you can specify the visibility of your function, i.e. determine if the function can be called externally by other contracts and by which ones. By default, the function is public.
As described in the Solidity documentation, there are four types of feature visibility:
- external
- public
- internal
- private
External functions are part of the contract interface. This means that they can be called from other contracts and through transactions. It is important to understand that an external function cannot be called internally.
Public functions are part of the contract interface and can be called internally or via messages.
Internal functions can only be accessed internally, that is, from the current contract or contracts derived from it.
Private functions are only visible to the contract in which they are defined, not to derived contracts.
The problem with visibility is that if the required type is not specified, a function that should be private becomes public and thus can be called by unauthorized persons.
contract HashForEther {
function withdrawWinnings() public{
// Winner if the last 8 hex characters of the address are 0.
require(uint32(msg.sender) == 0);
_sendWinnings();
}
function _sendWinnings() public {
msg.sender.transfer(address(this).balance);
}
}
The example above is a simple game where in order to get a balance, the user must create an Ethereum address with the last 8 hex characters being 0.
The visibility of the _send function is not specified, so as a result, anyone can call this function (the default mode is public) and get the balance.
The vulnerabilities of smart contracts listed above are not exhaustive. For a full assessment of smart contract security, identifying weaknesses and potential vulnerabilities, use a smart contract audit.
How to avoid smart contract vulnerabilities
Taking into account the relevance of digital agreements and the catastrophic consequences of assumed errors, we provide the following recommendations on how to fix vulnerabilities in smart contracts.
1. Reentrancy. There are several methods that help avoid “Reentrancy” vulnerabilities in smart contracts. The first one is to use the transfer() function and send Ether to external contract balances wherever necessary. Using this function will limit the amount of gas to 2,300 units, which will make it impossible for the recipient to call the contract repetitively.
The second method is that all operations that change the value of variables in the middle of the contract must occur before the Ether is sent by the contract. Any logic that makes calls to external contracts and other unknown addresses must be executed last in the function.
For the example of a vulnerable smart contract given in this article above, it is enough to change the order of calling the functions in Contract A:
- checkbalance() – checking the balance of the smart contract
- updatebalance() – smart contract balance update
- sendfunds() – sending funds
The third method is the use of a modifier that makes it impossible to exploit the Reentrancy attack. An example of such a modifier:
bool internal locked;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
You can also use the OpenZeppelin library with specially designed features that improve the level of security. To use this library, it is necessary to import it and inherit it in your contract. An example how to connect the library:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract ExampleName as ReentrancyGuard{
function attack() external payable nonReentrant() {
require(msg.value >= 1 ether);
depositFunds.deposit {value: 1 ether} ();
depositFunds.withdraw();
}
}
2. Integer overflow and underflow. The most effective way to protect against an overflow attack is to create your own or use third-party open source libraries that replace standard mathematical operations such as addition, subtraction, and multiplication. Division operations are practically invulnerable to this attack because the Ethereum Virtual Machine throws an error when dividing by 0.
To ensure protection, it is necessary to develop functions for each mathematical operation. An example of a safe add function:
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
In addition to the normal addition operation, the function checks that the result of the addition is greater than one of the summands. For multiplication, in addition to checking whether the result of dividing the product by one of the factors is equal to the second factor, the condition of multiplication by 0 is added.
function mul(uint256 a, uint256 b) internal pure returns (uint256) {
if (a == 0) {
return 0;
}
uint256 c = a * b;
assert(c / a == b);
return c;
}
You can also use the OpenZeppelin library as described in the Re-Entrancy guidelines.
3. Source of randomness. The use of block timestamps for random number generation should be avoided, or at least not used in critical places, such as determining the winner, changing the contract balance, etc.
Sometimes it is still necessary to use time-dependent information sources. For example, to unlock a contract or complete an ICO after a certain period of time. In such cases, it is recommended to use block.number and the average time of the block to determine the time. Thus, specifying a block number to change the state of a contract can be safer because hackers cannot manipulate it as easily.
4. Denial of Service. Loops should be avoided for user-manipulated data. It is recommended to develop a template for the withdrawal function, where each of the investors can call it independently of the others.
In the case when the preservation of the contract depends on its owner, it is necessary to develop a fail-safe mechanism for its operation so that even if the owner is unavailable, the contract continues to work. One of the possible solutions is to create a “multi-signature” contract, in which even if one of the owners is eliminated, all others would have the rights to manage the state of the contract. It is also possible to create a mechanism that will automatically renew the contract after a predetermined period of time without the intervention of the owner.
5. Unchecked Call return value. You should use the transfer() function instead of send() wherever possible. The transfer() function will cancel the transaction if the funds have not been transferred. If the use of the send() function is still necessary, the return value must be checked.
6. Visibility. It is a good practice to always specify the visibility of functions, even if they are supposed to be public. This will help avoid accidentally assigning the wrong visibility type. Newer versions of Solidity throw a compile error if functions have none of the visibility attributes.
Conclusion
Thus, we considered the basics of operation and security of smart contracts. We have given several examples of smart contract vulnerabilities and methods to reduce the risks of these vulnerabilities. Learn more about the audit of smart contracts to improve your understanding of their security.
Use our free online smart contract security analyzer to get a quick insight into the results of automated tools for security assessment of smart contracts.
If you have additional questions about the security of smart contracts, or other questions in the field of Web3 and blockchain security, you can get an answer from qualified specialists during our individual consultation.
Subscribe to our Telegram channel so you do not miss new articles on our blog.