Back to all stories
Blogs
Tech & Dev
Runtime Environment and Smart Contract Security Modeling
2/27/2023

The design and runtime of different blockchains alter the security of smart contracts built on top of them. There are a number of reasons for this: developers must use a new domain-specific language; transaction executions may involve asynchronous function finality; and tooling is not always equal for different blockchain environments.

Runtime Environment and Smart Contract Security Modeling

In this blog post, we will explore how security changes based on different runtime models. In particular we will compare the runtime assumptions for EVM smart contracts and a similar set of assumptions for NEAR smart contracts. We will then look into how this alters the design of a secure smart contract.

We will also highlight an attack vector found while auditing NEAR contracts which we will name an insolvency attack. While this attack is known to the some in the NEAR community, it is still not a well known attack. Further, it can effect any blockchain that requires additional fees for creating new storage.

Abstract Liquid Staking Contracts

We will begin by defining an abstract liquid staking contract. We will use this contract to understand how the runtime of a blockchain effects the security of a smart contract.

Our staking contract will have some common functionalities found in an ERC20-type contract. The common functionalities will be to track the token balance of a given user and allow for transfers of tokens between users. There are three functions that change state: transfer; transferFrom; and approve. Between two user’s Bob and Alice they are able to generally change the state as follows:

  1. transfer

a. Call - Bob calls transfer with input amount x and to address Alice.

b. Effect - The contract updates Alice’s balance with x tokens and removes x tokens from Bob’s balance.

  1. approve

a. Call - Bob calls approve with input allowance x and to address Alice.

b. Effect - The contract updates Alice’s allowance x with respective to Bob’s balance to allow Alice to call transferfrom on Bob's tokens.

transferFrom

Call - Alice calls transferFrom with input amount x input, from address Bobs account, and to address an Sally's address

Effect - The contract updates Sallys’s balance with x tokens and removes x tokens from Bob’s balance and removes Alice’s allowance x with respective to Bob’s balance.

The remaining functions of our liquid staking will be the following.

  1. depositAndStake

a. Call - Alice calls depositAndStake with x input amount

b. Effect - The contract checks whether there is sufficient attached GAS as input x. If there is then the contract mints Alice stGAS tokens and deposits the GAS into a node. It then calls depositandStakeCallback

  1. depositAndStakeCallback

a. Call - The contract can only call this function internally with no inputs.

b. Effect - It checks whether the Gas deposit is successful. If successful it returns true and otherwise it reverts the state updates and sends the user their GAS tokens back.

  1. withdraw

a. Call - Alice calls withdraw with input amount x

b. Effect - The contract unstakes Alice’s GAS tokens from the node and calls transferFrom on an equivalent amount of stGAS tokens that Alice owns. If Alice does not hold a sufficent amount of stGAS then the transaction reverts.

EVM Runtime Model and Asynchronous Function Calls

The runtime model for the EVM is well known. A general set of axioms assumed by Solidity developers are seen below:

  • If there is sufficient amount of gas then transactions may contain numerous and complex function calls;

  • If a transaction is executed then function calls are synchronous;

  • There is no difference in gas cost of a transaction if a function call increases the storage usage of an existing contract.

This model lead to an interesting family of attack surfaces called reentrancy attacks. While solutions for reentrancy attacks are now well known, there are still many different examples of reentrancy that are more difficult to detect such as multifunction reentrancy and read only reentrancy.

Using our liquid staking contract, let us review how a simple reentrancy attack would work on a EVM-style smart contract using our psuedo-code below.

function withdraw(uint amount) { require(balances[msg.sender] >= amount); msg.sender.transfer(amount); balances[msg.sender] -= amount; ... ... }

Reentrancy attack flow for synchronous function calls:

  • Suppose Bob is a smart contract and our liquid staking contract controls 100 GAS tokens.

  • If Bob holds at least 1 stGAS token then Bob can call withdraw and re-enter via the fallback.

  • The final withdraw will then complete the function call.

The check-effect-interact pattern is one solution to this type of reentrancy. In the example this is done by switching the line responsible for transferring with the line that updates balances. However, is this solution still secure if function calls are no longer synchronous? No! A concrete example can be found in Near smart contracts.

Lets look at the following psuedocode for a liquid staking contract. This example, we will assume that all external function calls are assumed to be finalized in the next block. Further note that the follow is based off the documentation from NEAR and is only meant to model a re-entrancy attack.

fn depositAndStake(){ let user = msg.sender(); let amount = msg.value(); balance[user]= balance[user] + amount; #External contract call - Takes 1 block to finalize validator.deposit_NEAR_and_stake() .attachNEAR(amount).thenCallback(depositAndStakeCallback) } fn withdraw(amount: u32){ if user has sufficent balance: balance[user] = balance[user] - amount msg.sender.transfer(amount); ... ... } function depositAndStakeCallback{ if deposit Failed: # Also assume not reverts on underflows balance[user] = balance[user] - amount msg.sender.transfer(amount); } ... ... }

Notice that in the above contract, the withdraw function follows check-effect-interact pattern. However it is still vulnerable to reentrancy as follows:

  • Suppose Bob is a smart contract on Near and suppose our liquid staking contract controls 100 Near tokens.

  • Bob calls depositAndStake with 49 Near attached at block zero.

Bob calls the withdraw function at block zero then Bob will receive 49 Near.

However, the callback function depositAndStakeCallback is executed at block one and Bob will receive another 49 Near.

Note that Bob is able to call depositAndStake and withdraw within the same transaction. This is because the NEAR blockchain allows for batched function calls. The key here is that the withdraw function is finalized before the depositAndStake has completed the external function call.

Reentrancy attacks are still permissible when function calls are asynchronous however in a slightly different form. One solution to protect against such an attack one is to follow a Check-Interact-Effect in combination with a local Mutex for certain situations. See below for how the Check-Interact-Effect would work here. Further to see the full example, please check the excellent explanation found in the NEAR documentation.

fn depositAndStake(){ let user = msg.sender(); let amount = msg.value(); #External contract call - Takes 1 block to finalize validator.deposit_NEAR_and_stake() .attachNEAR(amount).thenCallback(depositAndStakeCallback) } fn withdraw(amount: u32){ if user has sufficent balance: balance[user] = balance[user] - amount msg.sender.transfer(amount); ... ... } function depositAndStakeCallback{ if deposit Successful: balance[user]= balance[user] + amount; if deposit Failed: # Also assume not reverts on underflows msg.sender.transfer(amount); } ... ... }

Near Runtime Model and Storage Staking

In the previous section, we saw how the security model of our smart contract can change if function calls are no longer synchronous. In this section, lets see how security will change if gas increases when the state of a smart contract increases.

On the NEAR blockchain, the storage of on-chain data requires the deposit of NEAR tokens into the account that contains such data. This mechanism is called Storage Staking. Any piece of data is subject to the deposit including: account metadata; smart contract bytecode; data generated by function calls on smart contracts. The amount of Near needed for Storage Staking is defined to be the stored data length multiplied by the cost in byte.

As such we can update the general axioms for Near developers as follows:

  • If there is sufficient amount of gas then transactions may contain numerous and complex function calls;

  • If a transaction is executed then external function calls are asynchronous;

  • The gas cost of a transaction is defined to be the sum of the gas cost of the function call and a storage staking fee. If there is not enough storage staking fee then all function calls will revert and all other funds deposited in the contract are locked.

The introduces a new problem not found when writing EVM smart contracts. If a generic user can call a function that stores new on-chain data then the smart contract logic must verify there is sufficient funds for the Storage Staking. Otherwise a generic user can cause a denial of service attack on the smart contract. This attack is referred to as the Million Small Deposits attack.

Evgeny Kuzyakov from the Near Social team has provided a solution for storage staking found in NEP-145. However, even with this solution if storage staking is not properly calculated between contracts this opens up a new attack vector which we will call an insolvency attack.

See here for a discussion on NEP-145.

Insolvency Attack on Liquid Staking Contract

To minimize complexity let us suppose that our Liquid staking contract does not enforce a storage staking fee in the function depositAndStake. However the withdraw function refunds a storage staking fee.

fn depositAndStake(){ let user = msg.sender(); let amt = msg.value(); # increases contract size if user is calling for the first time. balance[user]= balance[user] + amt; #External contract call validator.deposit_NEAR_and_stake() .attachNEAR(amount).thenCallback(depositAndStakeCallback) } fn withdraw(amount: u32){ if user has sufficent balance: balance[user] = balance[user] - amount msg.sender.transfer(amount); refund storage fee ... ... }

Insolvency attack flow:

  • Suppose our liquid staking contract contains 100 Near such that 50 Near is the necessary storage fee for 200 accounts. The remaining 50 is rewards obtained by staking.

  • Further suppose that a new account requires 0.5 Near for storage staking to call depositAndStake.

  • Bob calls depositAndStake with the input amount 0.01.

  • Bob then batch calls the function withdraw with input amount 0.0001.

  • Bob is able to drain 50 NEAR from the Liquid staking and the contract will revert function calls that increase state.

While the above example attack is trivial to see, in practice it is much more difficult to detect for a large system of smart contracts. Further, this scenario does not appear to be logical. Why would a contract refund NEAR when calling withdraw if the balance[user] is not even 0?

This scenario has appeared while auditing NEAR smart contracts. In particular, a bridge can have escrow contracts on either side such that one side of the bridge will collect storage fees and the other side of the bridge will release storage fees. However if both sides do not have the correct storage accounting then a user can abuse the storage fee release. That was an issue with Calimero bridge which we will explore in more detail in the next post. For more details now, see the audit report.

Conclusion

Smart contract security is influenced by the underlying blockchain's runtime and the virtual machines that define the programming language. Different models of security are needed for different blockchains.

While there is already a large number of well known security models for different blockchains, the number of models will continue to increase as new blockchains are launched.

Further, we have seen that smart contract security is not composable between different models. In this blog, we provided a number of examples illustrating how NEAR and EVM smart contracts can differ.

One aspect that remains important however not explored in this post is how different smart contract security models interact with each other. These intersections remain critically important as they are found in bridges and other inter-blockchain communication systems. This topic will be explored in another blog post.

;