Smart Contracts Security
An overview of guidelines for building secure smart contracts
Smart contracts are extremely flexible, and capable of controlling large amounts of value and data, while running immutable logic based on code deployed on the blockchain. This has created a vibrant ecosystem of trustless and decentralized applications that provide many advantages over legacy systems. They also represent opportunities for attackers looking to profit by exploiting vulnerabilities in smart contracts.
Public blockchains, further complicate the issue of securing smart contracts. Deployed contract code usually cannot be changed to patch security flaws, while assets stolen from smart contracts are extremely difficult to track and mostly irrecoverable due to immutability.
The aforementioned issues make it imperative for developers to invest effort in building secure, robust, and resilient smart contracts. Smart contract security is serious business, and one that every developer will do well to learn. This guide will cover security considerations for developers and explore resources for improving smart contract security.
The following MAPO-Relay-Chain
is collectively referred to as MAPO.
prerequisites
Make sure you’re familiar with the fundamentals of smart contract development before tackling security.
smart-contract-security-guidelines
1. design-proper-access-controls
In smart contracts, functions marked public
or external
can be called by any externally owned accounts (EOAs) or contract accounts. Specifying public visibility for functions is necessary if you want others to interact with your contract. Functions marked private
however can only be called by functions within the smart contract, and not external accounts. Giving every network participant access to contract functions can cause problems, especially if it means anyone can perform sensitive operations (e.g., minting new tokens).
To prevent unauthorized use of smart contract functions, it is necessary to implement secure access controls. Access control mechanisms restrict the ability to use certain functions in a smart contract to approved entities, such as accounts responsible for managing the contract. The Ownable pattern and role-based control are two patterns useful for implementing access control in smart contracts:
ownable-pattern
In the Ownable pattern, an address is set as the “owner” of the contract during the contract-creation process. Protected functions are assigned an OnlyOwner
modifier, which ensures the contract authenticates the identity of the calling address before executing the function. Calls to protected functions from other addresses aside from the contract owner always revert, preventing unwanted access.
role-based-access-control
Registering a single address as Owner
in a smart contract introduces the risk of centralization and represents a single point-of-failure. If the owner’s account keys are compromised, attackers can attack the owned contract. This is why using a role-based access control pattern with multiple administrative accounts may be a better option.
In role-based access control, access to sensitive functions is distributed between a set of trusted participants. For instance, one account may be responsible for minting tokens, while another account performs upgrades or pauses the contract. Decentralizing access control this way eliminates single points of failure and reduces trust assumptions for users.
Using multi-signature wallets
Another approach for implementing secure access control is using a multi-signature account
to manage a contract. Unlike a regular EOA, multi-signature accounts are owned by multiple entities and require signatures from a minimum number of accounts—say 3-of-5—to execute transactions.
Using a multisig for access control introduces an extra layer of security since actions on the target contract require consent from multiple parties. This is particularly useful if using the Ownable pattern is necessary, as it makes it more difficult for an attacker or rogue insider to manipulate sensitive contract functions for malicious purposes.
2. use-require-assert-revert
As mentioned, anyone can call public functions in your smart contract once it is deployed on the blockchain. Since you cannot know in advance how external accounts will interact with a contract, it is ideal to implement internal safeguards against problematic operations before deploying. You can enforce correct behavior in smart contracts by using the require()
, assert()
, and revert()
statements to trigger exceptions and revert state changes if execution fails to satisfy certain requirements.
require()
: require
are defined at the start of functions and ensures predefined conditions are met before the called function is executed. A require
statement can be used to validate user inputs, check state variables, or authenticate the identity of the calling account before progressing with a function.
assert()
: assert()
is used to detect internal errors and check for violations of “invariants” in your code. An invariant is a logical assertion about a contract’s state that should hold true for all function executions. An example invariant is the maximum total supply or balance of a token contract. Using assert()
ensures that your contract never reaches a vulnerable state, and if it does, all changes to state variables are rolled back.
revert()
: revert()
can be used in an if-else statement that triggers an exception if the required condition is not satisfied. The sample contract below uses revert()
to guard the execution of functions:
3. 测试智能合约并验证代码正确性
The immutability of code running in the EVM means smart contracts demand a higher level of quality assessment during the development phase. Testing your contract extensively and observing it for any unexpected results will improve security a great deal and protect your users in the long run.
The usual method is to write small unit tests using mock data that the contract is expected to receive from users. Unit testing is good for testing the functionality of certain functions and ensuring a smart contract works as expected.
Unfortunately, unit testing is minimally effective for improving smart contract security when used in isolation. A unit test might prove a function executes properly for mock data, but unit tests are only as effective as the tests that are written. This makes it difficult to detect missed edge cases and vulnerabilities that could break the safety of your smart contract.
A better approach is to combine unit testing with property-based testing performed using static and dynamic analysis. Static analysis relies on low-level representations, such as control flow graphs and abstract syntax trees to analyze reachable program states and execution paths. Meanwhile, dynamic analysis techniques, such as fuzzing, execute contract code with random input values to detect operations that violate security properties.
Formal verification is another technique for verifying security properties in smart contracts. Unlike regular testing, formal verification can conclusively prove the absence of errors in a smart contract. This is achieved by creating a formal specification that captures desired security properties and proving that a formal model of the contracts adheres to this specification.
get-independent-code-reviews
After testing your contract, it is good to ask others to check the source code for any security issues. Testing will not uncover every flaw in a smart contract, but getting an independent review increases the possibility of spotting vulnerabilities.
audits
Commissioning a smart contract audit is one way of conducting an independent code review. Auditors play an important role in ensuring that smart contracts are secure and free from quality defects and design errors.
漏洞奖励
Setting up a bug bounty program is another approach for implementing external code reviews. A bug bounty is a financial reward given to individuals (usually whitehat hackers) that discover vulnerabilities in an application.
5. follow-smart-contract-development-best-practices
The existence of audits and bug bounties doesn’t excuse your responsibility to write high-quality code. Good smart contract security starts with following proper design and development processes:
Store all code in a version control system, such as git
Make all code modifications via pull requests
Ensure pull requests have at least one independent reviewer—if you are working solo on a project, consider finding other developers and trade code reviews
Use a development environment for testing, compiling, deploying smart contracts
Run your code through basic code analysis tools, such as Mythril and Slither. Ideally, you should do this before each pull request is merged and compare differences in output
Ensure your code compiles without errors, and the Solidity compiler emits no warnings
Properly document your code (using NatSpec) and describe details about the contract architecture in easy-to-understand language. This will make it easier for others to audit and review your code.
6. implement-disaster-recovery-plans
Designing secure access controls, implementing function modifiers, and other suggestions can improve smart contract security, but they cannot rule out the possibility of malicious exploits. Building secure smart contracts requires “preparing for failure” and having a fallback plan for responding effectively to attacks. A proper disaster recovery plan will incorporate some or all of the following components:
contract-upgrades
While smart contracts are immutable by default, it is possible to achieve some degree of mutability by using upgrade patterns. Upgrading contracts is necessary in cases where a critical flaw renders your old contract unusable and deploying new logic is the most feasible option.
Contract upgrade mechanisms work differently, but the “proxy pattern” is one of the more popular approaches for upgrading smart contracts. Proxy patterns split an application’s state and logic between two contracts. The first contract (called a ‘proxy contract’) stores state variables (e.g., user balances), while the second contract (called a ‘logic contract’) holds the code for executing contract functions.
Accounts interact with the proxy contract, which dispatches all function calls to the logic contract using the delegatecall()
low-level call. Unlike a regular message call, delegatecall()
ensures the code running at the logic contract’s address is executed in the context of the calling contract. This means the logic contract will always write to the proxy’s storage (instead of its own storage) and the original values of msg.sender
and msg.value
are preserved.
Delegating calls to the logic contract requires storing its address in the proxy contract's storage. Hence, upgrading the contract's logic is only a matter of deploying another logic contract and storing the new address in the proxy contract. As subsequent calls to the proxy contract are automatically routed to the new logic contract, you would have “upgraded” the contract without actually modifying the code.
emergency-stops
As mentioned, extensive auditing and testing cannot possibly discover all bugs in a smart contract. If a vulnerability appears in your code after deployment, patching it is impossible since you cannot change the code running at the contract address. Also, upgrade mechanisms (e.g., proxy patterns) may take time to implement (they often require approval from different parties), which only gives attackers more time to cause more damage.
The nuclear option is to implement an “emergency stop” function that blocks calls to vulnerable functions in a contract. Emergency stops typically comprise the following components:
A global Boolean variable indicating if the smart contract is in a stopped state or not. This variable is set to
false
when setting up the contract, but will revert totrue
once the contract is stopped.Functions that reference the Boolean variable in their execution. Such functions are accessible when the smart contract is not stopped, and become inaccessible when the emergency stop feature is triggered.
An entity that has access to the emergency stop function, which sets the Boolean variable to
true
. To prevent malicious actions, calls to this function can be restricted to a trusted address (e.g., the contract owner).
Once the contract activates the emergency stop, certain functions will not be callable. This is achieved by wrapping select functions in a modifier that references the global variable. Below is an example describing an implementation of this pattern in contracts:
This example shows the basic features of emergency stops:
isStopped
is a Boolean that evaluates tofalse
at the beginning andtrue
when the contract enters emergency mode.The function modifiers
onlyWhenStopped
andstoppedInEmergency
check theisStopped
variable.stoppedInEmergency
is used to control functions that should be inaccessible when the contract is vulnerable (e.g.,deposit()
). Calls to these functions will simply revert.
onlyWhenStopped
is used for functions that should be callable during an emergency (e.g., emergencyWithdraw()
). Such functions can help resolve the situation, hence their exclusion from the “restricted functions” list.
Using an emergency stop functionality provides an effective stopgap for dealing with serious vulnerabilities in your smart contract. However, it increases the need for users to trust developers not to activate it for self-serving reasons. To this end, decentralizing control of the emergency stop either by subjecting it to an on-chain voting mechanism, timelock, or approval from a multisig wallet are possible solutions.
event-monitoring
Events allow you to track calls to smart contract functions and monitor changes to state variables. It is ideal to program your smart contract to emit an event whenever some party takes a safety-critical action (e.g., withdrawing funds).
Logging events and monitoring them off-chain provides insights on contract operations and aids faster discovery of malicious actions. This means your team can respond faster to hacks and take action to mitigate impact on users, such as pausing functions or performing an upgrade.
You can also opt for an off-the-shelf monitoring tool that automatically forwards alerts whenever someone interacts with your contracts. These tools will allow you to create custom alerts based on different triggers, such as transaction volume, frequency of function calls, or the specific functions involved. For example, you could program an alert that comes in when the amount withdrawn in a single transaction crosses a particular threshold.
7.design-secure-governance-systems
You may want to decentralize your application by turning over control of core smart contracts to community members. In this case, the smart contract system will include a governance module—a mechanism that allows community members to approve administrative actions via an on-chain governance system. For example, a proposal to upgrade a proxy contract to a new implementation may be voted upon by token-holders.
Decentralized governance can be beneficial, especially because it aligns the interests of developers and end-users. Nevertheless, smart contract governance mechanisms may introduce new risks if implemented incorrectly. A plausible scenario is if an attacker acquires enormous voting power (measured in number of tokens held) by taking out a flash loan and pushes through a malicious proposal.
One way of preventing problems related to on-chain governance is to use a timelock. A timelock prevents a smart contract from executing certain actions until a specific amount of time passes. Other strategies include assigning a “voting weight” to each token based on how long it has been locked up for, or measuring the voting power of an address at a historical period (for example, 2-3 blocks in the past) instead of the current block. Both methods reduce the possibility of quickly amassing voting power to swing on-chain votes.
More on designing secure governance systems and different voting mechanisms in DAOs.
8.educe-code-complexity
Traditional software developers are familiar with the KISS (“keep it simple, stupid”) principle, which advises against introducing unnecessary complexity into software design. This follows the long-held thinking that “complex systems fail in complex ways” and are more susceptible to costly errors.
Keeping things simple is of particular importance when writing smart contracts, given that smart contracts are potentially controlling large amounts of value. A tip for achieving simplicity when writing smart contracts is to reuse existing libraries, such as OpenZeppelin Contracts, where possible. Because these libraries have been extensively audited and tested by developers, using them reduces the chances of introducing bugs by writing new functionality from scratch.
Another common advice is to write small functions and keep contracts modular by splitting business logic across multiple contracts. Not only does writing simpler code reduce the attack surface in a smart contract, it also makes it easier to reason about the correctness of the overall system and detect possible design errors early.
9.mitigate-common-smart-contract-vulnerabilities
reentrancy
The EVM doesn’t permit concurrency, meaning two contracts involved in a message call cannot run simultaneously. An external call pauses the calling contract's execution and memory until the call returns, at which point execution proceeds normally. This process can be formally described as transferring control flow to another contract.
Although mostly harmless, transferring control flow to untrusted contracts can cause problems, such as reentrancy. A reentrancy attack occurs when a malicious contract calls back into a vulnerable contract before the original function invocation is complete. This type of attack is best explained with an example.
Consider a simple smart contract (‘Victim’) that allows anyone to deposit and withdraw MAPO coins:
This contract exposes a withdraw()
function to allow users to withdraw MAPO coins previously deposited in the contract. When processing a withdrawal, the contract performs the following operations:
Checks the user’s MAPO balance
Sends funds to the calling address
Resets their balance to 0, preventing additional withdrawals from the user
The withdraw()
function in Victim
contract follows a “checks-interactions-effects” pattern. It checks if conditions necessary for execution are satisfied (i.e., the user has a positive ETH balance) and performs the interaction by sending ETH to the caller’s address, before applying the effects of the transaction (i.e., reducing the user’s balance).
If withdraw()
is called from an externally owned account (EOA), the function executes as expected: msg.sender.call.value()
sends ETH to the caller. However, if msg.sender
is a smart contract account calls withdraw()
, sending funds using msg.sender.call.value()
will also trigger code stored at that address to run.
Imagine this is the code deployed at the contract address:
This contract is designed to do three things:
Accept a deposit from another account (likely the attacker’s EOA)
Deposit 1 ETH into the Victim contract
Withdraw the 1 ETH stored in the smart contract
There’s nothing wrong here, except that Attacker
has another function that calls withdraw()
in Victim
again if the gas left over from the incoming msg.sender.call.value
is more than 40,000. This gives Attacker
the ability to reenter Victim
and withdraw more funds before the first invocation of withdraw
completes. The cycle looks like this:
The summary is that because the caller’s balance isn't set to 0 until the function execution completes, subsequent invocations will succeed and allow the caller to withdraw their balance multiple times. This kind of attack can be used to drain a smart contract of its funds, like what happened in the 2016 DAO hack. Reentrancy attacks are still a critical issue for smart contracts today as public listings of reentrancy exploits show.
how to prevent reentrancy attacks
An approach to dealing with reentrancy is following the checks-effects-interactions pattern. This pattern orders the execution of functions in a way that code that performs necessary checks before progressing with execution comes first, followed by code that manipulates contract state, with code that interacts with other contracts or EOAs arriving last.
The checks-effect-interaction pattern is used in a revised version of the Victim
contract shown below:
This contract performs a check on the user’s balance, applies the effects of the withdraw()
function (by resetting the user’s balance to 0), and proceeds to perform the interaction (sending ETH to the user’s address). This ensures the contract updates its storage before the external call, eliminating the re-entrancy condition that enabled the first attack. The Attacker
contract could still call back into NoLongerAVictim
, but since balances[msg.sender]
has been set to 0, additional withdrawals will throw an error.
Another option is to use a mutual exclusion lock (commonly described as a "mutex") that locks a portion of a contract’s state until a function invocation completes. This is implemented using a Boolean variable that is set to true
before the function executes and reverts to false
after the invocation is done. As seen in the example below, using a mutex protects a function against recursive calls while the original invocation is still processing, effectively stopping reentrancy.
You can also use a pull payments system that requires users to withdraw funds from the smart contracts, instead of a "push payments" system that sends funds to accounts. This removes the possibility of inadvertently triggering code at unknown addresses (and can also prevent certain denial-of-service attacks).
integer-underflows-and-overflows
An integer overflow occurs when the results of an arithmetic operation falls outside the acceptable range of values, causing it to "roll over" to the lowest representable value. For example, a uint8
can only store values up to 2^8-1=255. Arithmetic operations that result in values higher than 255
will overflow and reset uint
to 0
, similar to how the odometer on a car resets to 0 once it reaches the maximum mileage (999999).
Integer underflows happen for similar reasons: the the results of an arithmetic operation falls below the acceptable range. Say you tried decrementing 0
in a uint8
, the result would simply roll over to the maximum representable value (255
).
Both integer overflows and underflows can lead to unexpected changes to a contract’s state variables and result in unplanned execution. Below is an example showing how an attacker can exploit arithmetic overflow in a smart contract to perform an invalid operation:
how to prevent integer underflows and overflows
As of version 0.8.0, the Solidity compiler rejects code that results in integer underflows and overflows. However, contracts compiled with a lower compiler version should either perform checks on functions involving arithmetic operations or use a library (e.g., SafeMath) that checks for underflow/overflow.
related-tutorials
Last updated