Model checking is one of the most commonly used methods in the field of formal verification, typically divided into three stages: system modeling, specification, and verification. During the system modeling phase, a finite state machine (FSM) pattern, which is recommended for developing smart contracts, is suitable for modeling the behavior of smart contracts [
27]. FSMs are a specific type of automaton. The concept of an automaton is broader and not limited to a finite set of states. In the specification phase, modal logics such as linear temporal logic (LTL), CTL, and alternating-time temporal logic (ATL) are often employed to describe various requirements of the system, including its applications and safety. The verification phase is used to ascertain whether the system model conforms to the properties that the system should possess. If it does not conform, the system is considered to have corresponding issues that require modification, re-modeling, and re-verification.
In order to describe the dynamic behavior of the contract execution process, this paper models each Solidity contract as a time automaton, and in addition to modeling a corresponding number of timed automata, composite smart contracts also need to model other contracts that interact with them. For example, contracts and users can call other contracts, and the dynamic behavior of their business logic or execution process is closely related to the behavior of the called contract. Therefore, these individuals also need to be modeled as automata. After modeling, the purpose of verifying the composite contract can be achieved by verifying whether the automaton satisfies certain conditions, referred to as properties in the following text. The correct operation of composite contracts depends on accurate and errorfree business logic, which must meet business needs without security issues. This paper establishes a time automaton model for the contract and the initiator based on the dynamic behavior of contract execution, based on some common security vulnerabilities of smart contracts. Then, a set of business logic properties and security vulnerability properties are defined according to the application requirements and key vulnerabilities of the contract, and CTL is used to represent all the business and security properties that need to be satisfied. Finally, by verifying whether the model meets the above properties, we can verify whether the composite contract meets business requirements and whether there are any security vulnerabilities. Because the system properties to be described and validated include time, the UPPAAL model checking tool is used to implement modeling and define and validate all properties.
3.1. Smart Contract Automata Model
An automaton is a mathematical model that represents a set of states, along with the transitions and actions among them. If represented graphically, an automaton’s states are depicted as nodes, with the transitions between states represented as edges. State transitions are triggered when certain conditions are met or specific operations are performed; hence, each edge is characterized by three elements: a condition (guard), a variable update operation (update), and/or a synchronization operation (sync). The synchronization operation is used for communication between automata sent from one automaton (sending message is denoted by “!”) to another automaton (receiving message is denoted by “?”). When the conditions on the edge (as shown by the if statements, function modifier, visibility, and loop statements in
Table 1) are met and the required signal has arrived, or a variable update operation has occurred, the transition from one state to another will occur.
To model a smart contract, refer to the modeling rules shown in
Table 1; row 1 to row 5 are the basic contract statements. Row 1: About variables. Integer variables are used in the model to record common types. To represent the timestamp of the current block, one can use the uniquely self-incrementing variable of clock type that is specific to UPPAAL. Row 2: In arithmetic operation, each assignment operation is described in the model as a transition activity between states. Row 3: Model
if() and
require() in the conditional statements. If the condition is met, the state will transfer to state
if_true, and the statement
num++ will be executed normally. Otherwise, the state will transfer to
if_false, and the statement block in the curly braces will be skipped to execute the next statement. If the required condition is met, it will transfer to state
s1 and continue to execute subsequent codes normally. Otherwise, it will transfer to abnormal state
err. Row 4: In the loop statement
for() structure, if the loop condition is met, it will move to the state
for_true. Until the condition is not met, the state will transfer to the state
for_end to complete the loop execution. Row 5: The function modifier is modeled as a function in C language form according to the specific judgment logic. Before executing the business logic code, judge and verify whether the content of the modifier meets the requirements. For example, only the owner can continue to execute. Row 6: Similar to row 5, the four types of visibility domains (
private,
public,
external,
internal), as predefined modifiers, are also modeled as specific functions, in which the corresponding judgment can be made on whether the execution can continue. Row 7: A single contract function is modeled as an automaton, starting from the state
Start by default. When the called contract function receives a message with the same name as itself, such as
changeOwner? (Usually, this message is sent from another contract function, such as
changeOwner!), the state will transfer to another state with its function name, such as
changeOwner_C0, which means that the call occurred. Next, judge the function’s visibility domain and function modifiers, such as the external and onlyOwner of the function. If these judgments are true, the function body will execute, the corresponding state transfers to
modifier. The execution owner is reassigned, the state transfers to
change_owner. After executing
Owner = _newOwner, the state returns to
Start.
Some typical library functions of Solidity are predefined as templates during modeling. For example, call.value() means that when call.value() is initiated, the receiver’s fallback() function is automatically called for withdraw operations.
3.2. Composite Smart Contract Formal Properties and Verification
Formal verification of properties is responsible for providing contract properties to validate the security and correctness of a single or composite smart contract. In order to cover the complete range of security issues in composite smart contracts, two types of properties are defined: security properties and business logic properties. The former models 6 types of smart contract security vulnerabilities as examples (reentrancy attack, access control, privileged function exposure, cross-contract invocation, denial of service, miner privilege), then extracts implementation logic and dynamic interactions between different contracts from each security vulnerability model, defines CTL properties, and finally detects the existence of vulnerabilities by verifying whether the model satisfies the CTL properties. As for business logic properties, users define them based on the business logic context of composite smart contracts, similarly describing them as CTL properties and verifying them. The following section will introduce automaton modeling and verification of each vulnerability with CTL properties using the six vulnerabilities as examples.
3.2.1. Reentrancy
Smart contracts can call the code of other external contracts and send tokens to external addresses, but when performing these operations, the system automatically triggers the function
fallback of the recipient. Therefore, this external call may be exploited by malicious attackers to trigger recursive calls between contracts by the “re-entry” of the function
fallback, resulting in unexpected actions, such as unauthorized transfers.
Code block 1. Solidity reentrancy vulnerability sample code |
1 | contract Bank { |
2 | … |
3 | function recharge() payable public { |
4 | balances[msg.sender] += msg.value; |
5 | } |
6 | function withdraw() public { |
7 | require(msg.sender.call.value(balances[msg.sender])()); |
8 | balances[msg.sender] = 0; |
9 | } |
10 | } |
11 | |
12 | contract Attacker { |
13 | … |
14 | function attack() payable { |
15 | attackCount = 0; |
16 | Bank bank = Bank(bankAddr); |
17 | bank.recharge.value(msg.value)(); |
18 | bank.withdraw(); |
19 | } |
20 | function () payable { |
21 | if(msg.sender == bank.Addr && attackCount < 5) { |
22 | attackCount += 1; |
23 | Bank bank = Bank(bankAddr); |
24 | bank.withdraw(); |
25 | } |
26 | } |
27 | } |
In the code block 1, the bank contract’s balances[msg.sender] records the balance of each account. Normally, callers can recharge their own account using the recharge() function and withdraw their entire balance using the withdraw() function. The attacker contract launches an attack on the bank contract through the attack() function. The bank.recharge() function first recharges a certain amount specified by msg.value, and then calls the bank.withdraw() function, with the call.value() function transferring funds to the attacker contract. When this function is called, the system automatically invokes the fallback function of the attacker contract (i.e., the unnamed function of the receiver), leading to a recursive call. If attackCount < 5, which it can be, the bank.withdraw() function is continuously called, transferring funds to the receiver, and triggering the fallback function. This process repeats until condition attackCount < 5 is no longer satisfied, then the attacker contract receives 5 times the recharge amount, i.e., 5* msg.value.
To verify the execution process of the above composite contract, the automata of attacker user contract, bank contract, and attacker contract established by UPPAAL are shown in
Figure 2a–c. The execution of the reentrancy attack automaton is initiated by the attacker user sending the message
attack!, starting from the initial state
Start in
Figure 2a.
Figure 3 presents the simulation execution sequence. When the bank attacker contract receives
attack? from the state
Start, it initializes the
attackCount = 0 and the
bankAddr = 0. Subsequently, it assigns a value to the recharge amount
msg_value = 10 and its own contract address to
msg_sender, then sends a
recharge! call to the bank contract’s
recharge() function.
After receiving the recharge? in the state Start, the bank contract recharges msg_value to both its own account and the attacker’s account, and then sends the recharge_end! message to the attacker contract. After receiving the recharge_end? message, the attacker contract subtracts msg_value from its own balance, and then sends a withdraw! message to the bank contract.
After receiving the draw? message, the bank contract assigns message sender’s account balance to variable withdraw_value, and subtracts withdraw_value from its own balance, because function call.value() invokes the anonymous function of attacker contract by default (row 7), so here we use the message fallback! sending to the attacker contract in this automaton. Then it uses require to determine whether the attacker contract has completed function fallback executions, i.e., variable fallback_end is true. If fallback_end is false, the bank contract continues to wait for the message withdraw? again. If fallback_end is true, it sets message sender’s balances [msg_sender] to 0 and returns to its initial state Start.
After receiving the message fallback?, the attacker contract adds the withdraw_value to its own balance. It then checks the value of attackCount, and if attackCount < 5, it increments attackCount by 1. Subsequently, it calls the function withdraw() of the bank contract again by sending the message withdraw?. To differentiate the second and subsequent abnormal calls to the function withdraw() from the first one, the names of the states are bank_withdraw2 and bank_withdraw1, respectively. If the condition attackCount >= 5 is true, the contract sets fallback_end = true to indicate that the function fallback() has completed execution, and then returns to the initial state Start.
The CTL property (1) is derived based on the execution process of the automata. The property is used to verify whether there is a scenario where the bank contract has received a call message for the function withdraw, i.e., it is in the state withdraw_function and the attacker contract calls the function withdraw() again, i.e., it is in the state bank_withdraw2. If such a scenario exists, it indicates that the function fallback() in the attacker contract can call the bank contract’s function withdraw() again, enabling repeated transfer operations, which signifies the presence of a reentrancy vulnerability.
3.2.2. Access Control
Access control vulnerabilities typically arise from imprecise or erroneous definitions of certain conditions during the writing of smart contracts, such as function modifiers. Attackers can exploit these vulnerabilities to maliciously execute functions that they should not have permission to access, causing losses to the entire contract system.
Code block 2. Solidity access control vulnerability sample code |
1 | contract Wallet { |
2 | bool tokenTransfer; |
3 | address walletAddress; |
4 | mapping(address => uint256) _balances; |
5 | |
6 | modifier isTokenTransfer { |
7 | if(!tokenTransfer){ |
8 | revert(); |
9 | } |
10 | _; |
11 | } |
12 | |
13 | modifier onlyFromWallet { |
14 | require(msg.sender != walletAddress); |
15 | _; |
16 | } |
17 | |
18 | function transfer(address to, uint value) public isTokenTransfer returns(bool success) { |
19 | require(_balances[msg.sender] >= value); |
20 | _balances[msg.sender] = _balances[msg.sender] − value; |
21 | _balances[to] = _balances[to] + value; |
22 | Transfer(msg.sender, to, value); |
23 | return true; |
24 | } |
25 | |
26 | function enableTokenTransfer() external onlyFromWallet { |
27 | tokenTransfer = true; |
28 | } |
29 | |
30 | function disableTokenTransfer() external onlyFromWallet { |
31 | tokenTransfer = false; |
32 | } |
33 | } |
Code block 2 demonstrates the sample code of access control, the intention of the modifier onlyFromWallet at line 13 of the sample code of access control is to use “==” to determine whether the call originated from the wallet itself, but mistakenly use “!=” at line 14. As a result, after being decorated with onlyFromWallet, the functions enableTokenTransfer() at line 26 and disableTokenTransfer() at line 30 can only be executed by accounts other than walletAddress. Consequently, an attacker can modify the variable tokenTransfer to true by enableTokenTransfer() and then uncontrollably invoke the function transfer() at line 18 to carry out transfer operations.
The automaton model shown in the
Figure 4 represents the owner user, attacker user, and wallet contract.
Figure 5 illustrates the execution sequence of access control. Both the owner and attacker automata perform the same actions, where, upon initiation, they set the
msg_sender to their own address and send the messages
enableTokenTransfer! or
disableTokenTransfer!. The wallet contract automaton, upon receiving these messages, checks if the function
OnlyFromWallet() returns true, and then proceeds to respectively set
tokenTransfer to false and true before returning to the state
Start.
Due to the mistake at line 14 of code, the attacker user is able to freely control the opening and closing of the wallet because the modifier onlyFromWallet() returns true. In contrast, the owner user is restricted to return to the initial state from OnlyFromWallet and onlyFromWallet2, as the modifier OnlyFromWallet() returns false for them. Similarly, the owner user is unable to manually open the wallet switch through the function enableTokenTransfer().
If the attacker user closes the wallet by setting tokenTransfer to false, then when the owner user attempts to send a transfer, the wallet contract will execute the action specified by the modifier isTokenTransfer(), which returns false. Consequently, the owner user automaton will always return to the state Start without successfully invoking the function transfer() in the wallet contract to complete the transfer.
We derived CTL properties in Formula (2), where (a1) and (a2), respectively, indicate that the attacker user reaches the state call_disable or call_enable, and the wallet contract also reaches the state disableTransfer or enableTransfer accordingly. It implies that the attacker user can control the switch of the wallet contract. In addition, (b1) and (b2) signify that when the owner user reaches the state call_disable or call_enable, the wallet contract can perform the disableTransfer or enableTransfer operations. The owner user cannot alter the switch of the wallet contract, so when (a1) or (a2) satisfies, and (b1) or (b2) is not satisfied, it can be confirmed that there exists an access control vulnerability in the contract.
3.2.3. Privilege Function Exposure
Functions with no permission modifiers in Solidity can be invoked by anyone by default. The
selfdestruct() is a built-in function in Solidity that can destroy the current contract and send the balance of the contract to a specified address. Therefore, if the function
selfdestruct() in a contract does not have any permission modifiers, anyone can call this function to gain the privilege of destroying the contract. The code block 3 illustrates the scenario described above. At line 3 of the wallet contract, the function
destroyContract() lacks any permission modifiers, allowing an attacker to freely call the function
selfdestruct(_to) to destroy the current contract. Automatically, the contract, which is about to be terminated, will transfer all its balance to the designated address
_to.
Code block 3. Solidity privilege function exposure vulnerability sample code |
1 | contract Wallet { |
2 | … |
3 | function destroyContract(address _to) { |
4 | selfdestruct(_to); |
5 | } |
6 | } |
The models in
Figure 6 illustrate the owner and attacker users interacting with the wallet contract in the context of exploiting a privileged function exposure. Both types of users have the capability to invoke the function
func_destroy by sending a message
destroy! to the wallet contract.
Figure 7 represents the simulation execution sequence. Since the destructor function
destroyContract(address _to) is not protected by any modifiers, upon receiving the request message
destroy?, the wallet contract model can directly execute a transfer operation to the user via the function
selfdestruct(). The user receives the request
trans_to?, which increments their account balance, and then sends a message
destroy_end? to the wallet contract to return to the state
Start. Upon receiving the
destroy_end! request, the wallet contract resets its account balance to 0 and transitions to the state
Destroyed, rendering the contract inactive.
From the execution process of the automata, we can obtain CTL properties in Formula (3). It is indicated that when the wallet contract reaches the state transfer_end, the attacker or owner users are also able to reach the state transfer_end. If this situation exists, it means that attacker and owner users, meaning any user, can perform the destruct operation of the wallet contract. When both (a) and (b) are true, it means the presence of a privileged function exposure vulnerability.
3.2.4. Cross-Contract Invocation
Solidity provides three functions: call(), delegatecall(), and callcode() to facilitate interaction and invocation between contracts. Among these, the function call() poses a security risk if not handled properly. Attackers can impersonate the current contract and invoke internal functions of itself or other contracts, leading to cross-contract vulnerability.
In the code block 4, at line 7, the function
authorityTransfer() enables transfer operations, requiring the caller to be the current contract. The function
callFunc() in the CallBug contract, at line 3, allows anyone and any contracts to invoke the internal function
call(). If its parameter
data is constructed as
authorityTransfer(), then the require statement
this == msg.sender cannot work, which allows anyone or any contract to execute the subsequent operations after the require statement.
Code block 4. Solidity cross-contract invocation vulnerability sample code |
1 | contract CallBug { |
2 | … |
3 | function callFunc(bytes data) public { |
4 | this.call(data); |
5 | } |
6 | |
7 | function authorityTransfer(uint256 _amount) { |
8 | require(this == msg.sender); |
9 | //secret operations… |
10 | } |
11 | } |
Figure 8 shows the automata of the attacker user and the CallBug contract and
Figure 9 represents its simulation execution sequence. Normally the attacker sends the message
authorityTransfer! to the CallBug contract, after receiving it the CallBug calls the function
authorityTransfer() to perform the
require() statement. Since
msg_sender != CallBug.address, the CallBug contract sends the message
authorityTransfer_end! to the attacker and returns to the state
Start. When the attacker receives the message
authorityTransfer_end?, it also returns to the state
Start. Therefore, normally the require statement acts as a guard to prevent unauthorized cross-contract calls.
However, if the attacker sends the message callFunc! to the CallBug contract, and after receiving it, contract CallBug then calls the function callFunc() and reaches the state func_callFunc. Since the function authorityTransfer() is called through the internal function callFunc(), the value of the variable msg_sender is CallBug.address, thus the require statement this == msg.sender in function authorityTransfer() is true, allowing the subsequent transfer operations to be executed. Previously, the attacker is unable to invoke the function authorityTransfer(), but now by first calling function callFunc(), it is able to call the function authorityTransfer() to execute transfer operations.
We derive Formula (4), which indicates whether the CallBug contract reaches the state act_transfer when an attacker user accesses func_authorityTransfer or func_callFunc. If conditions (a) and (b) are not satisfied, it means that attacker users cannot directly or indirectly invoke the function authorityTransfer() for transfer operations, indicating the absence of a cross-contract calling vulnerability in the contract CallBug. If condition (a) or (b) is met, it implies that attacker users can cause the CallBug contract to reach the state act_transfer by invoking the function authorityTransfer() or callFunc(), enabling direct or indirect transfers. In such a case, it indicates the presence of a cross-contract calling vulnerability in this contract.
3.2.5. Denial of Service
In smart contracts, attackers can exploit contract resources to prevent other users from executing normal operations within a certain period by monopolizing available resources. This can lock funds in the attacked contract. In a transfer application, users can create a contract that does not accept tokens. If another contract needs to send tokens to this contract address, and then it is able to transit to a new state, the contract will never reach the new state because the transfer operation cannot be completed.
For example, in a transfer application, attacker users can create a contract, such as contract B, that does not accept tokens. If another contract, such as A, needs to send tokens to the address of contract B before entering a new state, as contract B does not accept tokens, the transfer operation of contract A cannot be completed, and contract A will never reach a new state.
The auction contract shown in code block 5 is used for bidding, and the function bid() at line 5 is responsible for updating the latest bid situation. Firstly, it checks if the current bid amount msg.value is greater than the highest bid in history highestBid, then it proceeds to refund the previous highest bidder. If both conditions are met and the refund is completed, the action contract updates the highest bidder and the highest bid amount.
One attacker may create a POC contract to illegally win the bids. The POC contract uses the function
attack() to win the bid with the highest bid. When other users participate in the bidding and offer the highest bid, the require statement at line 7 triggers the refund operation in POC contract at line 19, i.e., the unnamed fallback function decorated with payable. Due to the use of the function
revert() in the function, the refund operation cannot complete, causing the require condition at line 7 to always return false. As a result, the subsequent codes at line 8 and 9 cannot be executed, allowing the POC contract to win the bid at a lower price.
Code block 5. Solidity denial-of-service vulnerability sample code |
1 | contract Auction { |
2 | address public currentLeader; |
3 | uint256 public highestBid; |
4 | |
5 | function bid() public payable { |
6 | require(msg.value > highestBid); |
7 | require(currentLeader.send(highestBid)); |
8 | currentLeader = msg.sender; |
9 | highestBid = msg.value; |
10 | } |
11 | } |
12 | |
13 | contract POC { |
14 | … |
15 | function attack() public payable { |
16 | Auction auction = Auction(auctionAddr); |
17 | auction.bid.value(msg.value)(); |
18 | } |
19 | function () external payable { |
20 | revert(); |
21 | } |
22 | } |
The automata shown in
Figure 10 depicts the interactions among the attacker user, bidder user, auction contract, and POC contract.
Figure 11 illustrates its simulation execution sequence. Normally, a bidder user in
Figure 10c directly sends a message
bid! to the auction contract, specifying the bid amount variable
msg_value = 10 and their address as
msg_sender. The attacker user in
Figure 10a, in order to prevent other bidders from successfully placing higher bids, sends a message
attack! to the POC contract to call the function
attack(). Upon receiving the message
attack?, the POC contract in
Figure 10d internally sends a message
bid! to the auction contract, setting
msg_value=20 and
msg_sender to its own address.
When the auction contract in
Figure 10b receives the message
bid?, it checks whether the condition
msg_value > highestBid is true or not. As
20 > 10, the condition is true and the auction automaton continues execution to state
send_currentLeader. If the condition is false, it returns to the state
Start. The contract then checks if the current leader address is a contract address; if not, it refunds the bid amount to the bidder user’s address. When the user bids again with 40, triggering the function
refund, the auction contract sends a message
fallback! to invoke the function
fallback in the receiving contract.
Upon receiving the message fallback?, the POC contract sends a revert! to execute the refund operation. The auction contract, upon receiving revert?, restores the account balance and returns to the state Start. After the successful refund operation, it updates currentLeader and highestBid, and returns to the state Start.
By verifying the presence of denial-of-service vulnerabilities in the contract through the property (5), when the auction contract reaches the state require_false by executing the 7th line, the POC contract reaches the state revert_end after the function revert() is executed. If the above situation exists, it means that the POC contract can successfully prevent higher bids in the auction by rejecting payments.
3.2.6. Miner Privilege
The miner privilege vulnerability mainly refers to contract vulnerabilities that rely on timestamps. Block timestamps are widely used in various conditional statements based on time-changing states, such as generating random numbers and locking funds for a period of time. If miners have the ability to slightly adjust the timestamp (the adjusted value is still legal), and the smart contract mistakenly uses the block timestamp, this can have serious consequences. Taking the lottery roulette contract code shown in the code block 6 as an example, the function rollback at line 3 is used for a single bet. Firstly, the require statement at line 4 is used to limit the player’s betting amount to meet the condition
msg.value == 10 Ether. Then, the require statement in the 5th line is used to limit each block to only contain one bet transaction of 10 Ether. Finally, the 7th line determines that if the current timestamp is a multiple of 15, the player can win the full balance of the contract. Clearly, if miners help players adjust timestamps, players will easily win.
Code block 6. Solidity miner privilege vulnerability sample code |
1 | contract Roulette { |
2 | … |
3 | function () public payable { |
4 | require(msg.value == 10 Ether); |
5 | require(now != pastBlockTime); |
6 | pastBlockTime = now; |
7 | if (now % 15 == 0) { |
8 | msg.sender.transfer(this.balance); |
9 | } |
10 | } |
11 | } |
The automata for players, miners, and roulette contracts are shown in
Figure 12.
Figure 13 illustrates its simulation execution sequence. The variable
balance for each of these three models is initialized to 100. The player and miner automata are mostly the same, but miner users can control the timestamp
now based on the current clock variable
t, so the value of
now is assigned with a multiple of 15 (satisfying the condition for generating new blocks within 900s of the previous block). After variables configuration, the miner automaton sets the betting amount
msg_value to 10 ether (accordingly both its account balance
balance and roulette contract balance balance[address] decreased by 10) and sends the message
fallback! to the roulette contract to call its function
fallback. After receiving the
fallback?, the roulette automaton begins to execute the function
fallback. The first step is to determine whether the betting amount
msg_value == 10 ether. If they are equal, the roulette automaton reaches the state
request_time. If the current timestamp
now equals to
passBlockTime, the roulette automaton sends the message
fallback_end!, returns the received betting amount 10 Ether to the miner, and goes back to the state
Start. If the current timestamp
now is not equal to
pasteBlockTime, the roulette automaton updates
pasteBlockTime=now and checks whether the current timestamp
now is a multiple of 15 or not. As miners can control the timestamp
now, if the condition
now%15 == 0 is met, all balances of the roulette contract will be transferred to the betting party, most likely to be miners, the roulette automaton reaches the state
player_win and sends the message
fallback_end!. After receiving the message
fallback_end?, the miner automaton updates its balance. Clearly, because a miner automaton can set the current timestamp
now to a multiple of 15, it can always win the bet.
By analyzing the execution process of the above model, we can obtain property (6), which is used to verify whether there is a mining privilege risk in the contract. It indicates that if the roulette contract reaches the state player_win, the user is always the miner. If this property is satisfied, it indicates that there is a hidden privilege risk for miners in the roulette contract.