Hi
I’m a security researcher at Trail of Bits. We recently audited the Dexter contracts and found two critical security issues that are directly related to Tezos’ message-passing architecture. We believe that both issues are likely present in several other contracts. Due to the complexity and likely prevalence of these issues, we think it would be beneficial for the Tezos community to discuss them and consider changing the message-passing architecture.
Message passing
In Tezos, a call to an external contract in a function is not made during the execution of the function, but is queued in a list of calls to be executed. The call order follows breadth-first search (BFS).
Consider Figure 1:
function a():
call b()
call d()
return
function b():
call c()
return
function c():
return
function d():
return
In a traditional smart contract, the functions are executed in the following order:
- start a()
- start b()
- start c()
- end c()
- end b()
- start d()
- end d()
- start b()
- end a()
In a Tezos contract, the order is:
- Execute a() # Next calls: [b, d]
- Execute b() # Next calls: [d, c]
- Execute d() # Next calls: [c]
- Execute c() # Next calls: []
In Tezos, the code of d() is executed before the code of c().
This unusual order of execution leads to two types of vulnerabilities:
- Callback authorization bypass
- Call injection
Callback authorization bypass
Description
The message-passing architecture of Tezos prevents contracts from reading the return value of an external call, so a callback must be used. Since there’s no built-in mitigation, using a callback will likely lead to access control issues. We call this attack a callback authorization bypass.
To illustrate this, consider a contract that needs to read its balance in a token. Figure 2 shows how a contract in a direct call system would work:
### contract Token
function balanceOf(address addr):
return balances[addr]
### contract MyContract
function f():
my_balance = token.balanceOf(self)
Figure 3 is a naive implementation of its equivalent in a message-passing system:
### contract Token
function view balanceOf(address addr, callback addr):
call addr(balances[addr])
### contract MyContract
function f(uint value):
assert sender == token
my_balance = value
In Figure 3, the sender must be the token to prevent anyone from calling this function. The functions that allow a callback are called view.
If the token has any other view functions, they can be used to callback to MyContract.f
and compromise it.
This can be resolved by implementing a complex state-based transition that ensures MyContract.f
is only called after the execution of getBalance
. However, such access controls are difficult to implement and significantly increase the complexity of the codebase.
Typically, the FA1.2 token standard specifies three view functions:
- (view (address :owner, address :spender) nat) %getAllowance
- (view (address :owner) nat) %getBalance
- (view unit nat) %getTotalSupply
Any contract that wants to interact with one of these functions must implement complex access control to prevent security issues.
Although there is a hidden view standard warning, it’s not likely to be taken into account by the majority of developers:
Note that using view may potentially be insecure: users can invoke operations on an arbitrary callback contract on behalf of the contract that contains a view entrypoint. If you rely on SENDER value for authorization, please be sure you understand the security implications or avoid using views.
Exploit Scenario
Bob creates a contract that allows a callback from tokens to receive the balance from getBalance
. Eve uses getAllowance
to write an arbitrary value and compromise Bob’s contract.
Call injection
Description
An attacker can compromise a contract by injecting calls between a function and the external calls it generates.
As we saw, when a function is executed, all its generated calls will be queued in the list of calls to be executed. An attacker can place any call in the queue and then execute any code between the end of the function’s execution and its generated calls.
In particular, the contract’s balance or the memory of the called contracts might be in an invalid state when the attacker’s call is executed. We call this attack a call injection.
Exploit Scenario
Consider the Receiver
contract in Figure 4:
type parameter is
Receive of unit
| Set of unit
function entry_Receive (const current_balance : tez) : (list(operation) * tez) is
block {
const alice : address = ("some_address": address);
const to_contract: contract(unit) = get_contract(alice);
const op : operation = transaction(unit, amount, to_contract);
} with ((list [op]), current_balance)
function entry_Set (const current_balance : tez) : (list(operation) * tez) is
block {
if(amount > 0mutez) then {
failwith("Do not send tezos")
} else{
current_balance := balance
}
} with ((nil : list(operation)), current_balance)
function main (const action : parameter; const current_balance : tez) : (list(operation) * tez) is
case action of
Receive(x) -> entry_Receive(current_balance)
| Set(x) -> entry_Set(current_balance)
end
and its high-level representation in Figure 5:
storage:
current_balance
function receive():
transaction("some_addess", amount)
function set():
require(amount == 0)
current_balance = balance
Receiver
has two functions:
-
Receive
, which forwards the funds sent to a fixed address -
Set
, which stores the contract’s balance tocurrent_balance
If we do not consider call injection, an invariant of Receiver
would be that current_balance
is always zero as:
- All the tezos sent to receive are sent to a fixed address
-
Set
cannot receive tezos
But this invariant can be broken if an attacker injects a call to set
after the end of receive
’s execution and before its external call is executed (transaction("some_addess", amount)
). Figure 6 shows an example of such an attack:
function main (const param : unit; const current_balance : tez) : (list(operation) * tez) is
block {
const dst : address = ("receiver_address": address);
const receive: contract(unit) = get_entrypoint("%receive", dst);
const op1: operation = transaction(unit, amount, receive);
const set_balance: contract(unit) = get_entrypoint("%set", dst);
const op2: operation = transaction(unit, 0mutez, set_balance);
op_list := list op1; op2; end;
}
with (op_list, current_balance)
Figure 7 is a high-level representation of the exploit:
function main():
call receive()
call set()
In the exploit, set()
is executed before the tezos are transferred from the Receiver
contract to the static address. As a result, current_balance
will be a non-zero value.
Recommendations
The callback authorization bypass can be prevented if the developer implements specific access controls. Yet this comes with a significant cost for the developers. In other platforms, there are many contracts interactions, which makes us think that porting several existing smart contracts to Tezos is difficult and highly error-prone.
We are not aware of a general mitigation for the call injection vulnerability. Contracts that have external calls cannot rely on their balance. Moreover, developers must be extra careful with the value of the variables in called contrats.
One of the reasons Tezos has this unusual call order is to prevent reentrancy. However, the current solution creates vulnerabilities that are likely to be more difficult to prevent than reentrancy. Additionally, the call order is unusual and difficult to reason with, which is likely to confuse developers. Two critical issues found in Dexter were the results of this design.
Reentrancies can be prevented while keeping a traditional call order. For example, the VM can have a contract-level reentrancy lock that can be enabled by default. Function-level locks can also be used for more granularity, such as those in Vyper.
Overall, we recommend the Tezos developers 1) consider using direct calls instead of message passing and 2) implement reentrancy mitigation in the VM.
Related discussions: